Message: Display effects (#4697)

This commit is contained in:
zubiden 2024-07-15 15:50:42 +02:00 committed by Alexander Zinchuk
parent 5fb1283884
commit bbb5ef0a86
25 changed files with 411 additions and 84 deletions

View File

@ -235,6 +235,7 @@ export function buildApiMessageWithChatId(
senderBoosts,
viaBusinessBotId: mtpMessage.viaBusinessBotId?.toString(),
factCheck,
effectId: mtpMessage.effect?.toString(),
isInvertedMedia,
});
}

View File

@ -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(),
};
}

View File

@ -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,
}: {

View File

@ -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;

View File

@ -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 = <T extends number | ApiSticker | ApiBotInlineMediaResult |
<div
className="sticker-locked"
>
<i className="icon icon-lock-badge" />
<Icon name="lock-badge" />
</div>
)}
{!noShowPremium && isPremium && !isLocked && (
<div className="sticker-premium">
<i className="icon icon-premium" />
<Icon name="star" />
</div>
)}
{shouldShowCloseButton && (
@ -327,7 +328,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
noFastClick
onClick={handleRemoveClick}
>
<i className="icon icon-close" />
<Icon name="close" />
</Button>
)}
{Boolean(contextMenuItems.length) && (

View File

@ -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<OwnProps> = ({
{!shouldHideHeader && (
<div className="symbol-set-header">
<p className={buildClassName('symbol-set-title', withAddSetButton && 'symbol-set-title-external')}>
{isLocked && <i className="symbol-set-locked-icon icon icon-lock-badge" />}
{isLocked && <Icon name="lock-badge" className="symbol-set-locked-icon" />}
<span className="symbol-set-name">{stickerSet.title}</span>
{(isChatEmojiSet || isChatStickerSet) && (
<span className="symbol-set-chat">{lang(isChatEmojiSet ? 'GroupEmoji' : 'GroupStickers')}</span>
@ -280,7 +281,7 @@ const StickerSet: FC<OwnProps> = ({
)}
</p>
{isRecent && (
<i className="symbol-set-remove icon icon-close" onClick={openConfirmModal} />
<Icon className="symbol-set-remove" name="close" onClick={openConfirmModal} />
)}
{withAddSetButton && (
<Button
@ -323,7 +324,7 @@ const StickerSet: FC<OwnProps> = ({
onClick={handleDefaultStatusIconClick}
key="default-status-icon"
>
<i className="icon icon-premium" />
<Icon name="star" />
</Button>
)}
{shouldRender && stickerSet.reactions?.map((reaction) => {

View File

@ -11,6 +11,7 @@ type OwnProps = {
style?: string;
role?: AriaRole;
ariaLabel?: string;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
};
const Icon = ({
@ -19,6 +20,7 @@ const Icon = ({
style,
role,
ariaLabel,
onClick,
}: OwnProps) => {
return (
<i
@ -27,6 +29,7 @@ const Icon = ({
aria-hidden={!ariaLabel}
aria-label={ariaLabel}
role={role}
onClick={onClick}
/>
);
};

View File

@ -249,6 +249,7 @@ const Main: FC<OwnProps & StateProps> = ({
loadTimezones,
loadQuickReplies,
loadStarStatus,
loadAvailableEffects,
} = getActions();
if (DEBUG && !DEBUG_isLogged) {
@ -310,26 +311,27 @@ const Main: FC<OwnProps & StateProps> = ({
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]);

View File

@ -140,7 +140,7 @@
}
}
&.has-active-reaction {
&.has-active-effect {
.message-content-wrapper {
z-index: 1;
}

View File

@ -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<ApiReactionKey, ApiSavedReactionTag>;
canTranscribeVoice?: boolean;
viaBusinessBot?: ApiUser;
effect?: ApiAvailableEffect;
};
type MetaPosition =
@ -397,7 +400,7 @@ const Message: FC<OwnProps & StateProps> = ({
shouldDetectChatLanguage,
requestedTranslationLanguage,
requestedChatTranslationLanguage,
withStickerEffects,
withAnimatedEffects,
webPageStory,
isConnected,
getIsMessageListReady,
@ -406,6 +409,7 @@ const Message: FC<OwnProps & StateProps> = ({
tags,
canTranscribeVoice,
viaBusinessBot,
effect,
onPinnedIntersectionChange,
}) => {
const {
@ -429,7 +433,7 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
isRepliesChat,
);
const handleEffectClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
requestEffect();
});
useEffect(() => {
if (!isLastInList) {
return;
@ -662,7 +672,7 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
}
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<OwnProps & StateProps> = ({
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 && (
<AnimatedCustomEmoji
customEmojiId={animatedCustomEmoji}
withEffects={withStickerEffects && isUserId(chatId)}
withEffects={withAnimatedEffects && isUserId(chatId) && !effect}
isOwn={isOwn}
observeIntersection={observeIntersectionForLoading}
forceLoadPreview={isLocal}
@ -1091,7 +1106,7 @@ const Message: FC<OwnProps & StateProps> = ({
{hasAnimatedEmoji && animatedEmoji && (
<AnimatedEmoji
emoji={animatedEmoji}
withEffects={withStickerEffects && isUserId(chatId)}
withEffects={withAnimatedEffects && isUserId(chatId) && !effect}
isOwn={isOwn}
observeIntersection={observeIntersectionForLoading}
forceLoadPreview={isLocal}
@ -1100,6 +1115,16 @@ const Message: FC<OwnProps & StateProps> = ({
activeEmojiInteractions={activeEmojiInteractions}
/>
)}
{withAnimatedEffects && effect && (
<MessageEffect
shouldPlay={shouldPlayEffect}
message={message}
effect={effect}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onStop={hideEffect}
/>
)}
{phoneCall && (
<MessagePhoneCall
message={message}
@ -1616,7 +1641,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
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<OwnProps>(
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<OwnProps>(
tags: global.savedReactionTags?.byKey,
canTranscribeVoice,
viaBusinessBot,
effect,
};
},
)(Message));

View File

@ -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);
}

View File

@ -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<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(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 (
<div className={buildClassName(styles.anchor, isMirrored && styles.mirrorAnchor)} ref={anchorRef}>
{isPlaying && (
<Portal>
<AnimatedSticker
ref={ref}
key={`effect-${message.id}`}
className={effectClassName}
tgsUrl={effectBlob}
size={EFFECT_SIZE}
play
isLowPriority
noLoop
forceAlways
onEnded={handleEnded}
/>
</Portal>
)}
</div>
);
};
function getEffectHash(effect: ApiAvailableEffect) {
if (effect.effectAnimationId) {
return `sticker${effect.effectAnimationId}`;
}
return `sticker${effect.effectStickerId}?size=f`;
}
export default memo(MessageEffect);

View File

@ -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;
}

View File

@ -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<HTMLDivElement>) => void;
onTranslationClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onEffectClick: (e: React.MouseEvent<HTMLDivElement>) => void;
renderQuickReactionButton?: () => TeactNode | undefined;
onOpenThread: NoneToVoidFunction;
};
@ -47,8 +50,10 @@ const MessageMeta: FC<OwnProps> = ({
isTranslated,
isPinned,
withFullDate,
effectEmoji,
onClick,
onTranslationClick,
onEffectClick,
onOpenThread,
}) => {
const { showNotification } = getActions();
@ -118,15 +123,20 @@ const MessageMeta: FC<OwnProps> = ({
onClick={onClick}
data-ignore-on-paste
>
{effectEmoji && (
<span className="message-effect-icon" onClick={onEffectClick}>
{renderText(effectEmoji)}
</span>
)}
{isTranslated && (
<i className="icon icon-language message-translated" onClick={onTranslationClick} />
<Icon name="language" className="message-translated" onClick={onTranslationClick} />
)}
{Boolean(message.viewsCount) && (
<>
<span className="message-views">
{formatIntegerCompact(message.viewsCount!)}
</span>
<i className="icon icon-channelviews" />
<Icon name="channelviews" />
</>
)}
{!noReplies && Boolean(repliesThreadInfo?.messagesCount) && (
@ -134,11 +144,11 @@ const MessageMeta: FC<OwnProps> = ({
<span className="message-replies">
<AnimatedCounter text={formatIntegerCompact(repliesThreadInfo!.messagesCount!)} />
</span>
<i className="icon icon-reply-filled" />
<Icon name="reply-filled" />
</span>
)}
{isPinned && (
<i className="icon icon-pinned-message message-pinned" />
<Icon name="pinned-message" className="message-pinned" />
)}
{signature && (
<span className="message-signature">{renderText(signature)}</span>

View File

@ -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;
}

View File

@ -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%);
}
}

View File

@ -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<OwnProps> = ({
message, observeIntersection, observeIntersectionForPlaying, shouldLoop,
shouldPlayEffect, withEffect, onPlayEffect, onStopEffect,
shouldPlayEffect, withEffect, onStopEffect,
}) => {
const { showNotification, openStickerSet } = getActions();
@ -50,8 +50,12 @@ const Sticker: FC<OwnProps> = ({
// eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null
const effectRef = useRef<HTMLDivElement>(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<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
return;
} else if (withEffect) {
startPlayingEffect();
onPlayEffect?.();
return;
}
}
@ -113,9 +121,10 @@ const Sticker: FC<OwnProps> = ({
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<OwnProps> = ({
noPlay={!canPlay}
withSharedAnimation
/>
{hasEffect && withEffect && canLoad && isPlayingEffect && (
<AnimatedSticker
key={mediaHashEffect}
className="effect-sticker"
tgsUrl={effectBlobUrl}
size={width * EFFECT_SIZE_MULTIPLIER}
play
isLowPriority
noLoop
onEnded={handleEffectEnded}
/>
{shouldRenderEffect && (
<Portal>
<AnimatedSticker
ref={effectRef}
key={mediaHashEffect}
className={buildClassName(styles.effect, isMirrored && styles.mirrored)}
tgsUrl={effectBlobUrl}
size={width * EFFECT_SIZE_MULTIPLIER}
play
isLowPriority
noLoop
onEnded={handleEffectEnded}
/>
</Portal>
)}
</div>
);

View File

@ -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<HTMLDivElement>;
overlayRef: RefObject<HTMLDivElement>;
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<HTMLDivElement>('.MessageList')!;
messagesContainer.addEventListener('scroll', updatePosition, { passive: true });
return () => {
messagesContainer.removeEventListener('scroll', updatePosition);
};
}, [isDisabled, anchorRef]);
}

View File

@ -77,6 +77,22 @@ addActionHandler('loadAvailableReactions', async (global): Promise<void> => {
}, GENERAL_REFETCH_INTERVAL);
});
addActionHandler('loadAvailableEffects', async (global): Promise<void> => {
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(),

View File

@ -286,6 +286,7 @@ export function serializeGlobal<T extends GlobalState>(global: T) {
'peerColors',
'savedReactionTags',
'timezones',
'availableEffectById',
]),
lastIsChatInfoShown: !getIsMobile() ? global.lastIsChatInfoShown : undefined,
customEmojis: reduceCustomEmojis(global),

View File

@ -162,6 +162,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
recentReactions: [],
hash: {},
},
availableEffectById: {},
stickers: {
setsById: {},

View File

@ -2,6 +2,7 @@ import type {
ApiAppConfig,
ApiAttachBot,
ApiAttachment,
ApiAvailableEffect,
ApiAvailableReaction,
ApiBoost,
ApiBoostsStatus,
@ -986,6 +987,7 @@ export type GlobalState = {
defaultTags?: string;
};
};
availableEffectById: Record<string, ApiAvailableEffect>;
stickers: {
setsById: Record<string, ApiStickerSet>;
@ -2646,6 +2648,8 @@ export interface ActionPayloads {
loadGenericEmojiEffects: undefined;
loadBirthdayNumbersStickers: undefined;
loadAvailableEffects: undefined;
addRecentSticker: {
sticker: ApiSticker;
};

View File

@ -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<int> hash:long = messages.Messages;
messages.sendQuickReplyMessages#6c750de1 peer:InputPeer shortcut_id:int id:Vector<int> random_id:Vector<long> = Updates;
messages.getAvailableEffects#dea20a39 hash:int = messages.AvailableEffects;
messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector<int> = Vector<FactCheck>;
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;

View File

@ -263,6 +263,7 @@
"messages.getMessageReactionsList",
"messages.setChatAvailableReactions",
"messages.getAvailableReactions",
"messages.getAvailableEffects",
"messages.setDefaultReaction",
"messages.translateText",
"help.getAppConfig",

View File

@ -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;