fix(chat): anchored-scroll behavior parity with the reference model
Six reported defects, one root theme — our port diverged from the reference semantics: - a user gesture no longer collapses the reserved anchored end space (that snap-to-bottom was the 'anchoring vanishes on a micro-scroll'); it only disarms the follow/anchor machinery, and anchor remeasures after the user takes over can no longer re-position the viewport - the anchor arms synchronously BEFORE the send reaches the store, and the claim compares against the arm-time baseline — arming a frame after the optimistic row committed is why anchoring 'rarely worked' and sending from mid-history did nothing - gestures are selective (wheel up, touch/pointer away from the end, PageUp/Home/ArrowUp), so scrolling toward the end or clicking a row at the live edge no longer kills follow - 'at end' is a tight 40px band against the full content length instead of the list's half-viewport isNearEnd, so the scroll-to-bottom pill appears when the viewport actually leaves the end - reaching the end re-arms follow without knocking an anchored turn out of its mode, and the overlay scrollbar suppression follows scroll ownership rather than the anchor's mere existence - the status/working row moved out of the list footer to a fixed spot above the composer: inside the list it walked down with every streamed line, and its unmount was part of the end-of-stream jerk
This commit is contained in:
@@ -361,10 +361,6 @@ const ChatViewport = React.memo(({
|
|||||||
|
|
||||||
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
||||||
|
|
||||||
<div className="mb-3">
|
|
||||||
<StatusRowContainer />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
||||||
</>
|
</>
|
||||||
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
||||||
@@ -417,6 +413,13 @@ const ChatViewport = React.memo(({
|
|||||||
scrollContainerProps={scrollContainerProps}
|
scrollContainerProps={scrollContainerProps}
|
||||||
/>
|
/>
|
||||||
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
||||||
|
{/* Static above the composer: inside the list it walked down
|
||||||
|
with every streamed line while a turn was anchored. */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-2 z-10">
|
||||||
|
<div className="pointer-events-auto [&:not(:has(*))]:hidden">
|
||||||
|
<StatusRowContainer />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
||||||
<PromptNavigatorRail
|
<PromptNavigatorRail
|
||||||
turnIds={promptTurnIds}
|
turnIds={promptTurnIds}
|
||||||
|
|||||||
@@ -1289,6 +1289,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
...additionalParts.flatMap(p => p.attachments ?? []),
|
...additionalParts.flatMap(p => p.attachments ?? []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Arm the timeline anchor BEFORE the optimistic user row can commit;
|
||||||
|
// arming after (or a frame later) races the commit and the anchor
|
||||||
|
// never claims the new message.
|
||||||
|
scrollToBottom?.();
|
||||||
|
|
||||||
const sendPromise = sendMessage(
|
const sendPromise = sendMessage(
|
||||||
primaryText,
|
primaryText,
|
||||||
providerIdToSend,
|
providerIdToSend,
|
||||||
@@ -1307,14 +1312,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
scrollToBottom?.();
|
|
||||||
} else {
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
scrollToBottom?.();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void sendPromise.then(() => {
|
void sendPromise.then(() => {
|
||||||
// Record what this session was pointed at, so the work-status panel
|
// Record what this session was pointed at, so the work-status panel
|
||||||
// can show it as a context source long after the message scrolled
|
// can show it as a context source long after the message scrolled
|
||||||
|
|||||||
@@ -184,12 +184,14 @@ describe('getAnchoredTurnMetrics', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('resolveTimelineIsAtEnd', () => {
|
describe('resolveTimelineIsAtEnd', () => {
|
||||||
test('prefers the near-end threshold over the exact content bottom', () => {
|
test('uses a tight distance band against the full content length', () => {
|
||||||
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false);
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
|
||||||
|
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to the exact end when near-end is unavailable', () => {
|
test('falls back to the list flags when distances are unavailable', () => {
|
||||||
|
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
|
||||||
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
|
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -108,15 +108,35 @@ export const getAnchoredTurnMetrics = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// "At the end" for follow purposes is the NEAR-end threshold, not the exact
|
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
|
||||||
// content bottom: the timeline's footer (status row plus bottom spacer) sits
|
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
|
||||||
// below the last row, so requiring the exact bottom would drop out of follow —
|
// follow while the user had genuinely scrolled away, yanking them back on the
|
||||||
// and pop the scroll-to-bottom pill — while the user is still looking at the
|
// next stream chunk. Distance is measured against the full content length —
|
||||||
// live edge. `isAtEnd` is only the fallback for states that predate the
|
// reserved anchored end space included — so a parked anchored turn counts as
|
||||||
// near-end signal.
|
// the live edge.
|
||||||
|
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
|
||||||
|
|
||||||
export const resolveTimelineIsAtEnd = (
|
export const resolveTimelineIsAtEnd = (
|
||||||
state: { readonly isNearEnd?: boolean; readonly isAtEnd?: boolean } | undefined,
|
state: {
|
||||||
): boolean | undefined => state?.isNearEnd ?? state?.isAtEnd;
|
readonly contentLength?: number;
|
||||||
|
readonly scroll?: number;
|
||||||
|
readonly scrollLength?: number;
|
||||||
|
readonly isNearEnd?: boolean;
|
||||||
|
readonly isAtEnd?: boolean;
|
||||||
|
} | undefined,
|
||||||
|
): boolean | undefined => {
|
||||||
|
if (!state) return undefined;
|
||||||
|
const { contentLength, scroll, scrollLength } = state;
|
||||||
|
if (
|
||||||
|
typeof contentLength === 'number'
|
||||||
|
&& typeof scroll === 'number'
|
||||||
|
&& typeof scrollLength === 'number'
|
||||||
|
&& Number.isFinite(contentLength)
|
||||||
|
) {
|
||||||
|
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
|
||||||
|
}
|
||||||
|
return state.isNearEnd ?? state.isAtEnd;
|
||||||
|
};
|
||||||
|
|
||||||
export interface ChatListAnchoredEndSpace {
|
export interface ChatListAnchoredEndSpace {
|
||||||
readonly anchorIndex: number;
|
readonly anchorIndex: number;
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export const useChatTimelineScroll = ({
|
|||||||
// it may load older pages without disturbing the read position.
|
// it may load older pages without disturbing the read position.
|
||||||
const [isPinned, setIsPinned] = React.useState(true);
|
const [isPinned, setIsPinned] = React.useState(true);
|
||||||
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
|
||||||
|
// True after a real gesture until an explicit opt back in; drives the
|
||||||
|
// overlay scrollbar suppression instead of the anchor's mere existence.
|
||||||
|
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
|
||||||
|
|
||||||
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
const modeRef = React.useRef<TimelineScrollMode>('following-end');
|
||||||
const isAtEndRef = React.useRef(true);
|
const isAtEndRef = React.useRef(true);
|
||||||
@@ -203,13 +206,25 @@ export const useChatTimelineScroll = ({
|
|||||||
setAnchorMessageId(null);
|
setAnchorMessageId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// A real gesture: stop every automatic movement until the user opts back in.
|
// A real gesture: stop every automatic movement until the user opts back
|
||||||
|
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
|
||||||
|
// viewport back to the end — only the anchor machinery is disarmed.
|
||||||
const onManualNavigation = React.useCallback(() => {
|
const onManualNavigation = React.useCallback(() => {
|
||||||
userGenerationRef.current += 1;
|
userGenerationRef.current += 1;
|
||||||
modeRef.current = 'free-scrolling';
|
modeRef.current = 'free-scrolling';
|
||||||
liveFollowGenerationRef.current = null;
|
liveFollowGenerationRef.current = null;
|
||||||
clearAnchor();
|
setUserOwnsScroll(true);
|
||||||
}, [clearAnchor]);
|
armedForNextUserMessageRef.current = false;
|
||||||
|
pendingAnchorRef.current = null;
|
||||||
|
positionedAnchorRef.current = null;
|
||||||
|
settledAnchorRef.current = null;
|
||||||
|
activeAnchorIndexRef.current = null;
|
||||||
|
pendingAnchorRestoreRef.current = null;
|
||||||
|
if (anchorRestoreFrameRef.current !== null) {
|
||||||
|
cancelAnimationFrame(anchorRestoreFrameRef.current);
|
||||||
|
anchorRestoreFrameRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isLiveFollowActive = React.useCallback(() => (
|
const isLiveFollowActive = React.useCallback(() => (
|
||||||
liveFollowGenerationRef.current === userGenerationRef.current
|
liveFollowGenerationRef.current === userGenerationRef.current
|
||||||
@@ -267,6 +282,7 @@ export const useChatTimelineScroll = ({
|
|||||||
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
setIsPinned(true);
|
setIsPinned(true);
|
||||||
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
// Returning to the end is an explicit opt back IN to live follow.
|
// Returning to the end is an explicit opt back IN to live follow.
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
@@ -279,9 +295,14 @@ export const useChatTimelineScroll = ({
|
|||||||
// row is created by the store), so the next new user message id claims it.
|
// row is created by the store), so the next new user message id claims it.
|
||||||
const scrollToBottomOnSend = React.useCallback(() => {
|
const scrollToBottomOnSend = React.useCallback(() => {
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'anchoring-new-turn';
|
modeRef.current = 'anchoring-new-turn';
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
armedForNextUserMessageRef.current = true;
|
armedForNextUserMessageRef.current = true;
|
||||||
|
// The optimistic row is not committed yet; the next NEW user message id
|
||||||
|
// relative to this baseline claims the anchor, independent of whether
|
||||||
|
// the commit lands before or after this call.
|
||||||
|
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
|
||||||
pendingAnchorRef.current = null;
|
pendingAnchorRef.current = null;
|
||||||
positionedAnchorRef.current = null;
|
positionedAnchorRef.current = null;
|
||||||
settledAnchorRef.current = null;
|
settledAnchorRef.current = null;
|
||||||
@@ -289,13 +310,16 @@ export const useChatTimelineScroll = ({
|
|||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
}, [hideScrollButton]);
|
}, [hideScrollButton]);
|
||||||
|
|
||||||
// Claim the anchor as soon as the sent row exists in the timeline.
|
// Claim the anchor as soon as the sent row exists in the timeline. The
|
||||||
|
// comparison is against the baseline captured when the send armed the
|
||||||
|
// anchor, so the claim works whether the optimistic row committed before
|
||||||
|
// or after the arming call.
|
||||||
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||||
|
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const previous = lastArmedUserMessageIdRef.current;
|
|
||||||
lastArmedUserMessageIdRef.current = lastUserMessageId;
|
lastArmedUserMessageIdRef.current = lastUserMessageId;
|
||||||
if (!armedForNextUserMessageRef.current) return;
|
if (!armedForNextUserMessageRef.current) return;
|
||||||
if (!lastUserMessageId || lastUserMessageId === previous) return;
|
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
|
||||||
armedForNextUserMessageRef.current = false;
|
armedForNextUserMessageRef.current = false;
|
||||||
pendingAnchorRef.current = lastUserMessageId;
|
pendingAnchorRef.current = lastUserMessageId;
|
||||||
setAnchorMessageId(lastUserMessageId);
|
setAnchorMessageId(lastUserMessageId);
|
||||||
@@ -308,6 +332,7 @@ export const useChatTimelineScroll = ({
|
|||||||
// Entering a session always returns to the live edge. Late async growth
|
// Entering a session always returns to the live edge. Late async growth
|
||||||
// is handled by the list staying at the end, not by a timed hold.
|
// is handled by the list staying at the end, not by a timed hold.
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
clearAnchor();
|
clearAnchor();
|
||||||
@@ -336,8 +361,11 @@ export const useChatTimelineScroll = ({
|
|||||||
isAtEndRef.current = isAtEnd;
|
isAtEndRef.current = isAtEnd;
|
||||||
setIsPinned(isAtEnd);
|
setIsPinned(isAtEnd);
|
||||||
if (isAtEnd) {
|
if (isAtEnd) {
|
||||||
modeRef.current = 'following-end';
|
if (modeRef.current !== 'anchoring-new-turn') {
|
||||||
|
modeRef.current = 'following-end';
|
||||||
|
}
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
|
setUserOwnsScroll(false);
|
||||||
hideScrollButton();
|
hideScrollButton();
|
||||||
} else {
|
} else {
|
||||||
modeRef.current = 'free-scrolling';
|
modeRef.current = 'free-scrolling';
|
||||||
@@ -349,6 +377,10 @@ export const useChatTimelineScroll = ({
|
|||||||
|
|
||||||
// Park the anchored row near the top once the list has measured it.
|
// Park the anchored row near the top once the list has measured it.
|
||||||
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
|
||||||
|
// The anchored end space can be remeasured long after the send (turn
|
||||||
|
// completion, images decoding). Only the send-time anchoring mode may
|
||||||
|
// position the viewport.
|
||||||
|
if (modeRef.current !== 'anchoring-new-turn') return;
|
||||||
if (pendingAnchorRef.current === messageId) {
|
if (pendingAnchorRef.current === messageId) {
|
||||||
pendingAnchorRef.current = null;
|
pendingAnchorRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -404,7 +436,7 @@ export const useChatTimelineScroll = ({
|
|||||||
// against sub-pixel drift and only while the user has not taken over.
|
// against sub-pixel drift and only while the user has not taken over.
|
||||||
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
|
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
|
||||||
if (settledAnchorRef.current !== messageId) return;
|
if (settledAnchorRef.current !== messageId) return;
|
||||||
if (isLiveFollowActive()) return;
|
if (!isLiveFollowActive()) return;
|
||||||
const scrollOffset = listRef.current?.getState().scroll;
|
const scrollOffset = listRef.current?.getState().scroll;
|
||||||
if (scrollOffset === undefined) return;
|
if (scrollOffset === undefined) return;
|
||||||
|
|
||||||
@@ -530,25 +562,48 @@ export const useChatTimelineScroll = ({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!scrollNode) return;
|
if (!scrollNode) return;
|
||||||
|
|
||||||
const handleGesture = () => {
|
const contentScrollsUp = () => {
|
||||||
|
const list = listRef.current;
|
||||||
|
return list ? realContentOverflowsViewport(list) : false;
|
||||||
|
};
|
||||||
|
const gesture = () => {
|
||||||
onManualNavigationRef.current();
|
onManualNavigationRef.current();
|
||||||
};
|
};
|
||||||
|
const handleWheel = (event: WheelEvent) => {
|
||||||
|
// Scrolling toward the end is not opting out of follow.
|
||||||
|
if (event.deltaY < 0 && contentScrollsUp()) gesture();
|
||||||
|
};
|
||||||
|
const handleTouchMove = () => {
|
||||||
|
if (!isAtEndRef.current && contentScrollsUp()) gesture();
|
||||||
|
};
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
// The scrollbar track is the scroll node itself; a tap on a row
|
||||||
|
// only breaks follow when the viewport already left the end.
|
||||||
|
if ((event.target === scrollNode || !isAtEndRef.current) && contentScrollsUp()) gesture();
|
||||||
|
};
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && contentScrollsUp()) {
|
||||||
|
gesture();
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
queueSave();
|
queueSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
scrollNode.addEventListener('wheel', handleGesture, { passive: true });
|
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
|
||||||
scrollNode.addEventListener('touchmove', handleGesture, { passive: true });
|
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||||
scrollNode.addEventListener('pointerdown', handleGesture, { passive: true });
|
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
|
||||||
|
scrollNode.addEventListener('keydown', handleKeyDown);
|
||||||
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
scrollNode.removeEventListener('wheel', handleGesture);
|
scrollNode.removeEventListener('wheel', handleWheel);
|
||||||
scrollNode.removeEventListener('touchmove', handleGesture);
|
scrollNode.removeEventListener('touchmove', handleTouchMove);
|
||||||
scrollNode.removeEventListener('pointerdown', handleGesture);
|
scrollNode.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
scrollNode.removeEventListener('keydown', handleKeyDown);
|
||||||
scrollNode.removeEventListener('scroll', handleScroll);
|
scrollNode.removeEventListener('scroll', handleScroll);
|
||||||
};
|
};
|
||||||
}, [queueSave, scrollNode]);
|
}, [queueSave, realContentOverflowsViewport, scrollNode]);
|
||||||
|
|
||||||
// ── session lifecycle ───────────────────────────────────────────────────
|
// ── session lifecycle ───────────────────────────────────────────────────
|
||||||
const lastSessionKeyRef = React.useRef<string | null>(null);
|
const lastSessionKeyRef = React.useRef<string | null>(null);
|
||||||
@@ -561,6 +616,7 @@ export const useChatTimelineScroll = ({
|
|||||||
// Persist the outgoing session's position before the new one takes over.
|
// Persist the outgoing session's position before the new one takes over.
|
||||||
flushSave();
|
flushSave();
|
||||||
isAtEndRef.current = true;
|
isAtEndRef.current = true;
|
||||||
|
setUserOwnsScroll(false);
|
||||||
modeRef.current = 'following-end';
|
modeRef.current = 'following-end';
|
||||||
liveFollowGenerationRef.current = userGenerationRef.current;
|
liveFollowGenerationRef.current = userGenerationRef.current;
|
||||||
clearAnchor();
|
clearAnchor();
|
||||||
@@ -570,8 +626,8 @@ export const useChatTimelineScroll = ({
|
|||||||
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
// Suppress the overlay scrollbar thumb while automatic movement owns the
|
||||||
// scroll position, so it does not jump on each correction.
|
// scroll position, so it does not jump on each correction.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsFollowingProgrammatically(!showScrollButton && anchorMessageId === null);
|
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
|
||||||
}, [anchorMessageId, showScrollButton]);
|
}, [showScrollButton, userOwnsScroll]);
|
||||||
|
|
||||||
React.useEffect(() => () => {
|
React.useEffect(() => () => {
|
||||||
cancelShowButtonTimer();
|
cancelShowButtonTimer();
|
||||||
|
|||||||
Reference in New Issue
Block a user