Message: Stop typing draft scroll on top (#6853)

This commit is contained in:
zubiden 2026-04-27 14:29:29 +02:00 committed by Alexander Zinchuk
parent e5b932b8ea
commit 9798b5a851
12 changed files with 350 additions and 50 deletions

View File

@ -41,6 +41,7 @@ interface OwnProps {
maxTimestamp?: number; maxTimestamp?: number;
shouldAnimateTyping?: boolean; shouldAnimateTyping?: boolean;
canAnimateTextStreaming?: boolean; canAnimateTextStreaming?: boolean;
onTypingAnimationEnd?: NoneToVoidFunction;
} }
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
@ -68,6 +69,7 @@ function MessageText({
threadId, threadId,
shouldAnimateTyping, shouldAnimateTyping,
canAnimateTextStreaming, canAnimateTextStreaming,
onTypingAnimationEnd,
}: OwnProps) { }: OwnProps) {
const sharedCanvasRef = useRef<HTMLCanvasElement>(); const sharedCanvasRef = useRef<HTMLCanvasElement>();
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(); const sharedCanvasHqRef = useRef<HTMLCanvasElement>();
@ -147,6 +149,7 @@ function MessageText({
text: trimText(text || '', truncateLength), text: trimText(text || '', truncateLength),
entities: entitiesWithFocusedQuote, entities: entitiesWithFocusedQuote,
}; };
const shouldRenderTypingPlaceholder = !('previousLocalId' in messageOrStory) || !messageOrStory.previousLocalId;
return ( return (
<> <>
@ -159,6 +162,9 @@ function MessageText({
formattedText={textToRender} formattedText={textToRender}
renderText={renderText} renderText={renderText}
shouldAnimateMask={canAnimateTextStreaming} shouldAnimateMask={canAnimateTextStreaming}
shouldRenderPlaceholder={shouldRenderTypingPlaceholder}
onCompleted={onTypingAnimationEnd}
completionKey={messageOrStory.id}
/> />
) : renderText(textToRender), ) : renderText(textToRender),
].flat().filter(Boolean)} ].flat().filter(Boolean)}

View File

@ -18,7 +18,10 @@ import styles from './TypingWrapper.module.scss';
type OwnProps = { type OwnProps = {
formattedText: ApiFormattedText; formattedText: ApiFormattedText;
shouldAnimateMask?: boolean; shouldAnimateMask?: boolean;
shouldRenderPlaceholder: boolean;
completionKey: number;
renderText: (text: ApiFormattedText) => TeactNode; renderText: (text: ApiFormattedText) => TeactNode;
onCompleted?: NoneToVoidFunction;
}; };
const CHUNK_SIZE = 67; const CHUNK_SIZE = 67;
@ -46,24 +49,48 @@ function getRunningProgress(animation: Animation | undefined, baseProgress: numb
return baseProgress + (100 - baseProgress) * timing; return baseProgress + (100 - baseProgress) * timing;
} }
const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProps) => { const TypingWrapper = ({
formattedText,
shouldAnimateMask,
shouldRenderPlaceholder,
completionKey,
renderText,
onCompleted,
}: OwnProps) => {
const ref = useRef<HTMLSpanElement>(); const ref = useRef<HTMLSpanElement>();
const animationRef = useRef<Animation>(); const animationRef = useRef<Animation>();
const progressRef = useRef(0); const progressRef = useRef(0);
const prevRevealedRef = useRef(0); const prevRevealedRef = useRef(0);
const fullTextRef = useRef('');
const [revealedLength, setRevealedLength] = useState(0); const [revealedLength, setRevealedLength] = useState(0);
const revealedLengthRef = useRef(0); const revealedLengthRef = useRef(0);
const chunkTimerRef = useRef<number>(); const chunkTimerRef = useRef<number>();
const completedKeyRef = useRef<string>();
const prevFullTextRef = useRef(''); const prevFullTextRef = useRef('');
const fullText = formattedText.text; const fullText = formattedText.text;
fullTextRef.current = fullText;
const stopAnimation = useLastCallback(() => { const stopAnimation = useLastCallback(() => {
animationRef.current?.cancel(); animationRef.current?.cancel();
animationRef.current = undefined; animationRef.current = undefined;
}); });
const maybeNotifyCompleted = useLastCallback(() => {
const currentFullText = fullTextRef.current;
const currentCompletionKey = `${completionKey}:${currentFullText}`;
const isFullyRevealed = revealedLengthRef.current >= currentFullText.length;
const isMaskCompleted = !shouldAnimateMask || progressRef.current >= 100;
if (!isFullyRevealed || !isMaskCompleted || completedKeyRef.current === currentCompletionKey) {
return;
}
completedKeyRef.current = currentCompletionKey;
onCompleted?.();
});
const scheduleChunks = useLastCallback((from: number, to: number) => { const scheduleChunks = useLastCallback((from: number, to: number) => {
window.clearTimeout(chunkTimerRef.current); window.clearTimeout(chunkTimerRef.current);
@ -90,16 +117,6 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp
addChunk(); 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 --- // --- Chunking: spread incoming text over time ---
useEffect(() => { useEffect(() => {
if (fullText === prevFullTextRef.current) return; if (fullText === prevFullTextRef.current) return;
@ -109,12 +126,34 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp
const revealed = revealedLengthRef.current; const revealed = revealedLengthRef.current;
if (fullLen < revealed) { if (fullLen < revealed) {
resetChunking(); window.clearTimeout(chunkTimerRef.current);
scheduleChunks(0, fullLen); chunkTimerRef.current = undefined;
stopAnimation();
revealedLengthRef.current = fullLen;
prevRevealedRef.current = fullLen;
progressRef.current = 100;
setRevealedLength(fullLen);
requestMutation(() => {
const element = ref.current;
if (!element) return;
element.style.setProperty(SPREAD_CSS_PROPERTY, '0%');
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
});
return;
}
if (fullLen === revealed) {
return; return;
} }
scheduleChunks(revealed, fullLen); scheduleChunks(revealed, fullLen);
}, [fullText, scheduleChunks, stopAnimation]);
// Completion depends on several refs, so we are calling check after every render to avoid locking the UI
useEffect(() => {
maybeNotifyCompleted();
}); });
// --- Mask animation: smooth reveal of rendered content (layout effect to prevent flash) --- // --- Mask animation: smooth reveal of rendered content (layout effect to prevent flash) ---
@ -186,6 +225,8 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp
requestMutation(() => { requestMutation(() => {
element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%'); element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%');
}); });
maybeNotifyCompleted();
}; };
animation.oncancel = () => { animation.oncancel = () => {
@ -207,15 +248,17 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp
return ( return (
<span ref={ref} className={styles.root}> <span ref={ref} className={styles.root}>
{renderText(truncatedText)} {renderText(truncatedText)}
<span key="typing-placeholder" className={styles.placeholder}> {shouldRenderPlaceholder && (
<AnimatedIconWithPreview <span key="typing-placeholder" className={styles.placeholder}>
tgsUrl={LOCAL_TGS_URLS.Writing} <AnimatedIconWithPreview
size={PLACEHOLDER_SIZE} tgsUrl={LOCAL_TGS_URLS.Writing}
play size={PLACEHOLDER_SIZE}
noLoop={false} play
shouldUseTextColor noLoop={false}
/> shouldUseTextColor
</span> />
</span>
)}
</span> </span>
); );
}; };

View File

@ -17,6 +17,7 @@ import { forceMeasure, requestMeasure, requestMutation } from '../../lib/fasterd
import { import {
getIsSavedDialog, getIsSavedDialog,
getMessageHtmlId, getMessageHtmlId,
getMessageOriginalId,
isAnonymousForwardsChat, isAnonymousForwardsChat,
isChatChannel, isChatChannel,
isChatGroup, isChatGroup,
@ -272,6 +273,7 @@ const MessageList = ({
const isScrollTopJustUpdatedRef = useRef(false); const isScrollTopJustUpdatedRef = useRef(false);
const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage)); const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage));
const scrollSnapDisabledTimerRef = useRef<number>(); const scrollSnapDisabledTimerRef = useRef<number>();
const typingDraftSnapTriggeredIdRef = useRef<number>();
const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId);
const hasOpenChatButton = isSavedDialog const hasOpenChatButton = isSavedDialog
@ -401,6 +403,13 @@ const MessageList = ({
isServiceNotificationsChat, isForum, isServiceNotificationsChat, isForum,
threadId, isChatWithSelf, channelJoinInfo]); threadId, isChatWithSelf, channelJoinInfo]);
const currentLastMessageOriginalId = useMemo(() => {
const currentLastMessageId = messageIds?.[messageIds.length - 1];
const currentLastMessage = currentLastMessageId !== undefined ? messagesById?.[currentLastMessageId] : undefined;
return currentLastMessage ? getMessageOriginalId(currentLastMessage) : currentLastMessageId;
}, [messageIds, messagesById]);
useInterval(() => { useInterval(() => {
if (!messageIds || !messagesById || type === 'scheduled' || isAccountFrozen || !isActive) return; if (!messageIds || !messagesById || type === 'scheduled' || isAccountFrozen || !isActive) return;
if (!isChannelChat && !isGroupChat) return; if (!isChannelChat && !isGroupChat) return;
@ -505,6 +514,31 @@ const MessageList = ({
} }
}); });
const handleTallTypingDraft = useLastCallback((messageId: number, isNearExit: boolean) => {
if (!isNearExit) {
if (typingDraftSnapTriggeredIdRef.current === messageId) {
typingDraftSnapTriggeredIdRef.current = undefined;
}
return;
}
if (typingDraftSnapTriggeredIdRef.current === messageId) {
return;
}
const container = containerRef.current;
if (!container || !container.classList.contains(BOTTOM_SNAP_CLASS)) return;
typingDraftSnapTriggeredIdRef.current = messageId;
clearTimeout(scrollSnapDisabledTimerRef.current);
scrollSnapDisabledTimerRef.current = undefined;
requestMutation(() => {
removeExtraClass(container, BOTTOM_SNAP_CLASS);
});
});
const handleScroll = useLastCallback(() => { const handleScroll = useLastCallback(() => {
if (isScrollTopJustUpdatedRef.current) { if (isScrollTopJustUpdatedRef.current) {
isScrollTopJustUpdatedRef.current = false; isScrollTopJustUpdatedRef.current = false;
@ -613,7 +647,7 @@ const MessageList = ({
); );
// Handles updated message list, takes care of scroll repositioning // Handles updated message list, takes care of scroll repositioning
useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest]) => { useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest, prevCurrentLastMessageOriginalId]) => {
if (process.env.APP_ENV === 'perf') { if (process.env.APP_ENV === 'perf') {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.time('scrollTop'); console.time('scrollTop');
@ -640,9 +674,7 @@ const MessageList = ({
? container.querySelector<HTMLDivElement>(`#${getMessageHtmlId(memoFirstUnreadIdRef.current)}`) ? container.querySelector<HTMLDivElement>(`#${getMessageHtmlId(memoFirstUnreadIdRef.current)}`)
: undefined; : undefined;
const hasLastMessageChanged = ( const hasLastMessageChanged = currentLastMessageOriginalId !== prevCurrentLastMessageOriginalId;
messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1]
);
const hasViewportShifted = ( const hasViewportShifted = (
messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1)
); );
@ -674,10 +706,8 @@ const MessageList = ({
removeExtraClass(container, BOTTOM_SNAP_CLASS); removeExtraClass(container, BOTTOM_SNAP_CLASS);
scrollSnapDisabledTimerRef.current = window.setTimeout(() => { scrollSnapDisabledTimerRef.current = window.setTimeout(() => {
requestMutation(() => { scrollSnapDisabledTimerRef.current = undefined;
addExtraClass(container, BOTTOM_SNAP_CLASS); updateBottomSnapClass();
scrollSnapDisabledTimerRef.current = undefined;
});
}, MESSAGE_ANIMATION_DURATION); }, MESSAGE_ANIMATION_DURATION);
} }
@ -761,7 +791,14 @@ const MessageList = ({
}; };
}); });
// This should match deps for `useSyncEffect` above // This should match deps for `useSyncEffect` above
}, [messageIds, isViewportNewest, getContainerHeight, prevContainerHeightRef, noMessageSendingAnimation]); }, [
messageIds,
isViewportNewest,
currentLastMessageOriginalId,
getContainerHeight,
prevContainerHeightRef,
noMessageSendingAnimation,
]);
useEffectWithPrevDeps(([prevIsSelectModeActive]) => { useEffectWithPrevDeps(([prevIsSelectModeActive]) => {
if (prevIsSelectModeActive !== undefined) { if (prevIsSelectModeActive !== undefined) {
@ -886,6 +923,7 @@ const MessageList = ({
onScrollDownToggle={onScrollDownToggle} onScrollDownToggle={onScrollDownToggle}
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
onIntersectPinnedMessage={onIntersectPinnedMessage} onIntersectPinnedMessage={onIntersectPinnedMessage}
onTallTypingDraft={handleTallTypingDraft}
/> />
) : ( ) : (
<Loading color="white" backgroundColor="dark" /> <Loading color="white" backgroundColor="dark" />

View File

@ -84,6 +84,7 @@ interface OwnProps {
onScrollDownToggle?: BooleanToVoidFunction; onScrollDownToggle?: BooleanToVoidFunction;
onNotchToggle?: AnyToVoidFunction; onNotchToggle?: AnyToVoidFunction;
onIntersectPinnedMessage?: OnIntersectPinnedMessage; onIntersectPinnedMessage?: OnIntersectPinnedMessage;
onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void;
} }
const UNREAD_DIVIDER_CLASS = 'unread-divider'; const UNREAD_DIVIDER_CLASS = 'unread-divider';
@ -123,6 +124,7 @@ const MessageListContent = ({
onScrollDownToggle, onScrollDownToggle,
onNotchToggle, onNotchToggle,
onIntersectPinnedMessage, onIntersectPinnedMessage,
onTallTypingDraft,
}: OwnProps) => { }: OwnProps) => {
const { openHistoryCalendar } = getActions(); const { openHistoryCalendar } = getActions();
@ -153,6 +155,7 @@ const MessageListContent = ({
backwardsTriggerRef, backwardsTriggerRef,
forwardsTriggerRef, forwardsTriggerRef,
fabTriggerRef, fabTriggerRef,
observeIntersectionForTopExit,
} = useScrollHooks({ } = useScrollHooks({
type, type,
containerRef, containerRef,
@ -376,7 +379,9 @@ const MessageListContent = ({
isLastInList={position.isLastInList} isLastInList={position.isLastInList}
memoFirstUnreadIdRef={memoFirstUnreadIdRef} memoFirstUnreadIdRef={memoFirstUnreadIdRef}
getIsMessageListReady={getIsReady} getIsMessageListReady={getIsReady}
observeIntersectionForTopExit={observeIntersectionForTopExit}
onMessageUnmount={onMessageUnmount} onMessageUnmount={onMessageUnmount}
onTallTypingDraft={onTallTypingDraft}
/>, />,
]); ]);
} }

View File

@ -19,6 +19,7 @@ import useSyncEffect from '../../../hooks/useSyncEffect';
const FAB_THRESHOLD = 50; const FAB_THRESHOLD = 50;
const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect const NOTCH_THRESHOLD = 1; // Notch has zero height so we at least need a 1px margin to intersect
const TOP_EXIT_THRESHOLD = 50;
const CONTAINER_HEIGHT_DEBOUNCE = 200; const CONTAINER_HEIGHT_DEBOUNCE = 200;
const SCROLL_TOOLS_DEBOUNCE = 100; const SCROLL_TOOLS_DEBOUNCE = 100;
const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration
@ -150,6 +151,14 @@ export default function useScrollHooks({
useOnIntersect(fabTriggerRef, observeIntersectionForNotch); useOnIntersect(fabTriggerRef, observeIntersectionForNotch);
const {
observe: observeIntersectionForTopExit,
} = useIntersectionObserver({
rootRef: containerRef,
margin: `-${TOP_EXIT_THRESHOLD}px 0px 0px 0px`,
throttleScheduler: requestMeasure,
});
useEffect(() => { useEffect(() => {
if (isReady) { if (isReady) {
updateScrollTools(); updateScrollTools();
@ -189,5 +198,6 @@ export default function useScrollHooks({
backwardsTriggerRef, backwardsTriggerRef,
forwardsTriggerRef, forwardsTriggerRef,
fabTriggerRef, fabTriggerRef,
observeIntersectionForTopExit,
}; };
} }

View File

@ -801,6 +801,17 @@
visibility: hidden; visibility: hidden;
} }
.top-marker {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 1px;
visibility: hidden;
}
.giveaway-result-content { .giveaway-result-content {
min-width: 17rem; min-width: 17rem;
} }

View File

@ -244,7 +244,9 @@ type OwnProps = {
observeIntersectionForBottom?: ObserveFn; observeIntersectionForBottom?: ObserveFn;
observeIntersectionForLoading?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn; observeIntersectionForPlaying?: ObserveFn;
observeIntersectionForTopExit?: ObserveFn;
onMessageUnmount?: (messageId: number) => void; onMessageUnmount?: (messageId: number) => void;
onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void;
} & MessagePositionProperties; } & MessagePositionProperties;
type StateProps = { type StateProps = {
@ -473,7 +475,9 @@ const Message = ({
observeIntersectionForBottom, observeIntersectionForBottom,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
observeIntersectionForTopExit,
onMessageUnmount, onMessageUnmount,
onTallTypingDraft,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
toggleMessageSelection, toggleMessageSelection,
@ -484,6 +488,7 @@ const Message = ({
disableContextMenuHint, disableContextMenuHint,
animateUnreadReaction, animateUnreadReaction,
focusMessage, focusMessage,
markTypingDraftDone,
markMentionsRead, markMentionsRead,
markPollVotesRead, markPollVotesRead,
openThread, openThread,
@ -491,11 +496,16 @@ const Message = ({
} = getActions(); } = getActions();
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
const topMarkerRef = useRef<HTMLDivElement>();
const bottomMarkerRef = useRef<HTMLDivElement>(); const bottomMarkerRef = useRef<HTMLDivElement>();
const quickReactionRef = useRef<HTMLDivElement>(); const quickReactionRef = useRef<HTMLDivElement>();
const oldLang = useOldLang(); const oldLang = useOldLang();
const lang = useLang(); const lang = useLang();
const {
id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck,
isTypingDraft, previousLocalId, fromRank,
} = message;
const [isTranscriptionHidden, setIsTranscriptionHidden] = useState(false); const [isTranscriptionHidden, setIsTranscriptionHidden] = useState(false);
const [isPlayingSnapAnimation, setIsPlayingSnapAnimation] = useState(false); const [isPlayingSnapAnimation, setIsPlayingSnapAnimation] = useState(false);
@ -509,6 +519,15 @@ const Message = ({
useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); useOnIntersect(bottomMarkerRef, observeIntersectionForBottom);
const handleTypingDraftNearExit = useLastCallback(({ isIntersecting }: IntersectionObserverEntry) => {
onTallTypingDraft?.(messageId, !isIntersecting);
});
useOnIntersect(
topMarkerRef,
isTypingDraft && isLastInList ? observeIntersectionForTopExit : undefined,
handleTypingDraftNearExit,
);
const { const {
isContextMenuOpen, isContextMenuOpen,
contextMenuAnchor, contextMenuAnchor,
@ -553,10 +572,6 @@ const Message = ({
onMessageUnmount?.(messageId); onMessageUnmount?.(messageId);
}); });
const {
id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck,
isTypingDraft, fromRank,
} = message;
const hasSummary = Boolean(message.summaryLanguageCode); const hasSummary = Boolean(message.summaryLanguageCode);
const isLocal = isMessageLocal(message); const isLocal = isMessageLocal(message);
@ -1080,6 +1095,14 @@ const Message = ({
const contentStyle = buildStyle(peerColorStyle, sizeStyles); const contentStyle = buildStyle(peerColorStyle, sizeStyles);
const handleTypingAnimationEnd = useLastCallback(() => {
if (!isTypingDraft || !previousLocalId) {
return;
}
markTypingDraftDone({ chatId, messageId });
});
function renderMessageText(isForAnimation?: boolean) { function renderMessageText(isForAnimation?: boolean) {
if (!textMessage) return undefined; if (!textMessage) return undefined;
@ -1104,6 +1127,7 @@ const Message = ({
threadId={threadId} threadId={threadId}
shouldAnimateTyping={isTypingDraft} shouldAnimateTyping={isTypingDraft}
canAnimateTextStreaming={canAnimateTextStreaming} canAnimateTextStreaming={canAnimateTextStreaming}
onTypingAnimationEnd={handleTypingAnimationEnd}
/> />
); );
} }
@ -1870,6 +1894,10 @@ const Message = ({
onMouseMove={withQuickReactionButton ? handleMouseMove : undefined} onMouseMove={withQuickReactionButton ? handleMouseMove : undefined}
onMouseLeave={(withQuickReactionButton || isInDocumentGroupNotLast) ? handleMouseLeave : undefined} onMouseLeave={(withQuickReactionButton || isInDocumentGroupNotLast) ? handleMouseLeave : undefined}
> >
<div
ref={topMarkerRef}
className="top-marker"
/>
<div <div
ref={bottomMarkerRef} ref={bottomMarkerRef}
className="bottom-marker" className="bottom-marker"

View File

@ -31,6 +31,7 @@ import {
groupMessageIdsByThreadId, groupMessageIdsByThreadId,
isActionMessage, isActionMessage,
isMessageLocal, isMessageLocal,
pickMatchingTypingDraftMessage,
} from '../../helpers'; } from '../../helpers';
import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies'; import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies';
import { import {
@ -114,19 +115,115 @@ const SNAP_ANIMATION_DELAY = 1000;
const VIDEO_PROCESSING_NOTIFICATION_DELAY = 1000; const VIDEO_PROCESSING_NOTIFICATION_DELAY = 1000;
let lastVideoProcessingNotificationTime = 0; let lastVideoProcessingNotificationTime = 0;
type TypingDraftEntry = {
randomId: string;
message: ApiMessage;
};
function getTypingDraftEntries<T extends GlobalState>(
global: T,
chatId: string,
threadId: ThreadId,
) {
const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId');
const typingDraftEntries = Object.entries(typingDraftStore || {}).reduce((result, [randomId, messageId]) => {
const message = selectChatMessage(global, chatId, messageId);
if (!message?.isTypingDraft) {
return result;
}
result.push({ randomId, message });
return result;
}, [] as TypingDraftEntry[]);
return typingDraftEntries;
}
function removeTypingDraftEntries<T extends GlobalState>(
global: T,
chatId: string,
threadId: ThreadId,
typingDraftEntries: TypingDraftEntry[],
) {
if (!typingDraftEntries.length) {
return global;
}
const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId') || {};
const randomIds = typingDraftEntries.map(({ randomId }) => randomId);
const nextTypingDraftStore = omit(typingDraftStore, randomIds);
const messageIdsToDelete = randomIds.reduce((result, randomId) => {
const messageId = typingDraftStore[randomId];
const message = messageId ? selectChatMessage(global, chatId, messageId) : undefined;
if (!message?.isTypingDraft) {
return result;
}
result.push(messageId);
return result;
}, [] as number[]);
global = replaceThreadLocalStateParam(
global,
chatId,
threadId,
'typingDraftIdByRandomId',
Object.keys(nextTypingDraftStore).length ? nextTypingDraftStore : undefined,
);
if (messageIdsToDelete.length) {
global = deleteChatMessages(global, chatId, messageIdsToDelete);
}
return global;
}
addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
switch (update['@type']) { switch (update['@type']) {
case 'newMessage': { case 'newMessage': {
const { const {
chatId, id, message, shouldForceReply, wasDrafted, poll, webPage, chatId, id, message, shouldForceReply, wasDrafted, poll, webPage,
} = update; } = update;
global = updateWithLocalMedia(global, chatId, id, true, message); const chat = selectChat(global, chatId);
global = updateListedAndViewportIds(global, message); const isLocal = isMessageLocal(message);
const threadId = selectThreadIdFromMessage(global, message) || MAIN_THREAD_ID;
const typingDraftEntries = getTypingDraftEntries(global, chatId, threadId);
const hasTypingDraftsInThread = Boolean(typingDraftEntries.length);
const shouldAttemptTypingDraftHandoff = !isLocal && !message.isOutgoing && !message.content.action;
let matchedTypingDraftEntry: TypingDraftEntry | undefined;
let shouldClearTypingDraftsAfterRender = false;
if (hasTypingDraftsInThread && shouldAttemptTypingDraftHandoff) {
const matchedTypingDraft = pickMatchingTypingDraftMessage(
message,
typingDraftEntries.map(({ message: typingDraftMessage }) => typingDraftMessage),
);
matchedTypingDraftEntry = matchedTypingDraft
? typingDraftEntries.find(
({ message: typingDraftMessage }) => typingDraftMessage.id === matchedTypingDraft.id,
)
: undefined;
shouldClearTypingDraftsAfterRender = Boolean(typingDraftEntries.length && !matchedTypingDraftEntry);
}
const nextMessage = matchedTypingDraftEntry ? {
...message,
previousLocalId: matchedTypingDraftEntry.message.id,
isTypingDraft: true,
} : message;
global = updateWithLocalMedia(global, chatId, id, true, nextMessage);
global = updateListedAndViewportIds(global, nextMessage);
if (hasTypingDraftsInThread && matchedTypingDraftEntry) {
global = removeTypingDraftEntries(global, chatId, threadId, [matchedTypingDraftEntry]);
}
const newMessage = selectChatMessage(global, chatId, id)!; const newMessage = selectChatMessage(global, chatId, id)!;
const replyInfo = getMessageReplyInfo(newMessage); const replyInfo = getMessageReplyInfo(newMessage);
const storyReplyInfo = getStoryReplyInfo(newMessage); const storyReplyInfo = getStoryReplyInfo(newMessage);
const chat = selectChat(global, chatId);
if (chat?.isForum if (chat?.isForum
&& replyInfo?.isForumTopic && replyInfo?.isForumTopic
&& !selectTopicFromMessage(global, newMessage) && !selectTopicFromMessage(global, newMessage)
@ -134,16 +231,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId }); actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId });
} }
const isLocal = isMessageLocal(message);
Object.values(global.byTabId).forEach(({ id: tabId }) => { Object.values(global.byTabId).forEach(({ id: tabId }) => {
// Force update for last message on drafted messages to prevent flickering // Force update for last message on drafted messages to prevent flickering
if (isLocal && wasDrafted) { if (isLocal && wasDrafted) {
global = updateChatLastMessage(global, chatId, newMessage); global = updateChatLastMessage(global, chatId, newMessage);
} }
const threadId = selectThreadIdFromMessage(global, newMessage); const messageThreadId = selectThreadIdFromMessage(global, newMessage);
global = updateChatMediaLoadingState(global, newMessage, chatId, threadId, tabId); global = updateChatMediaLoadingState(global, newMessage, chatId, messageThreadId, tabId);
if (selectIsMessageInCurrentMessageList(global, chatId, message, tabId)) { if (selectIsMessageInCurrentMessageList(global, chatId, message, tabId)) {
if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId
@ -207,12 +302,12 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
actions.reportMessageDelivery({ chatId, messageId: id }); actions.reportMessageDelivery({ chatId, messageId: id });
} }
if (chat?.isBotForum && !newMessage.isOutgoing && !isLocal) { if (shouldClearTypingDraftsAfterRender) {
const threadId = selectThreadIdFromMessage(global, newMessage); onTickEnd(() => {
const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId'); global = getGlobal();
const localDraftIds = Object.values(typingDraftStore || {}); global = removeTypingDraftEntries(global, chatId, threadId, typingDraftEntries);
global = deleteChatMessages(global, chatId, localDraftIds); setGlobal(global);
global = replaceThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId', undefined); });
} }
if (!isLocal && message.content?.action?.type === 'noForwardsToggle') { if (!isLocal && message.content?.action?.type === 'noForwardsToggle') {

View File

@ -38,6 +38,7 @@ import {
enterMessageSelectMode, enterMessageSelectMode,
exitMessageSelectMode, exitMessageSelectMode,
toggleMessageSelection, toggleMessageSelection,
updateChatMessage,
updateFocusedMessage, updateFocusedMessage,
} from '../../reducers'; } from '../../reducers';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
@ -99,6 +100,16 @@ addActionHandler('setEditingId', (global, actions, payload): ActionReturnType =>
return replaceThreadLocalStateParam(global, chatId, threadId, paramName, messageId); return replaceThreadLocalStateParam(global, chatId, threadId, paramName, messageId);
}); });
addActionHandler('markTypingDraftDone', (global, actions, payload): ActionReturnType => {
const { chatId, messageId } = payload;
const message = selectChatMessage(global, chatId, messageId);
if (!message?.isTypingDraft) {
return undefined;
}
return updateChatMessage(global, chatId, messageId, { isTypingDraft: undefined });
});
addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType => { addActionHandler('setEditingDraft', (global, actions, payload): ActionReturnType => {
const { const {
text, chatId, threadId, type, text, chatId, threadId, type,

View File

@ -112,6 +112,55 @@ export function getMessageText(message: MediaContainer) {
return hasMessageText(message) ? message.content.text : undefined; return hasMessageText(message) ? message.content.text : undefined;
} }
function getSharedPrefixLength(firstText: string, secondText: string) {
const minLength = Math.min(firstText.length, secondText.length);
let index = 0;
while (index < minLength && firstText[index] === secondText[index]) {
index++;
}
return index;
}
export function pickMatchingTypingDraftMessage<T extends ApiMessage>(
incomingMessage: MediaContainer,
typingDraftMessages: T[],
) {
const incomingText = getMessageText(incomingMessage)?.text;
if (!incomingText) {
return undefined;
}
if (typingDraftMessages.length === 1) {
return typingDraftMessages[0];
}
let bestMatch: T | undefined;
let bestScore = 0;
typingDraftMessages.forEach((typingDraftMessage) => {
const draftText = getMessageText(typingDraftMessage)?.text;
if (!draftText) return;
const score = getSharedPrefixLength(incomingText, draftText);
if (!score) return;
if (!bestMatch) {
bestMatch = typingDraftMessage;
bestScore = score;
return;
}
if (score > bestScore) {
bestMatch = typingDraftMessage;
bestScore = score;
}
});
return bestMatch;
}
export function getMessageTextWithFallback(lang: LangFn, message: MediaContainer) { export function getMessageTextWithFallback(lang: LangFn, message: MediaContainer) {
return hasMessageText(message) ? message.content.text || { text: lang('MessageUnsupported') } : undefined; return hasMessageText(message) ? message.content.text || { text: lang('MessageUnsupported') } : undefined;
} }

View File

@ -742,6 +742,10 @@ export interface ActionPayloads {
setEditingId: { setEditingId: {
messageId?: number; messageId?: number;
} & WithTabId; } & WithTabId;
markTypingDraftDone: {
chatId: string;
messageId: number;
};
editLastMessage: WithTabId | undefined; editLastMessage: WithTabId | undefined;
saveDraft: { saveDraft: {
chatId: string; chatId: string;

View File

@ -43,7 +43,7 @@ export function useIntersectionObserver({
throttleScheduler?: Scheduler; throttleScheduler?: Scheduler;
debounceMs?: number; debounceMs?: number;
shouldSkipFirst?: boolean; shouldSkipFirst?: boolean;
margin?: number; margin?: number | string;
threshold?: number | number[]; threshold?: number | number[];
isDisabled?: boolean; isDisabled?: boolean;
}, rootCallback?: RootCallback): Response { }, rootCallback?: RootCallback): Response {
@ -155,7 +155,7 @@ export function useIntersectionObserver({
}, },
{ {
root: rootRef.current, root: rootRef.current,
rootMargin: margin ? `${margin}px` : undefined, rootMargin: typeof margin === 'number' ? `${margin}px` : margin,
threshold, threshold,
}, },
); );