diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 0c15c2dfc..75874c70d 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; diff --git a/src/assets/tgs/message/Typing.tgs b/src/assets/tgs/message/Typing.tgs new file mode 100644 index 000000000..f63b6e9a0 Binary files /dev/null and b/src/assets/tgs/message/Typing.tgs differ diff --git a/src/components/common/AnimatedIconWithPreview.tsx b/src/components/common/AnimatedIconWithPreview.tsx index fa7814e1b..094e173f1 100644 --- a/src/components/common/AnimatedIconWithPreview.tsx +++ b/src/components/common/AnimatedIconWithPreview.tsx @@ -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 - & { 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(); + 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 (
@@ -65,7 +70,7 @@ function AnimatedIconWithPreview(props: OwnProps) { onLoad={handlePreviewLoad} /> )} - +
); } diff --git a/src/components/common/MessageText.tsx b/src/components/common/MessageText.tsx index 389d94d81..62f4587b4 100644 --- a/src/components/common/MessageText.tsx +++ b/src/components/common/MessageText.tsx @@ -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(); const sharedCanvasHqRef = useRef(); @@ -152,7 +154,12 @@ function MessageText({ withSharedCanvas && , withSharedCanvas && , shouldAnimateTyping ? ( - {renderText} + ) : renderText(textToRender), ].flat().filter(Boolean)} diff --git a/src/components/common/TypingWrapper.module.scss b/src/components/common/TypingWrapper.module.scss new file mode 100644 index 000000000..d28297e28 --- /dev/null +++ b/src/components/common/TypingWrapper.module.scss @@ -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; +} diff --git a/src/components/common/TypingWrapper.tsx b/src/components/common/TypingWrapper.tsx index 074ccfeb6..56ee4928a 100644 --- a/src/components/common/TypingWrapper.tsx +++ b/src/components/common/TypingWrapper.tsx @@ -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(); +try { + window.CSS.registerProperty({ + name: PROGRESS_CSS_PROPERTY, + syntax: '', + 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(); + const animationRef = useRef(); + const progressRef = useRef(0); + const prevRevealedRef = useRef(0); - setCurrentTextLength(getCurrentTextLength() + nextSymbolBatchLength); - }, timeoutDuration); + const [revealedLength, setRevealedLength] = useState(0); + const revealedLengthRef = useRef(0); + const chunkTimerRef = useRef(); + 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 ( + + {renderText(truncatedText)} + + + + + ); }; export default memo(TypingWrapper); diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index cdd277be0..a20ba1707 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -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, }; diff --git a/src/components/left/settings/SettingsPerformance.tsx b/src/components/left/settings/SettingsPerformance.tsx index 242e34308..6512c3fc0 100644 --- a/src/components/left/settings/SettingsPerformance.tsx +++ b/src/components/left/settings/SettingsPerformance.tsx @@ -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' }, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 95e1b3af5..339c6fd46 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -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( requestedChatTranslationLanguage, hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), withAnimatedEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), + canAnimateTextStreaming: selectPerformanceSettingsValue(global, 'textStreaming'), webPageStory, isConnected, isLoadingComments: repliesThreadInfo?.isCommentsInfo diff --git a/src/components/test/demo/MessageTextStreamingTest.module.scss b/src/components/test/demo/MessageTextStreamingTest.module.scss new file mode 100644 index 000000000..4182c03b7 --- /dev/null +++ b/src/components/test/demo/MessageTextStreamingTest.module.scss @@ -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; +} diff --git a/src/components/test/demo/MessageTextStreamingTest.tsx b/src/components/test/demo/MessageTextStreamingTest.tsx new file mode 100644 index 000000000..0a39d88e9 --- /dev/null +++ b/src/components/test/demo/MessageTextStreamingTest.tsx @@ -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(); + 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 ( +
+
+
+ +
+ +
+ + {lang(isFinished ? 'GiftAuctionFinished' : 'Loading')} + + + + {lang('FileTransferProgress', { + currentSize: lang.number(shownSentenceCount), + totalSize: lang.number(sentences.length), + })} + +
+ +
+
+
+
{lang('Draft')}
+ +
+
+ +
+
+
+
+
+
+
+
+ ); +}; + +export default memo(MessageTextStreamingTest); diff --git a/src/global/cache.ts b/src/global/cache.ts index 4d2e2ee38..2852e5b9d 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -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; } diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 4e1374634..861ed213c 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -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 = { diff --git a/src/types/index.ts b/src/types/index.ts index c054b47ed..e1cc13a76 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; diff --git a/src/types/language.d.ts b/src/types/language.d.ts index cbbb1a2f7..054850890 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -628,6 +628,7 @@ export interface LangPair { 'SettingsPerformanceMessageBlur': undefined; 'SettingsPerformanceRightColumn': undefined; 'SettingsPerformanceThanos': undefined; + 'SettingsPerformanceTextStreaming': undefined; 'SettingsPerformanceAnimatedEmoji': undefined; 'SettingsPerformanceLoopStickers': undefined; 'SettingsPerformanceReactionEffects': undefined;