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 a9d5191fc..000000000 Binary files a/src/assets/reaction-thumbs.png and /dev/null differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index d9e2f710f..4f2f21d8e 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -45,6 +45,7 @@ export { default as StickerSetModal } from '../components/common/StickerSetModal export { default as CustomEmojiSetsModal } from '../components/common/CustomEmojiSetsModal'; export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer'; export { default as MobileSearch } from '../components/middle/MobileSearch'; +export { default as ReactionPicker } from '../components/middle/message/ReactionPicker'; export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; export { default as PollModal } from '../components/middle/composer/PollModal'; diff --git a/src/components/common/CustomEmoji.tsx b/src/components/common/CustomEmoji.tsx index b52262aa2..41eb2df5d 100644 --- a/src/components/common/CustomEmoji.tsx +++ b/src/components/common/CustomEmoji.tsx @@ -29,6 +29,7 @@ type OwnProps = { loopLimit?: number; style?: string; isBig?: boolean; + noPlay?: boolean; withGridFix?: boolean; withSharedAnimation?: boolean; sharedCanvasRef?: React.RefObject; @@ -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);