Message: Add text streaming animation (#6834)
This commit is contained in:
parent
60aaf90093
commit
da590cf02a
@ -708,6 +708,7 @@
|
|||||||
"SettingsPerformanceMessageBlur" = "Message Blur";
|
"SettingsPerformanceMessageBlur" = "Message Blur";
|
||||||
"SettingsPerformanceRightColumn" = "Right Column Animation";
|
"SettingsPerformanceRightColumn" = "Right Column Animation";
|
||||||
"SettingsPerformanceThanos" = "Dust-effect deletion";
|
"SettingsPerformanceThanos" = "Dust-effect deletion";
|
||||||
|
"SettingsPerformanceTextStreaming" = "Text Streaming";
|
||||||
"SettingsPerformanceAnimatedEmoji" = "Allow Animated Emoji";
|
"SettingsPerformanceAnimatedEmoji" = "Allow Animated Emoji";
|
||||||
"SettingsPerformanceLoopStickers" = "Loop Animated Stickers";
|
"SettingsPerformanceLoopStickers" = "Loop Animated Stickers";
|
||||||
"SettingsPerformanceReactionEffects" = "Reaction Effects";
|
"SettingsPerformanceReactionEffects" = "Reaction Effects";
|
||||||
|
|||||||
BIN
src/assets/tgs/message/Typing.tgs
Normal file
BIN
src/assets/tgs/message/Typing.tgs
Normal file
Binary file not shown.
@ -1,10 +1,11 @@
|
|||||||
import { memo } from '../../lib/teact/teact';
|
import { memo, useRef } from '../../lib/teact/teact';
|
||||||
|
|
||||||
import type { OwnProps as AnimatedIconProps } from './AnimatedIcon';
|
import type { OwnProps as AnimatedIconProps } from './AnimatedIcon';
|
||||||
|
|
||||||
import buildClassName from '../../util/buildClassName';
|
import buildClassName from '../../util/buildClassName';
|
||||||
import buildStyle from '../../util/buildStyle';
|
import buildStyle from '../../util/buildStyle';
|
||||||
|
|
||||||
|
import useDynamicColorListener from '../../hooks/stickers/useDynamicColorListener';
|
||||||
import useFlag from '../../hooks/useFlag';
|
import useFlag from '../../hooks/useFlag';
|
||||||
import useLastCallback from '../../hooks/useLastCallback';
|
import useLastCallback from '../../hooks/useLastCallback';
|
||||||
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
|
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
|
||||||
@ -15,7 +16,7 @@ import styles from './AnimatedIconWithPreview.module.scss';
|
|||||||
|
|
||||||
type OwnProps =
|
type OwnProps =
|
||||||
Partial<AnimatedIconProps>
|
Partial<AnimatedIconProps>
|
||||||
& { previewUrl?: string; thumbDataUri?: string; noPreviewTransition?: boolean };
|
& { previewUrl?: string; thumbDataUri?: string; noPreviewTransition?: boolean; shouldUseTextColor?: boolean };
|
||||||
|
|
||||||
const ANIMATION_DURATION = 300;
|
const ANIMATION_DURATION = 300;
|
||||||
|
|
||||||
@ -23,9 +24,12 @@ const loadedPreviewUrls = new Set();
|
|||||||
|
|
||||||
function AnimatedIconWithPreview(props: OwnProps) {
|
function AnimatedIconWithPreview(props: OwnProps) {
|
||||||
const {
|
const {
|
||||||
previewUrl, thumbDataUri, className, ...otherProps
|
previewUrl, thumbDataUri, className, shouldUseTextColor, ...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>();
|
||||||
|
const customColor = useDynamicColorListener(rootRef, undefined, !shouldUseTextColor);
|
||||||
|
|
||||||
const [isThumbOpen, , unmarkThumbOpen] = useFlag(Boolean(thumbDataUri));
|
const [isThumbOpen, , unmarkThumbOpen] = useFlag(Boolean(thumbDataUri));
|
||||||
const thumbClassNames = useMediaTransitionDeprecated(isThumbOpen);
|
const thumbClassNames = useMediaTransitionDeprecated(isThumbOpen);
|
||||||
|
|
||||||
@ -49,6 +53,7 @@ function AnimatedIconWithPreview(props: OwnProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
className={buildClassName(className, styles.root)}
|
className={buildClassName(className, styles.root)}
|
||||||
style={buildStyle(size !== undefined && `width: ${size}px; height: ${size}px;`)}
|
style={buildStyle(size !== undefined && `width: ${size}px; height: ${size}px;`)}
|
||||||
>
|
>
|
||||||
@ -65,7 +70,7 @@ function AnimatedIconWithPreview(props: OwnProps) {
|
|||||||
onLoad={handlePreviewLoad}
|
onLoad={handlePreviewLoad}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AnimatedIcon {...otherProps} onLoad={handleAnimationReady} />
|
<AnimatedIcon {...otherProps} color={customColor} onLoad={handleAnimationReady} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ interface OwnProps {
|
|||||||
canBeEmpty?: boolean;
|
canBeEmpty?: boolean;
|
||||||
maxTimestamp?: number;
|
maxTimestamp?: number;
|
||||||
shouldAnimateTyping?: boolean;
|
shouldAnimateTyping?: boolean;
|
||||||
|
canAnimateTextStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
|
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
|
||||||
@ -66,6 +67,7 @@ function MessageText({
|
|||||||
maxTimestamp,
|
maxTimestamp,
|
||||||
threadId,
|
threadId,
|
||||||
shouldAnimateTyping,
|
shouldAnimateTyping,
|
||||||
|
canAnimateTextStreaming,
|
||||||
}: OwnProps) {
|
}: OwnProps) {
|
||||||
const sharedCanvasRef = useRef<HTMLCanvasElement>();
|
const sharedCanvasRef = useRef<HTMLCanvasElement>();
|
||||||
const sharedCanvasHqRef = useRef<HTMLCanvasElement>();
|
const sharedCanvasHqRef = useRef<HTMLCanvasElement>();
|
||||||
@ -152,7 +154,12 @@ function MessageText({
|
|||||||
withSharedCanvas && <canvas key="shared-canvas" ref={sharedCanvasRef} className="shared-canvas" />,
|
withSharedCanvas && <canvas key="shared-canvas" ref={sharedCanvasRef} className="shared-canvas" />,
|
||||||
withSharedCanvas && <canvas key="shared-canvas-hq" ref={sharedCanvasHqRef} className="shared-canvas" />,
|
withSharedCanvas && <canvas key="shared-canvas-hq" ref={sharedCanvasHqRef} className="shared-canvas" />,
|
||||||
shouldAnimateTyping ? (
|
shouldAnimateTyping ? (
|
||||||
<TypingWrapper key="typing-wrapper" text={textToRender}>{renderText}</TypingWrapper>
|
<TypingWrapper
|
||||||
|
key="typing-wrapper"
|
||||||
|
formattedText={textToRender}
|
||||||
|
renderText={renderText}
|
||||||
|
shouldAnimateMask={canAnimateTextStreaming}
|
||||||
|
/>
|
||||||
) : renderText(textToRender),
|
) : renderText(textToRender),
|
||||||
].flat().filter(Boolean)}
|
].flat().filter(Boolean)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
29
src/components/common/TypingWrapper.module.scss
Normal file
29
src/components/common/TypingWrapper.module.scss
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
.root {
|
||||||
|
--typing-draft-progress: 0%;
|
||||||
|
--typing-draft-spread: 10%;
|
||||||
|
|
||||||
|
display: inline;
|
||||||
|
|
||||||
|
mask-image:
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
black var(--typing-draft-progress),
|
||||||
|
transparent calc(var(--typing-draft-progress) + var(--typing-draft-spread))
|
||||||
|
);
|
||||||
|
|
||||||
|
&:dir(rtl) {
|
||||||
|
mask-image:
|
||||||
|
linear-gradient(
|
||||||
|
to left,
|
||||||
|
black var(--typing-draft-progress),
|
||||||
|
transparent calc(var(--typing-draft-progress) + var(--typing-draft-spread))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
@ -1,71 +1,223 @@
|
|||||||
|
import type { TeactNode } from '../../lib/teact/teact';
|
||||||
import {
|
import {
|
||||||
memo, useEffect, useRef, useSignal, useUnmountCleanup,
|
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, useUnmountCleanup,
|
||||||
} from '../../lib/teact/teact';
|
} from '../../lib/teact/teact';
|
||||||
|
|
||||||
import {
|
import type { ApiFormattedText } from '../../api/types';
|
||||||
type ApiFormattedText,
|
|
||||||
} from '../../api/types';
|
import { requestMutation } from '../../lib/fasterdom/fasterdom';
|
||||||
|
import { LOCAL_TGS_URLS } from './helpers/animatedAssets';
|
||||||
|
import { REM } from './helpers/mediaDimensions';
|
||||||
|
|
||||||
import useDerivedState from '../../hooks/useDerivedState';
|
|
||||||
import useLastCallback from '../../hooks/useLastCallback';
|
import useLastCallback from '../../hooks/useLastCallback';
|
||||||
|
|
||||||
|
import AnimatedIconWithPreview from './AnimatedIconWithPreview';
|
||||||
|
|
||||||
|
import styles from './TypingWrapper.module.scss';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
text: ApiFormattedText;
|
formattedText: ApiFormattedText;
|
||||||
duration?: number;
|
shouldAnimateMask?: boolean;
|
||||||
children: (text: ApiFormattedText) => React.ReactNode;
|
renderText: (text: ApiFormattedText) => TeactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_HEADWAY_DURATION = 1000;
|
const CHUNK_SIZE = 67;
|
||||||
const MIN_TIMEOUT_DURATION = 1000 / 60; // 60 FPS
|
const CHUNK_SPREAD_DURATION = 500;
|
||||||
const MAX_SYMBOLS_BATCH = 10;
|
const HEADWAY_DURATION = 750;
|
||||||
|
const PLACEHOLDER_SIZE = 1.25 * REM;
|
||||||
|
const SPREAD_CHARS = 20;
|
||||||
|
const PROGRESS_CSS_PROPERTY = '--typing-draft-progress';
|
||||||
|
const SPREAD_CSS_PROPERTY = '--typing-draft-spread';
|
||||||
|
|
||||||
const TypingWrapper = ({
|
try {
|
||||||
text,
|
window.CSS.registerProperty({
|
||||||
duration = DEFAULT_HEADWAY_DURATION,
|
name: PROGRESS_CSS_PROPERTY,
|
||||||
children,
|
syntax: '<percentage>',
|
||||||
}: OwnProps) => {
|
inherits: false,
|
||||||
const [getCurrentTextLength, setCurrentTextLength] = useSignal(text.text.length);
|
initialValue: '0%',
|
||||||
const intervalRef = useRef<number>();
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore duplicate registrations
|
||||||
|
}
|
||||||
|
|
||||||
const animate = useLastCallback(() => {
|
function getRunningProgress(animation: Animation | undefined, baseProgress: number) {
|
||||||
const msPerSymbol = duration / text.text.length;
|
const timing = animation?.effect?.getComputedTiming().progress;
|
||||||
const timeoutDuration = Math.max(msPerSymbol, MIN_TIMEOUT_DURATION);
|
if (typeof timing !== 'number') return baseProgress;
|
||||||
const nextSymbolBatchLength = Math.min(Math.ceil(timeoutDuration / msPerSymbol), MAX_SYMBOLS_BATCH);
|
return baseProgress + (100 - baseProgress) * timing;
|
||||||
|
}
|
||||||
|
|
||||||
intervalRef.current = window.setTimeout(() => {
|
const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProps) => {
|
||||||
if (getCurrentTextLength() >= text.text.length) {
|
const ref = useRef<HTMLSpanElement>();
|
||||||
clearTimeout(intervalRef.current);
|
const animationRef = useRef<Animation>();
|
||||||
return;
|
const progressRef = useRef(0);
|
||||||
}
|
const prevRevealedRef = useRef(0);
|
||||||
|
|
||||||
setCurrentTextLength(getCurrentTextLength() + nextSymbolBatchLength);
|
const [revealedLength, setRevealedLength] = useState(0);
|
||||||
}, timeoutDuration);
|
const revealedLengthRef = useRef(0);
|
||||||
|
const chunkTimerRef = useRef<number>();
|
||||||
|
const prevFullTextRef = useRef('');
|
||||||
|
|
||||||
|
const fullText = formattedText.text;
|
||||||
|
|
||||||
|
const stopAnimation = useLastCallback(() => {
|
||||||
|
animationRef.current?.cancel();
|
||||||
|
animationRef.current = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scheduleChunks = useLastCallback((from: number, to: number) => {
|
||||||
|
window.clearTimeout(chunkTimerRef.current);
|
||||||
|
|
||||||
|
const delta = to - from;
|
||||||
|
if (delta <= 0) return;
|
||||||
|
|
||||||
|
const numChunks = Math.ceil(delta / CHUNK_SIZE);
|
||||||
|
const chunkInterval = numChunks > 1 ? CHUNK_SPREAD_DURATION / (numChunks - 1) : 0;
|
||||||
|
|
||||||
|
let position = from;
|
||||||
|
|
||||||
|
const addChunk = () => {
|
||||||
|
position = Math.min(position + CHUNK_SIZE, to);
|
||||||
|
revealedLengthRef.current = position;
|
||||||
|
setRevealedLength(position);
|
||||||
|
|
||||||
|
if (position < to) {
|
||||||
|
chunkTimerRef.current = window.setTimeout(addChunk, chunkInterval);
|
||||||
|
} else {
|
||||||
|
chunkTimerRef.current = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addChunk();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetChunking = useLastCallback(() => {
|
||||||
|
window.clearTimeout(chunkTimerRef.current);
|
||||||
|
chunkTimerRef.current = undefined;
|
||||||
|
revealedLengthRef.current = 0;
|
||||||
|
prevRevealedRef.current = 0;
|
||||||
|
progressRef.current = 0;
|
||||||
|
stopAnimation();
|
||||||
|
setRevealedLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Chunking: spread incoming text over time ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Text got shorter, skip animation
|
if (fullText === prevFullTextRef.current) return;
|
||||||
if (text.text.length < getCurrentTextLength()) {
|
prevFullTextRef.current = fullText;
|
||||||
clearTimeout(intervalRef.current);
|
|
||||||
setCurrentTextLength(text.text.length);
|
const fullLen = fullText.length;
|
||||||
|
const revealed = revealedLengthRef.current;
|
||||||
|
|
||||||
|
if (fullLen < revealed) {
|
||||||
|
resetChunking();
|
||||||
|
scheduleChunks(0, fullLen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(intervalRef.current);
|
scheduleChunks(revealed, fullLen);
|
||||||
animate();
|
|
||||||
}, [getCurrentTextLength, setCurrentTextLength, text.text.length]);
|
|
||||||
|
|
||||||
useUnmountCleanup(() => {
|
|
||||||
clearTimeout(intervalRef.current);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayedText = useDerivedState(() => {
|
// --- Mask animation: smooth reveal of rendered content (layout effect to prevent flash) ---
|
||||||
return {
|
useLayoutEffect(() => {
|
||||||
...text,
|
const element = ref.current;
|
||||||
text: text.text.slice(0, getCurrentTextLength()),
|
if (!element) return;
|
||||||
};
|
|
||||||
}, [getCurrentTextLength, text]);
|
|
||||||
|
|
||||||
return children(displayedText);
|
const revealed = revealedLength;
|
||||||
|
const prevRevealed = prevRevealedRef.current;
|
||||||
|
if (revealed === prevRevealed) return;
|
||||||
|
|
||||||
|
prevRevealedRef.current = revealed;
|
||||||
|
|
||||||
|
if (!shouldAnimateMask) {
|
||||||
|
stopAnimation();
|
||||||
|
progressRef.current = 100;
|
||||||
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let progress = animationRef.current
|
||||||
|
? getRunningProgress(animationRef.current, progressRef.current)
|
||||||
|
: progressRef.current;
|
||||||
|
|
||||||
|
stopAnimation();
|
||||||
|
|
||||||
|
if (revealed < prevRevealed) {
|
||||||
|
progress = 0;
|
||||||
|
} else if (prevRevealed && revealed) {
|
||||||
|
progress = Math.min((prevRevealed * progress) / revealed, 100);
|
||||||
|
} else if (!prevRevealed) {
|
||||||
|
progress = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!revealed) {
|
||||||
|
progress = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressRef.current = progress;
|
||||||
|
const remaining = 100 - progress;
|
||||||
|
const spread = revealed ? (SPREAD_CHARS / revealed) * 100 : 0;
|
||||||
|
|
||||||
|
if (!revealed || remaining <= 0) {
|
||||||
|
progressRef.current = 100;
|
||||||
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
element.style.setProperty(SPREAD_CSS_PROPERTY, `${spread}%`);
|
||||||
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, `${progress}%`);
|
||||||
|
|
||||||
|
const animation = element.animate([
|
||||||
|
{ [PROGRESS_CSS_PROPERTY]: `${progress}%` },
|
||||||
|
{ [PROGRESS_CSS_PROPERTY]: '100%' },
|
||||||
|
] as Keyframe[], {
|
||||||
|
duration: HEADWAY_DURATION,
|
||||||
|
easing: 'linear',
|
||||||
|
fill: 'forwards',
|
||||||
|
});
|
||||||
|
|
||||||
|
animationRef.current = animation;
|
||||||
|
|
||||||
|
animation.onfinish = () => {
|
||||||
|
if (animationRef.current !== animation) return;
|
||||||
|
|
||||||
|
progressRef.current = 100;
|
||||||
|
animationRef.current = undefined;
|
||||||
|
|
||||||
|
requestMutation(() => {
|
||||||
|
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
animation.oncancel = () => {
|
||||||
|
if (animationRef.current !== animation) return;
|
||||||
|
animationRef.current = undefined;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
useUnmountCleanup(() => {
|
||||||
|
window.clearTimeout(chunkTimerRef.current);
|
||||||
|
stopAnimation();
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncatedText = useMemo(() => ({
|
||||||
|
text: fullText.slice(0, revealedLength),
|
||||||
|
entities: formattedText.entities,
|
||||||
|
}), [fullText, formattedText.entities, revealedLength]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={styles.root}>
|
||||||
|
{renderText(truncatedText)}
|
||||||
|
<span key="typing-placeholder" className={styles.placeholder}>
|
||||||
|
<AnimatedIconWithPreview
|
||||||
|
tgsUrl={LOCAL_TGS_URLS.Typing}
|
||||||
|
size={PLACEHOLDER_SIZE}
|
||||||
|
play
|
||||||
|
noLoop={false}
|
||||||
|
shouldUseTextColor
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(TypingWrapper);
|
export default memo(TypingWrapper);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import PartyPopper from '../../../assets/tgs/general/PartyPopper.tgs';
|
|||||||
import Invite from '../../../assets/tgs/invites/Invite.tgs';
|
import Invite from '../../../assets/tgs/invites/Invite.tgs';
|
||||||
import JoinRequest from '../../../assets/tgs/invites/Requests.tgs';
|
import JoinRequest from '../../../assets/tgs/invites/Requests.tgs';
|
||||||
import LastSeen from '../../../assets/tgs/LastSeen.tgs';
|
import LastSeen from '../../../assets/tgs/LastSeen.tgs';
|
||||||
|
import Typing from '../../../assets/tgs/message/Typing.tgs';
|
||||||
import MonkeyClose from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyClose.tgs';
|
import MonkeyClose from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyClose.tgs';
|
||||||
import MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs';
|
import MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs';
|
||||||
import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs';
|
import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs';
|
||||||
@ -97,4 +98,5 @@ export const LOCAL_TGS_URLS = {
|
|||||||
Passkeys,
|
Passkeys,
|
||||||
DuckCake,
|
DuckCake,
|
||||||
HandStop,
|
HandStop,
|
||||||
|
Typing,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -64,6 +64,7 @@ const PERFORMANCE_OPTIONS: PerformanceSection[] = [
|
|||||||
{ key: 'messageBlur', label: 'SettingsPerformanceMessageBlur', disabled: !IS_BACKDROP_BLUR_SUPPORTED },
|
{ key: 'messageBlur', label: 'SettingsPerformanceMessageBlur', disabled: !IS_BACKDROP_BLUR_SUPPORTED },
|
||||||
{ key: 'rightColumnAnimations', label: 'SettingsPerformanceRightColumn' },
|
{ key: 'rightColumnAnimations', label: 'SettingsPerformanceRightColumn' },
|
||||||
{ key: 'snapEffect', label: 'SettingsPerformanceThanos' },
|
{ key: 'snapEffect', label: 'SettingsPerformanceThanos' },
|
||||||
|
{ key: 'textStreaming', label: 'SettingsPerformanceTextStreaming' },
|
||||||
]],
|
]],
|
||||||
['SettingsPerformanceStickers', [
|
['SettingsPerformanceStickers', [
|
||||||
{ key: 'animatedEmoji', label: 'SettingsPerformanceAnimatedEmoji' },
|
{ key: 'animatedEmoji', label: 'SettingsPerformanceAnimatedEmoji' },
|
||||||
|
|||||||
@ -313,6 +313,7 @@ type StateProps = {
|
|||||||
requestedTranslationLanguage?: string;
|
requestedTranslationLanguage?: string;
|
||||||
requestedChatTranslationLanguage?: string;
|
requestedChatTranslationLanguage?: string;
|
||||||
withAnimatedEffects?: boolean;
|
withAnimatedEffects?: boolean;
|
||||||
|
canAnimateTextStreaming?: boolean;
|
||||||
webPageStory?: ApiTypeStory;
|
webPageStory?: ApiTypeStory;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isLoadingComments?: boolean;
|
isLoadingComments?: boolean;
|
||||||
@ -440,6 +441,7 @@ const Message = ({
|
|||||||
requestedTranslationLanguage,
|
requestedTranslationLanguage,
|
||||||
requestedChatTranslationLanguage,
|
requestedChatTranslationLanguage,
|
||||||
withAnimatedEffects,
|
withAnimatedEffects,
|
||||||
|
canAnimateTextStreaming,
|
||||||
webPageStory,
|
webPageStory,
|
||||||
isConnected,
|
isConnected,
|
||||||
getIsMessageListReady,
|
getIsMessageListReady,
|
||||||
@ -1073,6 +1075,7 @@ const Message = ({
|
|||||||
maxTimestamp={maxTimestamp}
|
maxTimestamp={maxTimestamp}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
shouldAnimateTyping={isTypingDraft}
|
shouldAnimateTyping={isTypingDraft}
|
||||||
|
canAnimateTextStreaming={canAnimateTextStreaming}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2221,6 +2224,7 @@ export default memo(withGlobal<OwnProps>(
|
|||||||
requestedChatTranslationLanguage,
|
requestedChatTranslationLanguage,
|
||||||
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
|
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
|
||||||
withAnimatedEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
|
withAnimatedEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
|
||||||
|
canAnimateTextStreaming: selectPerformanceSettingsValue(global, 'textStreaming'),
|
||||||
webPageStory,
|
webPageStory,
|
||||||
isConnected,
|
isConnected,
|
||||||
isLoadingComments: repliesThreadInfo?.isCommentsInfo
|
isLoadingComments: repliesThreadInfo?.isCommentsInfo
|
||||||
|
|||||||
154
src/components/test/demo/MessageTextStreamingTest.module.scss
Normal file
154
src/components/test/demo/MessageTextStreamingTest.module.scss
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
.root {
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(var(--color-primary-shade-rgb), 0.18), transparent 28rem),
|
||||||
|
linear-gradient(180deg, var(--color-background) 0%, var(--color-background-secondary) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: min(100%, 56rem);
|
||||||
|
min-height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restartButton {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge,
|
||||||
|
.progress {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 999rem;
|
||||||
|
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
color: var(--color-primary);
|
||||||
|
background: var(--color-primary-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
color: var(--color-text-meta);
|
||||||
|
background: rgba(var(--color-text-meta-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
max-width: 42rem;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(var(--color-primary-shade-rgb), 0.08), transparent 45%),
|
||||||
|
var(--color-chat-hover);
|
||||||
|
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
width: min(100%, 38rem);
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.75rem;
|
||||||
|
|
||||||
|
background: var(--color-background);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(var(--color-text-meta-rgb), 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
scroll-snap-type: y proximity;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
min-height: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgba(var(--color-primary-shade-rgb), 0.12), transparent 12rem),
|
||||||
|
var(--color-background-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draftLabel {
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 999rem;
|
||||||
|
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-meta);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
|
||||||
|
background: rgba(var(--color-text-meta-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollSnapEnd {
|
||||||
|
scroll-snap-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 1.25rem 1.25rem 0.375rem 1.25rem;
|
||||||
|
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-shade));
|
||||||
|
box-shadow: 0 0.75rem 1.5rem rgba(var(--color-primary-shade-rgb), 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubbleText {
|
||||||
|
unicode-bidi: plaintext;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
211
src/components/test/demo/MessageTextStreamingTest.tsx
Normal file
211
src/components/test/demo/MessageTextStreamingTest.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
memo, useEffect, useMemo, useRef, useState,
|
||||||
|
} from '../../../lib/teact/teact';
|
||||||
|
|
||||||
|
import type { ApiMessage, ApiMessageEntity } from '../../../api/types';
|
||||||
|
import { ApiMessageEntityTypes } from '../../../api/types';
|
||||||
|
|
||||||
|
import buildClassName from '../../../util/buildClassName';
|
||||||
|
|
||||||
|
import useLang from '../../../hooks/useLang';
|
||||||
|
import useLastCallback from '../../../hooks/useLastCallback';
|
||||||
|
|
||||||
|
import MessageText from '../../common/MessageText';
|
||||||
|
import Button from '../../ui/Button';
|
||||||
|
|
||||||
|
import styles from './MessageTextStreamingTest.module.scss';
|
||||||
|
|
||||||
|
const MIN_CHUNK_DELAY_MS = 1500;
|
||||||
|
const MAX_CHUNK_DELAY_MS = 1500;
|
||||||
|
const MIN_CHUNK_SIZE = 1;
|
||||||
|
const MAX_CHUNK_SIZE = 5;
|
||||||
|
|
||||||
|
const MOCK_MESSAGE: ApiMessage = {
|
||||||
|
id: 1,
|
||||||
|
chatId: '1',
|
||||||
|
content: {
|
||||||
|
text: {
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
date: 0,
|
||||||
|
isOutgoing: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function splitIntoSentences(text: string) {
|
||||||
|
return (text.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) || [])
|
||||||
|
.map((sentence) => sentence.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandomInteger(minValue: number, maxValue: number) {
|
||||||
|
return Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_TYPES = [ApiMessageEntityTypes.Bold, ApiMessageEntityTypes.Code] as const;
|
||||||
|
const ENTITY_MIN_LEN = 5;
|
||||||
|
const ENTITY_MAX_LEN = 60;
|
||||||
|
const ENTITY_GAP = 10;
|
||||||
|
const ENTITY_COUNT = 8;
|
||||||
|
|
||||||
|
function generateRandomEntities(textLength: number): ApiMessageEntity[] {
|
||||||
|
const entities: ApiMessageEntity[] = [];
|
||||||
|
let cursor = pickRandomInteger(0, ENTITY_GAP);
|
||||||
|
|
||||||
|
for (let i = 0; i < ENTITY_COUNT && cursor < textLength; i++) {
|
||||||
|
const maxLen = Math.min(ENTITY_MAX_LEN, textLength - cursor);
|
||||||
|
if (maxLen < ENTITY_MIN_LEN) break;
|
||||||
|
|
||||||
|
const length = pickRandomInteger(ENTITY_MIN_LEN, maxLen);
|
||||||
|
const type = ENTITY_TYPES[i % ENTITY_TYPES.length];
|
||||||
|
|
||||||
|
entities.push({ type, offset: cursor, length });
|
||||||
|
cursor += length + pickRandomInteger(ENTITY_GAP, ENTITY_GAP * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageTextStreamingTest = () => {
|
||||||
|
const lang = useLang();
|
||||||
|
|
||||||
|
const timeoutRef = useRef<number>();
|
||||||
|
const runIdRef = useRef(0);
|
||||||
|
|
||||||
|
const [displayedText, setDisplayedText] = useState('');
|
||||||
|
const [shownSentenceCount, setShownSentenceCount] = useState(0);
|
||||||
|
const [isFinished, setIsFinished] = useState(false);
|
||||||
|
|
||||||
|
const { sentences, entities } = useMemo(() => {
|
||||||
|
const chunks: string[] = [
|
||||||
|
lang('SuggestionBirthdaySetupTitle'),
|
||||||
|
lang('ProfileBirthdayTodayValue', { date: 'January 1' }),
|
||||||
|
lang('PremiumPreviewReactionsDescription'),
|
||||||
|
lang('SuggestedPostAgreementReached'),
|
||||||
|
lang('SuggestedPostPublishScheduleYou', { peer: 'Test', date: 'April 15' }),
|
||||||
|
lang('MonetizationInfoTONTitle'),
|
||||||
|
lang('PremiumPreviewAdvancedChatManagementDescription'),
|
||||||
|
lang('PremiumPreviewAnimatedProfilesDescription'),
|
||||||
|
lang('SponsoredMessageInfoDescription1'),
|
||||||
|
lang('SponsoredMessageInfoDescription3'),
|
||||||
|
lang('SuggestedPostChargedYou', { amount: '⭐️250' }),
|
||||||
|
lang('PremiumPreviewStickersDescription'),
|
||||||
|
lang('SuggestedPostRefundYou', { peer: 'Test', duration: '48 hours' }),
|
||||||
|
lang('PremiumPreviewNoAdsDescription'),
|
||||||
|
lang('AreYouSureShareMyContactInfoBot'),
|
||||||
|
];
|
||||||
|
const fullText = chunks.join(' ');
|
||||||
|
return {
|
||||||
|
sentences: splitIntoSentences(fullText),
|
||||||
|
entities: generateRandomEntities(fullText.length),
|
||||||
|
};
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
const clearStreamingTimeout = useLastCallback(() => {
|
||||||
|
if (timeoutRef.current === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendNextChunk = useLastCallback((startIndex: number, runId: number) => {
|
||||||
|
if (runId !== runIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sentences.length || startIndex >= sentences.length) {
|
||||||
|
setIsFinished(true);
|
||||||
|
timeoutRef.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = Math.min(
|
||||||
|
startIndex + pickRandomInteger(MIN_CHUNK_SIZE, MAX_CHUNK_SIZE),
|
||||||
|
sentences.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
setDisplayedText(sentences.slice(0, nextIndex).join(' '));
|
||||||
|
setShownSentenceCount(nextIndex);
|
||||||
|
|
||||||
|
if (nextIndex >= sentences.length) {
|
||||||
|
setIsFinished(true);
|
||||||
|
timeoutRef.current = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
appendNextChunk(nextIndex, runId);
|
||||||
|
}, pickRandomInteger(MIN_CHUNK_DELAY_MS, MAX_CHUNK_DELAY_MS));
|
||||||
|
});
|
||||||
|
|
||||||
|
const restartStreaming = useLastCallback(() => {
|
||||||
|
clearStreamingTimeout();
|
||||||
|
|
||||||
|
runIdRef.current += 1;
|
||||||
|
|
||||||
|
setDisplayedText('');
|
||||||
|
setShownSentenceCount(0);
|
||||||
|
setIsFinished(false);
|
||||||
|
|
||||||
|
const runId = runIdRef.current;
|
||||||
|
appendNextChunk(0, runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restartStreaming();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearStreamingTimeout();
|
||||||
|
};
|
||||||
|
}, [clearStreamingTimeout, restartStreaming, sentences]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={buildClassName(styles.root, 'full-height')}>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<Button className={styles.restartButton} onClick={restartStreaming}>
|
||||||
|
{lang('BotRestart')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.meta}>
|
||||||
|
<span className={styles.statusBadge}>
|
||||||
|
{lang(isFinished ? 'GiftAuctionFinished' : 'Loading')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className={styles.progress}>
|
||||||
|
{lang('FileTransferProgress', {
|
||||||
|
currentSize: lang.number(shownSentenceCount),
|
||||||
|
totalSize: lang.number(sentences.length),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.stage}>
|
||||||
|
<div className={styles.device}>
|
||||||
|
<div className={styles.screen}>
|
||||||
|
<div className={styles.draftLabel}>{lang('Draft')}</div>
|
||||||
|
|
||||||
|
<div className={styles.bubble} dir="auto">
|
||||||
|
<div className={styles.bubbleText}>
|
||||||
|
<MessageText
|
||||||
|
messageOrStory={MOCK_MESSAGE}
|
||||||
|
forcedText={{ text: displayedText, entities }}
|
||||||
|
canBeEmpty
|
||||||
|
shouldAnimateTyping
|
||||||
|
canAnimateTextStreaming
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.scrollSnapEnd} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(MessageTextStreamingTest);
|
||||||
@ -357,6 +357,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
|
|||||||
cachedSharedSettings.performance.messageBlur = false;
|
cachedSharedSettings.performance.messageBlur = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cachedSharedSettings.performance.textStreaming === undefined) {
|
||||||
|
cachedSharedSettings.performance.textStreaming = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!cachedSharedSettings.foldersPosition) {
|
if (!cachedSharedSettings.foldersPosition) {
|
||||||
cachedSharedSettings.foldersPosition = FOLDERS_POSITION_DEFAULT;
|
cachedSharedSettings.foldersPosition = FOLDERS_POSITION_DEFAULT;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ export const INITIAL_PERFORMANCE_STATE_MAX: PerformanceType = {
|
|||||||
stickerEffects: true,
|
stickerEffects: true,
|
||||||
storyRibbonAnimations: true,
|
storyRibbonAnimations: true,
|
||||||
snapEffect: true,
|
snapEffect: true,
|
||||||
|
textStreaming: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const INITIAL_PERFORMANCE_STATE_MED: PerformanceType = {
|
export const INITIAL_PERFORMANCE_STATE_MED: PerformanceType = {
|
||||||
@ -54,6 +55,7 @@ export const INITIAL_PERFORMANCE_STATE_MED: PerformanceType = {
|
|||||||
stickerEffects: true,
|
stickerEffects: true,
|
||||||
storyRibbonAnimations: true,
|
storyRibbonAnimations: true,
|
||||||
snapEffect: false,
|
snapEffect: false,
|
||||||
|
textStreaming: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
|
export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
|
||||||
@ -73,6 +75,7 @@ export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
|
|||||||
stickerEffects: false,
|
stickerEffects: false,
|
||||||
storyRibbonAnimations: false,
|
storyRibbonAnimations: false,
|
||||||
snapEffect: false,
|
snapEffect: false,
|
||||||
|
textStreaming: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const INITIAL_SHARED_STATE: SharedState = {
|
export const INITIAL_SHARED_STATE: SharedState = {
|
||||||
|
|||||||
@ -112,7 +112,7 @@ export type PerformanceTypeKey = (
|
|||||||
'pageTransitions' | 'messageSendingAnimations' | 'mediaViewerAnimations'
|
'pageTransitions' | 'messageSendingAnimations' | 'mediaViewerAnimations'
|
||||||
| 'messageComposerAnimations' | 'contextMenuAnimations' | 'contextMenuBlur' | 'messageBlur'
|
| 'messageComposerAnimations' | 'contextMenuAnimations' | 'contextMenuBlur' | 'messageBlur'
|
||||||
| 'rightColumnAnimations' | 'animatedEmoji' | 'loopAnimatedStickers' | 'reactionEffects' | 'stickerEffects'
|
| 'rightColumnAnimations' | 'animatedEmoji' | 'loopAnimatedStickers' | 'reactionEffects' | 'stickerEffects'
|
||||||
| 'autoplayGifs' | 'autoplayVideos' | 'storyRibbonAnimations' | 'snapEffect'
|
| 'autoplayGifs' | 'autoplayVideos' | 'storyRibbonAnimations' | 'snapEffect' | 'textStreaming'
|
||||||
);
|
);
|
||||||
export type PerformanceType = Record<PerformanceTypeKey, boolean>;
|
export type PerformanceType = Record<PerformanceTypeKey, boolean>;
|
||||||
|
|
||||||
|
|||||||
1
src/types/language.d.ts
vendored
1
src/types/language.d.ts
vendored
@ -628,6 +628,7 @@ export interface LangPair {
|
|||||||
'SettingsPerformanceMessageBlur': undefined;
|
'SettingsPerformanceMessageBlur': undefined;
|
||||||
'SettingsPerformanceRightColumn': undefined;
|
'SettingsPerformanceRightColumn': undefined;
|
||||||
'SettingsPerformanceThanos': undefined;
|
'SettingsPerformanceThanos': undefined;
|
||||||
|
'SettingsPerformanceTextStreaming': undefined;
|
||||||
'SettingsPerformanceAnimatedEmoji': undefined;
|
'SettingsPerformanceAnimatedEmoji': undefined;
|
||||||
'SettingsPerformanceLoopStickers': undefined;
|
'SettingsPerformanceLoopStickers': undefined;
|
||||||
'SettingsPerformanceReactionEffects': undefined;
|
'SettingsPerformanceReactionEffects': undefined;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user