Message: Add text streaming animation (#6834)
This commit is contained in:
parent
60aaf90093
commit
da590cf02a
@ -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";
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)}
|
||||
</>
|
||||
|
||||
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 {
|
||||
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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (cachedSharedSettings.performance.textStreaming === undefined) {
|
||||
cachedSharedSettings.performance.textStreaming = true;
|
||||
}
|
||||
|
||||
if (!cachedSharedSettings.foldersPosition) {
|
||||
cachedSharedSettings.foldersPosition = FOLDERS_POSITION_DEFAULT;
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
1
src/types/language.d.ts
vendored
1
src/types/language.d.ts
vendored
@ -628,6 +628,7 @@ export interface LangPair {
|
||||
'SettingsPerformanceMessageBlur': undefined;
|
||||
'SettingsPerformanceRightColumn': undefined;
|
||||
'SettingsPerformanceThanos': undefined;
|
||||
'SettingsPerformanceTextStreaming': undefined;
|
||||
'SettingsPerformanceAnimatedEmoji': undefined;
|
||||
'SettingsPerformanceLoopStickers': undefined;
|
||||
'SettingsPerformanceReactionEffects': undefined;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user