diff --git a/src/components/animationIntersector.ts b/src/components/animationIntersector.ts index 86414164..563179e1 100644 --- a/src/components/animationIntersector.ts +++ b/src/components/animationIntersector.ts @@ -8,7 +8,7 @@ import rootScope from "../lib/rootScope"; import { IS_SAFARI } from "../environment/userAgent"; import { MOUNT_CLASS_TO } from "../config/debug"; import isInDOM from "../helpers/dom/isInDOM"; -import { indexOfAndSplice } from "../helpers/array"; +import { forEachReverse, indexOfAndSplice } from "../helpers/array"; import RLottiePlayer from "../lib/rlottie/rlottiePlayer"; export interface AnimationItem { @@ -18,9 +18,10 @@ export interface AnimationItem { }; export class AnimationIntersector { - public observer: IntersectionObserver; + private observer: IntersectionObserver; private visible: Set = new Set(); + private overrideIdleGroups: Set; private byGroups: {[group: string]: AnimationItem[]} = {}; private lockedGroups: {[group: string]: true} = {}; private onlyOnePlayableGroup: string = ''; @@ -30,7 +31,7 @@ export class AnimationIntersector { constructor() { this.observer = new IntersectionObserver((entries) => { - if(rootScope.idle.isIDLE) return; + // if(rootScope.idle.isIDLE) return; for(const entry of entries) { const target = entry.target; @@ -61,6 +62,8 @@ export class AnimationIntersector { } }); + this.overrideIdleGroups = new Set(); + rootScope.addEventListener('media_play', ({doc}) => { if(doc.type === 'round') { this.videosLocked = true; @@ -76,6 +79,11 @@ export class AnimationIntersector { }); } + public setOverrideIdleGroup(group: string, override: boolean) { + if(override) this.overrideIdleGroups.add(group); + else this.overrideIdleGroups.delete(group); + } + public getAnimations(element: HTMLElement) { const found: AnimationItem[] = []; for(const group in this.byGroups) { @@ -101,8 +109,12 @@ export class AnimationIntersector { }, 1e3); } - for(const group in this.byGroups) { - indexOfAndSplice(this.byGroups[group], player); + const group = this.byGroups[player.group]; + if(group) { + indexOfAndSplice(group, player); + if(!group.length) { + delete this.byGroups[player.group]; + } } this.observer.unobserve(el); @@ -127,20 +139,19 @@ export class AnimationIntersector { } public checkAnimations(blurred?: boolean, group?: string, destroy = false) { - if(rootScope.idle.isIDLE) return; - - const groups = group /* && false */ ? [group] : Object.keys(this.byGroups); + // if(rootScope.idle.isIDLE) return; - if(group && !this.byGroups[group]) { + if(group !== undefined && !this.byGroups[group]) { //console.warn('no animation group:', group); - this.byGroups[group] = []; return; } + + const groups = group !== undefined /* && false */ ? [group] : Object.keys(this.byGroups); for(const group of groups) { const animations = this.byGroups[group]; - animations.forEach(player => { + forEachReverse(animations, (player) => { this.checkAnimation(player, blurred, destroy); }); } @@ -162,7 +173,8 @@ export class AnimationIntersector { } else if(animation.paused && this.visible.has(player) && animation.autoplay && - (!this.onlyOnePlayableGroup || this.onlyOnePlayableGroup === group) + (!this.onlyOnePlayableGroup || this.onlyOnePlayableGroup === group) && + (!rootScope.idle.isIDLE || this.overrideIdleGroups.has(player.group)) ) { //console.warn('play animation:', animation); animation.play(); @@ -211,4 +223,4 @@ const animationIntersector = new AnimationIntersector(); if(MOUNT_CLASS_TO) { MOUNT_CLASS_TO.animationIntersector = animationIntersector; } -export default animationIntersector; \ No newline at end of file +export default animationIntersector; diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 421f06ab..f99309ce 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -2196,7 +2196,7 @@ export default class ChatBubbles { // this.ladderDeferred.resolve(); scrollable.lastScrollDirection = 0; - scrollable.lastScrollTop = 0; + scrollable.lastScrollPosition = 0; replaceContent(scrollable.container, chatInner); animationIntersector.unlockGroup(CHAT_ANIMATION_GROUP); @@ -3633,7 +3633,7 @@ export default class ChatBubbles { //this.scrollable.scrollTop = this.scrollable.scrollHeight; //isTouchSupported && isApple && (this.scrollable.container.style.overflow = ''); - this.scrollable.lastScrollTop = newScrollTop; + this.scrollable.lastScrollPosition = newScrollTop; this.scrollable.lastScrollDirection = 0; if(IS_SAFARI/* && !isAppleMobile */) { // * fix blinking and jumping diff --git a/src/components/chat/chat.ts b/src/components/chat/chat.ts index 9bbedef6..c9b3d212 100644 --- a/src/components/chat/chat.ts +++ b/src/components/chat/chat.ts @@ -23,6 +23,7 @@ import type { AppEmojiManager } from "../../lib/appManagers/appEmojiManager"; import type { ServerTimeManager } from "../../lib/mtproto/serverTimeManager"; import type { AppMessagesIdsManager } from "../../lib/appManagers/appMessagesIdsManager"; import type { AppGroupCallsManager } from "../../lib/appManagers/appGroupCallsManager"; +import type { AppReactionsManager } from "../../lib/appManagers/appReactionsManager"; import type { State } from "../../lib/appManagers/appStateManager"; import type stateStorage from '../../lib/stateStorage'; import EventListenerBase from "../../helpers/eventListenerBase"; @@ -92,7 +93,8 @@ export default class Chat extends EventListenerBase<{ public appNotificationsManager: AppNotificationsManager, public appEmojiManager: AppEmojiManager, public appMessagesIdsManager: AppMessagesIdsManager, - public appGroupCallsManager: AppGroupCallsManager + public appGroupCallsManager: AppGroupCallsManager, + public appReactionsManager: AppReactionsManager ) { super(); @@ -185,7 +187,7 @@ export default class Chat extends EventListenerBase<{ this.bubbles = new ChatBubbles(this, this.appMessagesManager, this.appStickersManager, this.appUsersManager, this.appInlineBotsManager, this.appPhotosManager, this.appPeersManager, this.appProfileManager, this.appDraftsManager, this.appMessagesIdsManager, this.appChatsManager); this.input = new ChatInput(this, this.appMessagesManager, this.appMessagesIdsManager, this.appDocsManager, this.appChatsManager, this.appPeersManager, this.appWebPagesManager, this.appImManager, this.appDraftsManager, this.serverTimeManager, this.appNotificationsManager, this.appEmojiManager, this.appUsersManager, this.appInlineBotsManager); this.selection = new ChatSelection(this, this.bubbles, this.input, this.appMessagesManager); - this.contextMenu = new ChatContextMenu(this.bubbles.bubblesContainer, this, this.appMessagesManager, this.appPeersManager, this.appPollsManager, this.appDocsManager, this.appMessagesIdsManager); + this.contextMenu = new ChatContextMenu(this.bubbles.bubblesContainer, this, this.appMessagesManager, this.appPeersManager, this.appPollsManager, this.appDocsManager, this.appMessagesIdsManager, this.appReactionsManager); if(this.type === 'chat') { this.topbar.constructUtils(); @@ -240,6 +242,7 @@ export default class Chat extends EventListenerBase<{ this.topbar.destroy(); this.bubbles.destroy(); this.input.destroy(); + this.contextMenu && this.contextMenu.destroy(); delete this.topbar; delete this.bubbles; diff --git a/src/components/chat/contextMenu.ts b/src/components/chat/contextMenu.ts index ee5efc1a..981558fa 100644 --- a/src/components/chat/contextMenu.ts +++ b/src/components/chat/contextMenu.ts @@ -9,6 +9,7 @@ import type { AppPeersManager } from "../../lib/appManagers/appPeersManager"; import type { AppPollsManager } from "../../lib/appManagers/appPollsManager"; import type { AppDocsManager, MyDocument } from "../../lib/appManagers/appDocsManager"; import type { AppMessagesIdsManager } from "../../lib/appManagers/appMessagesIdsManager"; +import type { AppReactionsManager } from "../../lib/appManagers/appReactionsManager"; import type Chat from "./chat"; import { IS_TOUCH_SUPPORTED } from "../../environment/touchSupport"; import ButtonMenu, { ButtonMenuItemOptions } from "../buttonMenu"; @@ -24,10 +25,211 @@ import findUpClassName from "../../helpers/dom/findUpClassName"; import { cancelEvent } from "../../helpers/dom/cancelEvent"; import { attachClickEvent, simulateClickEvent } from "../../helpers/dom/clickEvent"; import isSelectionEmpty from "../../helpers/dom/isSelectionEmpty"; -import { Message, Poll, Chat as MTChat, MessageMedia } from "../../layer"; +import { Message, Poll, Chat as MTChat, MessageMedia, AvailableReaction } from "../../layer"; import PopupReportMessages from "../popups/reportMessages"; import assumeType from "../../helpers/assumeType"; import PopupSponsored from "../popups/sponsored"; +import { ScrollableX } from "../scrollable"; +import { wrapSticker } from "../wrappers"; +import RLottiePlayer from "../../lib/rlottie/rlottiePlayer"; +import getVisibleRect from "../../helpers/dom/getVisibleRect"; +import ListenerSetter from "../../helpers/listenerSetter"; +import animationIntersector from "../animationIntersector"; +import { getMiddleware } from "../../helpers/middleware"; +import noop from "../../helpers/noop"; + +const REACTIONS_CLASS_NAME = 'btn-menu-reactions'; +const REACTION_CLASS_NAME = REACTIONS_CLASS_NAME + '-reaction'; + +const REACTION_SIZE = 24; +const PADDING = 4; +const REACTION_CONTAINER_SIZE = REACTION_SIZE + PADDING * 2; + +type ChatReactionsMenuPlayers = { + select?: RLottiePlayer, + appear?: RLottiePlayer, + selectWrapper: HTMLElement, + appearWrapper: HTMLElement +}; +export class ChatReactionsMenu { + public container: HTMLElement; + private reactionsMap: Map; + private scrollable: ScrollableX; + private animationGroup: string; + private middleware: ReturnType; + + constructor( + private appReactionsManager: AppReactionsManager + ) { + const reactionsContainer = this.container = document.createElement('div'); + reactionsContainer.classList.add(REACTIONS_CLASS_NAME); + + const reactionsScrollable = this.scrollable = new ScrollableX(undefined); + reactionsContainer.append(reactionsScrollable.container); + reactionsScrollable.onAdditionalScroll = this.onScroll; + reactionsScrollable.setListeners(); + + this.reactionsMap = new Map(); + this.animationGroup = 'CHAT-MENU-REACTIONS-' + Date.now(); + animationIntersector.setOverrideIdleGroup(this.animationGroup, true); + + if(!IS_TOUCH_SUPPORTED) { + reactionsContainer.addEventListener('mousemove', this.onMouseMove); + } + + this.middleware = getMiddleware(); + const middleware = this.middleware.get(); + appReactionsManager.getAvailableReactions().then(reactions => { + if(!middleware()) return; + reactions.forEach(reaction => { + this.renderReaction(reaction); + }); + }); + } + + public cleanup() { + this.middleware.clean(); + this.scrollable.removeListeners(); + this.reactionsMap.clear(); + animationIntersector.setOverrideIdleGroup(this.animationGroup, false); + animationIntersector.checkAnimations(true, this.animationGroup, true); + } + + private onScroll = () => { + this.reactionsMap.forEach((players, div) => { + this.onScrollProcessItem(div, players); + }); + }; + + private renderReaction(reaction: AvailableReaction) { + const reactionDiv = document.createElement('div'); + reactionDiv.classList.add(REACTION_CLASS_NAME); + + const scaleContainer = document.createElement('div'); + scaleContainer.classList.add(REACTION_CLASS_NAME + '-scale'); + + const appearWrapper = document.createElement('div'); + const selectWrapper = document.createElement('div'); + appearWrapper.classList.add(REACTION_CLASS_NAME + '-appear'); + selectWrapper.classList.add(REACTION_CLASS_NAME + '-select', 'hide'); + + const hoverScale = IS_TOUCH_SUPPORTED ? 1 : 1.25; + const size = REACTION_SIZE * hoverScale; + + const players: ChatReactionsMenuPlayers = { + selectWrapper, + appearWrapper + }; + this.reactionsMap.set(reactionDiv, players); + + const middleware = this.middleware.get(); + + const options = { + width: size, + height: size, + skipRatio: 1, + needFadeIn: false, + withThumb: false, + group: this.animationGroup, + middleware + }; + + let isFirst = true; + wrapSticker({ + doc: reaction.appear_animation, + div: appearWrapper, + play: true, + ...options + }).then(player => { + assumeType(player); + + players.appear = player; + + player.addEventListener('enterFrame', (frameNo) => { + if(player.maxFrame === frameNo) { + selectLoadPromise.then((selectPlayer) => { + assumeType(selectPlayer); + appearWrapper.classList.add('hide'); + selectWrapper.classList.remove('hide'); + + if(isFirst) { + players.select = selectPlayer; + isFirst = false; + } + }, noop); + } + }); + }, noop); + + const selectLoadPromise = wrapSticker({ + doc: reaction.select_animation, + div: selectWrapper, + ...options + }).catch(noop); + + scaleContainer.append(appearWrapper, selectWrapper); + reactionDiv.append(scaleContainer); + this.scrollable.append(reactionDiv); + } + + private onScrollProcessItem(div: HTMLElement, players: ChatReactionsMenuPlayers) { + if(!players.appear) { + return; + } + + const scaleContainer = div.firstElementChild as HTMLElement; + const visibleRect = getVisibleRect(div, this.scrollable.container); + if(!visibleRect) { + if(!players.appearWrapper.classList.contains('hide')) { + return; + } + + if(players.select) { + players.select.stop(); + } + + players.appear.stop(); + players.appear.autoplay = true; + players.appearWrapper.classList.remove('hide'); + players.selectWrapper.classList.add('hide'); + scaleContainer.style.transform = ''; + } else if(visibleRect.overflow.left || visibleRect.overflow.right) { + const diff = Math.abs(visibleRect.rect.left - visibleRect.rect.right); + const scale = Math.min(diff ** 2 / REACTION_CONTAINER_SIZE ** 2, 1); + + scaleContainer.style.transform = `scale(${scale})`; + } else { + scaleContainer.style.transform = ''; + } + } + + private onMouseMove = (e: MouseEvent) => { + const reactionDiv = findUpClassName(e.target, REACTION_CLASS_NAME); + if(!reactionDiv) { + return; + } + + const players = this.reactionsMap.get(reactionDiv); + if(!players) { + return; + } + + // do not play select animation when appearing + if(!players.appear?.paused) { + return; + } + + const player = players.select; + if(!player) { + return; + } + + if(player.paused) { + player.autoplay = true; + player.restart(); + } + }; +} export default class ChatContextMenu { private buttons: (ButtonMenuItemOptions & {verify: () => boolean, notDirect?: () => boolean, withSelection?: true, isSponsored?: true})[]; @@ -40,116 +242,27 @@ export default class ChatContextMenu { private isTextSelected: boolean; private isAnchorTarget: boolean; private isUsernameTarget: boolean; + private isSponsored: boolean; + private isOverBubble: boolean; private peerId: PeerId; private mid: number; private message: Message.message | Message.messageService; private noForwards: boolean; - constructor(private attachTo: HTMLElement, + private reactionsMenu: ChatReactionsMenu; + private listenerSetter: ListenerSetter; + + constructor( + private attachTo: HTMLElement, private chat: Chat, private appMessagesManager: AppMessagesManager, private appPeersManager: AppPeersManager, private appPollsManager: AppPollsManager, private appDocsManager: AppDocsManager, - private appMessagesIdsManager: AppMessagesIdsManager + private appMessagesIdsManager: AppMessagesIdsManager, + private appReactionsManager: AppReactionsManager ) { - const onContextMenu = (e: MouseEvent | Touch | TouchEvent) => { - if(this.init) { - this.init(); - this.init = null; - } - - let bubble: HTMLElement, contentWrapper: HTMLElement; - - try { - contentWrapper = findUpClassName(e.target, 'bubble-content-wrapper'); - bubble = contentWrapper ? contentWrapper.parentElement : findUpClassName(e.target, 'bubble'); - } catch(e) {} - - // ! context menu click by date bubble (there is no pointer-events) - if(!bubble || bubble.classList.contains('bubble-first')) return; - - if(e instanceof MouseEvent || e.hasOwnProperty('preventDefault')) (e as any).preventDefault(); - if(this.element.classList.contains('active')) { - return false; - } - if(e instanceof MouseEvent || e.hasOwnProperty('cancelBubble')) (e as any).cancelBubble = true; - - let mid = +bubble.dataset.mid; - if(!mid) return; - - const isSponsored = mid < 0; - this.isSelectable = this.chat.selection.canSelectBubble(bubble); - this.peerId = this.chat.peerId; - //this.msgID = msgID; - this.target = e.target as HTMLElement; - this.isTextSelected = !isSelectionEmpty(); - this.isAnchorTarget = this.target.tagName === 'A' && ( - (this.target as HTMLAnchorElement).target === '_blank' || - this.target.classList.contains('anchor-url') - ); - this.isUsernameTarget = this.target.tagName === 'A' && this.target.classList.contains('mention'); - - // * если открыть контекстное меню для альбома не по бабблу, и последний элемент не выбран, чтобы показать остальные пункты - if(chat.selection.isSelecting && !contentWrapper) { - if(isSponsored) { - return; - } - - const mids = this.chat.getMidsByMid(mid); - if(mids.length > 1) { - const selectedMid = this.chat.selection.isMidSelected(this.peerId, mid) ? - mid : - mids.find(mid => this.chat.selection.isMidSelected(this.peerId, mid)); - if(selectedMid) { - mid = selectedMid; - } - } - } - - const groupedItem = findUpClassName(this.target, 'grouped-item'); - this.isTargetAGroupedItem = !!groupedItem; - if(groupedItem) { - this.mid = +groupedItem.dataset.mid; - } else { - this.mid = mid; - } - - this.isSelected = this.chat.selection.isMidSelected(this.peerId, this.mid); - this.message = this.chat.getMessage(this.mid); - if(isSponsored) { - this.buttons.forEach(button => { - button.element.classList.toggle('hide', !button.isSponsored); - }); - } else { - this.noForwards = !this.appMessagesManager.canForward(this.message); - - this.buttons.forEach(button => { - let good: boolean; - - //if((appImManager.chatSelection.isSelecting && !button.withSelection) || (button.withSelection && !appImManager.chatSelection.isSelecting)) { - if(chat.selection.isSelecting && !button.withSelection) { - good = false; - } else { - good = contentWrapper || IS_TOUCH_SUPPORTED || true ? - button.verify() : - button.notDirect && button.verify() && button.notDirect(); - } - - button.element.classList.toggle('hide', !good); - }); - } - - const side: 'left' | 'right' = bubble.classList.contains('is-in') ? 'left' : 'right'; - //bubble.parentElement.append(this.element); - //appImManager.log('contextmenu', e, bubble, side); - positionMenu((e as TouchEvent).touches ? (e as TouchEvent).touches[0] : e as MouseEvent, this.element, side); - openBtnMenu(this.element, () => { - this.mid = 0; - this.peerId = undefined; - this.target = null; - }); - }; + this.listenerSetter = new ListenerSetter(); if(IS_TOUCH_SUPPORTED/* && false */) { attachClickEvent(attachTo, (e) => { @@ -167,13 +280,129 @@ export default class ChatContextMenu { cancelEvent(e); //onContextMenu((e as TouchEvent).changedTouches[0]); // onContextMenu((e as TouchEvent).changedTouches ? (e as TouchEvent).changedTouches[0] : e as MouseEvent); - onContextMenu(e); + this.onContextMenu(e); } }, {listenerSetter: this.chat.bubbles.listenerSetter}); - } else attachContextMenuListener(attachTo, onContextMenu, this.chat.bubbles.listenerSetter); + } else attachContextMenuListener(attachTo, this.onContextMenu, this.chat.bubbles.listenerSetter); } - private init() { + private onContextMenu = (e: MouseEvent | Touch | TouchEvent) => { + let bubble: HTMLElement, contentWrapper: HTMLElement; + + try { + contentWrapper = findUpClassName(e.target, 'bubble-content-wrapper'); + bubble = contentWrapper ? contentWrapper.parentElement : findUpClassName(e.target, 'bubble'); + } catch(e) {} + + // ! context menu click by date bubble (there is no pointer-events) + if(!bubble || bubble.classList.contains('bubble-first')) return; + + let element = this.element; + if(e instanceof MouseEvent || e.hasOwnProperty('preventDefault')) (e as any).preventDefault(); + if(element && element.classList.contains('active')) { + return false; + } + if(e instanceof MouseEvent || e.hasOwnProperty('cancelBubble')) (e as any).cancelBubble = true; + + let mid = +bubble.dataset.mid; + if(!mid) return; + + const isSponsored = this.isSponsored = mid < 0; + this.isSelectable = this.chat.selection.canSelectBubble(bubble); + this.peerId = this.chat.peerId; + //this.msgID = msgID; + this.target = e.target as HTMLElement; + this.isTextSelected = !isSelectionEmpty(); + this.isAnchorTarget = this.target.tagName === 'A' && ( + (this.target as HTMLAnchorElement).target === '_blank' || + this.target.classList.contains('anchor-url') + ); + this.isUsernameTarget = this.target.tagName === 'A' && this.target.classList.contains('mention'); + + // * если открыть контекстное меню для альбома не по бабблу, и последний элемент не выбран, чтобы показать остальные пункты + if(this.chat.selection.isSelecting && !contentWrapper) { + if(isSponsored) { + return; + } + + const mids = this.chat.getMidsByMid(mid); + if(mids.length > 1) { + const selectedMid = this.chat.selection.isMidSelected(this.peerId, mid) ? + mid : + mids.find(mid => this.chat.selection.isMidSelected(this.peerId, mid)); + if(selectedMid) { + mid = selectedMid; + } + } + } + + this.isOverBubble = !!contentWrapper; + + const groupedItem = findUpClassName(this.target, 'grouped-item'); + this.isTargetAGroupedItem = !!groupedItem; + if(groupedItem) { + this.mid = +groupedItem.dataset.mid; + } else { + this.mid = mid; + } + + this.isSelected = this.chat.selection.isMidSelected(this.peerId, this.mid); + this.message = this.chat.getMessage(this.mid); + this.noForwards = !isSponsored && !this.appMessagesManager.canForward(this.message); + + const initResult = this.init(); + element = initResult.element; + const {cleanup, destroy} = initResult; + + const side: 'left' | 'right' = bubble.classList.contains('is-in') ? 'left' : 'right'; + //bubble.parentElement.append(element); + //appImManager.log('contextmenu', e, bubble, side); + positionMenu((e as TouchEvent).touches ? (e as TouchEvent).touches[0] : e as MouseEvent, element, side); + openBtnMenu(element, () => { + this.mid = 0; + this.peerId = undefined; + this.target = null; + cleanup(); + + setTimeout(() => { + destroy(); + }, 300); + }); + }; + + public cleanup() { + this.listenerSetter.removeAll(); + this.reactionsMenu && this.reactionsMenu.cleanup(); + } + + public destroy() { + this.cleanup(); + } + + private filterButtons(buttons: ChatContextMenu['buttons']) { + if(this.isSponsored) { + return buttons.filter(button => { + return button.isSponsored; + }); + } else { + return buttons.filter(button => { + let good: boolean; + + //if((appImManager.chatSelection.isSelecting && !button.withSelection) || (button.withSelection && !appImManager.chatSelection.isSelecting)) { + if(this.chat.selection.isSelecting && !button.withSelection) { + good = false; + } else { + good = this.isOverBubble || IS_TOUCH_SUPPORTED || true ? + button.verify() : + button.notDirect && button.verify() && button.notDirect(); + } + + return good; + }); + } + } + + private setButtons() { this.buttons = [{ icon: 'send2', text: 'MessageScheduleSend', @@ -382,11 +611,32 @@ export default class ChatContextMenu { verify: () => false, isSponsored: true }]; + } - this.element = ButtonMenu(this.buttons, this.chat.bubbles.listenerSetter); - this.element.id = 'bubble-contextmenu'; - this.element.classList.add('contextmenu'); - this.chat.container.append(this.element); + private init() { + this.cleanup(); + this.setButtons(); + + const filteredButtons = this.filterButtons(this.buttons); + const element = this.element = ButtonMenu(filteredButtons, this.listenerSetter); + element.id = 'bubble-contextmenu'; + element.classList.add('contextmenu'); + + const reactionsMenu = this.reactionsMenu = new ChatReactionsMenu(this.appReactionsManager); + element.prepend(reactionsMenu.container); + + this.chat.container.append(element); + + return { + element, + cleanup: () => { + this.cleanup(); + reactionsMenu.cleanup(); + }, + destroy: () => { + element.remove(); + } + }; } private onSendScheduledClick = () => { diff --git a/src/components/scrollable.ts b/src/components/scrollable.ts index 2ef52c5b..551cfbd7 100644 --- a/src/components/scrollable.ts +++ b/src/components/scrollable.ts @@ -55,12 +55,25 @@ const scrollsIntersector = new IntersectionObserver(entries => { export class ScrollableBase { protected log: ReturnType; + public splitUp: HTMLElement; public onScrollMeasure: number = 0; - protected onScroll: () => void; + + public lastScrollPosition: number = 0; + public lastScrollDirection: number = 0; + + public onAdditionalScroll: () => void; + public onScrolledTop: () => void; + public onScrolledBottom: () => void; public isHeavyAnimationInProgress = false; protected needCheckAfterAnimation = false; + public checkForTriggers?: () => void; + + public scrollProperty: 'scrollTop' | 'scrollLeft'; + + private removeHeavyAnimationListener: () => void; + constructor(public el: HTMLElement, logPrefix = '', public container: HTMLElement = document.createElement('div')) { this.container.classList.add('scrollable'); @@ -74,11 +87,15 @@ export class ScrollableBase { //this.onScroll(); } - protected setListeners() { + public setListeners() { + if(this.removeHeavyAnimationListener) { + return; + } + window.addEventListener('resize', this.onScroll, {passive: true}); this.container.addEventListener('scroll', this.onScroll, {passive: true, capture: true}); - useHeavyAnimationCheck(() => { + this.removeHeavyAnimationListener = useHeavyAnimationCheck(() => { this.isHeavyAnimationInProgress = true; if(this.onScrollMeasure) { @@ -95,6 +112,17 @@ export class ScrollableBase { }); } + public removeListeners() { + if(!this.removeHeavyAnimationListener) { + return; + } + + window.removeEventListener('resize', this.onScroll); + this.container.removeEventListener('scroll', this.onScroll, {capture: true}); + + this.removeHeavyAnimationListener(); + } + public append(element: HTMLElement) { this.container.append(element); } @@ -106,42 +134,6 @@ export class ScrollableBase { container: this.container }); } -} - -export type SliceSides = 'top' | 'bottom'; -export type SliceSidesContainer = {[k in SliceSides]: boolean}; - -export default class Scrollable extends ScrollableBase { - public splitUp: HTMLElement; - public padding: HTMLElement; - - public onAdditionalScroll: () => void; - public onScrolledTop: () => void; - public onScrolledBottom: () => void; - - public lastScrollTop: number = 0; - public lastScrollDirection: number = 0; - - public loadedAll: SliceSidesContainer = {top: true, bottom: false}; - - constructor(el: HTMLElement, logPrefix = '', public onScrollOffset = 300, withPaddingContainer?: boolean) { - super(el, logPrefix); - - /* if(withPaddingContainer) { - this.padding = document.createElement('div'); - this.padding.classList.add('scrollable-padding'); - Array.from(this.container.children).forEach(c => this.padding.append(c)); - this.container.append(this.padding); - } */ - - this.container.classList.add('scrollable-y'); - this.setListeners(); - } - - public setVirtualContainer(el?: HTMLElement) { - this.splitUp = el; - this.log('setVirtualContainer:', el, this); - } public onScroll = () => { //if(this.debug) { @@ -165,9 +157,9 @@ export default class Scrollable extends ScrollableBase { this.onScrollMeasure = window.requestAnimationFrame(() => { this.onScrollMeasure = 0; - const scrollTop = this.container.scrollTop; - this.lastScrollDirection = this.lastScrollTop === scrollTop ? 0 : (this.lastScrollTop < scrollTop ? 1 : -1); // * 1 - bottom, -1 - top - this.lastScrollTop = scrollTop; + const scrollPosition = this.container[this.scrollProperty]; + this.lastScrollDirection = this.lastScrollPosition === scrollPosition ? 0 : (this.lastScrollPosition < scrollPosition ? 1 : -1); // * 1 - bottom, -1 - top + this.lastScrollPosition = scrollPosition; if(this.onAdditionalScroll && this.lastScrollDirection !== 0) { this.onAdditionalScroll(); @@ -178,6 +170,35 @@ export default class Scrollable extends ScrollableBase { } }); }; +} + +export type SliceSides = 'top' | 'bottom'; +export type SliceSidesContainer = {[k in SliceSides]: boolean}; + +export default class Scrollable extends ScrollableBase { + public padding: HTMLElement; + + public loadedAll: SliceSidesContainer = {top: true, bottom: false}; + + constructor(el: HTMLElement, logPrefix = '', public onScrollOffset = 300, withPaddingContainer?: boolean) { + super(el, logPrefix); + + /* if(withPaddingContainer) { + this.padding = document.createElement('div'); + this.padding.classList.add('scrollable-padding'); + Array.from(this.container.children).forEach(c => this.padding.append(c)); + this.container.append(this.padding); + } */ + + this.container.classList.add('scrollable-y'); + this.setListeners(); + this.scrollProperty = 'scrollTop'; + } + + public setVirtualContainer(el?: HTMLElement) { + this.splitUp = el; + this.log('setVirtualContainer:', el, this); + } public checkForTriggers = () => { if((!this.onScrolledTop && !this.onScrolledBottom)) return; @@ -194,7 +215,7 @@ export default class Scrollable extends ScrollableBase { const clientHeight = this.container.clientHeight; const maxScrollTop = scrollHeight - clientHeight; - const scrollTop = this.lastScrollTop; + const scrollTop = this.lastScrollPosition; //this.log('checkForTriggers:', scrollTop, maxScrollTop); @@ -253,5 +274,7 @@ export class ScrollableX extends ScrollableBase { this.container.addEventListener('wheel', scrollHorizontally, {passive: false}); } + + this.scrollProperty = 'scrollLeft'; } } diff --git a/src/components/sidebarLeft/tabs/generalSettings.ts b/src/components/sidebarLeft/tabs/generalSettings.ts index 44abd378..2266b78b 100644 --- a/src/components/sidebarLeft/tabs/generalSettings.ts +++ b/src/components/sidebarLeft/tabs/generalSettings.ts @@ -20,12 +20,13 @@ import appStickersManager from "../../../lib/appManagers/appStickersManager"; import assumeType from "../../../helpers/assumeType"; import { MessagesAllStickers, StickerSet } from "../../../layer"; import RichTextProcessor from "../../../lib/richtextprocessor"; -import { wrapStickerSetThumb } from "../../wrappers"; +import { wrapSticker, wrapStickerSetThumb } from "../../wrappers"; import LazyLoadQueue from "../../lazyLoadQueue"; import PopupStickers from "../../popups/stickers"; import eachMinute from "../../../helpers/eachMinute"; import { SliderSuperTabEventable } from "../../sliderTab"; import IS_GEOLOCATION_SUPPORTED from "../../../environment/geolocationSupport"; +import appReactionsManager from "../../../lib/appManagers/appReactionsManager"; export class RangeSettingSelector { public container: HTMLDivElement; @@ -285,6 +286,28 @@ export default class AppGeneralSettingsTab extends SliderSuperTabEventable { { const container = section('Telegram.InstalledStickerPacksController'); + const reactionsRow = new Row({ + titleLangKey: 'Reactions', + havePadding: true, + clickable: () => { + + } + }); + + const quickReactionMediaDiv = document.createElement('div'); + quickReactionMediaDiv.classList.add('row-media', 'row-media-small'); + + appReactionsManager.getQuickReaction().then(reaction => { + wrapSticker({ + div: quickReactionMediaDiv, + doc: reaction.static_icon, + width: 32, + height: 32 + }); + }); + + reactionsRow.container.append(quickReactionMediaDiv); + const suggestCheckboxField = new CheckboxField({ text: 'Stickers.SuggestStickers', name: 'suggest', @@ -356,7 +379,7 @@ export default class AppGeneralSettingsTab extends SliderSuperTabEventable { } }); - container.append(suggestCheckboxField.label, loopCheckboxField.label); + container.append(reactionsRow.container, suggestCheckboxField.label, loopCheckboxField.label); } } diff --git a/src/components/wrappers.ts b/src/components/wrappers.ts index 0c5eaf89..920a9b13 100644 --- a/src/components/wrappers.ts +++ b/src/components/wrappers.ts @@ -1118,7 +1118,7 @@ export function renderImageWithFadeIn(container: HTMLElement, // }); // } -export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, onlyThumb, emoji, width, height, withThumb, loop, loadPromises, needFadeIn, needUpscale}: { +export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, onlyThumb, emoji, width, height, withThumb, loop, loadPromises, needFadeIn, needUpscale, skipRatio}: { doc: MyDocument, div: HTMLElement, middleware?: () => boolean, @@ -1133,7 +1133,8 @@ export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, o loop?: boolean, loadPromises?: Promise[], needFadeIn?: boolean, - needUpscale?: boolean + needUpscale?: boolean, + skipRatio?: number }): Promise { const stickerType = doc.sticker; @@ -1280,7 +1281,9 @@ export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, o .then(async(json) => { //console.timeEnd('download sticker' + doc.id); //console.log('loaded sticker:', doc, div/* , blob */); - if(middleware && !middleware()) return; + if(middleware && !middleware()) { + throw new Error('wrapSticker 2 middleware'); + } let animation = await LottieLoader.loadAnimationWorker({ container: div, @@ -1290,14 +1293,17 @@ export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, o width, height, name: 'doc' + doc.id, - needUpscale - }, group, toneIndex); + needUpscale, + skipRatio + }, group, toneIndex, middleware); //const deferred = deferredPromise(); animation.addEventListener('firstFrame', () => { const element = div.firstElementChild; - needFadeIn = (needFadeIn || !element || element.tagName === 'svg') && rootScope.settings.animationsEnabled; + if(needFadeIn !== false) { + needFadeIn = (needFadeIn || !element || element.tagName === 'svg') && rootScope.settings.animationsEnabled; + } const cb = () => { if(element && element !== animation.canvas) { @@ -1325,7 +1331,9 @@ export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, o }); } - appDocsManager.saveLottiePreview(doc, animation.canvas, toneIndex); + if(withThumb !== false) { + appDocsManager.saveLottiePreview(doc, animation.canvas, toneIndex); + } //deferred.resolve(); }, {once: true}); @@ -1511,7 +1519,9 @@ export function wrapSticker({doc, div, middleware, lazyLoadQueue, group, play, o } else if(stickerType === 1) { const image = new Image(); const thumbImage = div.firstElementChild !== image && div.firstElementChild; - needFadeIn = (needFadeIn || !downloaded || thumbImage) && rootScope.settings.animationsEnabled; + if(needFadeIn !== false) { + needFadeIn = (needFadeIn || !downloaded || thumbImage) && rootScope.settings.animationsEnabled; + } image.classList.add('media-sticker'); diff --git a/src/helpers/blob.ts b/src/helpers/blob.ts index 538be155..14117c4a 100644 --- a/src/helpers/blob.ts +++ b/src/helpers/blob.ts @@ -13,9 +13,13 @@ export function readBlobAs(blob: Blob, method: 'readAsText'): Promise; export function readBlobAs(blob: Blob, method: 'readAsDataURL'): Promise; export function readBlobAs(blob: Blob, method: 'readAsArrayBuffer'): Promise; export function readBlobAs(blob: Blob, method: 'readAsArrayBuffer' | 'readAsText' | 'readAsDataURL'): Promise { + // const perf = performance.now(); return new Promise((resolve) => { const reader = new FileReader(); - reader.addEventListener('loadend', (e) => resolve(e.target.result)); + reader.addEventListener('loadend', (e) => { + // console.log('readBlobAs time:', method, performance.now() - perf); + resolve(e.target.result); + }); reader[method](blob); }); } diff --git a/src/helpers/dom/getVisibleRect.ts b/src/helpers/dom/getVisibleRect.ts index fd2663a5..8c59a881 100644 --- a/src/helpers/dom/getVisibleRect.ts +++ b/src/helpers/dom/getVisibleRect.ts @@ -8,7 +8,7 @@ export default function getVisibleRect(element: HTMLElement, overflowElement: HT const rect = element.getBoundingClientRect(); const overflowRect = overflowElement.getBoundingClientRect(); - let {top: overflowTop, bottom: overflowBottom} = overflowRect; + let {top: overflowTop, right: overflowRight, bottom: overflowBottom, left: overflowLeft} = overflowRect; // * respect sticky headers if(lookForSticky) { @@ -21,8 +21,8 @@ export default function getVisibleRect(element: HTMLElement, overflowElement: HT if(rect.top >= overflowBottom || rect.bottom <= overflowTop - || rect.right <= overflowRect.left - || rect.left >= overflowRect.right) { + || rect.right <= overflowLeft + || rect.left >= overflowRight) { return null; } @@ -43,9 +43,9 @@ export default function getVisibleRect(element: HTMLElement, overflowElement: HT return { rect: { top: rect.top < overflowTop && overflowTop !== 0 ? (overflow.top = true, ++overflow.vertical, overflowTop) : rect.top, - right: 0, + right: rect.right > overflowRight && overflowRight !== windowWidth ? (overflow.right = true, ++overflow.horizontal, overflowRight) : rect.right, bottom: rect.bottom > overflowBottom && overflowBottom !== windowHeight ? (overflow.bottom = true, ++overflow.vertical, overflowBottom) : rect.bottom, - left: 0 + left: rect.left < overflowLeft && overflowLeft !== 0 ? (overflow.left = true, ++overflow.horizontal, overflowLeft) : rect.left }, overflow }; diff --git a/src/lang.ts b/src/lang.ts index c6a5144e..55d1567b 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -640,6 +640,8 @@ const lang = { "RequestToJoinGroupApproved": "Your request to join the group was approved", "RequestToJoinChannelApproved": "Your request to join the channel was approved", "Update": "UPDATE", + "Reactions": "Reactions", + "DoubleTapSetting": "Quick Reaction", // * macos "AccountSettings.Filters": "Chat Folders", diff --git a/src/layer.d.ts b/src/layer.d.ts index e5a3ae78..ff1407cd 100644 --- a/src/layer.d.ts +++ b/src/layer.d.ts @@ -866,7 +866,6 @@ export namespace Message { replies?: MessageReplies, edit_date?: number, post_author?: string, - grouped_id?: string, reactions?: MessageReactions, restriction_reason?: Array, ttl_period?: number, @@ -875,6 +874,7 @@ export namespace Message { peerId?: PeerId, fromId?: PeerId, fwdFromId?: PeerId, + grouped_id?: string, random_id?: string, rReply?: string, viaBotId?: PeerId, @@ -3224,10 +3224,10 @@ export namespace Document { date: number, mime_type: string, size: number, - thumbs?: Array, video_thumbs?: Array, dc_id: number, attributes: Array, + thumbs?: Array, type?: 'gif' | 'sticker' | 'audio' | 'voice' | 'video' | 'round' | 'photo' | 'pdf', h?: number, w?: number, @@ -9355,13 +9355,13 @@ export namespace AvailableReaction { }>, reaction: string, title: string, - static_icon: Document, - appear_animation: Document, - select_animation: Document, - activate_animation: Document, - effect_animation: Document, - around_animation?: Document, - center_icon?: Document + static_icon: Document.document, + appear_animation: Document.document, + select_animation: Document.document, + activate_animation: Document.document, + effect_animation: Document.document, + around_animation: Document.document, + center_icon: Document.document }; } diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 62cc884b..075f0cf3 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -83,6 +83,7 @@ import { Modify, SendMessageEmojiInteractionData } from '../../types'; import htmlToSpan from '../../helpers/dom/htmlToSpan'; import getVisibleRect from '../../helpers/dom/getVisibleRect'; import { simulateClickEvent } from '../../helpers/dom/clickEvent'; +import appReactionsManager from './appReactionsManager'; //console.log('appImManager included33!'); @@ -1427,7 +1428,8 @@ export class AppImManager { appNotificationsManager, appEmojiManager, appMessagesIdsManager, - appGroupCallsManager + appGroupCallsManager, + appReactionsManager ); if(this.chats.length) { diff --git a/src/lib/appManagers/appReactionsManager.ts b/src/lib/appManagers/appReactionsManager.ts new file mode 100644 index 00000000..e1e7d5ae --- /dev/null +++ b/src/lib/appManagers/appReactionsManager.ts @@ -0,0 +1,77 @@ +/* + * https://github.com/morethanwords/tweb + * Copyright (C) 2019-2021 Eduard Kuzmenko + * https://github.com/morethanwords/tweb/blob/master/LICENSE + */ + +import { MOUNT_CLASS_TO } from "../../config/debug"; +import assumeType from "../../helpers/assumeType"; +import { AvailableReaction, MessagesAvailableReactions } from "../../layer"; +import apiManager from "../mtproto/mtprotoworker"; +import { ReferenceContext } from "../mtproto/referenceDatabase"; +import rootScope from "../rootScope"; +import appDocsManager from "./appDocsManager"; + +const SAVE_DOC_KEYS = [ + 'static_icon' as const, + 'appear_animation' as const, + 'select_animation' as const, + 'activate_animation' as const, + 'effect_animation' as const, + 'around_animation' as const, + 'center_icon' as const +]; + +const REFERENCE_CONTEXXT: ReferenceContext = { + type: 'reactions' +}; + +export class AppReactionsManager { + private availableReactions: AvailableReaction[]; + + constructor() { + rootScope.addEventListener('language_change', () => { + this.availableReactions = undefined; + this.getAvailableReactions(); + }); + } + + public getAvailableReactions() { + if(this.availableReactions) return Promise.resolve(this.availableReactions); + return apiManager.invokeApiSingleProcess({ + method: 'messages.getAvailableReactions', + processResult: (messagesAvailableReactions) => { + assumeType(messagesAvailableReactions); + + const availableReactions = this.availableReactions = messagesAvailableReactions.reactions; + for(const reaction of availableReactions) { + for(const key of SAVE_DOC_KEYS) { + if(!reaction[key]) { + continue; + } + + reaction[key] = appDocsManager.saveDoc(reaction[key], REFERENCE_CONTEXXT); + } + } + + return availableReactions; + }, + params: { + hash: 0 + } + }); + } + + public getQuickReaction() { + return Promise.all([ + apiManager.getAppConfig(), + this.getAvailableReactions() + ]).then(([appConfig, availableReactions]) => { + return availableReactions.find(reaction => reaction.reaction === appConfig.reactions_default); + }); + } +} + +const appReactionsManager = new AppReactionsManager(); +MOUNT_CLASS_TO && (MOUNT_CLASS_TO.appReactionsManager = appReactionsManager); +export default appReactionsManager; diff --git a/src/lib/mtproto/referenceDatabase.ts b/src/lib/mtproto/referenceDatabase.ts index df3d7b99..7c2ee9cc 100644 --- a/src/lib/mtproto/referenceDatabase.ts +++ b/src/lib/mtproto/referenceDatabase.ts @@ -15,7 +15,7 @@ import apiManager from "./mtprotoworker"; import assumeType from "../../helpers/assumeType"; import { logger } from "../logger"; -export type ReferenceContext = ReferenceContext.referenceContextProfilePhoto | ReferenceContext.referenceContextMessage | ReferenceContext.referenceContextEmojiesSounds; +export type ReferenceContext = ReferenceContext.referenceContextProfilePhoto | ReferenceContext.referenceContextMessage | ReferenceContext.referenceContextEmojiesSounds | ReferenceContext.referenceContextReactions; export namespace ReferenceContext { export type referenceContextProfilePhoto = { type: 'profilePhoto', @@ -31,6 +31,10 @@ export namespace ReferenceContext { export type referenceContextEmojiesSounds = { type: 'emojiesSounds' }; + + export type referenceContextReactions = { + type: 'reactions' + }; } export type ReferenceBytes = Photo.photo['file_reference']; diff --git a/src/lib/rlottie/lottieLoader.ts b/src/lib/rlottie/lottieLoader.ts index 912f2d4d..a56dfe71 100644 --- a/src/lib/rlottie/lottieLoader.ts +++ b/src/lib/rlottie/lottieLoader.ts @@ -130,7 +130,7 @@ export class LottieLoader { ]).then(() => player); } - public async loadAnimationWorker(params: RLottieOptions, group = params.group || '', toneIndex = -1): Promise { + public async loadAnimationWorker(params: RLottieOptions, group = params.group || '', toneIndex = -1, middleware?: () => boolean): Promise { if(!this.isWebAssemblySupported) { return this.loadPromise as any; } @@ -150,6 +150,10 @@ export class LottieLoader { await this.loadLottieWorkers(); } + if(middleware && !middleware()) { + throw new Error('middleware'); + } + if(!params.width || !params.height) { params.width = parseInt(params.container.style.width); params.height = parseInt(params.container.style.height); diff --git a/src/lib/rlottie/rlottiePlayer.ts b/src/lib/rlottie/rlottiePlayer.ts index dd418648..3a9cac88 100644 --- a/src/lib/rlottie/rlottiePlayer.ts +++ b/src/lib/rlottie/rlottiePlayer.ts @@ -342,6 +342,7 @@ export default class RLottiePlayer extends EventListenerBase<{ this.pause(); this.sendQuery('destroy'); if(this.cacheName) cache.releaseCache(this.cacheName); + this.cleanup(); //this.removed = true; } diff --git a/src/scripts/generate_mtproto_types.js b/src/scripts/generate_mtproto_types.js index d3864fc6..c4dd58d8 100644 --- a/src/scripts/generate_mtproto_types.js +++ b/src/scripts/generate_mtproto_types.js @@ -12,23 +12,37 @@ const replace = require(__dirname + '/in/schema_replace_types.json'); const mtproto = schema.API; for(const constructor of additional) { - constructor.params.forEach(param => { + const additionalParams = constructor.params || (constructor.params = []); + additionalParams.forEach(param => { param.type = 'flags.-1?' + param.type; }); + if(constructor.properties) { + additionalParams.push(...constructor.properties); + } + if(constructor.type) { mtproto.constructors.push(constructor); } const realConstructor = constructor.type ? constructor : mtproto.constructors.find(c => c.predicate == constructor.predicate); + if(!constructor.type) { + for(let i = realConstructor.params.length - 1; i >= 0; --i) { + const param = realConstructor.params[i]; + if(additionalParams.find(newParam => newParam.name === param.name)) { + realConstructor.params.splice(i, 1); + } + } + } + /* constructor.params.forEach(param => { const index = realConstructor.params.findIndex(_param => _param.predicate == param.predicate); if(index !== -1) { realConstructor.params.splice(index, 1); } }); */ - realConstructor.params.splice(realConstructor.params.length, 0, ...constructor.params); + realConstructor.params.splice(realConstructor.params.length, 0, ...additionalParams); } ['Vector t', 'Bool', 'True', 'Null'].forEach(key => { @@ -116,7 +130,7 @@ const processParamType = (type, parseBooleanFlags, overrideTypes) => { default: //console.log('no such type', type); //throw new Error('no such type: ' + type); - return isAdditional ? type : camelizeName(type, true); + return isAdditional || type[0] === type[0].toUpperCase() ? type : camelizeName(type, true); } }; diff --git a/src/scripts/in/schema_additional_params.json b/src/scripts/in/schema_additional_params.json index e01e4cd2..67030fb2 100644 --- a/src/scripts/in/schema_additional_params.json +++ b/src/scripts/in/schema_additional_params.json @@ -346,4 +346,15 @@ {"name": "action", "type": "MessageAction.messageActionPhoneCall"} ], "type": "MessageMedia" +}, { + "predicate": "availableReaction", + "properties": [ + {"name": "static_icon", "type": "Document.document"}, + {"name": "appear_animation", "type": "Document.document"}, + {"name": "select_animation", "type": "Document.document"}, + {"name": "activate_animation", "type": "Document.document"}, + {"name": "effect_animation", "type": "Document.document"}, + {"name": "around_animation", "type": "Document.document"}, + {"name": "center_icon", "type": "Document.document"} + ] }] \ No newline at end of file diff --git a/src/scss/partials/_button.scss b/src/scss/partials/_button.scss index 8a3e33c7..75d9bc28 100644 --- a/src/scss/partials/_button.scss +++ b/src/scss/partials/_button.scss @@ -96,7 +96,6 @@ visibility: hidden; position: absolute; background-color: var(--surface-color); - box-shadow: 0px 2px 8px 1px rgba(0, 0, 0, .24); z-index: 3; top: 100%; padding: .5rem 0; @@ -106,6 +105,11 @@ transition: opacity var(--btn-menu-transition), transform var(--btn-menu-transition), visibility var(--btn-menu-transition); font-size: 16px; + &, + &-reactions { + box-shadow: 0px 2px 8px 1px rgba(0, 0, 0, .24); + } + body.animation-level-0 & { transition: none; } @@ -261,6 +265,79 @@ padding: 0; margin: .5rem 0; } + + &-reactions { + --height: 2.5rem; + height: var(--height); + border-radius: 1.25rem; + background-color: var(--surface-color); + position: absolute; + top: calc((var(--height) + .5rem) * -1); + max-width: 100%; + + &:after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: " "; + pointer-events: none; + border-radius: inherit; + background: linear-gradient(90deg, var(--surface-color) 0%, transparent 1rem, transparent calc(100% - 1rem), var(--surface-color) 100%); + } + + .scrollable-x { + position: relative; + display: flex; + align-items: center; + padding: 0 .25rem; + border-radius: inherit; + } + + &-reaction { + width: 2rem; + height: 1.5rem; + flex: 0 0 auto; + padding: 0 .25rem; + cursor: pointer; + + &-scale { + width: 100%; + height: 100%; + transform: scale(1); + + @include animation-level(2) { + transition: transform .1s linear; + } + } + + &-select { + html.no-touch & { + transform: scale(1); + } + + html.no-touch body.animation-level-2 & { + transition: transform var(--transition-standard-in); + } + + @include hover() { + transform: scale(1.25); + } + } + + .media-sticker-wrapper { + position: relative; + width: 100%; + height: 100%; + /* position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; */ + } + } + } } .btn-primary { diff --git a/src/scss/partials/_row.scss b/src/scss/partials/_row.scss index 921416af..57fbbda7 100644 --- a/src/scss/partials/_row.scss +++ b/src/scss/partials/_row.scss @@ -114,6 +114,12 @@ position: absolute !important; margin: 0 !important; left: .5rem; + + &-small { + width: 32px !important; + height: 32px !important; + left: .75rem !important; + } } &.menu-open { diff --git a/tweb-design b/tweb-design index d10c87ef..2c4d0858 160000 --- a/tweb-design +++ b/tweb-design @@ -1 +1 @@ -Subproject commit d10c87ef1aec54cdd2e506fcd980ea848e60eedc +Subproject commit 2c4d08587b77a388d4beb8bc018dcb56ebd8a589