Message: Stop typing draft scroll on top (#6853)
This commit is contained in:
parent
e5b932b8ea
commit
9798b5a851
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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}
|
||||
/>,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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') {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -742,6 +742,10 @@ export interface ActionPayloads {
|
||||
setEditingId: {
|
||||
messageId?: number;
|
||||
} & WithTabId;
|
||||
markTypingDraftDone: {
|
||||
chatId: string;
|
||||
messageId: number;
|
||||
};
|
||||
editLastMessage: WithTabId | undefined;
|
||||
saveDraft: {
|
||||
chatId: string;
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user