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, senderBoosts,
viaBusinessBotId: mtpMessage.viaBusinessBotId?.toString(), viaBusinessBotId: mtpMessage.viaBusinessBotId?.toString(),
factCheck, factCheck,
effectId: mtpMessage.effect?.toString(),
isInvertedMedia, isInvertedMedia,
}); });
} }

View File

@ -1,6 +1,7 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiAvailableEffect,
ApiAvailableReaction, ApiAvailableReaction,
ApiPeerReaction, ApiPeerReaction,
ApiReaction, ApiReaction,
@ -117,3 +118,18 @@ export function buildApiAvailableReaction(availableReaction: GramJs.AvailableRea
isPremium: premium, 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 { split } from '../../../util/iteratees';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { import {
buildApiAvailableEffect,
buildApiAvailableReaction, buildApiAvailableReaction,
buildApiReaction, buildApiReaction,
buildApiSavedReactionTag, buildApiSavedReactionTag,
@ -95,6 +96,22 @@ export async function fetchAvailableReactions() {
return result.reactions.map(buildApiAvailableReaction); 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({ export function sendReaction({
chat, messageId, reactions, shouldAddToRecent, chat, messageId, reactions, shouldAddToRecent,
}: { }: {

View File

@ -606,6 +606,7 @@ export interface ApiMessage {
savedPeerId?: string; savedPeerId?: string;
senderBoosts?: number; senderBoosts?: number;
factCheck?: ApiFactCheck; factCheck?: ApiFactCheck;
effectId?: string;
isInvertedMedia?: true; isInvertedMedia?: true;
} }
@ -645,6 +646,15 @@ export interface ApiAvailableReaction {
isPremium?: boolean; isPremium?: boolean;
} }
export interface ApiAvailableEffect {
id: string;
emoticon: string;
staticIconId?: string;
effectAnimationId?: string;
effectStickerId: string;
isPremium?: boolean;
}
type ApiChatReactionsAll = { type ApiChatReactionsAll = {
type: 'all'; type: 'all';
areCustomAllowed?: true; areCustomAllowed?: true;

View File

@ -22,6 +22,7 @@ import useOldLang from '../../hooks/useOldLang';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Menu from '../ui/Menu'; import Menu from '../ui/Menu';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import Icon from './icons/Icon';
import StickerView from './StickerView'; import StickerView from './StickerView';
import './StickerButton.scss'; import './StickerButton.scss';
@ -311,12 +312,12 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
<div <div
className="sticker-locked" className="sticker-locked"
> >
<i className="icon icon-lock-badge" /> <Icon name="lock-badge" />
</div> </div>
)} )}
{!noShowPremium && isPremium && !isLocked && ( {!noShowPremium && isPremium && !isLocked && (
<div className="sticker-premium"> <div className="sticker-premium">
<i className="icon icon-premium" /> <Icon name="star" />
</div> </div>
)} )}
{shouldShowCloseButton && ( {shouldShowCloseButton && (
@ -327,7 +328,7 @@ const StickerButton = <T extends number | ApiSticker | ApiBotInlineMediaResult |
noFastClick noFastClick
onClick={handleRemoveClick} onClick={handleRemoveClick}
> >
<i className="icon icon-close" /> <Icon name="close" />
</Button> </Button>
)} )}
{Boolean(contextMenuItems.length) && ( {Boolean(contextMenuItems.length) && (

View File

@ -32,6 +32,7 @@ import useWindowSize from '../../hooks/window/useWindowSize';
import Button from '../ui/Button'; import Button from '../ui/Button';
import ConfirmDialog from '../ui/ConfirmDialog'; import ConfirmDialog from '../ui/ConfirmDialog';
import Icon from './icons/Icon';
import ReactionEmoji from './ReactionEmoji'; import ReactionEmoji from './ReactionEmoji';
import StickerButton from './StickerButton'; import StickerButton from './StickerButton';
@ -268,7 +269,7 @@ const StickerSet: FC<OwnProps> = ({
{!shouldHideHeader && ( {!shouldHideHeader && (
<div className="symbol-set-header"> <div className="symbol-set-header">
<p className={buildClassName('symbol-set-title', withAddSetButton && 'symbol-set-title-external')}> <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> <span className="symbol-set-name">{stickerSet.title}</span>
{(isChatEmojiSet || isChatStickerSet) && ( {(isChatEmojiSet || isChatStickerSet) && (
<span className="symbol-set-chat">{lang(isChatEmojiSet ? 'GroupEmoji' : 'GroupStickers')}</span> <span className="symbol-set-chat">{lang(isChatEmojiSet ? 'GroupEmoji' : 'GroupStickers')}</span>
@ -280,7 +281,7 @@ const StickerSet: FC<OwnProps> = ({
)} )}
</p> </p>
{isRecent && ( {isRecent && (
<i className="symbol-set-remove icon icon-close" onClick={openConfirmModal} /> <Icon className="symbol-set-remove" name="close" onClick={openConfirmModal} />
)} )}
{withAddSetButton && ( {withAddSetButton && (
<Button <Button
@ -323,7 +324,7 @@ const StickerSet: FC<OwnProps> = ({
onClick={handleDefaultStatusIconClick} onClick={handleDefaultStatusIconClick}
key="default-status-icon" key="default-status-icon"
> >
<i className="icon icon-premium" /> <Icon name="star" />
</Button> </Button>
)} )}
{shouldRender && stickerSet.reactions?.map((reaction) => { {shouldRender && stickerSet.reactions?.map((reaction) => {

View File

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

View File

@ -249,6 +249,7 @@ const Main: FC<OwnProps & StateProps> = ({
loadTimezones, loadTimezones,
loadQuickReplies, loadQuickReplies,
loadStarStatus, loadStarStatus,
loadAvailableEffects,
} = getActions(); } = getActions();
if (DEBUG && !DEBUG_isLogged) { if (DEBUG && !DEBUG_isLogged) {
@ -310,26 +311,27 @@ const Main: FC<OwnProps & StateProps> = ({
initMain(); initMain();
loadAvailableReactions(); loadAvailableReactions();
loadAnimatedEmojis(); loadAnimatedEmojis();
loadBirthdayNumbersStickers();
loadGenericEmojiEffects();
loadNotificationSettings(); loadNotificationSettings();
loadNotificationExceptions(); loadNotificationExceptions();
loadTopInlineBots();
loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG });
loadAttachBots(); loadAttachBots();
loadContactList(); loadContactList();
loadPremiumGifts();
loadDefaultTopicIcons(); loadDefaultTopicIcons();
checkAppVersion(); checkAppVersion();
loadTopReactions(); loadTopReactions();
loadRecentReactions(); loadRecentReactions();
loadDefaultTagReactions(); loadDefaultTagReactions();
loadFeaturedEmojiStickers(); loadFeaturedEmojiStickers();
loadAuthorizations(); loadTopInlineBots();
loadSavedReactionTags(); loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG });
loadTimezones(); loadTimezones();
loadQuickReplies(); loadQuickReplies();
loadStarStatus(); loadStarStatus();
loadPremiumGifts();
loadAvailableEffects();
loadBirthdayNumbersStickers();
loadGenericEmojiEffects();
loadSavedReactionTags();
loadAuthorizations();
} }
}, [isMasterTab, isSynced]); }, [isMasterTab, isSynced]);

View File

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

View File

@ -5,6 +5,7 @@ import React, {
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { import type {
ApiAvailableEffect,
ApiAvailableReaction, ApiAvailableReaction,
ApiChat, ApiChat,
ApiChatMember, ApiChatMember,
@ -166,6 +167,7 @@ import Invoice from './Invoice';
import InvoiceMediaPreview from './InvoiceMediaPreview'; import InvoiceMediaPreview from './InvoiceMediaPreview';
import Location from './Location'; import Location from './Location';
import MessageAppendix from './MessageAppendix'; import MessageAppendix from './MessageAppendix';
import MessageEffect from './MessageEffect';
import MessageMeta from './MessageMeta'; import MessageMeta from './MessageMeta';
import MessagePhoneCall from './MessagePhoneCall'; import MessagePhoneCall from './MessagePhoneCall';
import Photo from './Photo'; import Photo from './Photo';
@ -278,7 +280,7 @@ type StateProps = {
shouldDetectChatLanguage?: boolean; shouldDetectChatLanguage?: boolean;
requestedTranslationLanguage?: string; requestedTranslationLanguage?: string;
requestedChatTranslationLanguage?: string; requestedChatTranslationLanguage?: string;
withStickerEffects?: boolean; withAnimatedEffects?: boolean;
webPageStory?: ApiTypeStory; webPageStory?: ApiTypeStory;
isConnected: boolean; isConnected: boolean;
isLoadingComments?: boolean; isLoadingComments?: boolean;
@ -287,6 +289,7 @@ type StateProps = {
tags?: Record<ApiReactionKey, ApiSavedReactionTag>; tags?: Record<ApiReactionKey, ApiSavedReactionTag>;
canTranscribeVoice?: boolean; canTranscribeVoice?: boolean;
viaBusinessBot?: ApiUser; viaBusinessBot?: ApiUser;
effect?: ApiAvailableEffect;
}; };
type MetaPosition = type MetaPosition =
@ -397,7 +400,7 @@ const Message: FC<OwnProps & StateProps> = ({
shouldDetectChatLanguage, shouldDetectChatLanguage,
requestedTranslationLanguage, requestedTranslationLanguage,
requestedChatTranslationLanguage, requestedChatTranslationLanguage,
withStickerEffects, withAnimatedEffects,
webPageStory, webPageStory,
isConnected, isConnected,
getIsMessageListReady, getIsMessageListReady,
@ -406,6 +409,7 @@ const Message: FC<OwnProps & StateProps> = ({
tags, tags,
canTranscribeVoice, canTranscribeVoice,
viaBusinessBot, viaBusinessBot,
effect,
onPinnedIntersectionChange, onPinnedIntersectionChange,
}) => { }) => {
const { const {
@ -429,7 +433,7 @@ const Message: FC<OwnProps & StateProps> = ({
const lang = useOldLang(); const lang = useOldLang();
const [isTranscriptionHidden, setTranscriptionHidden] = useState(false); const [isTranscriptionHidden, setTranscriptionHidden] = useState(false);
const [hasActiveStickerEffect, startStickerEffect, stopStickerEffect] = useFlag(); const [shouldPlayEffect, requestEffect, hideEffect] = useFlag();
const { isMobile, isTouchScreen } = useAppLayout(); const { isMobile, isTouchScreen } = useAppLayout();
useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); useOnIntersect(bottomMarkerRef, observeIntersectionForBottom);
@ -622,6 +626,12 @@ const Message: FC<OwnProps & StateProps> = ({
isRepliesChat, isRepliesChat,
); );
const handleEffectClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
requestEffect();
});
useEffect(() => { useEffect(() => {
if (!isLastInList) { if (!isLastInList) {
return; return;
@ -662,7 +672,7 @@ const Message: FC<OwnProps & StateProps> = ({
isSwiped && 'is-swiped', isSwiped && 'is-swiped',
transitionClassNames, transitionClassNames,
isJustAdded && 'is-just-added', isJustAdded && 'is-just-added',
(hasActiveReactions || hasActiveStickerEffect) && 'has-active-reaction', (hasActiveReactions || shouldPlayEffect) && 'has-active-effect',
isStoryMention && 'is-story-mention', isStoryMention && 'is-story-mention',
); );
@ -679,6 +689,14 @@ const Message: FC<OwnProps & StateProps> = ({
const { replyToMsgId, replyToPeerId, isQuote } = messageReplyInfo || {}; const { replyToMsgId, replyToPeerId, isQuote } = messageReplyInfo || {};
const { peerId: storyReplyPeerId, storyId: storyReplyId } = storyReplyInfo || {}; 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( const detectedLanguage = useTextLanguage(
text?.text, text?.text,
!(areTranslationsEnabled || shouldDetectChatLanguage), !(areTranslationsEnabled || shouldDetectChatLanguage),
@ -980,7 +998,9 @@ const Message: FC<OwnProps & StateProps> = ({
} }
availableReactions={availableReactions} availableReactions={availableReactions}
isTranslated={Boolean(requestedTranslationLanguage ? currentTranslatedText : undefined)} isTranslated={Boolean(requestedTranslationLanguage ? currentTranslatedText : undefined)}
effectEmoji={effect?.emoticon}
onClick={handleMetaClick} onClick={handleMetaClick}
onEffectClick={handleEffectClick}
onTranslationClick={handleTranslationClick} onTranslationClick={handleTranslationClick}
onOpenThread={handleOpenThread} onOpenThread={handleOpenThread}
/> />
@ -1066,20 +1086,15 @@ const Message: FC<OwnProps & StateProps> = ({
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying} observeIntersectionForPlaying={observeIntersectionForPlaying}
shouldLoop={shouldLoopStickers} shouldLoop={shouldLoopStickers}
shouldPlayEffect={( shouldPlayEffect={shouldPlayEffect}
sticker.hasEffect && (( withEffect={withAnimatedEffects}
memoFirstUnreadIdRef.current && messageId >= memoFirstUnreadIdRef.current onStopEffect={hideEffect}
) || isLocal)
) || undefined}
withEffect={withStickerEffects}
onPlayEffect={startStickerEffect}
onStopEffect={stopStickerEffect}
/> />
)} )}
{hasAnimatedEmoji && animatedCustomEmoji && ( {hasAnimatedEmoji && animatedCustomEmoji && (
<AnimatedCustomEmoji <AnimatedCustomEmoji
customEmojiId={animatedCustomEmoji} customEmojiId={animatedCustomEmoji}
withEffects={withStickerEffects && isUserId(chatId)} withEffects={withAnimatedEffects && isUserId(chatId) && !effect}
isOwn={isOwn} isOwn={isOwn}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
forceLoadPreview={isLocal} forceLoadPreview={isLocal}
@ -1091,7 +1106,7 @@ const Message: FC<OwnProps & StateProps> = ({
{hasAnimatedEmoji && animatedEmoji && ( {hasAnimatedEmoji && animatedEmoji && (
<AnimatedEmoji <AnimatedEmoji
emoji={animatedEmoji} emoji={animatedEmoji}
withEffects={withStickerEffects && isUserId(chatId)} withEffects={withAnimatedEffects && isUserId(chatId) && !effect}
isOwn={isOwn} isOwn={isOwn}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
forceLoadPreview={isLocal} forceLoadPreview={isLocal}
@ -1100,6 +1115,16 @@ const Message: FC<OwnProps & StateProps> = ({
activeEmojiInteractions={activeEmojiInteractions} activeEmojiInteractions={activeEmojiInteractions}
/> />
)} )}
{withAnimatedEffects && effect && (
<MessageEffect
shouldPlay={shouldPlayEffect}
message={message}
effect={effect}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onStop={hideEffect}
/>
)}
{phoneCall && ( {phoneCall && (
<MessagePhoneCall <MessagePhoneCall
message={message} message={message}
@ -1616,7 +1641,7 @@ export default memo(withGlobal<OwnProps>(
message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup, message, album, withSenderName, withAvatar, threadId, messageListType, isLastInDocumentGroup, isFirstInGroup,
} = ownProps; } = ownProps;
const { const {
id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, id, chatId, viaBotId, isOutgoing, forwardInfo, transcriptionId, isPinned, viaBusinessBotId, effectId,
} = message; } = message;
const chat = selectChat(global, chatId); const chat = selectChat(global, chatId);
@ -1728,6 +1753,8 @@ export default memo(withGlobal<OwnProps>(
const viaBusinessBot = viaBusinessBotId ? selectUser(global, viaBusinessBotId) : undefined; const viaBusinessBot = viaBusinessBotId ? selectUser(global, viaBusinessBotId) : undefined;
const effect = effectId ? global.availableEffectById[effectId] : undefined;
return { return {
theme: selectTheme(global), theme: selectTheme(global),
forceSenderName, forceSenderName,
@ -1793,7 +1820,7 @@ export default memo(withGlobal<OwnProps>(
requestedTranslationLanguage, requestedTranslationLanguage,
requestedChatTranslationLanguage, requestedChatTranslationLanguage,
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), withAnimatedEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
webPageStory, webPageStory,
isConnected, isConnected,
isLoadingComments: repliesThreadInfo?.isCommentsInfo isLoadingComments: repliesThreadInfo?.isCommentsInfo
@ -1813,6 +1840,7 @@ export default memo(withGlobal<OwnProps>(
tags: global.savedReactionTags?.byKey, tags: global.savedReactionTags?.byKey,
canTranscribeVoice, canTranscribeVoice,
viaBusinessBot, viaBusinessBot,
effect,
}; };
}, },
)(Message)); )(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-views,
.message-replies, .message-replies,
.message-translated, .message-translated,
.message-pinned { .message-pinned,
.message-effect-icon {
font-size: 0.75rem; font-size: 0.75rem;
white-space: nowrap; white-space: nowrap;
} }
@ -47,6 +48,16 @@
margin-inline-end: 0.25rem; 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 { .message-pinned {
margin-inline-end: 0.1875rem; margin-inline-end: 0.1875rem;
} }

View File

@ -15,6 +15,7 @@ import useFlag from '../../../hooks/useFlag';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import AnimatedCounter from '../../common/AnimatedCounter'; import AnimatedCounter from '../../common/AnimatedCounter';
import Icon from '../../common/icons/Icon';
import MessageOutgoingStatus from '../../common/MessageOutgoingStatus'; import MessageOutgoingStatus from '../../common/MessageOutgoingStatus';
import './MessageMeta.scss'; import './MessageMeta.scss';
@ -30,8 +31,10 @@ type OwnProps = {
isTranslated?: boolean; isTranslated?: boolean;
isPinned?: boolean; isPinned?: boolean;
withFullDate?: boolean; withFullDate?: boolean;
effectEmoji?: string;
onClick: (e: React.MouseEvent<HTMLDivElement>) => void; onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onTranslationClick: (e: React.MouseEvent<HTMLDivElement>) => void; onTranslationClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onEffectClick: (e: React.MouseEvent<HTMLDivElement>) => void;
renderQuickReactionButton?: () => TeactNode | undefined; renderQuickReactionButton?: () => TeactNode | undefined;
onOpenThread: NoneToVoidFunction; onOpenThread: NoneToVoidFunction;
}; };
@ -47,8 +50,10 @@ const MessageMeta: FC<OwnProps> = ({
isTranslated, isTranslated,
isPinned, isPinned,
withFullDate, withFullDate,
effectEmoji,
onClick, onClick,
onTranslationClick, onTranslationClick,
onEffectClick,
onOpenThread, onOpenThread,
}) => { }) => {
const { showNotification } = getActions(); const { showNotification } = getActions();
@ -118,15 +123,20 @@ const MessageMeta: FC<OwnProps> = ({
onClick={onClick} onClick={onClick}
data-ignore-on-paste data-ignore-on-paste
> >
{effectEmoji && (
<span className="message-effect-icon" onClick={onEffectClick}>
{renderText(effectEmoji)}
</span>
)}
{isTranslated && ( {isTranslated && (
<i className="icon icon-language message-translated" onClick={onTranslationClick} /> <Icon name="language" className="message-translated" onClick={onTranslationClick} />
)} )}
{Boolean(message.viewsCount) && ( {Boolean(message.viewsCount) && (
<> <>
<span className="message-views"> <span className="message-views">
{formatIntegerCompact(message.viewsCount!)} {formatIntegerCompact(message.viewsCount!)}
</span> </span>
<i className="icon icon-channelviews" /> <Icon name="channelviews" />
</> </>
)} )}
{!noReplies && Boolean(repliesThreadInfo?.messagesCount) && ( {!noReplies && Boolean(repliesThreadInfo?.messagesCount) && (
@ -134,11 +144,11 @@ const MessageMeta: FC<OwnProps> = ({
<span className="message-replies"> <span className="message-replies">
<AnimatedCounter text={formatIntegerCompact(repliesThreadInfo!.messagesCount!)} /> <AnimatedCounter text={formatIntegerCompact(repliesThreadInfo!.messagesCount!)} />
</span> </span>
<i className="icon icon-reply-filled" /> <Icon name="reply-filled" />
</span> </span>
)} )}
{isPinned && ( {isPinned && (
<i className="icon icon-pinned-message message-pinned" /> <Icon name="pinned-message" className="message-pinned" />
)} )}
{signature && ( {signature && (
<span className="message-signature">{renderText(signature)}</span> <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 useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia'; import useMedia from '../../../hooks/useMedia';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import usePrevious from '../../../hooks/usePrevious'; import useOverlayPosition from './hooks/useOverlayPosition';
import AnimatedSticker from '../../common/AnimatedSticker'; import AnimatedSticker from '../../common/AnimatedSticker';
import StickerView from '../../common/StickerView'; 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 // 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; const EFFECT_SIZE_MULTIPLIER = 1 + 0.245 * 2;
@ -34,13 +35,12 @@ type OwnProps = {
shouldLoop?: boolean; shouldLoop?: boolean;
shouldPlayEffect?: boolean; shouldPlayEffect?: boolean;
withEffect?: boolean; withEffect?: boolean;
onPlayEffect?: VoidFunction;
onStopEffect?: VoidFunction; onStopEffect?: VoidFunction;
}; };
const Sticker: FC<OwnProps> = ({ const Sticker: FC<OwnProps> = ({
message, observeIntersection, observeIntersectionForPlaying, shouldLoop, message, observeIntersection, observeIntersectionForPlaying, shouldLoop,
shouldPlayEffect, withEffect, onPlayEffect, onStopEffect, shouldPlayEffect, withEffect, onStopEffect,
}) => { }) => {
const { showNotification, openStickerSet } = getActions(); const { showNotification, openStickerSet } = getActions();
@ -50,8 +50,12 @@ const Sticker: FC<OwnProps> = ({
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(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 sticker = message.content.sticker!;
const { stickerSetInfo, isVideo, hasEffect } = sticker; const { stickerSetInfo, isVideo, hasEffect } = sticker;
const isMirrored = !message.isOutgoing;
const mediaHash = sticker.isPreloadedGlobally ? undefined : ( const mediaHash = sticker.isPreloadedGlobally ? undefined : (
getMessageMediaHash(message, isVideo && !IS_WEBM_SUPPORTED ? 'pictogram' : 'inline')! getMessageMediaHash(message, isVideo && !IS_WEBM_SUPPORTED ? 'pictogram' : 'inline')!
@ -62,7 +66,7 @@ const Sticker: FC<OwnProps> = ({
const mediaHashEffect = `sticker${sticker.id}?size=f`; const mediaHashEffect = `sticker${sticker.id}?size=f`;
const effectBlobUrl = useMedia( const effectBlobUrl = useMedia(
mediaHashEffect, mediaHashEffect,
!canLoad || !hasEffect, !canLoad || !hasEffect || !withEffect,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
); );
const [isPlayingEffect, startPlayingEffect, stopPlayingEffect] = useFlag(); const [isPlayingEffect, startPlayingEffect, stopPlayingEffect] = useFlag();
@ -72,14 +76,19 @@ const Sticker: FC<OwnProps> = ({
onStopEffect?.(); onStopEffect?.();
}); });
const previousShouldPlayEffect = usePrevious(shouldPlayEffect);
useEffect(() => { useEffect(() => {
if (hasEffect && withEffect && canPlay && (shouldPlayEffect || previousShouldPlayEffect)) { if (hasEffect && withEffect && canPlay && shouldPlayEffect) {
startPlayingEffect(); 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(() => { const openModal = useLastCallback(() => {
openStickerSet({ openStickerSet({
@ -103,7 +112,6 @@ const Sticker: FC<OwnProps> = ({
return; return;
} else if (withEffect) { } else if (withEffect) {
startPlayingEffect(); startPlayingEffect();
onPlayEffect?.();
return; return;
} }
} }
@ -113,9 +121,10 @@ const Sticker: FC<OwnProps> = ({
const isMemojiSticker = 'isMissing' in stickerSetInfo; const isMemojiSticker = 'isMissing' in stickerSetInfo;
const { width, height } = getStickerDimensions(sticker, isMobile); const { width, height } = getStickerDimensions(sticker, isMobile);
const className = buildClassName( const className = buildClassName(
'Sticker media-inner', 'media-inner',
isMemojiSticker && 'inactive', styles.root,
hasEffect && !message.isOutgoing && 'reversed', isMemojiSticker && styles.inactive,
hasEffect && isMirrored && styles.mirrored,
); );
return ( return (
@ -136,17 +145,20 @@ const Sticker: FC<OwnProps> = ({
noPlay={!canPlay} noPlay={!canPlay}
withSharedAnimation withSharedAnimation
/> />
{hasEffect && withEffect && canLoad && isPlayingEffect && ( {shouldRenderEffect && (
<AnimatedSticker <Portal>
key={mediaHashEffect} <AnimatedSticker
className="effect-sticker" ref={effectRef}
tgsUrl={effectBlobUrl} key={mediaHashEffect}
size={width * EFFECT_SIZE_MULTIPLIER} className={buildClassName(styles.effect, isMirrored && styles.mirrored)}
play tgsUrl={effectBlobUrl}
isLowPriority size={width * EFFECT_SIZE_MULTIPLIER}
noLoop play
onEnded={handleEffectEnded} isLowPriority
/> noLoop
onEnded={handleEffectEnded}
/>
</Portal>
)} )}
</div> </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); }, 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 => { addActionHandler('interactWithAnimatedEmoji', (global, actions, payload): ActionReturnType => {
const { const {
emoji, x, y, startSize, isReversed, tabId = getCurrentTabId(), emoji, x, y, startSize, isReversed, tabId = getCurrentTabId(),

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import type {
ApiAppConfig, ApiAppConfig,
ApiAttachBot, ApiAttachBot,
ApiAttachment, ApiAttachment,
ApiAvailableEffect,
ApiAvailableReaction, ApiAvailableReaction,
ApiBoost, ApiBoost,
ApiBoostsStatus, ApiBoostsStatus,
@ -986,6 +987,7 @@ export type GlobalState = {
defaultTags?: string; defaultTags?: string;
}; };
}; };
availableEffectById: Record<string, ApiAvailableEffect>;
stickers: { stickers: {
setsById: Record<string, ApiStickerSet>; setsById: Record<string, ApiStickerSet>;
@ -2646,6 +2648,8 @@ export interface ActionPayloads {
loadGenericEmojiEffects: undefined; loadGenericEmojiEffects: undefined;
loadBirthdayNumbersStickers: undefined; loadBirthdayNumbersStickers: undefined;
loadAvailableEffects: undefined;
addRecentSticker: { addRecentSticker: {
sticker: ApiSticker; 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.getQuickReplies#d483f2a8 hash:long = messages.QuickReplies;
messages.getQuickReplyMessages#94a495c3 flags:# shortcut_id:int id:flags.0?Vector<int> hash:long = messages.Messages; 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.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>; messages.getFactCheck#b9cdc5ee peer:InputPeer msg_id:Vector<int> = Vector<FactCheck>;
updates.getState#edd4882a = updates.State; 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; 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.getMessageReactionsList",
"messages.setChatAvailableReactions", "messages.setChatAvailableReactions",
"messages.getAvailableReactions", "messages.getAvailableReactions",
"messages.getAvailableEffects",
"messages.setDefaultReaction", "messages.setDefaultReaction",
"messages.translateText", "messages.translateText",
"help.getAppConfig", "help.getAppConfig",

View File

@ -250,6 +250,7 @@ $color-message-story-mention-to: #74bcff;
--z-animation-fade: 50; --z-animation-fade: 50;
--z-menu-bubble: 21; --z-menu-bubble: 21;
--z-menu-backdrop: 20; --z-menu-backdrop: 20;
--z-message-effect: 15;
--z-message-highlighted: 14; --z-message-highlighted: 14;
--z-forum-panel: 13; --z-forum-panel: 13;
--z-message-context-menu: 13; --z-message-context-menu: 13;