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;
|
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)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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}
|
||||||
/>,
|
/>,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user