From 9798b5a851b32df68d14b9b550439eb35e4d6099 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:29:29 +0200 Subject: [PATCH] Message: Stop typing draft scroll on top (#6853) --- src/components/common/MessageText.tsx | 6 + src/components/common/TypingWrapper.tsx | 87 +++++++++---- src/components/middle/MessageList.tsx | 56 ++++++-- src/components/middle/MessageListContent.tsx | 5 + src/components/middle/hooks/useScrollHooks.ts | 10 ++ src/components/middle/message/Message.scss | 11 ++ src/components/middle/message/Message.tsx | 36 +++++- src/global/actions/apiUpdaters/messages.ts | 121 ++++++++++++++++-- src/global/actions/ui/messages.ts | 11 ++ src/global/helpers/messages.ts | 49 +++++++ src/global/types/actions.ts | 4 + src/hooks/useIntersectionObserver.ts | 4 +- 12 files changed, 350 insertions(+), 50 deletions(-) diff --git a/src/components/common/MessageText.tsx b/src/components/common/MessageText.tsx index 62f4587b4..3039b855e 100644 --- a/src/components/common/MessageText.tsx +++ b/src/components/common/MessageText.tsx @@ -41,6 +41,7 @@ interface OwnProps { maxTimestamp?: number; shouldAnimateTyping?: boolean; canAnimateTextStreaming?: boolean; + onTypingAnimationEnd?: NoneToVoidFunction; } const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; @@ -68,6 +69,7 @@ function MessageText({ threadId, shouldAnimateTyping, canAnimateTextStreaming, + onTypingAnimationEnd, }: OwnProps) { const sharedCanvasRef = useRef(); const sharedCanvasHqRef = useRef(); @@ -147,6 +149,7 @@ function MessageText({ text: trimText(text || '', truncateLength), entities: entitiesWithFocusedQuote, }; + const shouldRenderTypingPlaceholder = !('previousLocalId' in messageOrStory) || !messageOrStory.previousLocalId; return ( <> @@ -159,6 +162,9 @@ function MessageText({ formattedText={textToRender} renderText={renderText} shouldAnimateMask={canAnimateTextStreaming} + shouldRenderPlaceholder={shouldRenderTypingPlaceholder} + onCompleted={onTypingAnimationEnd} + completionKey={messageOrStory.id} /> ) : renderText(textToRender), ].flat().filter(Boolean)} diff --git a/src/components/common/TypingWrapper.tsx b/src/components/common/TypingWrapper.tsx index 26252e791..2079fc9b4 100644 --- a/src/components/common/TypingWrapper.tsx +++ b/src/components/common/TypingWrapper.tsx @@ -18,7 +18,10 @@ import styles from './TypingWrapper.module.scss'; type OwnProps = { formattedText: ApiFormattedText; shouldAnimateMask?: boolean; + shouldRenderPlaceholder: boolean; + completionKey: number; renderText: (text: ApiFormattedText) => TeactNode; + onCompleted?: NoneToVoidFunction; }; const CHUNK_SIZE = 67; @@ -46,24 +49,48 @@ function getRunningProgress(animation: Animation | undefined, baseProgress: numb return baseProgress + (100 - baseProgress) * timing; } -const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProps) => { +const TypingWrapper = ({ + formattedText, + shouldAnimateMask, + shouldRenderPlaceholder, + completionKey, + renderText, + onCompleted, +}: OwnProps) => { const ref = useRef(); const animationRef = useRef(); const progressRef = useRef(0); const prevRevealedRef = useRef(0); + const fullTextRef = useRef(''); const [revealedLength, setRevealedLength] = useState(0); const revealedLengthRef = useRef(0); const chunkTimerRef = useRef(); + const completedKeyRef = useRef(); const prevFullTextRef = useRef(''); const fullText = formattedText.text; + fullTextRef.current = fullText; const stopAnimation = useLastCallback(() => { animationRef.current?.cancel(); 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) => { window.clearTimeout(chunkTimerRef.current); @@ -90,16 +117,6 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp 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(() => { if (fullText === prevFullTextRef.current) return; @@ -109,12 +126,34 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp const revealed = revealedLengthRef.current; if (fullLen < revealed) { - resetChunking(); - scheduleChunks(0, fullLen); + window.clearTimeout(chunkTimerRef.current); + 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; } 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) --- @@ -186,6 +225,8 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp requestMutation(() => { element.style.setProperty(PROGRESS_CSS_PROPERTY, '100%'); }); + + maybeNotifyCompleted(); }; animation.oncancel = () => { @@ -207,15 +248,17 @@ const TypingWrapper = ({ formattedText, shouldAnimateMask, renderText }: OwnProp return ( {renderText(truncatedText)} - - - + {shouldRenderPlaceholder && ( + + + + )} ); }; diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 2900c724e..be5d7a41a 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -17,6 +17,7 @@ import { forceMeasure, requestMeasure, requestMutation } from '../../lib/fasterd import { getIsSavedDialog, getMessageHtmlId, + getMessageOriginalId, isAnonymousForwardsChat, isChatChannel, isChatGroup, @@ -272,6 +273,7 @@ const MessageList = ({ const isScrollTopJustUpdatedRef = useRef(false); const shouldAnimateAppearanceRef = useRef(Boolean(lastMessage)); const scrollSnapDisabledTimerRef = useRef(); + const typingDraftSnapTriggeredIdRef = useRef(); const isSavedDialog = getIsSavedDialog(chatId, threadId, currentUserId); const hasOpenChatButton = isSavedDialog @@ -401,6 +403,13 @@ const MessageList = ({ isServiceNotificationsChat, isForum, 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(() => { if (!messageIds || !messagesById || type === 'scheduled' || isAccountFrozen || !isActive) 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(() => { if (isScrollTopJustUpdatedRef.current) { isScrollTopJustUpdatedRef.current = false; @@ -613,7 +647,7 @@ const MessageList = ({ ); // Handles updated message list, takes care of scroll repositioning - useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest]) => { + useLayoutEffectWithPrevDeps(([prevMessageIds, prevIsViewportNewest, prevCurrentLastMessageOriginalId]) => { if (process.env.APP_ENV === 'perf') { // eslint-disable-next-line no-console console.time('scrollTop'); @@ -640,9 +674,7 @@ const MessageList = ({ ? container.querySelector(`#${getMessageHtmlId(memoFirstUnreadIdRef.current)}`) : undefined; - const hasLastMessageChanged = ( - messageIds && prevMessageIds && messageIds[messageIds.length - 1] !== prevMessageIds[prevMessageIds.length - 1] - ); + const hasLastMessageChanged = currentLastMessageOriginalId !== prevCurrentLastMessageOriginalId; const hasViewportShifted = ( messageIds?.[0] !== prevMessageIds?.[0] && messageIds?.length === (MESSAGE_LIST_SLICE / 2 + 1) ); @@ -674,10 +706,8 @@ const MessageList = ({ removeExtraClass(container, BOTTOM_SNAP_CLASS); scrollSnapDisabledTimerRef.current = window.setTimeout(() => { - requestMutation(() => { - addExtraClass(container, BOTTOM_SNAP_CLASS); - scrollSnapDisabledTimerRef.current = undefined; - }); + scrollSnapDisabledTimerRef.current = undefined; + updateBottomSnapClass(); }, MESSAGE_ANIMATION_DURATION); } @@ -761,7 +791,14 @@ const MessageList = ({ }; }); // This should match deps for `useSyncEffect` above - }, [messageIds, isViewportNewest, getContainerHeight, prevContainerHeightRef, noMessageSendingAnimation]); + }, [ + messageIds, + isViewportNewest, + currentLastMessageOriginalId, + getContainerHeight, + prevContainerHeightRef, + noMessageSendingAnimation, + ]); useEffectWithPrevDeps(([prevIsSelectModeActive]) => { if (prevIsSelectModeActive !== undefined) { @@ -886,6 +923,7 @@ const MessageList = ({ onScrollDownToggle={onScrollDownToggle} onNotchToggle={onNotchToggle} onIntersectPinnedMessage={onIntersectPinnedMessage} + onTallTypingDraft={handleTallTypingDraft} /> ) : ( diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 8cf9e0134..cde3b2ce9 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -84,6 +84,7 @@ interface OwnProps { onScrollDownToggle?: BooleanToVoidFunction; onNotchToggle?: AnyToVoidFunction; onIntersectPinnedMessage?: OnIntersectPinnedMessage; + onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void; } const UNREAD_DIVIDER_CLASS = 'unread-divider'; @@ -123,6 +124,7 @@ const MessageListContent = ({ onScrollDownToggle, onNotchToggle, onIntersectPinnedMessage, + onTallTypingDraft, }: OwnProps) => { const { openHistoryCalendar } = getActions(); @@ -153,6 +155,7 @@ const MessageListContent = ({ backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef, + observeIntersectionForTopExit, } = useScrollHooks({ type, containerRef, @@ -376,7 +379,9 @@ const MessageListContent = ({ isLastInList={position.isLastInList} memoFirstUnreadIdRef={memoFirstUnreadIdRef} getIsMessageListReady={getIsReady} + observeIntersectionForTopExit={observeIntersectionForTopExit} onMessageUnmount={onMessageUnmount} + onTallTypingDraft={onTallTypingDraft} />, ]); } diff --git a/src/components/middle/hooks/useScrollHooks.ts b/src/components/middle/hooks/useScrollHooks.ts index 2bb7251eb..02c078c0c 100644 --- a/src/components/middle/hooks/useScrollHooks.ts +++ b/src/components/middle/hooks/useScrollHooks.ts @@ -19,6 +19,7 @@ import useSyncEffect from '../../../hooks/useSyncEffect'; const FAB_THRESHOLD = 50; 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 SCROLL_TOOLS_DEBOUNCE = 100; const TOOLS_FREEZE_TIMEOUT = 350; // Approximate message sending animation duration @@ -150,6 +151,14 @@ export default function useScrollHooks({ useOnIntersect(fabTriggerRef, observeIntersectionForNotch); + const { + observe: observeIntersectionForTopExit, + } = useIntersectionObserver({ + rootRef: containerRef, + margin: `-${TOP_EXIT_THRESHOLD}px 0px 0px 0px`, + throttleScheduler: requestMeasure, + }); + useEffect(() => { if (isReady) { updateScrollTools(); @@ -189,5 +198,6 @@ export default function useScrollHooks({ backwardsTriggerRef, forwardsTriggerRef, fabTriggerRef, + observeIntersectionForTopExit, }; } diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 0995cee8e..d31696b5f 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -801,6 +801,17 @@ visibility: hidden; } + .top-marker { + position: absolute; + top: 0; + left: 0; + + width: 1px; + height: 1px; + + visibility: hidden; + } + .giveaway-result-content { min-width: 17rem; } diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index a5245afbd..77ec35296 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -244,7 +244,9 @@ type OwnProps = { observeIntersectionForBottom?: ObserveFn; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; + observeIntersectionForTopExit?: ObserveFn; onMessageUnmount?: (messageId: number) => void; + onTallTypingDraft?: (messageId: number, isNearExit: boolean) => void; } & MessagePositionProperties; type StateProps = { @@ -473,7 +475,9 @@ const Message = ({ observeIntersectionForBottom, observeIntersectionForLoading, observeIntersectionForPlaying, + observeIntersectionForTopExit, onMessageUnmount, + onTallTypingDraft, }: OwnProps & StateProps) => { const { toggleMessageSelection, @@ -484,6 +488,7 @@ const Message = ({ disableContextMenuHint, animateUnreadReaction, focusMessage, + markTypingDraftDone, markMentionsRead, markPollVotesRead, openThread, @@ -491,11 +496,16 @@ const Message = ({ } = getActions(); const ref = useRef(); + const topMarkerRef = useRef(); const bottomMarkerRef = useRef(); const quickReactionRef = useRef(); const oldLang = useOldLang(); const lang = useLang(); + const { + id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck, + isTypingDraft, previousLocalId, fromRank, + } = message; const [isTranscriptionHidden, setIsTranscriptionHidden] = useState(false); const [isPlayingSnapAnimation, setIsPlayingSnapAnimation] = useState(false); @@ -509,6 +519,15 @@ const Message = ({ useOnIntersect(bottomMarkerRef, observeIntersectionForBottom); + const handleTypingDraftNearExit = useLastCallback(({ isIntersecting }: IntersectionObserverEntry) => { + onTallTypingDraft?.(messageId, !isIntersecting); + }); + useOnIntersect( + topMarkerRef, + isTypingDraft && isLastInList ? observeIntersectionForTopExit : undefined, + handleTypingDraftNearExit, + ); + const { isContextMenuOpen, contextMenuAnchor, @@ -553,10 +572,6 @@ const Message = ({ onMessageUnmount?.(messageId); }); - const { - id: messageId, chatId, forwardInfo, viaBotId, isTranscriptionError, factCheck, - isTypingDraft, fromRank, - } = message; const hasSummary = Boolean(message.summaryLanguageCode); const isLocal = isMessageLocal(message); @@ -1080,6 +1095,14 @@ const Message = ({ const contentStyle = buildStyle(peerColorStyle, sizeStyles); + const handleTypingAnimationEnd = useLastCallback(() => { + if (!isTypingDraft || !previousLocalId) { + return; + } + + markTypingDraftDone({ chatId, messageId }); + }); + function renderMessageText(isForAnimation?: boolean) { if (!textMessage) return undefined; @@ -1104,6 +1127,7 @@ const Message = ({ threadId={threadId} shouldAnimateTyping={isTypingDraft} canAnimateTextStreaming={canAnimateTextStreaming} + onTypingAnimationEnd={handleTypingAnimationEnd} /> ); } @@ -1870,6 +1894,10 @@ const Message = ({ onMouseMove={withQuickReactionButton ? handleMouseMove : undefined} onMouseLeave={(withQuickReactionButton || isInDocumentGroupNotLast) ? handleMouseLeave : undefined} > +
( + 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( + 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 => { switch (update['@type']) { case 'newMessage': { const { chatId, id, message, shouldForceReply, wasDrafted, poll, webPage, } = update; - global = updateWithLocalMedia(global, chatId, id, true, message); - global = updateListedAndViewportIds(global, message); + const chat = selectChat(global, chatId); + 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 replyInfo = getMessageReplyInfo(newMessage); const storyReplyInfo = getStoryReplyInfo(newMessage); - const chat = selectChat(global, chatId); if (chat?.isForum && replyInfo?.isForumTopic && !selectTopicFromMessage(global, newMessage) @@ -134,16 +231,14 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.loadTopicById({ chatId, topicId: replyInfo.replyToMsgId }); } - const isLocal = isMessageLocal(message); - Object.values(global.byTabId).forEach(({ id: tabId }) => { // Force update for last message on drafted messages to prevent flickering if (isLocal && wasDrafted) { global = updateChatLastMessage(global, chatId, newMessage); } - const threadId = selectThreadIdFromMessage(global, newMessage); - global = updateChatMediaLoadingState(global, newMessage, chatId, threadId, tabId); + const messageThreadId = selectThreadIdFromMessage(global, newMessage); + global = updateChatMediaLoadingState(global, newMessage, chatId, messageThreadId, tabId); if (selectIsMessageInCurrentMessageList(global, chatId, message, tabId)) { if (isLocal && message.isOutgoing && !(message.content?.action) && !storyReplyInfo?.storyId @@ -207,12 +302,12 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.reportMessageDelivery({ chatId, messageId: id }); } - if (chat?.isBotForum && !newMessage.isOutgoing && !isLocal) { - const threadId = selectThreadIdFromMessage(global, newMessage); - const typingDraftStore = selectThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId'); - const localDraftIds = Object.values(typingDraftStore || {}); - global = deleteChatMessages(global, chatId, localDraftIds); - global = replaceThreadLocalStateParam(global, chatId, threadId, 'typingDraftIdByRandomId', undefined); + if (shouldClearTypingDraftsAfterRender) { + onTickEnd(() => { + global = getGlobal(); + global = removeTypingDraftEntries(global, chatId, threadId, typingDraftEntries); + setGlobal(global); + }); } if (!isLocal && message.content?.action?.type === 'noForwardsToggle') { diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index fa37e5508..1f2d78a47 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -38,6 +38,7 @@ import { enterMessageSelectMode, exitMessageSelectMode, toggleMessageSelection, + updateChatMessage, updateFocusedMessage, } from '../../reducers'; import { updateTabState } from '../../reducers/tabs'; @@ -99,6 +100,16 @@ addActionHandler('setEditingId', (global, actions, payload): ActionReturnType => 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 => { const { text, chatId, threadId, type, diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 89365764c..4aed163c8 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -112,6 +112,55 @@ export function getMessageText(message: MediaContainer) { 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( + 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) { return hasMessageText(message) ? message.content.text || { text: lang('MessageUnsupported') } : undefined; } diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 4e2f5802e..93a9a0e73 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -742,6 +742,10 @@ export interface ActionPayloads { setEditingId: { messageId?: number; } & WithTabId; + markTypingDraftDone: { + chatId: string; + messageId: number; + }; editLastMessage: WithTabId | undefined; saveDraft: { chatId: string; diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts index d2dcb9b83..b8971988e 100644 --- a/src/hooks/useIntersectionObserver.ts +++ b/src/hooks/useIntersectionObserver.ts @@ -43,7 +43,7 @@ export function useIntersectionObserver({ throttleScheduler?: Scheduler; debounceMs?: number; shouldSkipFirst?: boolean; - margin?: number; + margin?: number | string; threshold?: number | number[]; isDisabled?: boolean; }, rootCallback?: RootCallback): Response { @@ -155,7 +155,7 @@ export function useIntersectionObserver({ }, { root: rootRef.current, - rootMargin: margin ? `${margin}px` : undefined, + rootMargin: typeof margin === 'number' ? `${margin}px` : margin, threshold, }, );