From 22054c441663da754101cf6662a647a94909c36c Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sun, 23 Apr 2023 18:11:28 +0400 Subject: [PATCH] Introduce Reaction Picker --- src/api/gramjs/apiBuilders/messages.ts | 17 +- src/api/gramjs/methods/index.ts | 4 +- src/api/gramjs/methods/reactions.ts | 64 ++++- src/api/gramjs/updater.ts | 2 + src/api/types/messages.ts | 1 + src/api/types/updates.ts | 7 +- src/assets/reaction-thumbs.png | Bin 75564 -> 0 bytes src/bundles/extra.ts | 1 + src/components/common/CustomEmoji.tsx | 3 + .../common/CustomEmojiPicker.module.scss | 5 + .../composer => common}/CustomEmojiPicker.tsx | 261 ++++++++++++------ src/components/common/GifButton.tsx | 4 +- .../common/ReactionEmoji.module.scss | 18 ++ src/components/common/ReactionEmoji.tsx | 101 +++++++ src/components/common/StickerButton.scss | 6 + src/components/common/StickerButton.tsx | 8 +- .../composer => common}/StickerSet.tsx | 126 ++++++--- src/components/common/UiLoader.tsx | 2 - src/components/left/main/StatusPickerMenu.tsx | 19 +- src/components/main/Main.tsx | 34 ++- .../middle/composer/AttachBotIcon.tsx | 9 +- .../composer/ComposerEmbeddedMessage.tsx | 4 +- .../middle/composer/StickerPicker.scss | 4 - .../middle/composer/StickerPicker.tsx | 6 +- .../middle/composer/SymbolMenu.scss | 54 ++-- src/components/middle/composer/SymbolMenu.tsx | 10 +- .../middle/composer/SymbolMenuButton.tsx | 4 +- .../middle/message/ContextMenuContainer.tsx | 26 +- .../middle/message/MessageContextMenu.scss | 14 +- .../middle/message/MessageContextMenu.tsx | 73 +++-- .../middle/message/ReactionPicker.async.tsx | 21 ++ .../middle/message/ReactionPicker.module.scss | 49 ++++ .../middle/message/ReactionPicker.tsx | 191 +++++++++++++ .../message/ReactionPickerLimited.module.scss | 11 + .../middle/message/ReactionPickerLimited.tsx | 135 +++++++++ .../middle/message/ReactionSelector.scss | 127 +++++---- .../middle/message/ReactionSelector.tsx | 100 ++++--- .../message/ReactionSelectorReaction.scss | 19 +- .../message/ReactionSelectorReaction.tsx | 31 ++- src/components/right/CreateTopic.tsx | 2 +- src/components/right/EditTopic.tsx | 2 +- src/components/ui/ListItem.tsx | 4 +- src/components/ui/Menu.tsx | 16 +- src/components/ui/MenuItem.tsx | 5 +- src/config.ts | 10 + src/global/actions/all.ts | 1 + src/global/actions/api/reactions.ts | 90 +++++- src/global/actions/apiUpdaters/misc.ts | 4 + src/global/actions/ui/reactions.ts | 58 ++++ src/global/cache.ts | 2 + src/global/helpers/reactions.ts | 16 ++ src/global/initialState.ts | 2 + src/global/selectors/ui.ts | 7 + src/global/types.ts | 20 ++ src/hooks/useAppLayout.ts | 9 +- src/hooks/useBoundsInSharedCanvas.ts | 4 +- ...textMenuPosition.ts => useMenuPosition.ts} | 34 ++- src/lib/gramjs/tl/apiTl.js | 3 + src/lib/gramjs/tl/static/api.json | 3 + src/styles/_variables.scss | 1 + src/types/index.ts | 6 +- src/util/windowEnvironment.ts | 1 - 62 files changed, 1478 insertions(+), 393 deletions(-) delete mode 100644 src/assets/reaction-thumbs.png create mode 100644 src/components/common/CustomEmojiPicker.module.scss rename src/components/{middle/composer => common}/CustomEmojiPicker.tsx (53%) create mode 100644 src/components/common/ReactionEmoji.module.scss create mode 100644 src/components/common/ReactionEmoji.tsx rename src/components/{middle/composer => common}/StickerSet.tsx (69%) create mode 100644 src/components/middle/message/ReactionPicker.async.tsx create mode 100644 src/components/middle/message/ReactionPicker.module.scss create mode 100644 src/components/middle/message/ReactionPicker.tsx create mode 100644 src/components/middle/message/ReactionPickerLimited.module.scss create mode 100644 src/components/middle/message/ReactionPickerLimited.tsx create mode 100644 src/global/actions/ui/reactions.ts rename src/hooks/{useContextMenuPosition.ts => useMenuPosition.ts} (73%) diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 8da7648db..d339b59de 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -54,7 +54,9 @@ import { } from '../../../config'; import { pick } from '../../../util/iteratees'; import { buildStickerFromDocument } from './symbols'; -import { buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromStripped } from './common'; +import { + buildApiPhoto, buildApiPhotoSize, buildApiThumbnailFromPath, buildApiThumbnailFromStripped, +} from './common'; import { interpolateArray } from '../../../util/waveform'; import { buildPeer } from '../gramjsBuilders'; import { addPhotoToLocalDb, resolveMessageApiChatId, serializeBytes } from '../helpers'; @@ -313,13 +315,14 @@ export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | u export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction { const { - selectAnimation, staticIcon, reaction, title, + selectAnimation, staticIcon, reaction, title, appearAnimation, inactive, aroundAnimation, centerIcon, effectAnimation, activateAnimation, premium, } = availableReaction; return { selectAnimation: buildApiDocument(selectAnimation), + appearAnimation: buildApiDocument(appearAnimation), activateAnimation: buildApiDocument(activateAnimation), effectAnimation: buildApiDocument(effectAnimation), staticIcon: buildApiDocument(staticIcon), @@ -609,11 +612,17 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u id, size, mimeType, date, thumbs, attributes, } = document; - const thumbnail = thumbs && buildApiThumbnailFromStripped(thumbs); + const photoSize = thumbs && thumbs.find((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize); + let thumbnail = thumbs && buildApiThumbnailFromStripped(thumbs); + if (!thumbnail && thumbs && photoSize) { + const photoPath = thumbs.find((s: any): s is GramJs.PhotoPathSize => s instanceof GramJs.PhotoPathSize); + if (photoPath) { + thumbnail = buildApiThumbnailFromPath(photoPath, photoSize); + } + } let mediaType: ApiDocument['mediaType'] | undefined; let mediaSize: ApiDocument['mediaSize'] | undefined; - const photoSize = thumbs && thumbs.find((s: any): s is GramJs.PhotoSize => s instanceof GramJs.PhotoSize); if (photoSize) { mediaSize = { width: photoSize.w, diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 2e23ca586..7b127454b 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -87,8 +87,8 @@ export { } from './calls'; export { - getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList, - setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, + getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList, clearRecentReactions, + setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, fetchRecentReactions, fetchTopReactions, } from './reactions'; export { diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index 6db6382ad..a5ad54738 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -1,12 +1,15 @@ -import type { ApiChat, ApiReaction } from '../../types'; -import { invokeRequest } from './client'; +import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; + +import type { ApiChat, ApiReaction } from '../../types'; + +import { REACTION_LIST_LIMIT, RECENT_REACTIONS_LIMIT, TOP_REACTIONS_LIMIT } from '../../../config'; import { buildInputPeer, buildInputReaction } from '../gramjsBuilders'; -import localDb from '../localDb'; -import { buildApiAvailableReaction, buildMessagePeerReaction } from '../apiBuilders/messages'; -import { REACTION_LIST_LIMIT } from '../../../config'; -import { addEntitiesWithPhotosToLocalDb } from '../helpers'; import { buildApiUser } from '../apiBuilders/users'; +import { buildApiAvailableReaction, buildApiReaction, buildMessagePeerReaction } from '../apiBuilders/messages'; +import { invokeRequest } from './client'; +import localDb from '../localDb'; +import { addEntitiesWithPhotosToLocalDb } from '../helpers'; export function sendWatchingEmojiInteraction({ chat, @@ -65,6 +68,9 @@ export async function getAvailableReactions() { if (reaction.aroundAnimation instanceof GramJs.Document) { localDb.documents[String(reaction.aroundAnimation.id)] = reaction.aroundAnimation; } + if (reaction.appearAnimation instanceof GramJs.Document) { + localDb.documents[String(reaction.appearAnimation.id)] = reaction.appearAnimation; + } if (reaction.centerIcon instanceof GramJs.Document) { localDb.documents[String(reaction.centerIcon.id)] = reaction.centerIcon; } @@ -74,15 +80,19 @@ export async function getAvailableReactions() { } export function sendReaction({ - chat, messageId, reactions, + chat, messageId, reactions, shouldAddToRecent, }: { - chat: ApiChat; messageId: number; reactions?: ApiReaction[]; + chat: ApiChat; + messageId: number; + reactions?: ApiReaction[]; + shouldAddToRecent?: boolean; }) { return invokeRequest(new GramJs.messages.SendReaction({ reaction: reactions?.map((r) => buildInputReaction(r)), peer: buildInputPeer(chat.id, chat.accessHash), msgId: messageId, - }), true); + ...(shouldAddToRecent && { addToRecent: true }), + }), true, true); } export function fetchMessageReactions({ @@ -134,3 +144,39 @@ export function setDefaultReaction({ reaction: buildInputReaction(reaction), })); } + +export async function fetchTopReactions({ hash = '0' }: { hash?: string }) { + const result = await invokeRequest(new GramJs.messages.GetTopReactions({ + limit: TOP_REACTIONS_LIMIT, + hash: BigInt(hash), + })); + + if (!result || result instanceof GramJs.messages.ReactionsNotModified) { + return undefined; + } + + return { + hash: String(result.hash), + reactions: result.reactions.map(buildApiReaction).filter(Boolean), + }; +} + +export async function fetchRecentReactions({ hash = '0' }: { hash?: string }) { + const result = await invokeRequest(new GramJs.messages.GetRecentReactions({ + limit: RECENT_REACTIONS_LIMIT, + hash: BigInt(hash), + })); + + if (!result || result instanceof GramJs.messages.ReactionsNotModified) { + return undefined; + } + + return { + hash: String(result.hash), + reactions: result.reactions.map(buildApiReaction).filter(Boolean), + }; +} + +export function clearRecentReactions() { + return invokeRequest(new GramJs.messages.ClearRecentReactions()); +} diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 149e9115c..d0773c6c1 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -888,6 +888,8 @@ export function updater(update: Update) { onUpdate({ '@type': 'updateFavoriteStickers' }); } else if (update instanceof GramJs.UpdateRecentStickers) { onUpdate({ '@type': 'updateRecentStickers' }); + } else if (update instanceof GramJs.UpdateRecentReactions) { + onUpdate({ '@type': 'updateRecentReactions' }); } else if (update instanceof GramJs.UpdateMoveStickerSetToTop) { if (!update.masks) { onUpdate({ diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 2b428fcfb..c3b306bec 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -459,6 +459,7 @@ export interface ApiReactionCount { export interface ApiAvailableReaction { selectAnimation?: ApiDocument; + appearAnimation?: ApiDocument; activateAnimation?: ApiDocument; effectAnimation?: ApiDocument; staticIcon?: ApiDocument; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index bd1d14178..2d22fa195 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -400,6 +400,10 @@ export type ApiUpdateRecentStickers = { '@type': 'updateRecentStickers'; }; +export type ApiUpdateRecentReactions = { + '@type': 'updateRecentReactions'; +}; + export type ApiUpdateMoveStickerSetToTop = { '@type': 'updateMoveStickerSetToTop'; isCustomEmoji?: boolean; @@ -638,7 +642,8 @@ export type ApiUpdate = ( ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState | ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio | ApiUpdateUserEmojiStatus | ApiUpdateMessageExtendedMedia | ApiUpdateConfig | ApiUpdateTopicNotifyExceptions | ApiUpdatePinnedTopic | - ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses | ApiRequestInitApi + ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses | + ApiUpdateRecentReactions | ApiRequestInitApi ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/assets/reaction-thumbs.png b/src/assets/reaction-thumbs.png deleted file mode 100644 index a9d5191fc8edfdc6d00314244067b654148ccbca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75564 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�$`Lol8zut!tIye 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 @@ -48,6 +49,7 @@ const CustomEmoji: FC = ({ documentId, size = STICKER_SIZE, isBig, + noPlay, className, loopLimit, style, @@ -129,6 +131,7 @@ const CustomEmoji: FC = ({ sticker={customEmoji} isSmall={!isBig} size={size} + noPlay={noPlay} customColor={customColor} thumbClassName={styles.thumb} fullMediaClassName={styles.media} diff --git a/src/components/common/CustomEmojiPicker.module.scss b/src/components/common/CustomEmojiPicker.module.scss new file mode 100644 index 000000000..51745e872 --- /dev/null +++ b/src/components/common/CustomEmojiPicker.module.scss @@ -0,0 +1,5 @@ +.root { + --emoji-size: 2.5rem; + + max-height: calc(100 * var(--vh)); +} diff --git a/src/components/middle/composer/CustomEmojiPicker.tsx b/src/components/common/CustomEmojiPicker.tsx similarity index 53% rename from src/components/middle/composer/CustomEmojiPicker.tsx rename to src/components/common/CustomEmojiPicker.tsx index e9fe6b3ae..c5cf08a89 100644 --- a/src/components/middle/composer/CustomEmojiPicker.tsx +++ b/src/components/common/CustomEmojiPicker.tsx @@ -1,54 +1,67 @@ -import type { FC } from '../../../lib/teact/teact'; +import type { RefObject } from 'react'; import React, { useState, useEffect, memo, useRef, useMemo, useCallback, -} from '../../../lib/teact/teact'; -import { getGlobal, withGlobal } from '../../../global'; +} from '../../lib/teact/teact'; +import { getGlobal, withGlobal } from '../../global'; -import type { ApiStickerSet, ApiSticker } from '../../../api/types'; -import type { StickerSetOrRecent } from '../../../types'; +import type { FC } from '../../lib/teact/teact'; +import type { + ApiStickerSet, ApiSticker, ApiReaction, ApiAvailableReaction, +} from '../../api/types'; +import type { StickerSetOrReactionsSetOrRecent } from '../../types'; import { CHAT_STICKER_SET_ID, FAVORITE_SYMBOL_SET_ID, + POPULAR_SYMBOL_SET_ID, PREMIUM_STICKER_SET_ID, RECENT_SYMBOL_SET_ID, SLIDE_TRANSITION_DURATION, STICKER_PICKER_MAX_SHARED_COVERS, STICKER_SIZE_PICKER_HEADER, -} from '../../../config'; -import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; -import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; -import fastSmoothScroll from '../../../util/fastSmoothScroll'; -import buildClassName from '../../../util/buildClassName'; -import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal'; -import { pickTruthy, unique } from '../../../util/iteratees'; + TOP_SYMBOL_SET_ID, +} from '../../config'; +import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; +import { MEMO_EMPTY_ARRAY } from '../../util/memo'; +import fastSmoothScroll from '../../util/fastSmoothScroll'; +import buildClassName from '../../util/buildClassName'; +import fastSmoothScrollHorizontal from '../../util/fastSmoothScrollHorizontal'; +import { pickTruthy, unique } from '../../util/iteratees'; +import { isSameReaction } from '../../global/helpers'; import { selectIsAlwaysHighPriorityEmoji, selectIsChatWithSelf, selectIsCurrentUserPremium, -} from '../../../global/selectors'; +} from '../../global/selectors'; -import useAsyncRendering from '../../right/hooks/useAsyncRendering'; -import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; -import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; -import useLang from '../../../hooks/useLang'; +import useAsyncRendering from '../right/hooks/useAsyncRendering'; +import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'; +import useHorizontalScroll from '../../hooks/useHorizontalScroll'; +import useLang from '../../hooks/useLang'; +import useAppLayout from '../../hooks/useAppLayout'; -import Loading from '../../ui/Loading'; -import Button from '../../ui/Button'; -import StickerButton from '../../common/StickerButton'; +import Loading from '../ui/Loading'; +import Button from '../ui/Button'; +import StickerButton from './StickerButton'; import StickerSet from './StickerSet'; -import StickerSetCover from './StickerSetCover'; +import StickerSetCover from '../middle/composer/StickerSetCover'; -import './StickerPicker.scss'; +import '../middle/composer/StickerPicker.scss'; +import styles from './CustomEmojiPicker.module.scss'; type OwnProps = { + scrollContainerRef?: RefObject; + scrollHeaderRef?: RefObject; chatId?: string; className?: string; loadAndPlay: boolean; - isStatusPicker?: boolean; idPrefix?: string; withDefaultTopicIcons?: boolean; onCustomEmojiSelect: (sticker: ApiSticker) => void; + onReactionSelect?: (reaction: ApiReaction) => void; + selectedReactionIds?: string[]; + isStatusPicker?: boolean; + isReactionPicker?: boolean; onContextMenuOpen?: NoneToVoidFunction; onContextMenuClose?: NoneToVoidFunction; onContextMenuClick?: NoneToVoidFunction; @@ -58,7 +71,10 @@ type StateProps = { customEmojisById?: Record; recentCustomEmojiIds?: string[]; recentStatusEmojis?: ApiSticker[]; + topReactions?: ApiReaction[]; + recentReactions?: ApiReaction[]; stickerSetsById: Record; + availableReactions?: ApiAvailableReaction[]; addedCustomEmojiIds?: string[]; defaultTopicIconsId?: string; defaultStatusIconsId?: string; @@ -72,20 +88,37 @@ const SMOOTH_SCROLL_DISTANCE = 500; const HEADER_BUTTON_WIDTH = 52; // px (including margin) const STICKER_INTERSECTION_THROTTLE = 200; const DEFAULT_ID_PREFIX = 'custom-emoji-set'; +const TOP_REACTIONS_COUNT = 16; +const RECENT_REACTIONS_COUNT = 32; +const FADED_BUTTON_SET_IDS = new Set([RECENT_SYMBOL_SET_ID, FAVORITE_SYMBOL_SET_ID, POPULAR_SYMBOL_SET_ID]); +const STICKER_SET_IDS_WITH_COVER = new Set([ + RECENT_SYMBOL_SET_ID, + FAVORITE_SYMBOL_SET_ID, + POPULAR_SYMBOL_SET_ID, + CHAT_STICKER_SET_ID, + PREMIUM_STICKER_SET_ID, +]); const stickerSetIntersections: boolean[] = []; const CustomEmojiPicker: FC = ({ + scrollContainerRef, + scrollHeaderRef, className, loadAndPlay, addedCustomEmojiIds, customEmojisById, recentCustomEmojiIds, + selectedReactionIds, recentStatusEmojis, stickerSetsById, + topReactions, + recentReactions, + availableReactions, idPrefix = DEFAULT_ID_PREFIX, customEmojiFeaturedIds, canAnimate, + isReactionPicker, isStatusPicker, isSavedMessages, isCurrentUserPremium, @@ -93,20 +126,28 @@ const CustomEmojiPicker: FC = ({ defaultTopicIconsId, defaultStatusIconsId, onCustomEmojiSelect, + onReactionSelect, onContextMenuOpen, onContextMenuClose, onContextMenuClick, }) => { // eslint-disable-next-line no-null/no-null - const containerRef = useRef(null); + let containerRef = useRef(null); // eslint-disable-next-line no-null/no-null - const headerRef = useRef(null); + let headerRef = useRef(null); // eslint-disable-next-line no-null/no-null const sharedCanvasRef = useRef(null); // eslint-disable-next-line no-null/no-null const sharedCanvasHqRef = useRef(null); + if (scrollContainerRef) { + containerRef = scrollContainerRef; + } + if (scrollHeaderRef) { + headerRef = scrollHeaderRef; + } const [activeSetIndex, setActiveSetIndex] = useState(0); + const { isMobile } = useAppLayout(); const recentCustomEmojis = useMemo(() => { return isStatusPicker @@ -149,9 +190,43 @@ const CustomEmojiPicker: FC = ({ return MEMO_EMPTY_ARRAY; } - const defaultSets: StickerSetOrRecent[] = []; + const defaultSets: StickerSetOrReactionsSetOrRecent[] = []; - if (isStatusPicker) { + if (isReactionPicker) { + const topReactionsSlice = topReactions?.slice(0, TOP_REACTIONS_COUNT) || []; + if (topReactionsSlice?.length) { + defaultSets.push({ + id: TOP_SYMBOL_SET_ID, + accessHash: '', + title: lang('Reactions'), + reactions: topReactionsSlice, + count: topReactionsSlice.length, + isEmoji: true, + }); + } + + const cleanRecentReactions = (recentReactions || []) + .filter((reaction) => !topReactionsSlice.some((topReaction) => isSameReaction(topReaction, reaction))) + .slice(0, RECENT_REACTIONS_COUNT); + const cleanAvailableReactions = (availableReactions || []) + .map(({ reaction }) => reaction) + .filter((reaction) => { + return !topReactionsSlice.some((topReaction) => isSameReaction(topReaction, reaction)) + && !cleanRecentReactions.some((topReaction) => isSameReaction(topReaction, reaction)); + }); + if (cleanAvailableReactions?.length || cleanRecentReactions?.length) { + const isPopular = !cleanRecentReactions?.length; + const allRecentReactions = cleanRecentReactions.concat(cleanAvailableReactions); + defaultSets.push({ + id: isPopular ? POPULAR_SYMBOL_SET_ID : RECENT_SYMBOL_SET_ID, + accessHash: '', + title: lang(isPopular ? 'PopularReactions' : 'RecentStickers'), + reactions: allRecentReactions, + count: allRecentReactions.length, + isEmoji: true, + }); + } + } else if (isStatusPicker) { const defaultStatusIconsPack = stickerSetsById[defaultStatusIconsId!]; if (defaultStatusIconsPack.stickers?.length) { const stickers = (defaultStatusIconsPack.stickers || []).concat(recentCustomEmojis || []); @@ -179,7 +254,7 @@ const CustomEmojiPicker: FC = ({ title: lang('RecentStickers'), stickers: recentCustomEmojis, count: recentCustomEmojis.length, - isEmoji: true as true, + isEmoji: true, }); } @@ -192,8 +267,9 @@ const CustomEmojiPicker: FC = ({ ...setsToDisplay, ]; }, [ - addedCustomEmojiIds, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis, - customEmojiFeaturedIds, stickerSetsById, defaultStatusIconsId, lang, defaultTopicIconsId, + addedCustomEmojiIds, isReactionPicker, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis, + customEmojiFeaturedIds, stickerSetsById, topReactions, availableReactions, lang, recentReactions, + defaultStatusIconsId, defaultTopicIconsId, ]); const noPopulatedSets = useMemo(() => ( @@ -201,10 +277,10 @@ const CustomEmojiPicker: FC = ({ && allSets.filter((set) => set.stickers?.length).length === 0 ), [allSets, areAddedLoaded]); - const canRenderContents = useAsyncRendering([], SLIDE_TRANSITION_DURATION); - const shouldRenderContents = areAddedLoaded && canRenderContents && !noPopulatedSets; + const canRenderContent = useAsyncRendering([], SLIDE_TRANSITION_DURATION); + const shouldRenderContent = areAddedLoaded && canRenderContent && !noPopulatedSets; - useHorizontalScroll(headerRef, !shouldRenderContents); + useHorizontalScroll(headerRef, !(isMobile && shouldRenderContent)); // Scroll container and header when active set changes useEffect(() => { @@ -232,7 +308,11 @@ const CustomEmojiPicker: FC = ({ onCustomEmojiSelect(emoji); }, [onCustomEmojiSelect]); - function renderCover(stickerSet: StickerSetOrRecent, index: number) { + const handleReactionSelect = useCallback((reaction: ApiReaction) => { + onReactionSelect?.(reaction); + }, [onReactionSelect]); + + function renderCover(stickerSet: StickerSetOrReactionsSetOrRecent, index: number) { const firstSticker = stickerSet.stickers?.[0]; const buttonClassName = buildClassName( 'symbol-set-button sticker-set-button', @@ -242,25 +322,24 @@ const CustomEmojiPicker: FC = ({ const withSharedCanvas = index < STICKER_PICKER_MAX_SHARED_COVERS; const isHq = selectIsAlwaysHighPriorityEmoji(getGlobal(), stickerSet as ApiStickerSet); - if (stickerSet.id === RECENT_SYMBOL_SET_ID - || stickerSet.id === FAVORITE_SYMBOL_SET_ID - || stickerSet.id === CHAT_STICKER_SET_ID - || stickerSet.id === PREMIUM_STICKER_SET_ID - || stickerSet.hasThumbnail - || !firstSticker - ) { + if (stickerSet.id === TOP_SYMBOL_SET_ID) { + return undefined; + } + + if (STICKER_SET_IDS_WITH_COVER.has(stickerSet.id) || stickerSet.hasThumbnail || !firstSticker) { + const isFaded = FADED_BUTTON_SET_IDS.has(stickerSet.id); return ( ); - } else { - return ( - - ); } + + return ( + + ); } - const fullClassName = buildClassName('StickerPicker', 'CustomEmojiPicker', className); + const fullClassName = buildClassName('StickerPicker', styles.root, className); - if (!shouldRenderContents) { + if (!shouldRenderContent) { return (
{noPopulatedSets ? ( @@ -322,34 +401,43 @@ const CustomEmojiPicker: FC = ({ ref={containerRef} className={buildClassName('StickerPicker-main no-selection', IS_TOUCH_ENV ? 'no-scrollbar' : 'custom-scroll')} > - {allSets.map((stickerSet, i) => ( - = i - 1 && activeSetIndex <= i + 1} - isSavedMessages={isSavedMessages} - isStatusPicker={isStatusPicker} - shouldHideRecentHeader={withDefaultTopicIcons || isStatusPicker} - withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID} - withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID} - isCurrentUserPremium={isCurrentUserPremium} - onStickerSelect={handleEmojiSelect} - onContextMenuOpen={onContextMenuOpen} - onContextMenuClose={onContextMenuClose} - onContextMenuClick={onContextMenuClick} - /> - ))} + {allSets.map((stickerSet, i) => { + const shouldHideHeader = stickerSet.id === TOP_SYMBOL_SET_ID + || (stickerSet.id === RECENT_SYMBOL_SET_ID && (withDefaultTopicIcons || isStatusPicker)); + + return ( + = i - 1 && activeSetIndex <= i + 1} + isSavedMessages={isSavedMessages} + isStatusPicker={isStatusPicker} + isReactionPicker={isReactionPicker} + shouldHideHeader={shouldHideHeader} + withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID} + withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID} + isCurrentUserPremium={isCurrentUserPremium} + selectedReactionIds={selectedReactionIds} + availableReactions={availableReactions} + onReactionSelect={handleReactionSelect} + onStickerSelect={handleEmojiSelect} + onContextMenuOpen={onContextMenuOpen} + onContextMenuClose={onContextMenuClose} + onContextMenuClick={onContextMenuClick} + /> + ); + })}
); }; export default memo(withGlobal( - (global, { chatId, isStatusPicker }): StateProps => { + (global, { chatId, isStatusPicker, isReactionPicker }): StateProps => { const { stickers: { setsById: stickerSetsById, @@ -362,6 +450,8 @@ export default memo(withGlobal( }, }, recentCustomEmojis: recentCustomEmojiIds, + recentReactions, + topReactions, } = global; const isSavedMessages = Boolean(chatId && selectIsChatWithSelf(global, chatId)); @@ -378,6 +468,9 @@ export default memo(withGlobal( customEmojiFeaturedIds, defaultTopicIconsId: global.defaultTopicIconsId, defaultStatusIconsId: global.defaultStatusIconsId, + topReactions: isReactionPicker ? topReactions : undefined, + recentReactions: isReactionPicker ? recentReactions : undefined, + availableReactions: isReactionPicker ? global.availableReactions : undefined, }; }, )(CustomEmojiPicker)); diff --git a/src/components/common/GifButton.tsx b/src/components/common/GifButton.tsx index 9400705b9..ffed33d1d 100644 --- a/src/components/common/GifButton.tsx +++ b/src/components/common/GifButton.tsx @@ -16,7 +16,7 @@ import useMedia from '../../hooks/useMedia'; import useBuffering from '../../hooks/useBuffering'; import useCanvasBlur from '../../hooks/useCanvasBlur'; import useLang from '../../hooks/useLang'; -import useContextMenuPosition from '../../hooks/useContextMenuPosition'; +import useMenuPosition from '../../hooks/useMenuPosition'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import Spinner from '../ui/Spinner'; @@ -83,7 +83,7 @@ const GifButton: FC = ({ const { positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useContextMenuPosition( + } = useMenuPosition( contextMenuPosition, getTriggerElement, getRootElement, diff --git a/src/components/common/ReactionEmoji.module.scss b/src/components/common/ReactionEmoji.module.scss new file mode 100644 index 000000000..00ce1aa0f --- /dev/null +++ b/src/components/common/ReactionEmoji.module.scss @@ -0,0 +1,18 @@ +.root { + --custom-emoji-size: 2.5rem; + + cursor: pointer; + display: inline-block; + width: var(--custom-emoji-size); + height: var(--custom-emoji-size); + border-radius: var(--border-radius-messages-small); + background: transparent no-repeat center; + background-size: contain; + transition: background-color 0.15s ease, opacity 0.3s ease !important; + position: relative; + + &.selected, + &:hover { + background-color: var(--color-interactive-element-hover); + } +} diff --git a/src/components/common/ReactionEmoji.tsx b/src/components/common/ReactionEmoji.tsx new file mode 100644 index 000000000..e7f27dde2 --- /dev/null +++ b/src/components/common/ReactionEmoji.tsx @@ -0,0 +1,101 @@ +import React, { + memo, useCallback, useMemo, useRef, +} from '../../lib/teact/teact'; + +import type { FC } from '../../lib/teact/teact'; +import type { ApiAvailableReaction, ApiReaction } from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; + +import { EMOJI_SIZE_PICKER } from '../../config'; +import buildClassName from '../../util/buildClassName'; +import { getDocumentMediaHash, isSameReaction } from '../../global/helpers'; + +import useBoundsInSharedCanvas from '../../hooks/useBoundsInSharedCanvas'; +import useMediaTransition from '../../hooks/useMediaTransition'; +import useMedia from '../../hooks/useMedia'; + +import CustomEmoji from './CustomEmoji'; +import AnimatedIconWithPreview from './AnimatedIconWithPreview'; + +import styles from './ReactionEmoji.module.scss'; + +type OwnProps = { + reaction: ApiReaction; + availableReactions?: ApiAvailableReaction[]; + className?: string; + isSelected?: boolean; + loadAndPlay?: boolean; + observeIntersection?: ObserveFn; + sharedCanvasRef?: React.RefObject; + sharedCanvasHqRef?: React.RefObject; + onClick: (reaction: ApiReaction) => void; +}; + +const ReactionEmoji: FC = ({ + reaction, + availableReactions, + isSelected, + loadAndPlay, + observeIntersection, + sharedCanvasRef, + sharedCanvasHqRef, + onClick, +}) => { + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const isCustom = 'documentId' in reaction; + const availableReaction = useMemo(() => ( + availableReactions?.find((available) => isSameReaction(available.reaction, reaction)) + ), [availableReactions, reaction]); + const thumbDataUri = availableReaction?.staticIcon?.thumbnail?.dataUri; + const animationId = availableReaction?.selectAnimation?.id; + const bounds = useBoundsInSharedCanvas(ref, sharedCanvasHqRef); + const mediaData = useMedia( + availableReaction?.selectAnimation ? getDocumentMediaHash(availableReaction.selectAnimation) : undefined, + !animationId, + ); + const handleClick = useCallback(() => { + onClick(reaction); + }, [onClick, reaction]); + + const transitionClassNames = useMediaTransition(mediaData); + const fullClassName = buildClassName( + styles.root, + isSelected && styles.selected, + !isCustom && 'sticker-reaction', + ); + + return ( +
+ {isCustom ? ( + + ) : ( + + )} +
+ ); +}; + +export default memo(ReactionEmoji); diff --git a/src/components/common/StickerButton.scss b/src/components/common/StickerButton.scss index 5eae702c7..969b1ee85 100644 --- a/src/components/common/StickerButton.scss +++ b/src/components/common/StickerButton.scss @@ -13,6 +13,8 @@ position: relative; &.custom-emoji { + color: var(--color-primary); + width: var(--custom-emoji-size); height: var(--custom-emoji-size); margin: 0.3125rem; @@ -63,6 +65,10 @@ z-index: 1; } + &.selected { + background-color: var(--color-interactive-element-hover); + } + &.interactive { cursor: pointer; diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index 1d4e1f64e..b83e0dd39 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -15,7 +15,7 @@ import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useLang from '../../hooks/useLang'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; -import useContextMenuPosition from '../../hooks/useContextMenuPosition'; +import useMenuPosition from '../../hooks/useMenuPosition'; import useDynamicColorListener from '../../hooks/useDynamicColorListener'; import StickerView from './StickerView'; @@ -35,6 +35,7 @@ type OwnProps = { isSavedMessages?: boolean; isStatusPicker?: boolean; canViewSet?: boolean; + isSelected?: boolean; isCurrentUserPremium?: boolean; sharedCanvasRef?: React.RefObject; observeIntersection: ObserveFn; @@ -68,6 +69,7 @@ const StickerButton = void; + onReactionSelect?: (reaction: ApiReaction) => void; onStickerUnfave?: (sticker: ApiSticker) => void; onStickerFave?: (sticker: ApiSticker) => void; onStickerRemoveRecent?: (sticker: ApiSticker) => void; @@ -53,6 +64,10 @@ type OwnProps = { }; const ITEMS_PER_ROW_FALLBACK = 8; +const ITEMS_MOBILE_PER_ROW_FALLBACK = 7; +const ITEMS_MINI_MOBILE_PER_ROW_FALLBACK = 6; +const MOBILE_WIDTH_THRESHOLD_PX = 440; +const MINI_MOBILE_WIDTH_THRESHOLD_PX = 362; const StickerSet: FC = ({ stickerSet, @@ -61,13 +76,17 @@ const StickerSet: FC = ({ idPrefix, shouldRender, favoriteStickers, + availableReactions, isSavedMessages, isStatusPicker, + isReactionPicker, isCurrentUserPremium, - shouldHideRecentHeader, + shouldHideHeader, withDefaultTopicIcon, + selectedReactionIds, withDefaultStatusIcon, observeIntersection, + onReactionSelect, onStickerSelect, onStickerUnfave, onStickerFave, @@ -79,6 +98,7 @@ const StickerSet: FC = ({ const { clearRecentStickers, clearRecentCustomEmoji, + clearRecentReactions, openPremiumModal, toggleStickerSet, loadStickers, @@ -93,10 +113,11 @@ const StickerSet: FC = ({ const sharedCanvasHqRef = useRef(null); const lang = useLang(); + const { width: windowWidth } = useWindowSize(); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useFlag(); const { isMobile } = useAppLayout(); - const [itemsPerRow, setItemsPerRow] = useState(ITEMS_PER_ROW_FALLBACK); + const [itemsPerRow, setItemsPerRow] = useState(getItemsPerRowFallback(windowWidth)); const isIntersecting = useIsIntersecting(ref, observeIntersection); @@ -106,17 +127,22 @@ const StickerSet: FC = ({ const emojiMarginPx = isMobile ? 8 : 10; const isRecent = stickerSet.id === RECENT_SYMBOL_SET_ID; const isFavorite = stickerSet.id === FAVORITE_SYMBOL_SET_ID; + const isPopular = stickerSet.id === POPULAR_SYMBOL_SET_ID; const isEmoji = stickerSet.isEmoji; const isPremiumSet = !isRecent && selectIsSetPremium(stickerSet); const handleClearRecent = useCallback(() => { - if (isEmoji) { + if (isReactionPicker) { + clearRecentReactions(); + } else if (isEmoji) { clearRecentCustomEmoji(); } else { clearRecentStickers(); } closeConfirmModal(); - }, [clearRecentCustomEmoji, clearRecentStickers, closeConfirmModal, isEmoji]); + }, [ + clearRecentCustomEmoji, clearRecentReactions, clearRecentStickers, closeConfirmModal, isEmoji, isReactionPicker, + ]); const handleAddClick = useCallback(() => { if (isPremiumSet && !isCurrentUserPremium) { @@ -156,10 +182,12 @@ const StickerSet: FC = ({ const margin = isEmoji ? emojiMarginPx : stickerMarginPx; const calculateItemsPerRow = useCallback((width: number) => { - if (!width) return ITEMS_PER_ROW_FALLBACK; + if (!width) { + return getItemsPerRowFallback(windowWidth); + } return Math.floor(width / (itemSize + margin)); - }, [itemSize, margin]); + }, [itemSize, margin, windowWidth]); const handleResize = useCallback((entry: ResizeObserverEntry) => { setItemsPerRow(calculateItemsPerRow(entry.contentRect.width)); @@ -172,7 +200,7 @@ const StickerSet: FC = ({ }, [calculateItemsPerRow]); useEffect(() => { - if (isIntersecting && !stickerSet.stickers?.length && stickerSet.accessHash) { + if (isIntersecting && !stickerSet.stickers?.length && !stickerSet.reactions?.length && stickerSet.accessHash) { loadStickers({ stickerSetInfo: { id: stickerSet.id, @@ -186,7 +214,7 @@ const StickerSet: FC = ({ && stickerSet.stickers?.some(({ isFree }) => !isFree); const isInstalled = stickerSet.installedDate && !stickerSet.isArchived; - const canCut = !isInstalled && stickerSet.id !== RECENT_SYMBOL_SET_ID; + const canCut = !isInstalled && stickerSet.id !== RECENT_SYMBOL_SET_ID && stickerSet.id !== POPULAR_SYMBOL_SET_ID; const [isCut, , expand] = useFlag(canCut); const itemsBeforeCutout = itemsPerRow * 3 - 1; const totalItemsCount = withDefaultTopicIcon ? stickerSet.count + 1 : stickerSet.count; @@ -194,8 +222,6 @@ const StickerSet: FC = ({ const heightWhenCut = Math.ceil(Math.min(itemsBeforeCutout, totalItemsCount) / itemsPerRow) * (itemSize + margin); const height = isCut ? heightWhenCut : Math.ceil(totalItemsCount / itemsPerRow) * (itemSize + margin); - const shouldHideHeader = isRecent && shouldHideRecentHeader; - const favoriteStickerIdsSet = useMemo(() => ( favoriteStickers ? new Set(favoriteStickers.map(({ id }) => id)) : undefined ), [favoriteStickers]); @@ -218,7 +244,7 @@ const StickerSet: FC = ({ {isRecent && ( )} - {!isRecent && isEmoji && !isInstalled && ( + {!isRecent && isEmoji && !isInstalled && !isPopular && ( )} - {shouldRender && stickerSet.stickers && stickerSet.stickers - .slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length) + {shouldRender && stickerSet.reactions?.map((reaction) => { + const reactionId = getReactionUniqueKey(reaction); + const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined; + + return ( + + ); + })} + {shouldRender && stickerSet.stickers?.slice(0, isCut ? itemsBeforeCutout : stickerSet.stickers.length) .map((sticker, i) => { const isHqEmoji = (isRecent || isFavorite) && selectIsAlwaysHighPriorityEmoji(getGlobal(), sticker.stickerSetInfo); const canvasRef = (canCut && i >= itemsBeforeCutout) || isHqEmoji ? sharedCanvasHqRef : sharedCanvasRef; + const reactionId = sticker.isCustomEmoji ? sticker.id : sticker.emoji; + const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined; return ( = ({ sharedCanvasRef={canvasRef} onClick={onStickerSelect} clickArg={sticker} + isSelected={isSelected} onUnfaveClick={isFavorite && favoriteStickerIdsSet?.has(sticker.id) ? onStickerUnfave : undefined} onFaveClick={!favoriteStickerIdsSet?.has(sticker.id) ? onStickerFave : undefined} onRemoveRecentClick={isRecent ? onStickerRemoveRecent : undefined} @@ -309,7 +355,7 @@ const StickerSet: FC = ({ {isRecent && ( = ({ }; export default memo(StickerSet); + +function getItemsPerRowFallback(windowWidth: number): number { + return windowWidth > MOBILE_WIDTH_THRESHOLD_PX + ? ITEMS_PER_ROW_FALLBACK + : (windowWidth < MINI_MOBILE_WIDTH_THRESHOLD_PX + ? ITEMS_MINI_MOBILE_PER_ROW_FALLBACK + : ITEMS_MOBILE_PER_ROW_FALLBACK); +} diff --git a/src/components/common/UiLoader.tsx b/src/components/common/UiLoader.tsx index ad682e095..744c96166 100644 --- a/src/components/common/UiLoader.tsx +++ b/src/components/common/UiLoader.tsx @@ -26,7 +26,6 @@ import '../ui/Modal.scss'; import './Avatar.scss'; import telegramLogoPath from '../../assets/telegram-logo.svg'; -import reactionThumbsPath from '../../assets/reaction-thumbs.png'; import lockPreviewPath from '../../assets/lock.png'; import monkeyPath from '../../assets/monkey.svg'; import spoilerMaskPath from '../../assets/spoilers/mask.svg'; @@ -81,7 +80,6 @@ const preloadTasks = { loadModule(Bundles.Main) .then(preloadFonts), preloadAvatars(), - preloadImage(reactionThumbsPath), preloadImage(spoilerMaskPath), ]), authPhoneNumber: () => Promise.all([ diff --git a/src/components/left/main/StatusPickerMenu.tsx b/src/components/left/main/StatusPickerMenu.tsx index 4e7f780d1..980cb2f5b 100644 --- a/src/components/left/main/StatusPickerMenu.tsx +++ b/src/components/left/main/StatusPickerMenu.tsx @@ -11,7 +11,7 @@ import useFlag from '../../../hooks/useFlag'; import Menu from '../../ui/Menu'; import Portal from '../../ui/Portal'; -import CustomEmojiPicker from '../../middle/composer/CustomEmojiPicker'; +import CustomEmojiPicker from '../../common/CustomEmojiPicker'; import styles from './StatusPickerMenu.module.scss'; @@ -35,6 +35,11 @@ const StatusPickerMenu: FC = ({ }) => { const { loadFeaturedEmojiStickers } = getActions(); + // eslint-disable-next-line no-null/no-null + const scrollHeaderRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const scrollContainerRef = useRef(null); + const transformOriginX = useRef(); const [isContextMenuShown, markContextMenuShown, unmarkContextMenuShown] = useFlag(); useEffect(() => { @@ -47,6 +52,15 @@ const StatusPickerMenu: FC = ({ } }, [areFeaturedStickersLoaded, isOpen, loadFeaturedEmojiStickers]); + const handleResetScrollPosition = useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + if (scrollHeaderRef.current) { + scrollHeaderRef.current.scrollLeft = 0; + } + }, []); + const handleEmojiSelect = useCallback((sticker: ApiSticker) => { onEmojiStatusSelect(sticker); onClose(); @@ -62,11 +76,14 @@ const StatusPickerMenu: FC = ({ onClose={onClose} transformOriginX={transformOriginX.current} noCloseOnBackdrop={isContextMenuShown} + onCloseAnimationEnd={handleResetScrollPosition} > = ({ isPremiumModalOpen, isPaymentModalOpen, isReceiptModalOpen, + isReactionPickerOpen, isCurrentUserPremium, deleteFolderDialogId, isMasterTab, @@ -219,6 +224,9 @@ const Main: FC = ({ toggleLeftColumn, loadRecentEmojiStatuses, updatePageTitle, + loadTopReactions, + loadRecentReactions, + loadFeaturedEmojiStickers, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -232,6 +240,9 @@ const Main: FC = ({ void loadBundle(Bundles.Calls); }, CALL_BUNDLE_LOADING_DELAY_MS); + const [shouldLoadReactionPicker, markShouldLoadReactionPicker] = useFlag(false); + useTimeout(markShouldLoadReactionPicker, REACTION_PICKER_LOADING_DELAY_MS); + // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); @@ -266,19 +277,26 @@ const Main: FC = ({ loadContactList(); loadPremiumGifts(); loadDefaultTopicIcons(); - loadDefaultStatusIcons(); checkAppVersion(); - if (isCurrentUserPremium) { - loadRecentEmojiStatuses(); - } + loadTopReactions(); + loadRecentReactions(); + loadFeaturedEmojiStickers(); } }, [ lastSyncTime, loadAnimatedEmojis, loadEmojiKeywords, loadNotificationExceptions, loadNotificationSettings, loadTopInlineBots, updateIsOnline, loadAvailableReactions, loadAppConfig, loadAttachBots, loadContactList, - loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons, - loadDefaultStatusIcons, loadRecentEmojiStatuses, isCurrentUserPremium, isMasterTab, initMain, + loadPremiumGifts, checkAppVersion, loadConfig, loadGenericEmojiEffects, loadDefaultTopicIcons, loadTopReactions, + loadDefaultStatusIcons, loadRecentReactions, loadRecentEmojiStatuses, isCurrentUserPremium, isMasterTab, initMain, ]); + // Initial Premium API calls + useEffect(() => { + if (lastSyncTime && isMasterTab && isCurrentUserPremium) { + loadDefaultStatusIcons(); + loadRecentEmojiStatuses(); + } + }, [isCurrentUserPremium, isMasterTab, lastSyncTime, loadDefaultStatusIcons, loadRecentEmojiStatuses]); + // Language-based API calls useEffect(() => { if (lastSyncTime && isMasterTab) { @@ -502,6 +520,7 @@ const Main: FC = ({ + ); }; @@ -559,6 +578,7 @@ export default memo(withGlobal( isRightColumnOpen: selectIsRightColumnShown(global, isMobile), isMediaViewerOpen: selectIsMediaViewerOpen(global), isForwardModalOpen: selectIsForwardModalOpen(global), + isReactionPickerOpen: selectIsReactionPickerOpen(global), hasNotifications: Boolean(notifications.length), hasDialogs: Boolean(dialogs.length), audioMessage, diff --git a/src/components/middle/composer/AttachBotIcon.tsx b/src/components/middle/composer/AttachBotIcon.tsx index 4ebbabfe8..755431bc4 100644 --- a/src/components/middle/composer/AttachBotIcon.tsx +++ b/src/components/middle/composer/AttachBotIcon.tsx @@ -5,10 +5,10 @@ import type { ISettings } from '../../../types'; import type { ApiDocument } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types'; -import { IS_COMPACT_MENU } from '../../../util/windowEnvironment'; -import useMedia from '../../../hooks/useMedia'; import { getDocumentMediaHash } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; +import useAppLayout from '../../../hooks/useAppLayout'; +import useMedia from '../../../hooks/useMedia'; import styles from './AttachBotIcon.module.scss'; @@ -25,6 +25,7 @@ const COLOR_REPLACE_PATTERN = /#fff/gi; const AttachBotIcon: FC = ({ icon, theme, }) => { + const { isTouchScreen } = useAppLayout(); const mediaData = useMedia(getDocumentMediaHash(icon), false, ApiMediaFormat.Text); const iconSvg = useMemo(() => { @@ -42,8 +43,8 @@ const AttachBotIcon: FC = ({ }, [mediaData, theme]); return ( - - + + ); }; diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index 982404b73..fe9a4b357 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -29,7 +29,7 @@ import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import useShowTransition from '../../../hooks/useShowTransition'; import useLang from '../../../hooks/useLang'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; -import useContextMenuPosition from '../../../hooks/useContextMenuPosition'; +import useMenuPosition from '../../../hooks/useMenuPosition'; import Button from '../../ui/Button'; import EmbeddedMessage from '../../common/EmbeddedMessage'; @@ -141,7 +141,7 @@ const ComposerEmbeddedMessage: FC = ({ const { positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useContextMenuPosition( + } = useMenuPosition( contextMenuPosition, getTriggerElement, getRootElement, diff --git a/src/components/middle/composer/StickerPicker.scss b/src/components/middle/composer/StickerPicker.scss index 9482d1384..7edfbea00 100644 --- a/src/components/middle/composer/StickerPicker.scss +++ b/src/components/middle/composer/StickerPicker.scss @@ -107,7 +107,3 @@ justify-content: center; } } - -.CustomEmojiPicker { - --emoji-size: 2.5rem; -} diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index cb6a6c203..ca9bff2e2 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -4,7 +4,7 @@ import React, { import { getActions, withGlobal } from '../../../global'; import type { ApiStickerSet, ApiSticker, ApiChat } from '../../../api/types'; -import type { StickerSetOrRecent } from '../../../types'; +import type { StickerSetOrReactionsSetOrRecent } from '../../../types'; import type { FC } from '../../../lib/teact/teact'; import { @@ -33,7 +33,7 @@ import Avatar from '../../common/Avatar'; import Loading from '../../ui/Loading'; import Button from '../../ui/Button'; import StickerButton from '../../common/StickerButton'; -import StickerSet from './StickerSet'; +import StickerSet from '../../common/StickerSet'; import StickerSetCover from './StickerSetCover'; import PremiumIcon from '../../common/PremiumIcon'; @@ -263,7 +263,7 @@ const StickerPicker: FC = ({ removeRecentSticker({ sticker }); }, [removeRecentSticker]); - function renderCover(stickerSet: StickerSetOrRecent, index: number) { + function renderCover(stickerSet: StickerSetOrReactionsSetOrRecent, index: number) { const firstSticker = stickerSet.stickers?.[0]; const buttonClassName = buildClassName( 'symbol-set-button sticker-set-button', diff --git a/src/components/middle/composer/SymbolMenu.scss b/src/components/middle/composer/SymbolMenu.scss index d56a44ca8..739078857 100644 --- a/src/components/middle/composer/SymbolMenu.scss +++ b/src/components/middle/composer/SymbolMenu.scss @@ -236,10 +236,19 @@ position: absolute; font-size: 1rem; cursor: pointer; - } + border-radius: 50%; + padding: 0.25rem; + transition: background-color 0.15s; - &-container { - text-align: initial; + &:active, + &:focus { + background-color: var(--color-interactive-element-hover); + } + @media (hover: hover) { + &:hover { + background-color: var(--color-interactive-element-hover); + } + } } &-button { @@ -253,29 +262,30 @@ @include while-transition() { overflow: hidden; } +} - .symbol-set-container { - display: grid; - justify-content: space-between; - grid-template-columns: repeat(auto-fill, var(--emoji-size, 4rem)); - grid-gap: 0.625rem; - padding: 0.3125rem; +.symbol-set-container { + display: grid !important; + justify-content: space-between; + grid-template-columns: repeat(auto-fill, var(--emoji-size, 4rem)); + grid-gap: 0.625rem; + padding: 0.3125rem; + text-align: initial; - @media (max-width: 600px) { - grid-gap: 0.5rem; - } + @media (max-width: 600px) { + grid-gap: 0.5rem; + } - &:not(.shown) { - display: block; - } + &:not(.shown) { + display: block; + } - &.closing { - transition: none; - } + &.closing { + transition: none; + } - > .EmojiButton, - > .StickerButton { - margin: 0; - } + > .EmojiButton, + > .StickerButton { + margin: 0; } } diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 36c4c27c6..3f45a940b 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -21,7 +21,7 @@ import Button from '../../ui/Button'; import Menu from '../../ui/Menu'; import Transition from '../../ui/Transition'; import EmojiPicker from './EmojiPicker'; -import CustomEmojiPicker from './CustomEmojiPicker'; +import CustomEmojiPicker from '../../common/CustomEmojiPicker'; import StickerPicker from './StickerPicker'; import GifPicker from './GifPicker'; import SymbolMenuFooter, { SYMBOL_MENU_TAB_TITLES, SymbolMenuTabs } from './SymbolMenuFooter'; @@ -100,7 +100,7 @@ const SymbolMenu: FC = ({ transformOriginY, style, }) => { - const { loadPremiumSetStickers, loadFeaturedEmojiStickers } = getActions(); + const { loadPremiumSetStickers } = getActions(); const [activeTab, setActiveTab] = useState(0); const [recentEmojis, setRecentEmojis] = useState([]); const [recentCustomEmojis, setRecentCustomEmojis] = useState([]); @@ -124,12 +124,10 @@ const SymbolMenu: FC = ({ }, [canSendPlainText]); useEffect(() => { - if (!lastSyncTime) return; - if (isCurrentUserPremium) { + if (lastSyncTime && isCurrentUserPremium) { loadPremiumSetStickers(); } - loadFeaturedEmojiStickers(); - }, [isCurrentUserPremium, lastSyncTime, loadFeaturedEmojiStickers, loadPremiumSetStickers]); + }, [isCurrentUserPremium, lastSyncTime, loadPremiumSetStickers]); useLayoutEffect(() => { if (!isMobile || isAttachmentModal) { diff --git a/src/components/middle/composer/SymbolMenuButton.tsx b/src/components/middle/composer/SymbolMenuButton.tsx index aa4b0c350..8147373a1 100644 --- a/src/components/middle/composer/SymbolMenuButton.tsx +++ b/src/components/middle/composer/SymbolMenuButton.tsx @@ -10,7 +10,7 @@ import type { ApiVideo, ApiSticker } from '../../../api/types'; import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_MODAL_CSS_SELECTOR } from '../../../config'; import buildClassName from '../../../util/buildClassName'; import useFlag from '../../../hooks/useFlag'; -import useContextMenuPosition from '../../../hooks/useContextMenuPosition'; +import useMenuPosition from '../../../hooks/useMenuPosition'; import Button from '../../ui/Button'; import Spinner from '../../ui/Spinner'; @@ -146,7 +146,7 @@ const SymbolMenuButton: FC = ({ const { positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useContextMenuPosition( + } = useMenuPosition( contextMenuPosition, getTriggerElement, getRootElement, diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 8b403ba21..2cd0a01cd 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -61,13 +61,14 @@ export type OwnProps = { messageListType: MessageListType; noReplies?: boolean; detectedLanguage?: string; - onClose: () => void; - onCloseAnimationEnd: () => void; repliesThreadInfo?: ApiThreadInfo; + onClose: NoneToVoidFunction; + onCloseAnimationEnd: NoneToVoidFunction; }; type StateProps = { availableReactions?: ApiAvailableReaction[]; + topReactions?: ApiReaction[]; customEmojiSetsInfo?: ApiStickerSetInfo[]; customEmojiSets?: ApiStickerSet[]; noOptions?: boolean; @@ -106,8 +107,11 @@ type StateProps = { threadId?: number; }; +const REACTION_PICKER_APPEARANCE_DURATION_MS = 250; + const ContextMenuContainer: FC = ({ availableReactions, + topReactions, isOpen, messageListType, chatUsername, @@ -182,11 +186,13 @@ const ContextMenuContainer: FC = ({ requestMessageTranslation, showOriginalMessage, openMessageLanguageModal, + openReactionPicker, } = getActions(); const lang = useLang(); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const [isMenuOpen, setIsMenuOpen] = useState(true); + const [noAnimationOnClose, setNoAnimationOnClose] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isPinModalOpen, setIsPinModalOpen] = useState(false); @@ -255,7 +261,8 @@ const ContextMenuContainer: FC = ({ setIsReportModalOpen(true); }, []); - const closeMenu = useCallback(() => { + const closeMenu = useCallback((noCloseAnimation = false) => { + setNoAnimationOnClose(noCloseAnimation); setIsMenuOpen(false); onClose(); }, [onClose]); @@ -409,11 +416,18 @@ const ContextMenuContainer: FC = ({ const handleToggleReaction = useCallback((reaction: ApiReaction) => { toggleReaction({ - chatId: message.chatId, messageId: message.id, reaction, + chatId: message.chatId, messageId: message.id, reaction, shouldAddToRecent: true, }); closeMenu(); }, [closeMenu, message, toggleReaction]); + const handleReactionPickerOpen = useCallback((position: IAnchorPosition) => { + openReactionPicker({ chatId: message.chatId, messageId: message.id, position }); + setTimeout(() => { + closeMenu(true); + }, REACTION_PICKER_APPEARANCE_DURATION_MS); + }, [closeMenu, message.chatId, message.id]); + const handleTranslate = useCallback(() => { requestMessageTranslation({ chatId: message.chatId, @@ -453,6 +467,7 @@ const ContextMenuContainer: FC = ({
= ({ canSelectLanguage={canSelectLanguage} hasCustomEmoji={hasCustomEmoji} customEmojiSets={customEmojiSets} + noTransition={noAnimationOnClose} isDownloading={isDownloading} seenByRecentUsers={seenByRecentUsers} noReplies={noReplies} @@ -515,6 +531,7 @@ const ContextMenuContainer: FC = ({ onShowSeenBy={handleOpenSeenByModal} onToggleReaction={handleToggleReaction} onShowReactors={handleOpenReactorListModal} + onReactionPickerOpen={handleReactionPickerOpen} onTranslate={handleTranslate} onShowOriginal={handleShowOriginal} onSelectLanguage={handleSelectLanguage} @@ -617,6 +634,7 @@ export default memo(withGlobal( return { availableReactions: global.availableReactions, + topReactions: global.topReactions, noOptions, canSendNow: isScheduled, canReschedule: isScheduled, diff --git a/src/components/middle/message/MessageContextMenu.scss b/src/components/middle/message/MessageContextMenu.scss index dc3f2497a..a41e54ba4 100644 --- a/src/components/middle/message/MessageContextMenu.scss +++ b/src/components/middle/message/MessageContextMenu.scss @@ -21,9 +21,21 @@ } &.with-reactions .bubble { - margin-top: 3.5rem; + background: none; + backdrop-filter: none; + box-shadow: none; + padding: 3.5rem 0 0 !important; } + &.with-reactions .scrollable-content { + background: var(--color-background-compact-menu); + backdrop-filter: blur(10px); + box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow); + border-radius: var(--border-radius-default); + padding: 0.25rem 0; + } + + .backdrop { touch-action: none; } diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 1d673665d..05c39ab1e 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -16,6 +16,7 @@ import type { } from '../../../api/types'; import type { IAnchorPosition } from '../../../types'; +import { REM } from '../../common/helpers/mediaDimensions'; import { getMessageCopyOptions } from './helpers/copyOptions'; import { disableScrolling, enableScrolling } from '../../../util/scrollLock'; import { getUserFullName } from '../../../global/helpers'; @@ -23,7 +24,7 @@ import buildClassName from '../../../util/buildClassName'; import renderText from '../../common/helpers/renderText'; import useFlag from '../../../hooks/useFlag'; -import useContextMenuPosition from '../../../hooks/useContextMenuPosition'; +import useMenuPosition from '../../../hooks/useMenuPosition'; import useLang from '../../../hooks/useLang'; import useAppLayout from '../../../hooks/useAppLayout'; @@ -38,6 +39,7 @@ import './MessageContextMenu.scss'; type OwnProps = { availableReactions?: ApiAvailableReaction[]; + topReactions?: ApiReaction[]; isOpen: boolean; anchor: IAnchorPosition; message: ApiMessage | ApiSponsoredMessage; @@ -76,44 +78,48 @@ type OwnProps = { noReplies?: boolean; hasCustomEmoji?: boolean; customEmojiSets?: ApiStickerSet[]; - onReply?: () => void; + noTransition?: boolean; + onReply?: NoneToVoidFunction; onOpenThread?: VoidFunction; - onEdit?: () => void; - onPin?: () => void; - onUnpin?: () => void; - onForward?: () => void; - onDelete?: () => void; - onReport?: () => void; - onFaveSticker?: () => void; - onUnfaveSticker?: () => void; - onSelect?: () => void; - onSend?: () => void; - onReschedule?: () => void; - onClose: () => void; - onCloseAnimationEnd?: () => void; - onCopyLink?: () => void; + onEdit?: NoneToVoidFunction; + onPin?: NoneToVoidFunction; + onUnpin?: NoneToVoidFunction; + onForward?: NoneToVoidFunction; + onDelete?: NoneToVoidFunction; + onReport?: NoneToVoidFunction; + onFaveSticker?: NoneToVoidFunction; + onUnfaveSticker?: NoneToVoidFunction; + onSelect?: NoneToVoidFunction; + onSend?: NoneToVoidFunction; + onReschedule?: NoneToVoidFunction; + onClose: NoneToVoidFunction; + onCloseAnimationEnd?: NoneToVoidFunction; + onCopyLink?: NoneToVoidFunction; onCopyMessages?: (messageIds: number[]) => void; - onCopyNumber?: () => void; - onDownload?: () => void; - onSaveGif?: () => void; - onCancelVote?: () => void; - onClosePoll?: () => void; - onShowSeenBy?: () => void; - onShowReactors?: () => void; - onAboutAds?: () => void; - onSponsoredHide?: () => void; - onTranslate?: () => void; - onShowOriginal?: () => void; - onSelectLanguage?: () => void; + onCopyNumber?: NoneToVoidFunction; + onDownload?: NoneToVoidFunction; + onSaveGif?: NoneToVoidFunction; + onCancelVote?: NoneToVoidFunction; + onClosePoll?: NoneToVoidFunction; + onShowSeenBy?: NoneToVoidFunction; + onShowReactors?: NoneToVoidFunction; + onAboutAds?: NoneToVoidFunction; + onSponsoredHide?: NoneToVoidFunction; + onTranslate?: NoneToVoidFunction; + onShowOriginal?: NoneToVoidFunction; + onSelectLanguage?: NoneToVoidFunction; onToggleReaction?: (reaction: ApiReaction) => void; + onReactionPickerOpen?: (position: IAnchorPosition) => void; }; const SCROLLBAR_WIDTH = 10; const REACTION_BUBBLE_EXTRA_WIDTH = 32; +const REACTION_SELECTOR_WIDTH_REM = 19.25; const ANIMATION_DURATION = 200; const MessageContextMenu: FC = ({ availableReactions, + topReactions, isOpen, message, isPrivate, @@ -152,6 +158,7 @@ const MessageContextMenu: FC = ({ seenByRecentUsers, hasCustomEmoji, customEmojiSets, + noTransition, onReply, onOpenThread, onEdit, @@ -179,6 +186,7 @@ const MessageContextMenu: FC = ({ onCopyMessages, onAboutAds, onSponsoredHide, + onReactionPickerOpen, onTranslate, onShowOriginal, onSelectLanguage, @@ -255,6 +263,8 @@ const MessageContextMenu: FC = ({ extraTopPadding: (document.querySelector('.MiddleHeader')!).offsetHeight, marginSides: withReactions ? REACTION_BUBBLE_EXTRA_WIDTH : undefined, extraMarginTop: extraHeightPinned + extraHeightAudioPlayer, + shouldAvoidNegativePosition: true, + menuElMinWidth: withReactions && isMobile ? REACTION_SELECTOR_WIDTH_REM * REM : undefined, }; }, [isMobile, withReactions]); @@ -271,7 +281,7 @@ const MessageContextMenu: FC = ({ const { positionX, positionY, transformOriginX, transformOriginY, style, menuStyle, withScroll, - } = useContextMenuPosition(anchor, getTriggerElement, getRootElement, getMenuElement, getLayout); + } = useMenuPosition(anchor, getTriggerElement, getRootElement, getMenuElement, getLayout); useEffect(() => { disableScrolling(withScroll ? scrollableRef.current : undefined, '.ReactionSelector'); @@ -292,20 +302,23 @@ const MessageContextMenu: FC = ({ className={buildClassName( 'MessageContextMenu', 'fluid', withReactions && 'with-reactions', )} + shouldSkipTransition={noTransition} onClose={onClose} onCloseAnimationEnd={onCloseAnimationEnd} > {withReactions && ( )} diff --git a/src/components/middle/message/ReactionPicker.async.tsx b/src/components/middle/message/ReactionPicker.async.tsx new file mode 100644 index 000000000..a3aabf90c --- /dev/null +++ b/src/components/middle/message/ReactionPicker.async.tsx @@ -0,0 +1,21 @@ +import React, { memo } from '../../../lib/teact/teact'; + +import type { FC } from '../../../lib/teact/teact'; +import type { OwnProps } from './ReactionPicker'; + +import { Bundles } from '../../../util/moduleLoader'; +import useModuleLoader from '../../../hooks/useModuleLoader'; + +interface LocalOwnProps { + shouldLoad?: boolean; +} + +const ReactionPickerAsync: FC = (props) => { + const { isOpen, shouldLoad } = props; + const ReactionPicker = useModuleLoader(Bundles.Extra, 'ReactionPicker', !isOpen && !shouldLoad); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ReactionPicker ? : undefined; +}; + +export default memo(ReactionPickerAsync); diff --git a/src/components/middle/message/ReactionPicker.module.scss b/src/components/middle/message/ReactionPicker.module.scss new file mode 100644 index 000000000..e02edb8a3 --- /dev/null +++ b/src/components/middle/message/ReactionPicker.module.scss @@ -0,0 +1,49 @@ +.menu { + position: absolute; + z-index: var(--z-reaction-picker); + + @media (max-width: 600px) { + max-width: 100%; + left: 0 !important; + right: 0 !important; + } +} + +.menuContent { + width: 26.25rem; + height: 26.25rem; + padding: 0 !important; + transform-origin: 50% 3.5rem !important; + + &:global(.bubble) { + transform: scale(0.7) !important; + transition: opacity 140ms cubic-bezier(0.2, 0, 0.2, 1), transform 140ms cubic-bezier(0.2, 0, 0.2, 1) !important; + } + + &:global(.bubble.open) { + transform: scale(1) !important; + } + + @media (max-width: 440px) { + max-width: min(calc(100% - 1rem), 26.25rem); + left: 50% !important; + right: auto !important; + + &:global(.bubble) { + transform: scale(0.5) translateX(-50%) !important; + transform-origin: 0 3.5rem !important; + } + + &:global(.bubble.open) { + transform: scale(1) translateX(-50%) !important; + } + } +} + +.onlyReactions { + height: auto; +} + +.hidden { + display: none !important; +} diff --git a/src/components/middle/message/ReactionPicker.tsx b/src/components/middle/message/ReactionPicker.tsx new file mode 100644 index 000000000..6e3c16ec8 --- /dev/null +++ b/src/components/middle/message/ReactionPicker.tsx @@ -0,0 +1,191 @@ +import React, { + memo, useCallback, useMemo, useRef, +} from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { + ApiMessage, ApiReaction, ApiSticker, ApiReactionCustomEmoji, +} from '../../../api/types'; +import type { IAnchorPosition } from '../../../types'; + +import buildClassName from '../../../util/buildClassName'; +import { isUserId } from '../../../global/helpers'; +import { selectChat, selectChatMessage, selectTabState } from '../../../global/selectors'; +import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; +import useMenuPosition from '../../../hooks/useMenuPosition'; + +import CustomEmojiPicker from '../../common/CustomEmojiPicker'; +import ReactionPickerLimited from './ReactionPickerLimited'; +import Menu from '../../ui/Menu'; + +import styles from './ReactionPicker.module.scss'; + +export type OwnProps = { + isOpen: boolean; +}; + +interface StateProps { + withCustomReactions?: boolean; + message?: ApiMessage; + position?: IAnchorPosition; +} + +const FULL_PICKER_SHIFT_DELTA = { x: -30, y: -66 }; +const LIMITED_PICKER_SHIFT_DELTA = { x: -25, y: -10 }; + +const ReactionPicker: FC = ({ + isOpen, + message, + position, + withCustomReactions, +}) => { + const { toggleReaction, closeReactionPicker } = getActions(); + + // eslint-disable-next-line no-null/no-null + const scrollHeaderRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const scrollContainerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const limitedScrollContainerRef = useRef(null); + + const renderedMessageId = useCurrentOrPrev(message?.id, true); + const renderedChatId = useCurrentOrPrev(message?.chatId, true); + const storedPosition = useCurrentOrPrev(position, true); + // eslint-disable-next-line no-null/no-null + const menuRef = useRef(null); + const renderingPosition = useMemo((): IAnchorPosition | undefined => { + if (!storedPosition) { + return undefined; + } + + return { + x: storedPosition.x + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.x : LIMITED_PICKER_SHIFT_DELTA.x), + y: storedPosition.y + (withCustomReactions ? FULL_PICKER_SHIFT_DELTA.y : LIMITED_PICKER_SHIFT_DELTA.y), + }; + }, [storedPosition, withCustomReactions]); + + const getMenuElement = useCallback(() => menuRef.current, []); + const getLayout = useCallback(() => ({ withPortal: true, isDense: true }), []); + const { + positionX, positionY, transformOriginX, transformOriginY, style, + } = useMenuPosition(renderingPosition, getTriggerElement, getRootElement, getMenuElement, getLayout); + + const handleResetScrollPosition = useCallback(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + if (scrollHeaderRef.current) { + scrollHeaderRef.current.scrollLeft = 0; + } + if (limitedScrollContainerRef.current) { + limitedScrollContainerRef.current.scrollTop = 0; + } + }, []); + + const handleToggleCustomReaction = useCallback((sticker: ApiSticker) => { + if (!renderedChatId || !renderedMessageId) { + return; + } + const reaction = sticker.isCustomEmoji + ? { documentId: sticker.id } as ApiReactionCustomEmoji + : { emoticon: sticker.emoji } as ApiReaction; + + toggleReaction({ + chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true, + }); + closeReactionPicker(); + }, [renderedChatId, renderedMessageId]); + + const handleToggleReaction = useCallback((reaction: ApiReaction) => { + if (!renderedChatId || !renderedMessageId) { + return; + } + + toggleReaction({ + chatId: renderedChatId, messageId: renderedMessageId, reaction, shouldAddToRecent: true, + }); + closeReactionPicker(); + }, [renderedChatId, renderedMessageId]); + + const selectedReactionIds = useMemo(() => { + return (message?.reactions?.results || []).reduce((acc, { chosenOrder, reaction }) => { + if (chosenOrder !== undefined) { + acc.push('emoticon' in reaction ? reaction.emoticon : reaction.documentId); + } + + return acc; + }, []); + }, [message?.reactions?.results]); + + const bubbleFullClassName = buildClassName( + styles.menuContent, + !withCustomReactions && styles.onlyReactions, + ); + + return ( + + + {!withCustomReactions && Boolean(renderedChatId) && ( + + )} + + ); +}; + +export default memo(withGlobal((global): StateProps => { + const state = selectTabState(global); + const { chatId, messageId, position } = state.reactionPicker || {}; + const chat = chatId ? selectChat(global, chatId) : undefined; + const message = chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined; + const isPrivateChat = chatId ? isUserId(chatId) : false; + const areSomeReactionsAllowed = chat?.fullInfo?.enabledReactions?.type === 'some'; + const areCustomReactionsAllowed = chat?.fullInfo?.enabledReactions?.type === 'all' + && chat?.fullInfo?.enabledReactions?.areCustomAllowed; + + return { + message, + position, + withCustomReactions: chat?.isForbidden || areSomeReactionsAllowed + ? false + : areCustomReactionsAllowed || isPrivateChat, + }; +})(ReactionPicker)); + +function getTriggerElement(): HTMLElement | null { + return document.querySelector('body'); +} + +function getRootElement() { + return document.querySelector('body'); +} diff --git a/src/components/middle/message/ReactionPickerLimited.module.scss b/src/components/middle/message/ReactionPickerLimited.module.scss new file mode 100644 index 000000000..39294c367 --- /dev/null +++ b/src/components/middle/message/ReactionPickerLimited.module.scss @@ -0,0 +1,11 @@ +.root { + --emoji-size: 2.5rem; +} + +.wrapper { + position: relative; + height: auto; + max-height: 18rem; + overflow-y: auto; + padding: 0.5rem 0.25rem; +} diff --git a/src/components/middle/message/ReactionPickerLimited.tsx b/src/components/middle/message/ReactionPickerLimited.tsx new file mode 100644 index 000000000..77fd12ddc --- /dev/null +++ b/src/components/middle/message/ReactionPickerLimited.tsx @@ -0,0 +1,135 @@ +import type { RefObject } from 'react'; +import React, { memo, useRef, useMemo } from '../../../lib/teact/teact'; +import { withGlobal } from '../../../global'; + +import type { FC } from '../../../lib/teact/teact'; +import type { ApiReaction, ApiAvailableReaction, ApiChatReactions } from '../../../api/types'; + +import { REM } from '../../common/helpers/mediaDimensions'; +import buildClassName from '../../../util/buildClassName'; +import { getReactionUniqueKey, sortReactions } from '../../../global/helpers'; +import { selectChat } from '../../../global/selectors'; + +import useWindowSize from '../../../hooks/useWindowSize'; +import useAppLayout from '../../../hooks/useAppLayout'; + +import ReactionEmoji from '../../common/ReactionEmoji'; + +import styles from './ReactionPickerLimited.module.scss'; + +type OwnProps = { + scrollContainerRef?: RefObject; + chatId: string; + loadAndPlay: boolean; + onReactionSelect?: (reaction: ApiReaction) => void; + selectedReactionIds?: string[]; +}; + +type StateProps = { + enabledReactions?: ApiChatReactions; + availableReactions?: ApiAvailableReaction[]; + topReactions: ApiReaction[]; + canAnimate?: boolean; + isSavedMessages?: boolean; + isCurrentUserPremium?: boolean; +}; + +const REACTION_SIZE = 40; +const GRID_GAP_THRESHOLD = 600; +const MODAL_PADDING_SIZE_REM = 0.5; +const MODAL_MAX_HEIGHT_REM = 18; +const MODAL_MAX_WIDTH_REM = 26.25; +const GRID_GAP_DESKTOP_REM = 0.625; +const GRID_GAP_MOBILE_REM = 0.5; + +const ReactionPickerLimited: FC = ({ + scrollContainerRef, + loadAndPlay, + enabledReactions, + availableReactions, + topReactions, + selectedReactionIds, + onReactionSelect, +}) => { + // eslint-disable-next-line no-null/no-null + let containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const sharedCanvasHqRef = useRef(null); + const { width: windowWidth } = useWindowSize(); + const { isTouchScreen } = useAppLayout(); + if (scrollContainerRef) { + containerRef = scrollContainerRef; + } + + const allAvailableReactions = useMemo(() => { + if (!enabledReactions) { + return []; + } + + if (enabledReactions.type === 'all') { + return sortReactions((availableReactions || []).map(({ reaction }) => reaction), topReactions); + } + + return sortReactions(enabledReactions.allowed, topReactions); + }, [availableReactions, enabledReactions, topReactions]); + + const pickerHeight = useMemo(() => { + const pickerWidth = Math.min(MODAL_MAX_WIDTH_REM * REM, windowWidth); + const gapWidth = (windowWidth > GRID_GAP_THRESHOLD ? GRID_GAP_DESKTOP_REM : GRID_GAP_MOBILE_REM) * REM; + const availableWidth = pickerWidth - MODAL_PADDING_SIZE_REM * REM; + + const itemsInRow = Math.floor((availableWidth + gapWidth) / (REACTION_SIZE + gapWidth)); + const rowsCount = Math.ceil(allAvailableReactions.length / itemsInRow); + + const pickerMaxHeight = rowsCount * REACTION_SIZE + (rowsCount + 1) * gapWidth + MODAL_PADDING_SIZE_REM * REM; + + return Math.min(MODAL_MAX_HEIGHT_REM * REM, pickerMaxHeight); + }, [allAvailableReactions.length, windowWidth]); + + return ( +
+
+
+ + + {allAvailableReactions.map((reaction) => { + const reactionId = getReactionUniqueKey(reaction); + const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined; + + return ( + + ); + })} +
+
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const chat = selectChat(global, chatId); + const { availableReactions, topReactions } = global; + const { enabledReactions } = chat?.fullInfo || {}; + + return { + enabledReactions, + availableReactions, + topReactions, + }; + }, +)(ReactionPickerLimited)); diff --git a/src/components/middle/message/ReactionSelector.scss b/src/components/middle/message/ReactionSelector.scss index 96ed56f9a..ad8998136 100644 --- a/src/components/middle/message/ReactionSelector.scss +++ b/src/components/middle/message/ReactionSelector.scss @@ -1,103 +1,122 @@ .ReactionSelector { position: absolute; - height: 3rem; - background: var(--color-background); + height: 2.5rem; min-width: 3rem; - max-width: calc(100% + 5rem); + max-width: calc(100% + 4rem); z-index: 100; border-radius: 3rem; - filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow)); - right: -3rem; - top: -3.5rem; + right: -2rem; + top: 0.5rem; + + &--isRtl { + right: auto; + left: -3rem; + } + + @media (max-width: 600px) { + left: 0; + right: 0; + display: flex; + justify-content: center; + } + + &--withBlur { + background: none; + filter: none; + + .ReactionSelector__bubble-big, + .ReactionSelector__bubble-small, + .ReactionSelector__items-wrapper { + background: var(--color-background-compact-menu); + backdrop-filter: blur(10px); + } + } + + .ReactionSelector__bubble-big, + .ReactionSelector__bubble-small, + .ReactionSelector__items-wrapper { + filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow)); + + body.is-safari & { + filter: none; + box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow); + } + } &__bubble-big { - border: 0.5rem solid var(--color-background); + background: var(--color-background); position: absolute; display: block; content: ""; - right: 1.5rem; + right: 1.125rem; bottom: -0.5rem; width: 1rem; - height: 1rem; + height: 0.5rem; border-top: 0; border-left: 0; border-right: 0; - border-radius: 0 0 50% 50%; + border-radius: 0 0 1rem 1rem; z-index: -1; + + &--isRtl { + right: auto; + left: 1.5rem; + } + + @media (max-width: 600px) { + display: none; + } } &__bubble-small { position: absolute; display: block; content: ""; - right: 1.25rem; + right: 1.125rem; bottom: -1.25rem; width: 0.5rem; height: 0.5rem; border-radius: 50%; background: var(--color-background); - } - body.is-safari & { - filter: none; - box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow); - } + &--isRtl { + right: auto; + left: 2.125rem; + } - body.is-safari &__bubble-small, - body.is-safari &__bubble-big { - box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow); + @media (max-width: 600px) { + display: none; + } } &__items-wrapper { width: 100%; height: 100%; - overflow: hidden; border-radius: 3rem; + background: var(--color-background); + + @media (max-width: 600px) { + width: fit-content; + } } &__items { - padding: 0 1rem; + padding: 0 0.5rem; width: 100%; height: 100%; - overflow-x: auto; - overflow: overlay; - overflow-y: hidden; display: flex; cursor: pointer; align-items: center; border-radius: 3rem; } - &--compact { - background: var(--color-background-compact-menu-reactions); + &__show-more { + width: 2rem; height: 2rem; - top: -2.5rem; - } - - &--compact &__items { - padding: 0 0.5rem; - } - - &--compact &__bubble-big { - border-color: var(--color-background-compact-menu-reactions); - } - - &--compact &__bubble-small { - background: var(--color-background-compact-menu-reactions); - } - - &__blocked-button { - width: 2rem !important; - height: 2rem !important; - margin-left: 0.5rem !important; - } - - &--compact &__blocked-button { - width: 1.5rem !important; - height: 1.5rem !important; - - i { - font-size: 1.25rem; - } + padding: 0; + margin-inline-start: 0.25rem; + margin-inline-end: -0.125rem; + border-radius: 50%; + font-size: 1.5rem; } } diff --git a/src/components/middle/message/ReactionSelector.tsx b/src/components/middle/message/ReactionSelector.tsx index b3534899d..efa3d9f04 100644 --- a/src/components/middle/message/ReactionSelector.tsx +++ b/src/components/middle/message/ReactionSelector.tsx @@ -1,20 +1,22 @@ import React, { - memo, useMemo, useRef, + memo, useCallback, useMemo, useRef, } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact'; import type { ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount, } from '../../../api/types'; +import type { IAnchorPosition } from '../../../types'; -import { getTouchY } from '../../../util/scrollLock'; import { createClassNameBuilder } from '../../../util/buildClassName'; -import { IS_COMPACT_MENU } from '../../../util/windowEnvironment'; -import { isSameReaction, canSendReaction, getReactionUniqueKey } from '../../../global/helpers'; - -import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; +import { + isSameReaction, canSendReaction, getReactionUniqueKey, sortReactions, +} from '../../../global/helpers'; +import useAppLayout from '../../../hooks/useAppLayout'; +import useLang from '../../../hooks/useLang'; import ReactionSelectorReaction from './ReactionSelectorReaction'; +import Button from '../../ui/Button'; import './ReactionSelector.scss'; @@ -22,39 +24,37 @@ type OwnProps = { enabledReactions?: ApiChatReactions; onToggleReaction: (reaction: ApiReaction) => void; isPrivate?: boolean; - availableReactions?: ApiAvailableReaction[]; + topReactions?: ApiReaction[]; + allAvailableReactions?: ApiAvailableReaction[]; currentReactions?: ApiReactionCount[]; maxUniqueReactions?: number; isReady?: boolean; canBuyPremium?: boolean; isCurrentUserPremium?: boolean; + onShowMore: (position: IAnchorPosition) => void; }; const cn = createClassNameBuilder('ReactionSelector'); +const REACTIONS_AMOUNT = 6; const ReactionSelector: FC = ({ - availableReactions, + allAvailableReactions, + topReactions, enabledReactions, currentReactions, maxUniqueReactions, isPrivate, isReady, onToggleReaction, + onShowMore, }) => { // eslint-disable-next-line no-null/no-null - const itemsScrollRef = useRef(null); - useHorizontalScroll(itemsScrollRef); + const ref = useRef(null); + const { isTouchScreen } = useAppLayout(); + const lang = useLang(); - const handleWheel = (e: React.WheelEvent | React.TouchEvent) => { - const deltaY = 'deltaY' in e ? e.deltaY : getTouchY(e); - - if (deltaY && e.cancelable) { - e.preventDefault(); - } - }; - - const reactionsToRender = useMemo(() => { - return availableReactions?.map((availableReaction) => { + const availableReactions = useMemo(() => { + const reactions = allAvailableReactions?.map((availableReaction) => { if (availableReaction.isInactive) return undefined; if (!isPrivate && (!enabledReactions || !canSendReaction(availableReaction.reaction, enabledReactions))) { return undefined; @@ -64,8 +64,17 @@ const ReactionSelector: FC = ({ return undefined; } return availableReaction; - }) || []; - }, [availableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions]); + }).filter(Boolean) || []; + + return sortReactions(reactions, topReactions); + }, [allAvailableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions, topReactions]); + + const reactionsToRender = useMemo(() => { + return availableReactions.length === REACTIONS_AMOUNT + 1 + ? availableReactions + : availableReactions.slice(0, REACTIONS_AMOUNT); + }, [availableReactions]); + const withMoreButton = reactionsToRender.length < availableReactions.length; const userReactionIndexes = useMemo(() => { const chosenReactions = currentReactions?.filter(({ chosenOrder }) => chosenOrder !== undefined) || []; @@ -74,27 +83,40 @@ const ReactionSelector: FC = ({ ))); }, [currentReactions, reactionsToRender]); + const handleShowMoreClick = useCallback(() => { + const bound = ref.current?.getBoundingClientRect() || { x: 0, y: 0 }; + onShowMore({ + x: bound.x, + y: bound.y, + }); + }, [onShowMore]); + if (!reactionsToRender.length) return undefined; return ( -
-
-
+
+
-
- {reactionsToRender.map((reaction, i) => { - if (!reaction) return undefined; - return ( - - ); - })} +
+
+ {reactionsToRender.map((reaction, i) => ( + + ))} + {withMoreButton && ( + + )}
diff --git a/src/components/middle/message/ReactionSelectorReaction.scss b/src/components/middle/message/ReactionSelectorReaction.scss index 4150fcb56..6e7a6ab24 100644 --- a/src/components/middle/message/ReactionSelectorReaction.scss +++ b/src/components/middle/message/ReactionSelectorReaction.scss @@ -1,22 +1,11 @@ .ReactionSelectorReaction { - margin-left: 0.5rem; + margin-inline-start: 0.25rem; position: relative; min-width: 2rem; min-height: 2rem; &:first-child { - margin-left: 0; - } - - &__static { - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; - background-image: url('../../../assets/reaction-thumbs.png'); - background-repeat: no-repeat; - background-size: auto 100%; + margin-inline-start: 0; } .AnimatedSticker { @@ -36,8 +25,8 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - width: 120%; - height: 120%; + width: 2.375rem; + height: 2.375rem; border-radius: 50%; background-color: var(--color-background-compact-menu-hover); } diff --git a/src/components/middle/message/ReactionSelectorReaction.tsx b/src/components/middle/message/ReactionSelectorReaction.tsx index b2c409ad3..ea6f0a59b 100644 --- a/src/components/middle/message/ReactionSelectorReaction.tsx +++ b/src/components/middle/message/ReactionSelectorReaction.tsx @@ -3,7 +3,6 @@ import React, { memo } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact'; import type { ApiAvailableReaction, ApiReaction } from '../../../api/types'; -import { IS_COMPACT_MENU } from '../../../util/windowEnvironment'; import { createClassNameBuilder } from '../../../util/buildClassName'; import useMedia from '../../../hooks/useMedia'; import useFlag from '../../../hooks/useFlag'; @@ -12,11 +11,10 @@ import AnimatedSticker from '../../common/AnimatedSticker'; import './ReactionSelectorReaction.scss'; -const REACTION_SIZE = IS_COMPACT_MENU ? 24 : 32; +const REACTION_SIZE = 32; type OwnProps = { reaction: ApiAvailableReaction; - previewIndex: number; isReady?: boolean; chosen?: boolean; onToggleReaction: (reaction: ApiReaction) => void; @@ -26,18 +24,16 @@ const cn = createClassNameBuilder('ReactionSelectorReaction'); const ReactionSelectorReaction: FC = ({ reaction, - previewIndex, isReady, chosen, onToggleReaction, }) => { + const mediaAppearData = useMedia(`sticker${reaction.appearAnimation?.id}`, !isReady); const mediaData = useMedia(`document${reaction.selectAnimation?.id}`, !isReady); - - const [isActivated, activate, deactivate] = useFlag(); const [isAnimationLoaded, markAnimationLoaded] = useFlag(); - const shouldRenderStatic = !isReady || !isAnimationLoaded; - const shouldRenderAnimated = Boolean(isReady && mediaData); + const [isFirstPlay, , unmarkIsFirstPlay] = useFlag(true); + const [isActivated, activate, deactivate] = useFlag(); function handleClick() { onToggleReaction(reaction.reaction); @@ -45,18 +41,23 @@ const ReactionSelectorReaction: FC = ({ return (
- {shouldRenderStatic && ( -
)} - {shouldRenderAnimated && ( + {!isFirstPlay && ( = ({ const { positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, - } = useContextMenuPosition( + } = useMenuPosition( contextMenuPosition, getTriggerElement, getRootElement, diff --git a/src/components/ui/Menu.tsx b/src/components/ui/Menu.tsx index 5575313b3..fa557f606 100644 --- a/src/components/ui/Menu.tsx +++ b/src/components/ui/Menu.tsx @@ -2,17 +2,18 @@ import type { RefObject } from 'react'; import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect, useRef } from '../../lib/teact/teact'; -import useShowTransition from '../../hooks/useShowTransition'; -import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; -import useVirtualBackdrop from '../../hooks/useVirtualBackdrop'; -import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; +import { IS_BACKDROP_BLUR_SUPPORTED } from '../../util/windowEnvironment'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; import { dispatchHeavyAnimationEvent } from '../../hooks/useHeavyAnimationCheck'; -import useHistoryBack from '../../hooks/useHistoryBack'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; -import { IS_BACKDROP_BLUR_SUPPORTED, IS_COMPACT_MENU } from '../../util/windowEnvironment'; +import useShowTransition from '../../hooks/useShowTransition'; +import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; +import useVirtualBackdrop from '../../hooks/useVirtualBackdrop'; +import useEffectWithPrevDeps from '../../hooks/useEffectWithPrevDeps'; +import useHistoryBack from '../../hooks/useHistoryBack'; +import useAppLayout from '../../hooks/useAppLayout'; import Portal from './Portal'; @@ -82,6 +83,7 @@ const Menu: FC = ({ menuRef = ref; } const backdropContainerRef = containerRef || menuRef; + const { isTouchScreen } = useAppLayout(); const { transitionClassNames, @@ -135,7 +137,7 @@ const Menu: FC = ({ id={id} className={buildClassName( 'Menu no-selection', - !noCompact && IS_COMPACT_MENU && 'compact', + !noCompact && !isTouchScreen && 'compact', !IS_BACKDROP_BLUR_SUPPORTED && 'no-blur', className, )} diff --git a/src/components/ui/MenuItem.tsx b/src/components/ui/MenuItem.tsx index 2c92f6b6b..f297b762e 100644 --- a/src/components/ui/MenuItem.tsx +++ b/src/components/ui/MenuItem.tsx @@ -4,7 +4,7 @@ import React, { useCallback } from '../../lib/teact/teact'; import { IS_TEST } from '../../config'; import buildClassName from '../../util/buildClassName'; import useLang from '../../hooks/useLang'; -import { IS_COMPACT_MENU } from '../../util/windowEnvironment'; +import useAppLayout from '../../hooks/useAppLayout'; import './MenuItem.scss'; @@ -42,6 +42,7 @@ const MenuItem: FC = (props) => { } = props; const lang = useLang(); + const { isTouchScreen } = useAppLayout(); const handleClick = useCallback((e: React.MouseEvent) => { if (disabled || !onClick) { e.stopPropagation(); @@ -73,7 +74,7 @@ const MenuItem: FC = (props) => { className, disabled && 'disabled', destructive && 'destructive', - IS_COMPACT_MENU && 'compact', + !isTouchScreen && 'compact', withWrap && 'wrap', ); diff --git a/src/config.ts b/src/config.ts index b7ed4e6f5..7a914f805 100644 --- a/src/config.ts +++ b/src/config.ts @@ -79,6 +79,14 @@ export const PROFILE_SENSITIVE_AREA = 500; export const TOPIC_LIST_SENSITIVE_AREA = 600; export const COMMON_CHATS_LIMIT = 100; export const GROUP_CALL_PARTICIPANTS_LIMIT = 100; + +// As in Telegram for Android +// https://github.com/DrKLO/Telegram/blob/51e9947527/TMessagesProj/src/main/java/org/telegram/messenger/MediaDataController.java#L7799 +export const TOP_REACTIONS_LIMIT = 100; + +// As in Telegram for Android +// https://github.com/DrKLO/Telegram/blob/51e9947527/TMessagesProj/src/main/java/org/telegram/messenger/MediaDataController.java#L7781 +export const RECENT_REACTIONS_LIMIT = 50; export const REACTION_LIST_LIMIT = 100; export const REACTION_UNREAD_SLICE = 100; export const MENTION_UNREAD_SLICE = 100; @@ -172,6 +180,8 @@ export const RECENT_STICKERS_LIMIT = 20; export const RECENT_STATUS_LIMIT = 20; export const EMOJI_STATUS_LOOP_LIMIT = 2; export const EMOJI_SIZES = 7; +export const TOP_SYMBOL_SET_ID = 'top'; +export const POPULAR_SYMBOL_SET_ID = 'popular'; export const RECENT_SYMBOL_SET_ID = 'recent'; export const FAVORITE_SYMBOL_SET_ID = 'favorite'; export const CHAT_STICKER_SET_ID = 'chatStickers'; diff --git a/src/global/actions/all.ts b/src/global/actions/all.ts index d5fe1e2ad..0172db771 100644 --- a/src/global/actions/all.ts +++ b/src/global/actions/all.ts @@ -11,6 +11,7 @@ import './ui/payments'; import './ui/calls'; import './ui/mediaViewer'; import './ui/passcode'; +import './ui/reactions'; import './api/initial'; import './api/chats'; diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index 04124c836..cbafe348d 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -1,7 +1,10 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { callApi } from '../../../api/gramjs'; -import * as mediaLoader from '../../../util/mediaLoader'; + +import type { ActionReturnType } from '../../types'; import { ApiMediaFormat } from '../../../api/types'; + +import { ANIMATION_LEVEL_MAX } from '../../../config'; import { selectChat, selectChatMessage, selectCurrentChat, selectTabState, @@ -13,12 +16,11 @@ import { addMessageReaction, subtractXForEmojiInteraction, updateUnreadReactions import { addChatMessagesById, addChats, addUsers, updateChatMessage, } from '../../reducers'; -import { buildCollectionByKey, omit } from '../../../util/iteratees'; -import { ANIMATION_LEVEL_MAX } from '../../../config'; -import { isSameReaction, getUserReactions, isMessageLocal } from '../../helpers'; -import type { ActionReturnType } from '../../types'; import { updateTabState } from '../../reducers/tabs'; +import * as mediaLoader from '../../../util/mediaLoader'; +import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import { isSameReaction, getUserReactions, isMessageLocal } from '../../helpers'; const INTERACTION_RANDOM_OFFSET = 40; @@ -38,6 +40,9 @@ addActionHandler('loadAvailableReactions', async (global): Promise => { if (availableReaction.centerIcon) { mediaLoader.fetch(`sticker${availableReaction.centerIcon.id}`, ApiMediaFormat.BlobUrl); } + if (availableReaction.appearAnimation) { + mediaLoader.fetch(`sticker${availableReaction.appearAnimation.id}`, ApiMediaFormat.BlobUrl); + } }); global = getGlobal(); @@ -104,15 +109,20 @@ addActionHandler('sendDefaultReaction', (global, actions, payload): ActionReturn }); }); -addActionHandler('toggleReaction', (global, actions, payload): ActionReturnType => { - const { chatId, reaction, tabId = getCurrentTabId() } = payload; +addActionHandler('toggleReaction', async (global, actions, payload): Promise => { + const { + chatId, + reaction, + shouldAddToRecent, + tabId = getCurrentTabId(), + } = payload; let { messageId } = payload; const chat = selectChat(global, chatId); let message = selectChatMessage(global, chatId, messageId); if (!chat || !message) { - return undefined; + return; } const isInDocumentGroup = Boolean(message.groupedId) && !message.isInAlbum; @@ -131,14 +141,10 @@ addActionHandler('toggleReaction', (global, actions, payload): ActionReturnType ? userReactions.filter((userReaction) => !isSameReaction(userReaction, reaction)) : [...userReactions, reaction]; const limit = selectMaxUserReactions(global); - const reactions = newUserReactions.slice(-limit); - - void callApi('sendReaction', { chat, messageId, reactions }); - const { animationLevel } = global.settings.byKey; - const tabState = selectTabState(global, tabId); + if (animationLevel === ANIMATION_LEVEL_MAX) { const newActiveReactions = hasReaction ? omit(tabState.activeReactions, [messageId]) : { ...tabState.activeReactions, @@ -155,7 +161,21 @@ addActionHandler('toggleReaction', (global, actions, payload): ActionReturnType }, tabId); } - return addMessageReaction(global, message, reactions); + global = addMessageReaction(global, message, reactions); + setGlobal(global); + + try { + await callApi('sendReaction', { + chat, + messageId, + reactions, + shouldAddToRecent, + }); + } catch (error) { + global = getGlobal(); + global = addMessageReaction(global, message, userReactions); + setGlobal(global); + } }); addActionHandler('stopActiveReaction', (global, actions, payload): ActionReturnType => { @@ -395,3 +415,45 @@ addActionHandler('readAllReactions', (global, actions, payload): ActionReturnTyp unreadReactions: undefined, }); }); + +addActionHandler('loadTopReactions', async (global): Promise => { + const result = await callApi('fetchTopReactions', {}); + if (!result) { + return; + } + + global = getGlobal(); + global = { + ...global, + topReactions: result.reactions, + }; + setGlobal(global); +}); + +addActionHandler('loadRecentReactions', async (global): Promise => { + const result = await callApi('fetchRecentReactions', {}); + if (!result) { + return; + } + + global = getGlobal(); + global = { + ...global, + recentReactions: result.reactions, + }; + setGlobal(global); +}); + +addActionHandler('clearRecentReactions', async (global): Promise => { + const result = await callApi('clearRecentReactions'); + if (!result) { + return; + } + + global = getGlobal(); + global = { + ...global, + recentReactions: [], + }; + setGlobal(global); +}); diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index 07bed7269..3fd339cc2 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -38,6 +38,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.loadRecentStickers(); break; + case 'updateRecentReactions': + actions.loadRecentReactions(); + break; + case 'updateRecentEmojiStatuses': actions.loadRecentEmojiStatuses(); break; diff --git a/src/global/actions/ui/reactions.ts b/src/global/actions/ui/reactions.ts new file mode 100644 index 000000000..3bb8a2d89 --- /dev/null +++ b/src/global/actions/ui/reactions.ts @@ -0,0 +1,58 @@ +import { addActionHandler } from '../../index'; + +import type { ActionReturnType } from '../../types'; + +import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import { updateTabState } from '../../reducers/tabs'; +import { selectTabState } from '../../selectors'; + +addActionHandler('openChat', (global, actions, payload): ActionReturnType => { + const { + id, + tabId = getCurrentTabId(), + } = payload; + + if (id) { + return updateTabState(global, { + reactionPicker: { + chatId: id, + messageId: undefined, + position: undefined, + }, + }, tabId); + } + + return updateTabState(global, { + reactionPicker: undefined, + }, tabId); +}); + +addActionHandler('openReactionPicker', (global, actions, payload): ActionReturnType => { + const { + chatId, + messageId, + position, + tabId = getCurrentTabId(), + } = payload!; + + return updateTabState(global, { + reactionPicker: { + chatId, + messageId, + position, + }, + }, tabId); +}); + +addActionHandler('closeReactionPicker', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + const tabState = selectTabState(global, tabId); + + return updateTabState(global, { + reactionPicker: { + ...tabState.reactionPicker, + messageId: undefined, + position: undefined, + }, + }, tabId); +}); diff --git a/src/global/cache.ts b/src/global/cache.ts index 9b509382b..76a4ce85a 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -334,6 +334,8 @@ export function serializeGlobal(global: T) { 'topInlineBots', 'recentEmojis', 'recentCustomEmojis', + 'topReactions', + 'recentReactions', 'push', 'serviceNotifications', 'attachmentSettings', diff --git a/src/global/helpers/reactions.ts b/src/global/helpers/reactions.ts index c22bd71c9..d8ecc2d80 100644 --- a/src/global/helpers/reactions.ts +++ b/src/global/helpers/reactions.ts @@ -4,6 +4,7 @@ import type { ApiReaction, ApiReactions, ApiReactionCount, + ApiAvailableReaction, } from '../../api/types'; import type { GlobalState } from '../types'; @@ -49,6 +50,21 @@ export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatRea return false; } +export function sortReactions( + reactions: T[], + topReactions?: ApiReaction[], +): T[] { + return reactions.slice().sort((left, right) => { + const reactionOne = left ? ('reaction' in left ? left.reaction : left) as ApiReaction : undefined; + const reactionTwo = right ? ('reaction' in right ? right.reaction : right) as ApiReaction : undefined; + const indexOne = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionOne)) || 0; + const indexTwo = topReactions?.findIndex((reaction) => isSameReaction(reaction, reactionTwo)) || 0; + return ( + (indexOne > -1 ? indexOne : Infinity) - (indexTwo > -1 ? indexTwo : Infinity) + ); + }); +} + export function getUserReactions(message: ApiMessage): ApiReaction[] { return message.reactions?.results?.filter((r): r is Required => isReactionChosen(r)) .sort((a, b) => a.chosenOrder - b.chosenOrder) diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 5ba8256d1..c70362a66 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -76,6 +76,8 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'], recentCustomEmojis: ['5377305978079288312'], + topReactions: [], + recentReactions: [], stickers: { setsById: {}, diff --git a/src/global/selectors/ui.ts b/src/global/selectors/ui.ts index 5f90bb144..40e6b74bb 100644 --- a/src/global/selectors/ui.ts +++ b/src/global/selectors/ui.ts @@ -74,3 +74,10 @@ export function selectIsForumPanelOpen( tabState.globalSearch.query === undefined || tabState.globalSearch.isClosing ); } +export function selectIsReactionPickerOpen( + global: T, + ...[tabId = getCurrentTabId()]: TabArgs +) { + const { reactionPicker } = selectTabState(global, tabId); + return Boolean(reactionPicker?.position); +} diff --git a/src/global/types.ts b/src/global/types.ts index edfebf16c..edb4dd405 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -68,6 +68,7 @@ import type { EmojiKeywords, FocusDirection, GlobalSearchContent, + IAnchorPosition, InlineBotSettings, ISettings, IThemeSettings, @@ -246,6 +247,12 @@ export type TabState = { messageId: number; }; + reactionPicker?: { + chatId?: string; + messageId?: number; + position?: IAnchorPosition; + }; + inlineBots: { isLoading: boolean; byUsername: Record; @@ -687,6 +694,8 @@ export type GlobalState = { recentEmojis: string[]; recentCustomEmojis: string[]; + topReactions: ApiReaction[]; + recentReactions: ApiReaction[]; stickers: { setsById: Record; @@ -1717,7 +1726,10 @@ export interface ActionPayloads { }; // Reactions + loadTopReactions: undefined; + loadRecentReactions: undefined; loadAvailableReactions: undefined; + clearRecentReactions: undefined; loadMessageReactions: { chatId: string; @@ -1728,6 +1740,7 @@ export interface ActionPayloads { chatId: string; messageId: number; reaction: ApiReaction; + shouldAddToRecent?: boolean; } & WithTabId; setDefaultReaction: { @@ -1748,6 +1761,13 @@ export interface ActionPayloads { reaction: ApiReaction; } & WithTabId; + openReactionPicker: { + chatId: string; + messageId: number; + position: IAnchorPosition; + } & WithTabId; + closeReactionPicker: WithTabId | undefined; + // Media Viewer & Audio Player openMediaViewer: { chatId?: string; diff --git a/src/hooks/useAppLayout.ts b/src/hooks/useAppLayout.ts index 054aa3fd6..62ede8d39 100644 --- a/src/hooks/useAppLayout.ts +++ b/src/hooks/useAppLayout.ts @@ -11,7 +11,7 @@ import { createCallbackManager } from '../util/callbacks'; import { updateSizes } from '../util/windowSize'; import useForceUpdate from './useForceUpdate'; -type MediaQueryCacheKey = 'mobile' | 'tablet' | 'landscape'; +type MediaQueryCacheKey = 'mobile' | 'tablet' | 'landscape' | 'touch'; const mediaQueryCache = new Map(); const callbacks = createCallbackManager(); @@ -19,6 +19,7 @@ const callbacks = createCallbackManager(); let isMobile: boolean | undefined; let isTablet: boolean | undefined; let isLandscape: boolean | undefined; +let isTouchScreen: boolean | undefined; export function getIsMobile() { return isMobile; @@ -32,6 +33,7 @@ function handleMediaQueryChange() { isMobile = mediaQueryCache.get('mobile')?.matches || false; isTablet = !isMobile && (mediaQueryCache.get('tablet')?.matches || false); isLandscape = mediaQueryCache.get('landscape')?.matches || false; + isTouchScreen = mediaQueryCache.get('touch')?.matches || false; updateSizes(); callbacks.runCallbacks(); } @@ -56,6 +58,10 @@ function initMediaQueryCache() { ); mediaQueryCache.set('landscape', landscapeQuery); landscapeQuery.addEventListener('change', handleMediaQueryChange); + + const isTouchScreenQuery = window.matchMedia('(pointer: coarse)'); + mediaQueryCache.set('touch', isTouchScreenQuery); + isTouchScreenQuery.addEventListener('change', handleMediaQueryChange); } initMediaQueryCache(); @@ -71,5 +77,6 @@ export default function useAppLayout() { isTablet, isLandscape, isDesktop: !isMobile && !isTablet, + isTouchScreen, }; } diff --git a/src/hooks/useBoundsInSharedCanvas.ts b/src/hooks/useBoundsInSharedCanvas.ts index aa8bcb989..ab0ae032d 100644 --- a/src/hooks/useBoundsInSharedCanvas.ts +++ b/src/hooks/useBoundsInSharedCanvas.ts @@ -31,7 +31,9 @@ export default function useBoundsInSharedCanvas( return; } - const target = container.classList.contains('sticker-set-cover') ? container : container.querySelector('img')!; + const target = container.classList.contains('sticker-set-cover') || container.classList.contains('sticker-reaction') + ? container + : container.querySelector('img')!; const targetBounds = target.getBoundingClientRect(); const canvasBounds = canvas.getBoundingClientRect(); diff --git a/src/hooks/useContextMenuPosition.ts b/src/hooks/useMenuPosition.ts similarity index 73% rename from src/hooks/useContextMenuPosition.ts rename to src/hooks/useMenuPosition.ts index 356f4139b..624e32aa6 100644 --- a/src/hooks/useContextMenuPosition.ts +++ b/src/hooks/useMenuPosition.ts @@ -6,7 +6,10 @@ interface Layout { extraTopPadding?: number; marginSides?: number; extraMarginTop?: number; + menuElMinWidth?: number; + shouldAvoidNegativePosition?: boolean; withPortal?: boolean; + isDense?: boolean; // Allows you to place the menu as close to the edges of the area as possible } const MENU_POSITION_VISUAL_COMFORT_SPACE_PX = 16; @@ -15,7 +18,7 @@ const EMPTY_RECT = { width: 0, left: 0, height: 0, top: 0, }; -export default function useContextMenuPosition( +export default function useMenuPosition( anchor: IAnchorPosition | undefined, getTriggerElement: () => HTMLElement | null, getRootElement: () => HTMLElement | null, @@ -48,21 +51,25 @@ export default function useContextMenuPosition( extraTopPadding = 0, marginSides = 0, extraMarginTop = 0, + menuElMinWidth = 0, + shouldAvoidNegativePosition = false, withPortal = false, + isDense = false, } = getLayout?.() || {}; const marginTop = menuEl ? parseInt(getComputedStyle(menuEl).marginTop, 10) + extraMarginTop : undefined; + const { offsetWidth: menuElWidth, offsetHeight: menuElHeight } = menuEl || { offsetWidth: 0, offsetHeight: 0 }; const menuRect = menuEl ? { - width: menuEl.offsetWidth, - height: menuEl.offsetHeight + marginTop!, + width: Math.max(menuElWidth, menuElMinWidth), + height: menuElHeight + marginTop!, } : EMPTY_RECT; const rootRect = rootEl ? rootEl.getBoundingClientRect() : EMPTY_RECT; let horizontalPosition: 'left' | 'right'; let verticalPosition: 'top' | 'bottom'; - if (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left) { + if (isDense || (x + menuRect.width + extraPaddingX < rootRect.width + rootRect.left)) { x += 3; horizontalPosition = 'left'; } else if (x - menuRect.width - rootRect.left > 0) { @@ -87,7 +94,7 @@ export default function useContextMenuPosition( } } - if (y + menuRect.height < rootRect.height + rootRect.top) { + if (isDense || (y + menuRect.height < rootRect.height + rootRect.top)) { verticalPosition = 'top'; } else { verticalPosition = 'bottom'; @@ -107,12 +114,23 @@ export default function useContextMenuPosition( x - triggerRect.left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX, ); - const left = (horizontalPosition === 'left' - ? (withPortal + let left = (horizontalPosition === 'left' + ? (withPortal || shouldAvoidNegativePosition ? Math.max(MENU_POSITION_VISUAL_COMFORT_SPACE_PX, leftWithPossibleNegative) : leftWithPossibleNegative) : (x - triggerRect.left)) + addedXForPortalPositioning; - const top = y - triggerRect.top + addedYForPortalPositioning; + let top = y - triggerRect.top + addedYForPortalPositioning; + + if (isDense) { + left = Math.min(left, rootRect.width - menuRect.width - MENU_POSITION_VISUAL_COMFORT_SPACE_PX); + top = Math.min(top, rootRect.height - menuRect.height - MENU_POSITION_VISUAL_COMFORT_SPACE_PX); + } + + // Avoid hiding external parts of menus on mobile devices behind the edges of the screen (ReactionSelector for example) + const addedXForMenuPositioning = menuElMinWidth ? Math.max(0, (menuElMinWidth - menuElWidth) / 2) : 0; + if (left - addedXForMenuPositioning < 0 && shouldAvoidNegativePosition) { + left = addedXForMenuPositioning + MENU_POSITION_VISUAL_COMFORT_SPACE_PX; + } const menuMaxHeight = rootRect.height - MENU_POSITION_BOTTOM_MARGIN - (marginTop || 0); diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 4cd18589f..877c45ace 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1278,6 +1278,9 @@ messages.transcribeAudio#269e9a49 peer:InputPeer msg_id:int = messages.Transcrib messages.getCustomEmojiDocuments#d9ab0f54 document_id:Vector = Vector; messages.getEmojiStickers#fbfca18f hash:long = messages.AllStickers; messages.getFeaturedEmojiStickers#ecf6736 hash:long = messages.FeaturedStickers; +messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; +messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; +messages.clearRecentReactions#9dfeefb4 = Bool; messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 3941ec7a4..411b1aaa8 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -264,6 +264,9 @@ "messages.getFeaturedEmojiStickers", "messages.readReactions", "messages.getUnreadReactions", + "messages.getTopReactions", + "messages.getRecentReactions", + "messages.clearRecentReactions", "messages.readMentions", "messages.getUnreadMentions", "help.getPremiumPromo", diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 120125aae..ae3177685 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -219,6 +219,7 @@ $color-message-reaction-own-hover: #b5e0a4; --z-ui-loader-mask: 2000; --z-notification: 1700; --z-confetti: 1600; + --z-reaction-picker: 1200; --z-right-column: 900; --z-header-menu: 990; --z-header-menu-backdrop: 980; diff --git a/src/types/index.ts b/src/types/index.ts index c7d1578c4..bb70e6b5b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,7 +3,7 @@ import type { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm, ApiChatInviteImporter, ApiExportedInvite, - ApiLanguage, ApiMessage, ApiStickerSet, + ApiLanguage, ApiMessage, ApiReaction, ApiStickerSet, } from '../api/types'; export type TextPart = TeactNode; @@ -223,10 +223,10 @@ export enum SettingsScreens { DoNotTranslate, } -export type StickerSetOrRecent = Pick; +)> & { reactions?: ApiReaction[] }; export enum LeftColumnContent { ChatList, diff --git a/src/util/windowEnvironment.ts b/src/util/windowEnvironment.ts index d0ae75f2b..6242ed1bd 100644 --- a/src/util/windowEnvironment.ts +++ b/src/util/windowEnvironment.ts @@ -109,7 +109,6 @@ if (IS_OPFS_SUPPORTED) { export const IS_OFFSET_PATH_SUPPORTED = CSS.supports('offset-rotate: 0deg'); export const IS_BACKDROP_BLUR_SUPPORTED = CSS.supports('backdrop-filter: blur()') || CSS.supports('-webkit-backdrop-filter: blur()'); -export const IS_COMPACT_MENU = !IS_TOUCH_ENV; export const IS_INSTALL_PROMPT_SUPPORTED = 'onbeforeinstallprompt' in window; export const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in window; export const IS_OPEN_IN_NEW_TAB_SUPPORTED = IS_MULTITAB_SUPPORTED && !(IS_PWA && IS_MOBILE);