Message: Add text streaming animation (#6834)

This commit is contained in:
zubiden 2026-04-08 02:35:25 +02:00 committed by Alexander Zinchuk
parent 60aaf90093
commit da590cf02a
15 changed files with 626 additions and 52 deletions

View File

@ -708,6 +708,7 @@
"SettingsPerformanceMessageBlur" = "Message Blur";
"SettingsPerformanceRightColumn" = "Right Column Animation";
"SettingsPerformanceThanos" = "Dust-effect deletion";
"SettingsPerformanceTextStreaming" = "Text Streaming";
"SettingsPerformanceAnimatedEmoji" = "Allow Animated Emoji";
"SettingsPerformanceLoopStickers" = "Loop Animated Stickers";
"SettingsPerformanceReactionEffects" = "Reaction Effects";

Binary file not shown.

View File

@ -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 buildClassName from '../../util/buildClassName';
import buildStyle from '../../util/buildStyle';
import useDynamicColorListener from '../../hooks/stickers/useDynamicColorListener';
import useFlag from '../../hooks/useFlag';
import useLastCallback from '../../hooks/useLastCallback';
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
@ -15,7 +16,7 @@ import styles from './AnimatedIconWithPreview.module.scss';
type OwnProps =
Partial<AnimatedIconProps>
& { previewUrl?: string; thumbDataUri?: string; noPreviewTransition?: boolean };
& { previewUrl?: string; thumbDataUri?: string; noPreviewTransition?: boolean; shouldUseTextColor?: boolean };
const ANIMATION_DURATION = 300;
@ -23,9 +24,12 @@ const loadedPreviewUrls = new Set();
function AnimatedIconWithPreview(props: OwnProps) {
const {
previewUrl, thumbDataUri, className, ...otherProps
previewUrl, thumbDataUri, className, shouldUseTextColor, ...otherProps
} = props;
const rootRef = useRef<HTMLDivElement>();
const customColor = useDynamicColorListener(rootRef, undefined, !shouldUseTextColor);
const [isThumbOpen, , unmarkThumbOpen] = useFlag(Boolean(thumbDataUri));
const thumbClassNames = useMediaTransitionDeprecated(isThumbOpen);
@ -49,6 +53,7 @@ function AnimatedIconWithPreview(props: OwnProps) {
return (
<div
ref={rootRef}
className={buildClassName(className, styles.root)}
style={buildStyle(size !== undefined && `width: ${size}px; height: ${size}px;`)}
>
@ -65,7 +70,7 @@ function AnimatedIconWithPreview(props: OwnProps) {
onLoad={handlePreviewLoad}
/>
)}
<AnimatedIcon {...otherProps} onLoad={handleAnimationReady} />
<AnimatedIcon {...otherProps} color={customColor} onLoad={handleAnimationReady} />
</div>
);
}

View File

@ -40,6 +40,7 @@ interface OwnProps {
canBeEmpty?: boolean;
maxTimestamp?: number;
shouldAnimateTyping?: boolean;
canAnimateTextStreaming?: boolean;
}
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
@ -66,6 +67,7 @@ function MessageText({
maxTimestamp,
threadId,
shouldAnimateTyping,
canAnimateTextStreaming,
}: OwnProps) {
const sharedCanvasRef = 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-hq" ref={sharedCanvasHqRef} className="shared-canvas" />,
shouldAnimateTyping ? (
<TypingWrapper key="typing-wrapper" text={textToRender}>{renderText}</TypingWrapper>
<TypingWrapper
key="typing-wrapper"
formattedText={textToRender}
renderText={renderText}
shouldAnimateMask={canAnimateTextStreaming}
/>
) : renderText(textToRender),
].flat().filter(Boolean)}
</>

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

View File

@ -1,71 +1,223 @@
import type { TeactNode } from '../../lib/teact/teact';
import {
memo, useEffect, useRef, useSignal, useUnmountCleanup,
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, useUnmountCleanup,
} from '../../lib/teact/teact';
import {
type ApiFormattedText,
} from '../../api/types';
import 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 AnimatedIconWithPreview from './AnimatedIconWithPreview';
import styles from './TypingWrapper.module.scss';
type OwnProps = {
text: ApiFormattedText;
duration?: number;
children: (text: ApiFormattedText) => React.ReactNode;
formattedText: ApiFormattedText;
shouldAnimateMask?: boolean;
renderText: (text: ApiFormattedText) => TeactNode;
};
const DEFAULT_HEADWAY_DURATION = 1000;
const MIN_TIMEOUT_DURATION = 1000 / 60; // 60 FPS
const MAX_SYMBOLS_BATCH = 10;
const CHUNK_SIZE = 67;
const CHUNK_SPREAD_DURATION = 500;
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 = ({
text,
duration = DEFAULT_HEADWAY_DURATION,
children,
}: OwnProps) => {
const [getCurrentTextLength, setCurrentTextLength] = useSignal(text.text.length);
const intervalRef = useRef<number>();
try {
window.CSS.registerProperty({
name: PROGRESS_CSS_PROPERTY,
syntax: '<percentage>',
inherits: false,
initialValue: '0%',
});
} catch (_) {
// Ignore duplicate registrations
}
const animate = useLastCallback(() => {
const msPerSymbol = duration / text.text.length;
const timeoutDuration = Math.max(msPerSymbol, MIN_TIMEOUT_DURATION);
const nextSymbolBatchLength = Math.min(Math.ceil(timeoutDuration / msPerSymbol), MAX_SYMBOLS_BATCH);
function getRunningProgress(animation: Animation | undefined, baseProgress: number) {
const timing = animation?.effect?.getComputedTiming().progress;
if (typeof timing !== 'number') return baseProgress;
return baseProgress + (100 - baseProgress) * timing;
}
intervalRef.current = window.setTimeout(() => {
if (getCurrentTextLength() >= text.text.length) {
clearTimeout(intervalRef.current);
return;
}
const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProps) => {
const ref = useRef<HTMLSpanElement>();
const animationRef = useRef<Animation>();
const progressRef = useRef(0);
const prevRevealedRef = useRef(0);
setCurrentTextLength(getCurrentTextLength() + nextSymbolBatchLength);
}, timeoutDuration);
const [revealedLength, setRevealedLength] = useState(0);
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(() => {
// Text got shorter, skip animation
if (text.text.length < getCurrentTextLength()) {
clearTimeout(intervalRef.current);
setCurrentTextLength(text.text.length);
if (fullText === prevFullTextRef.current) return;
prevFullTextRef.current = fullText;
const fullLen = fullText.length;
const revealed = revealedLengthRef.current;
if (fullLen < revealed) {
resetChunking();
scheduleChunks(0, fullLen);
return;
}
clearTimeout(intervalRef.current);
animate();
}, [getCurrentTextLength, setCurrentTextLength, text.text.length]);
useUnmountCleanup(() => {
clearTimeout(intervalRef.current);
scheduleChunks(revealed, fullLen);
});
const displayedText = useDerivedState(() => {
return {
...text,
text: text.text.slice(0, getCurrentTextLength()),
};
}, [getCurrentTextLength, text]);
// --- Mask animation: smooth reveal of rendered content (layout effect to prevent flash) ---
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
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);

View File

@ -20,6 +20,7 @@ import PartyPopper from '../../../assets/tgs/general/PartyPopper.tgs';
import Invite from '../../../assets/tgs/invites/Invite.tgs';
import JoinRequest from '../../../assets/tgs/invites/Requests.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 MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs';
import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs';
@ -97,4 +98,5 @@ export const LOCAL_TGS_URLS = {
Passkeys,
DuckCake,
HandStop,
Typing,
};

View File

@ -64,6 +64,7 @@ const PERFORMANCE_OPTIONS: PerformanceSection[] = [
{ key: 'messageBlur', label: 'SettingsPerformanceMessageBlur', disabled: !IS_BACKDROP_BLUR_SUPPORTED },
{ key: 'rightColumnAnimations', label: 'SettingsPerformanceRightColumn' },
{ key: 'snapEffect', label: 'SettingsPerformanceThanos' },
{ key: 'textStreaming', label: 'SettingsPerformanceTextStreaming' },
]],
['SettingsPerformanceStickers', [
{ key: 'animatedEmoji', label: 'SettingsPerformanceAnimatedEmoji' },

View File

@ -313,6 +313,7 @@ type StateProps = {
requestedTranslationLanguage?: string;
requestedChatTranslationLanguage?: string;
withAnimatedEffects?: boolean;
canAnimateTextStreaming?: boolean;
webPageStory?: ApiTypeStory;
isConnected: boolean;
isLoadingComments?: boolean;
@ -440,6 +441,7 @@ const Message = ({
requestedTranslationLanguage,
requestedChatTranslationLanguage,
withAnimatedEffects,
canAnimateTextStreaming,
webPageStory,
isConnected,
getIsMessageListReady,
@ -1073,6 +1075,7 @@ const Message = ({
maxTimestamp={maxTimestamp}
threadId={threadId}
shouldAnimateTyping={isTypingDraft}
canAnimateTextStreaming={canAnimateTextStreaming}
/>
);
}
@ -2221,6 +2224,7 @@ export default memo(withGlobal<OwnProps>(
requestedChatTranslationLanguage,
hasLinkedChat: Boolean(chatFullInfo?.linkedChatId),
withAnimatedEffects: selectPerformanceSettingsValue(global, 'stickerEffects'),
canAnimateTextStreaming: selectPerformanceSettingsValue(global, 'textStreaming'),
webPageStory,
isConnected,
isLoadingComments: repliesThreadInfo?.isCommentsInfo

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

View 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);

View File

@ -357,6 +357,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
cachedSharedSettings.performance.messageBlur = false;
}
if (cachedSharedSettings.performance.textStreaming === undefined) {
cachedSharedSettings.performance.textStreaming = true;
}
if (!cachedSharedSettings.foldersPosition) {
cachedSharedSettings.foldersPosition = FOLDERS_POSITION_DEFAULT;
}

View File

@ -35,6 +35,7 @@ export const INITIAL_PERFORMANCE_STATE_MAX: PerformanceType = {
stickerEffects: true,
storyRibbonAnimations: true,
snapEffect: true,
textStreaming: true,
};
export const INITIAL_PERFORMANCE_STATE_MED: PerformanceType = {
@ -54,6 +55,7 @@ export const INITIAL_PERFORMANCE_STATE_MED: PerformanceType = {
stickerEffects: true,
storyRibbonAnimations: true,
snapEffect: false,
textStreaming: true,
};
export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
@ -73,6 +75,7 @@ export const INITIAL_PERFORMANCE_STATE_MIN: PerformanceType = {
stickerEffects: false,
storyRibbonAnimations: false,
snapEffect: false,
textStreaming: false,
};
export const INITIAL_SHARED_STATE: SharedState = {

View File

@ -112,7 +112,7 @@ export type PerformanceTypeKey = (
'pageTransitions' | 'messageSendingAnimations' | 'mediaViewerAnimations'
| 'messageComposerAnimations' | 'contextMenuAnimations' | 'contextMenuBlur' | 'messageBlur'
| 'rightColumnAnimations' | 'animatedEmoji' | 'loopAnimatedStickers' | 'reactionEffects' | 'stickerEffects'
| 'autoplayGifs' | 'autoplayVideos' | 'storyRibbonAnimations' | 'snapEffect'
| 'autoplayGifs' | 'autoplayVideos' | 'storyRibbonAnimations' | 'snapEffect' | 'textStreaming'
);
export type PerformanceType = Record<PerformanceTypeKey, boolean>;

View File

@ -628,6 +628,7 @@ export interface LangPair {
'SettingsPerformanceMessageBlur': undefined;
'SettingsPerformanceRightColumn': undefined;
'SettingsPerformanceThanos': undefined;
'SettingsPerformanceTextStreaming': undefined;
'SettingsPerformanceAnimatedEmoji': undefined;
'SettingsPerformanceLoopStickers': undefined;
'SettingsPerformanceReactionEffects': undefined;