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;
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<HTMLCanvasElement>();
const sharedCanvasHqRef = useRef<HTMLCanvasElement>();
@ -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)}

View File

@ -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<HTMLSpanElement>();
const animationRef = useRef<Animation>();
const progressRef = useRef(0);
const prevRevealedRef = useRef(0);
const fullTextRef = useRef('');
const [revealedLength, setRevealedLength] = useState(0);
const revealedLengthRef = useRef(0);
const chunkTimerRef = useRef<number>();
const completedKeyRef = useRef<string>();
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 (
<span ref={ref} className={styles.root}>
{renderText(truncatedText)}
<span key="typing-placeholder" className={styles.placeholder}>
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Writing}
size={PLACEHOLDER_SIZE}
play
noLoop={false}
shouldUseTextColor
/>
</span>
{shouldRenderPlaceholder && (
<span key="typing-placeholder" className={styles.placeholder}>
<AnimatedIconWithPreview
tgsUrl={LOCAL_TGS_URLS.Writing}
size={PLACEHOLDER_SIZE}
play
noLoop={false}
shouldUseTextColor
/>
</span>
)}
</span>
);
};

View File

@ -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<number>();
const typingDraftSnapTriggeredIdRef = useRef<number>();
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<HTMLDivElement>(`#${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}
/>
) : (
<Loading color="white" backgroundColor="dark" />

View File

@ -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}
/>,
]);
}

View File

@ -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,
};
}

View File

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

View File

@ -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<HTMLDivElement>();
const topMarkerRef = useRef<HTMLDivElement>();
const bottomMarkerRef = useRef<HTMLDivElement>();
const quickReactionRef = useRef<HTMLDivElement>();
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}
>
<div
ref={topMarkerRef}
className="top-marker"
/>
<div
ref={bottomMarkerRef}
className="bottom-marker"

View File

@ -31,6 +31,7 @@ import {
groupMessageIdsByThreadId,
isActionMessage,
isMessageLocal,
pickMatchingTypingDraftMessage,
} from '../../helpers';
import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies';
import {
@ -114,19 +115,115 @@ const SNAP_ANIMATION_DELAY = 1000;
const VIDEO_PROCESSING_NOTIFICATION_DELAY = 1000;
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 => {
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') {

View File

@ -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,

View File

@ -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<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) {
return hasMessageText(message) ? message.content.text || { text: lang('MessageUnsupported') } : undefined;
}

View File

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

View File

@ -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,
},
);