From bbb5ef0a86b8e9d8569fa54871a334b087b313e6 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Mon, 15 Jul 2024 15:50:42 +0200 Subject: [PATCH] Message: Display effects (#4697) --- src/api/gramjs/apiBuilders/messages.ts | 1 + src/api/gramjs/apiBuilders/reactions.ts | 16 +++ src/api/gramjs/methods/reactions.ts | 17 +++ src/api/types/messages.ts | 10 ++ src/components/common/StickerButton.tsx | 7 +- src/components/common/StickerSet.tsx | 7 +- src/components/common/icons/Icon.tsx | 3 + src/components/main/Main.tsx | 16 +-- src/components/middle/message/Message.scss | 2 +- src/components/middle/message/Message.tsx | 60 +++++++--- .../middle/message/MessageEffect.module.scss | 21 ++++ .../middle/message/MessageEffect.tsx | 105 ++++++++++++++++++ .../middle/message/MessageMeta.scss | 13 ++- src/components/middle/message/MessageMeta.tsx | 18 ++- .../middle/message/Sticker.module.scss | 23 ++++ src/components/middle/message/Sticker.scss | 24 ---- src/components/middle/message/Sticker.tsx | 62 ++++++----- .../message/hooks/useOverlayPosition.ts | 65 +++++++++++ src/global/actions/api/reactions.ts | 16 +++ src/global/cache.ts | 1 + src/global/initialState.ts | 1 + src/global/types.ts | 4 + src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/styles/_variables.scss | 1 + 25 files changed, 411 insertions(+), 84 deletions(-) create mode 100644 src/components/middle/message/MessageEffect.module.scss create mode 100644 src/components/middle/message/MessageEffect.tsx create mode 100644 src/components/middle/message/Sticker.module.scss delete mode 100644 src/components/middle/message/Sticker.scss create mode 100644 src/components/middle/message/hooks/useOverlayPosition.ts diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 94feb784c..c46e29f63 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -235,6 +235,7 @@ export function buildApiMessageWithChatId( senderBoosts, viaBusinessBotId: mtpMessage.viaBusinessBotId?.toString(), factCheck, + effectId: mtpMessage.effect?.toString(), isInvertedMedia, }); } diff --git a/src/api/gramjs/apiBuilders/reactions.ts b/src/api/gramjs/apiBuilders/reactions.ts index 744900e09..0e1f10c1c 100644 --- a/src/api/gramjs/apiBuilders/reactions.ts +++ b/src/api/gramjs/apiBuilders/reactions.ts @@ -1,6 +1,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { + ApiAvailableEffect, ApiAvailableReaction, ApiPeerReaction, ApiReaction, @@ -117,3 +118,18 @@ export function buildApiAvailableReaction(availableReaction: GramJs.AvailableRea isPremium: premium, }; } + +export function buildApiAvailableEffect(availableEffect: GramJs.AvailableEffect): ApiAvailableEffect { + const { + id, emoticon, premiumRequired, staticIconId, effectStickerId, effectAnimationId, + } = availableEffect; + + return { + id: id.toString(), + emoticon, + isPremium: premiumRequired, + staticIconId: staticIconId?.toString(), + effectStickerId: effectStickerId.toString(), + effectAnimationId: effectAnimationId?.toString(), + }; +} diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index 961bf454a..c595e3f2b 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -12,6 +12,7 @@ import { import { split } from '../../../util/iteratees'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { + buildApiAvailableEffect, buildApiAvailableReaction, buildApiReaction, buildApiSavedReactionTag, @@ -95,6 +96,22 @@ export async function fetchAvailableReactions() { return result.reactions.map(buildApiAvailableReaction); } +export async function fetchAvailableEffects() { + const result = await invokeRequest(new GramJs.messages.GetAvailableEffects({})); + + if (!result || result instanceof GramJs.messages.AvailableEffectsNotModified) { + return undefined; + } + + result.documents.forEach((document) => { + if (document instanceof GramJs.Document) { + localDb.documents[String(document.id)] = document; + } + }); + + return result.effects.map(buildApiAvailableEffect); +} + export function sendReaction({ chat, messageId, reactions, shouldAddToRecent, }: { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index e338fc806..0f8acf3bf 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -606,6 +606,7 @@ export interface ApiMessage { savedPeerId?: string; senderBoosts?: number; factCheck?: ApiFactCheck; + effectId?: string; isInvertedMedia?: true; } @@ -645,6 +646,15 @@ export interface ApiAvailableReaction { isPremium?: boolean; } +export interface ApiAvailableEffect { + id: string; + emoticon: string; + staticIconId?: string; + effectAnimationId?: string; + effectStickerId: string; + isPremium?: boolean; +} + type ApiChatReactionsAll = { type: 'all'; areCustomAllowed?: true; diff --git a/src/components/common/StickerButton.tsx b/src/components/common/StickerButton.tsx index 715604a55..0df89b3e3 100644 --- a/src/components/common/StickerButton.tsx +++ b/src/components/common/StickerButton.tsx @@ -22,6 +22,7 @@ import useOldLang from '../../hooks/useOldLang'; import Button from '../ui/Button'; import Menu from '../ui/Menu'; import MenuItem from '../ui/MenuItem'; +import Icon from './icons/Icon'; import StickerView from './StickerView'; import './StickerButton.scss'; @@ -311,12 +312,12 @@ const StickerButton = - + )} {!noShowPremium && isPremium && !isLocked && (
- +
)} {shouldShowCloseButton && ( @@ -327,7 +328,7 @@ const StickerButton = - + )} {Boolean(contextMenuItems.length) && ( diff --git a/src/components/common/StickerSet.tsx b/src/components/common/StickerSet.tsx index afe8619bc..68b7de26c 100644 --- a/src/components/common/StickerSet.tsx +++ b/src/components/common/StickerSet.tsx @@ -32,6 +32,7 @@ import useWindowSize from '../../hooks/window/useWindowSize'; import Button from '../ui/Button'; import ConfirmDialog from '../ui/ConfirmDialog'; +import Icon from './icons/Icon'; import ReactionEmoji from './ReactionEmoji'; import StickerButton from './StickerButton'; @@ -268,7 +269,7 @@ const StickerSet: FC = ({ {!shouldHideHeader && (

- {isLocked && } + {isLocked && } {stickerSet.title} {(isChatEmojiSet || isChatStickerSet) && ( {lang(isChatEmojiSet ? 'GroupEmoji' : 'GroupStickers')} @@ -280,7 +281,7 @@ const StickerSet: FC = ({ )}

{isRecent && ( - + )} {withAddSetButton && ( )} {shouldRender && stickerSet.reactions?.map((reaction) => { diff --git a/src/components/common/icons/Icon.tsx b/src/components/common/icons/Icon.tsx index 4c4625bba..ef84d79fe 100644 --- a/src/components/common/icons/Icon.tsx +++ b/src/components/common/icons/Icon.tsx @@ -11,6 +11,7 @@ type OwnProps = { style?: string; role?: AriaRole; ariaLabel?: string; + onClick?: (e: React.MouseEvent) => void; }; const Icon = ({ @@ -19,6 +20,7 @@ const Icon = ({ style, role, ariaLabel, + onClick, }: OwnProps) => { return ( ); }; diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 5631060dc..fc9ee73ad 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -249,6 +249,7 @@ const Main: FC = ({ loadTimezones, loadQuickReplies, loadStarStatus, + loadAvailableEffects, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -310,26 +311,27 @@ const Main: FC = ({ initMain(); loadAvailableReactions(); loadAnimatedEmojis(); - loadBirthdayNumbersStickers(); - loadGenericEmojiEffects(); loadNotificationSettings(); loadNotificationExceptions(); - loadTopInlineBots(); - loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG }); loadAttachBots(); loadContactList(); - loadPremiumGifts(); loadDefaultTopicIcons(); checkAppVersion(); loadTopReactions(); loadRecentReactions(); loadDefaultTagReactions(); loadFeaturedEmojiStickers(); - loadAuthorizations(); - loadSavedReactionTags(); + loadTopInlineBots(); + loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG }); loadTimezones(); loadQuickReplies(); loadStarStatus(); + loadPremiumGifts(); + loadAvailableEffects(); + loadBirthdayNumbersStickers(); + loadGenericEmojiEffects(); + loadSavedReactionTags(); + loadAuthorizations(); } }, [isMasterTab, isSynced]); diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index b006cecf3..550a73be3 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -140,7 +140,7 @@ } } - &.has-active-reaction { + &.has-active-effect { .message-content-wrapper { z-index: 1; } diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index b0cf60a38..ecf1c5abc 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -5,6 +5,7 @@ import React, { import { getActions, withGlobal } from '../../../global'; import type { + ApiAvailableEffect, ApiAvailableReaction, ApiChat, ApiChatMember, @@ -166,6 +167,7 @@ import Invoice from './Invoice'; import InvoiceMediaPreview from './InvoiceMediaPreview'; import Location from './Location'; import MessageAppendix from './MessageAppendix'; +import MessageEffect from './MessageEffect'; import MessageMeta from './MessageMeta'; import MessagePhoneCall from './MessagePhoneCall'; import Photo from './Photo'; @@ -278,7 +280,7 @@ type StateProps = { shouldDetectChatLanguage?: boolean; requestedTranslationLanguage?: string; requestedChatTranslationLanguage?: string; - withStickerEffects?: boolean; + withAnimatedEffects?: boolean; webPageStory?: ApiTypeStory; isConnected: boolean; isLoadingComments?: boolean; @@ -287,6 +289,7 @@ type StateProps = { tags?: Record; canTranscribeVoice?: boolean; viaBusinessBot?: ApiUser; + effect?: ApiAvailableEffect; }; type MetaPosition = @@ -397,7 +400,7 @@ const Message: FC = ({ shouldDetectChatLanguage, requestedTranslationLanguage, requestedChatTranslationLanguage, - withStickerEffects, + withAnimatedEffects, webPageStory, isConnected, getIsMessageListReady, @@ -406,6 +409,7 @@ const Message: FC = ({ tags, canTranscribeVoice, viaBusinessBot, + effect, onPinnedIntersectionChange, }) => { const { @@ -429,7 +433,7 @@ const Message: FC = ({ const lang = useOldLang(); const [isTranscriptionHidden, setTranscriptionHidden] = useState(false); - const [hasActiveStickerEffect, startStickerEffect, stopStickerEffect] = useFlag(); + const [shouldPlayEffect, requestEffect, hideEffect] = useFlag(); const { isMobile, isTouchScreen } = useAppLayout(); useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); @@ -622,6 +626,12 @@ const Message: FC = ({ isRepliesChat, ); + const handleEffectClick = useLastCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + requestEffect(); + }); + useEffect(() => { if (!isLastInList) { return; @@ -662,7 +672,7 @@ const Message: FC = ({ isSwiped && 'is-swiped', transitionClassNames, isJustAdded && 'is-just-added', - (hasActiveReactions || hasActiveStickerEffect) && 'has-active-reaction', + (hasActiveReactions || shouldPlayEffect) && 'has-active-effect', isStoryMention && 'is-story-mention', ); @@ -679,6 +689,14 @@ const Message: FC = ({ const { replyToMsgId, replyToPeerId, isQuote } = messageReplyInfo || {}; const { peerId: storyReplyPeerId, storyId: storyReplyId } = storyReplyInfo || {}; + useEffect(() => { + if ((sticker?.hasEffect || effect) && (( + memoFirstUnreadIdRef.current && messageId >= memoFirstUnreadIdRef.current + ) || isLocal)) { + requestEffect(); + } + }, [effect, isLocal, memoFirstUnreadIdRef, messageId, sticker?.hasEffect]); + const detectedLanguage = useTextLanguage( text?.text, !(areTranslationsEnabled || shouldDetectChatLanguage), @@ -980,7 +998,9 @@ const Message: FC = ({ } availableReactions={availableReactions} isTranslated={Boolean(requestedTranslationLanguage ? currentTranslatedText : undefined)} + effectEmoji={effect?.emoticon} onClick={handleMetaClick} + onEffectClick={handleEffectClick} onTranslationClick={handleTranslationClick} onOpenThread={handleOpenThread} /> @@ -1066,20 +1086,15 @@ const Message: FC = ({ observeIntersection={observeIntersectionForLoading} observeIntersectionForPlaying={observeIntersectionForPlaying} shouldLoop={shouldLoopStickers} - shouldPlayEffect={( - sticker.hasEffect && (( - memoFirstUnreadIdRef.current && messageId >= memoFirstUnreadIdRef.current - ) || isLocal) - ) || undefined} - withEffect={withStickerEffects} - onPlayEffect={startStickerEffect} - onStopEffect={stopStickerEffect} + shouldPlayEffect={shouldPlayEffect} + withEffect={withAnimatedEffects} + onStopEffect={hideEffect} /> )} {hasAnimatedEmoji && animatedCustomEmoji && ( = ({ {hasAnimatedEmoji && animatedEmoji && ( = ({ activeEmojiInteractions={activeEmojiInteractions} /> )} + {withAnimatedEffects && effect && ( + + )} {phoneCall && ( ( message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup, } = ownProps; const { - id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, + id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, effectId, } = message; const chat = selectChat(global, chatId); @@ -1728,6 +1753,8 @@ export default memo(withGlobal( const viaBusinessBot = viaBusinessBotId ? selectUser(global, viaBusinessBotId) : undefined; + const effect = effectId ? global.availableEffectById[effectId] : undefined; + return { theme: selectTheme(global), forceSenderName, @@ -1793,7 +1820,7 @@ export default memo(withGlobal( requestedTranslationLanguage, requestedChatTranslationLanguage, hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), - withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), + withAnimatedEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), webPageStory, isConnected, isLoadingComments: repliesThreadInfo?.isCommentsInfo @@ -1813,6 +1840,7 @@ export default memo(withGlobal( tags: global.savedReactionTags?.byKey, canTranscribeVoice, viaBusinessBot, + effect, }; }, )(Message)); diff --git a/src/components/middle/message/MessageEffect.module.scss b/src/components/middle/message/MessageEffect.module.scss new file mode 100644 index 000000000..1da22c34e --- /dev/null +++ b/src/components/middle/message/MessageEffect.module.scss @@ -0,0 +1,21 @@ +.anchor { + position: absolute; + bottom: 0; + right: 0; +} + +.mirrorAnchor { + right: auto; + left: 0; +} + +.root { + position: fixed; + z-index: var(--z-message-effect); + + pointer-events: none; +} + +.mirror { + transform: scaleX(-1); +} diff --git a/src/components/middle/message/MessageEffect.tsx b/src/components/middle/message/MessageEffect.tsx new file mode 100644 index 000000000..1eba57562 --- /dev/null +++ b/src/components/middle/message/MessageEffect.tsx @@ -0,0 +1,105 @@ +import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; + +import type { ApiAvailableEffect, ApiMessage } from '../../../api/types'; + +import buildClassName from '../../../util/buildClassName'; + +import useFlag from '../../../hooks/useFlag'; +import { type ObserveFn, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useMedia from '../../../hooks/useMedia'; +import useOverlayPosition from './hooks/useOverlayPosition'; + +import AnimatedSticker from '../../common/AnimatedSticker'; +import Portal from '../../ui/Portal'; + +import styles from './MessageEffect.module.scss'; + +type OwnProps = { + message: ApiMessage; + effect: ApiAvailableEffect; + shouldPlay?: boolean; + observeIntersectionForLoading: ObserveFn; + observeIntersectionForPlaying: ObserveFn; + onStop?: VoidFunction; +}; + +const EFFECT_SIZE = 256; + +const MessageEffect = ({ + message, + effect, + shouldPlay, + observeIntersectionForLoading, + observeIntersectionForPlaying, + onStop, +}: OwnProps) => { + // eslint-disable-next-line no-null/no-null + const anchorRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const canLoad = useIsIntersecting(anchorRef, observeIntersectionForLoading); + const canPlay = useIsIntersecting(anchorRef, observeIntersectionForPlaying); + + const [isPlaying, startPlaying, stopPlaying] = useFlag(); + + const effectHash = getEffectHash(effect); + const effectBlob = useMedia(effectHash, !canLoad); + + const isMirrored = !message.isOutgoing; + + const handleEnded = useLastCallback(() => { + stopPlaying(); + onStop?.(); + }); + + useEffect(() => { + if (canPlay && shouldPlay && effectBlob) { + startPlaying(); + } + }, [canPlay, effectBlob, shouldPlay]); + + useOverlayPosition({ + anchorRef, + overlayRef: ref, + isMirrored, + isDisabled: !isPlaying, + isForMessageEffect: true, + }); + + const effectClassName = buildClassName( + styles.root, + isMirrored && styles.mirror, + ); + + return ( +
+ {isPlaying && ( + + + + )} +
+ ); +}; + +function getEffectHash(effect: ApiAvailableEffect) { + if (effect.effectAnimationId) { + return `sticker${effect.effectAnimationId}`; + } + + return `sticker${effect.effectStickerId}?size=f`; +} + +export default memo(MessageEffect); diff --git a/src/components/middle/message/MessageMeta.scss b/src/components/middle/message/MessageMeta.scss index e3a9ad527..34acc22ee 100644 --- a/src/components/middle/message/MessageMeta.scss +++ b/src/components/middle/message/MessageMeta.scss @@ -19,7 +19,8 @@ .message-views, .message-replies, .message-translated, - .message-pinned { + .message-pinned, + .message-effect-icon { font-size: 0.75rem; white-space: nowrap; } @@ -47,6 +48,16 @@ margin-inline-end: 0.25rem; } + .message-effect-icon { + margin-inline-end: 0.25rem; + + color: var(--color-text); + & > .emoji { + width: 1rem !important; + height: 1rem !important; + } + } + .message-pinned { margin-inline-end: 0.1875rem; } diff --git a/src/components/middle/message/MessageMeta.tsx b/src/components/middle/message/MessageMeta.tsx index 912ab339d..f811d84f9 100644 --- a/src/components/middle/message/MessageMeta.tsx +++ b/src/components/middle/message/MessageMeta.tsx @@ -15,6 +15,7 @@ import useFlag from '../../../hooks/useFlag'; import useOldLang from '../../../hooks/useOldLang'; import AnimatedCounter from '../../common/AnimatedCounter'; +import Icon from '../../common/icons/Icon'; import MessageOutgoingStatus from '../../common/MessageOutgoingStatus'; import './MessageMeta.scss'; @@ -30,8 +31,10 @@ type OwnProps = { isTranslated?: boolean; isPinned?: boolean; withFullDate?: boolean; + effectEmoji?: string; onClick: (e: React.MouseEvent) => void; onTranslationClick: (e: React.MouseEvent) => void; + onEffectClick: (e: React.MouseEvent) => void; renderQuickReactionButton?: () => TeactNode | undefined; onOpenThread: NoneToVoidFunction; }; @@ -47,8 +50,10 @@ const MessageMeta: FC = ({ isTranslated, isPinned, withFullDate, + effectEmoji, onClick, onTranslationClick, + onEffectClick, onOpenThread, }) => { const { showNotification } = getActions(); @@ -118,15 +123,20 @@ const MessageMeta: FC = ({ onClick={onClick} data-ignore-on-paste > + {effectEmoji && ( + + {renderText(effectEmoji)} + + )} {isTranslated && ( - + )} {Boolean(message.viewsCount) && ( <> {formatIntegerCompact(message.viewsCount!)} - + )} {!noReplies && Boolean(repliesThreadInfo?.messagesCount) && ( @@ -134,11 +144,11 @@ const MessageMeta: FC = ({ - + )} {isPinned && ( - + )} {signature && ( {renderText(signature)} diff --git a/src/components/middle/message/Sticker.module.scss b/src/components/middle/message/Sticker.module.scss new file mode 100644 index 000000000..a5afc3c38 --- /dev/null +++ b/src/components/middle/message/Sticker.module.scss @@ -0,0 +1,23 @@ +.root { + overflow: visible !important; + contain: layout; + position: relative; + + &:not(.inactive) { + cursor: var(--custom-cursor, pointer); + } +} + +.mirrored { + transform: scaleX(-1); +} + +.inactive { + pointer-events: none; +} + +.effect { + position: fixed; + z-index: var(--z-message-effect); + pointer-events: none; +} diff --git a/src/components/middle/message/Sticker.scss b/src/components/middle/message/Sticker.scss deleted file mode 100644 index f8c5a18ca..000000000 --- a/src/components/middle/message/Sticker.scss +++ /dev/null @@ -1,24 +0,0 @@ -.Sticker { - overflow: visible !important; - contain: layout; - position: relative; - - &.reversed { - transform: scaleX(-1); - } - - &:not(.inactive) { - cursor: var(--custom-cursor, pointer); - } - - &.inactive { - pointer-events: none; - } - - .effect-sticker { - position: absolute; - top: 50%; - right: -1rem; - transform: translateY(-50%); - } -} diff --git a/src/components/middle/message/Sticker.tsx b/src/components/middle/message/Sticker.tsx index 431f12174..ac409ec68 100644 --- a/src/components/middle/message/Sticker.tsx +++ b/src/components/middle/message/Sticker.tsx @@ -17,12 +17,13 @@ import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; import useMedia from '../../../hooks/useMedia'; import useOldLang from '../../../hooks/useOldLang'; -import usePrevious from '../../../hooks/usePrevious'; +import useOverlayPosition from './hooks/useOverlayPosition'; import AnimatedSticker from '../../common/AnimatedSticker'; import StickerView from '../../common/StickerView'; +import Portal from '../../ui/Portal'; -import './Sticker.scss'; +import styles from './Sticker.module.scss'; // https://github.com/telegramdesktop/tdesktop/blob/master/Telegram/SourceFiles/history/view/media/history_view_sticker.cpp#L42 const EFFECT_SIZE_MULTIPLIER = 1 + 0.245 * 2; @@ -34,13 +35,12 @@ type OwnProps = { shouldLoop?: boolean; shouldPlayEffect?: boolean; withEffect?: boolean; - onPlayEffect?: VoidFunction; onStopEffect?: VoidFunction; }; const Sticker: FC = ({ message, observeIntersection, observeIntersectionForPlaying, shouldLoop, - shouldPlayEffect, withEffect, onPlayEffect, onStopEffect, + shouldPlayEffect, withEffect, onStopEffect, }) => { const { showNotification, openStickerSet } = getActions(); @@ -50,8 +50,12 @@ const Sticker: FC = ({ // eslint-disable-next-line no-null/no-null const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const effectRef = useRef(null); + const sticker = message.content.sticker!; const { stickerSetInfo, isVideo, hasEffect } = sticker; + const isMirrored = !message.isOutgoing; const mediaHash = sticker.isPreloadedGlobally ? undefined : ( getMessageMediaHash(message, isVideo && !IS_WEBM_SUPPORTED ? 'pictogram' : 'inline')! @@ -62,7 +66,7 @@ const Sticker: FC = ({ const mediaHashEffect = `sticker${sticker.id}?size=f`; const effectBlobUrl = useMedia( mediaHashEffect, - !canLoad || !hasEffect, + !canLoad || !hasEffect || !withEffect, ApiMediaFormat.BlobUrl, ); const [isPlayingEffect, startPlayingEffect, stopPlayingEffect] = useFlag(); @@ -72,14 +76,19 @@ const Sticker: FC = ({ onStopEffect?.(); }); - const previousShouldPlayEffect = usePrevious(shouldPlayEffect); - useEffect(() => { - if (hasEffect && withEffect && canPlay && (shouldPlayEffect || previousShouldPlayEffect)) { + if (hasEffect && withEffect && canPlay && shouldPlayEffect) { startPlayingEffect(); - onPlayEffect?.(); } - }, [hasEffect, canPlay, onPlayEffect, shouldPlayEffect, previousShouldPlayEffect, startPlayingEffect, withEffect]); + }, [hasEffect, canPlay, shouldPlayEffect, startPlayingEffect, withEffect]); + + const shouldRenderEffect = hasEffect && withEffect && effectBlobUrl && isPlayingEffect; + useOverlayPosition({ + anchorRef: ref, + overlayRef: effectRef, + isMirrored, + isDisabled: !shouldRenderEffect, + }); const openModal = useLastCallback(() => { openStickerSet({ @@ -103,7 +112,6 @@ const Sticker: FC = ({ return; } else if (withEffect) { startPlayingEffect(); - onPlayEffect?.(); return; } } @@ -113,9 +121,10 @@ const Sticker: FC = ({ const isMemojiSticker = 'isMissing' in stickerSetInfo; const { width, height } = getStickerDimensions(sticker, isMobile); const className = buildClassName( - 'Sticker media-inner', - isMemojiSticker && 'inactive', - hasEffect && !message.isOutgoing && 'reversed', + 'media-inner', + styles.root, + isMemojiSticker && styles.inactive, + hasEffect && isMirrored && styles.mirrored, ); return ( @@ -136,17 +145,20 @@ const Sticker: FC = ({ noPlay={!canPlay} withSharedAnimation /> - {hasEffect && withEffect && canLoad && isPlayingEffect && ( - + {shouldRenderEffect && ( + + + )}
); diff --git a/src/components/middle/message/hooks/useOverlayPosition.ts b/src/components/middle/message/hooks/useOverlayPosition.ts new file mode 100644 index 000000000..52026a07d --- /dev/null +++ b/src/components/middle/message/hooks/useOverlayPosition.ts @@ -0,0 +1,65 @@ +import { type RefObject } from 'react'; +import { useEffect } from '../../../../lib/teact/teact'; + +import { requestMutation } from '../../../../lib/fasterdom/fasterdom'; +import { REM } from '../../../common/helpers/mediaDimensions'; + +import useLastCallback from '../../../../hooks/useLastCallback'; + +const OFFSET_X = REM; + +export default function useOverlayPosition({ + anchorRef, + overlayRef, + isMirrored, + isForMessageEffect, + isDisabled, +} : { + anchorRef: RefObject; + overlayRef: RefObject; + isMirrored?: boolean; + isForMessageEffect?: boolean; + isDisabled?: boolean; +}) { + const updatePosition = useLastCallback(() => { + const element = overlayRef.current; + const anchor = anchorRef.current; + if (!element || !anchor) { + return; + } + + const anchorRect = anchor.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const windowWidth = window.innerWidth; + + requestMutation(() => { + const anchorCenterY = anchorRect.top + anchorRect.height / 2; + const anchorBottomY = anchorRect.bottom; + const y = isForMessageEffect ? anchorBottomY : anchorCenterY; + element.style.top = `${y - elementRect.height / 2}px`; + + if (isMirrored) { + element.style.left = `${anchorRect.left - OFFSET_X}px`; + } else { + element.style.right = `${windowWidth - anchorRect.right - OFFSET_X}px`; + } + }); + }); + + useEffect(() => { + if (isDisabled) return; + updatePosition(); + }, [isDisabled]); + + useEffect(() => { + if (isDisabled) return undefined; + + const messagesContainer = anchorRef.current!.closest('.MessageList')!; + + messagesContainer.addEventListener('scroll', updatePosition, { passive: true }); + + return () => { + messagesContainer.removeEventListener('scroll', updatePosition); + }; + }, [isDisabled, anchorRef]); +} diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index 077790f97..0a5c39ced 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -77,6 +77,22 @@ addActionHandler('loadAvailableReactions', async (global): Promise => { }, GENERAL_REFETCH_INTERVAL); }); +addActionHandler('loadAvailableEffects', async (global): Promise => { + const result = await callApi('fetchAvailableEffects'); + if (!result) { + return; + } + + const effectById = buildCollectionByKey(result, 'id'); + + global = getGlobal(); + global = { + ...global, + availableEffectById: effectById, + }; + setGlobal(global); +}); + addActionHandler('interactWithAnimatedEmoji', (global, actions, payload): ActionReturnType => { const { emoji, x, y, startSize, isReversed, tabId = getCurrentTabId(), diff --git a/src/global/cache.ts b/src/global/cache.ts index bc03b15e6..b2e09bbc9 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -286,6 +286,7 @@ export function serializeGlobal(global: T) { 'peerColors', 'savedReactionTags', 'timezones', + 'availableEffectById', ]), lastIsChatInfoShown: !getIsMobile() ? global.lastIsChatInfoShown : undefined, customEmojis: reduceCustomEmojis(global), diff --git a/src/global/initialState.ts b/src/global/initialState.ts index a85be6699..6431e95aa 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -162,6 +162,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { recentReactions: [], hash: {}, }, + availableEffectById: {}, stickers: { setsById: {}, diff --git a/src/global/types.ts b/src/global/types.ts index 534ca392c..c2640dc2f 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -2,6 +2,7 @@ import type { ApiAppConfig, ApiAttachBot, ApiAttachment, + ApiAvailableEffect, ApiAvailableReaction, ApiBoost, ApiBoostsStatus, @@ -986,6 +987,7 @@ export type GlobalState = { defaultTags?: string; }; }; + availableEffectById: Record; stickers: { setsById: Record; @@ -2646,6 +2648,8 @@ export interface ActionPayloads { loadGenericEmojiEffects: undefined; loadBirthdayNumbersStickers: undefined; + loadAvailableEffects: undefined; + addRecentSticker: { sticker: ApiSticker; }; diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 4d24fe876..6d3e7f2cd 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1523,6 +1523,7 @@ messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; messages.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies; messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector hash:long = messages.Messages; messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector random_id:Vector = Updates; +messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects; messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector = Vector; updates.getState#edd4882a = updates.State; updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 3a16835ba..89ad81c72 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -263,6 +263,7 @@ "messages.getMessageReactionsList", "messages.setChatAvailableReactions", "messages.getAvailableReactions", + "messages.getAvailableEffects", "messages.setDefaultReaction", "messages.translateText", "help.getAppConfig", diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index 264d76513..d9a60a48e 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -250,6 +250,7 @@ $color-message-story-mention-to: #74bcff; --z-animation-fade: 50; --z-menu-bubble: 21; --z-menu-backdrop: 20; + --z-message-effect: 15; --z-message-highlighted: 14; --z-forum-panel: 13; --z-message-context-menu: 13;