Message: Display effects (#4697)
This commit is contained in:
parent
5fb1283884
commit
bbb5ef0a86
@ -235,6 +235,7 @@ export function buildApiMessageWithChatId(
|
||||
senderBoosts,
|
||||
viaBusinessBotId: mtpMessage.viaBusinessBotId?.toString(),
|
||||
factCheck,
|
||||
effectId: mtpMessage.effect?.toString(),
|
||||
isInvertedMedia,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) && (
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -140,7 +140,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.has-active-reaction {
|
||||
&.has-active-effect {
|
||||
.message-content-wrapper {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
21
src/components/middle/message/MessageEffect.module.scss
Normal file
21
src/components/middle/message/MessageEffect.module.scss
Normal 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);
|
||||
}
|
||||
105
src/components/middle/message/MessageEffect.tsx
Normal file
105
src/components/middle/message/MessageEffect.tsx
Normal 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);
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
23
src/components/middle/message/Sticker.module.scss
Normal file
23
src/components/middle/message/Sticker.module.scss
Normal 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;
|
||||
}
|
||||
@ -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%);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
65
src/components/middle/message/hooks/useOverlayPosition.ts
Normal file
65
src/components/middle/message/hooks/useOverlayPosition.ts
Normal 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]);
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
@ -286,6 +286,7 @@ export function serializeGlobal<T extends GlobalState>(global: T) {
|
||||
'peerColors',
|
||||
'savedReactionTags',
|
||||
'timezones',
|
||||
'availableEffectById',
|
||||
]),
|
||||
lastIsChatInfoShown: !getIsMobile() ? global.lastIsChatInfoShown : undefined,
|
||||
customEmojis: reduceCustomEmojis(global),
|
||||
|
||||
@ -162,6 +162,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
|
||||
recentReactions: [],
|
||||
hash: {},
|
||||
},
|
||||
availableEffectById: {},
|
||||
|
||||
stickers: {
|
||||
setsById: {},
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -263,6 +263,7 @@
|
||||
"messages.getMessageReactionsList",
|
||||
"messages.setChatAvailableReactions",
|
||||
"messages.getAvailableReactions",
|
||||
"messages.getAvailableEffects",
|
||||
"messages.setDefaultReaction",
|
||||
"messages.translateText",
|
||||
"help.getAppConfig",
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user