diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 89183d0856..9d78b18963 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -440,8 +440,11 @@ function VirtualizedTimelineRows({ (version: number) => version + 1, 0, ); - const { cancel: cancelBottomSettle, settle: settleAtBottom } = - useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef); + const { + cancel: cancelBottomSettle, + hasBottomIntent, + settle: settleAtBottom, + } = useVirtualizedBottomSettle(hostRef, listRef, itemsLengthRef); const { arm: armUpwardMomentum } = useUpwardPaginationWheel( hostRef, cancelBottomSettle, @@ -549,7 +552,15 @@ function VirtualizedTimelineRows({ // emit `onScroll` without any user input. Cancelling here strands the // channel above its newest message. The settle hook's wheel, pointer, // touch, and key listeners are the authoritative user-interaction gate. - onAtBottomStateChange?.(distanceFromBottom <= 32); + const atBottom = distanceFromBottom <= 32; + // Geometry remeasurement can emit an intermediate offset while the + // bottom-settle loop still owns the scroller. Reporting that transient as + // reader navigation freezes live arrivals until the user sends or jumps + // to the new-message pill. Only a real input transfer (which cancels + // bottom intent) may report a non-bottom state. + if (atBottom || !hasBottomIntent()) { + onAtBottomStateChange?.(atBottom); + } if (offset <= 200) { // Layout scrolls near the top must not poison the reader's next input. armUpwardMomentum(onStartReached?.() ?? false); @@ -557,6 +568,7 @@ function VirtualizedTimelineRows({ }, [ armUpwardMomentum, + hasBottomIntent, onAtBottomStateChange, onStartReached, onVirtualizerRangeChanged, diff --git a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs index bc0aebe917..3f78e05e39 100644 --- a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs +++ b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.test.mjs @@ -153,7 +153,9 @@ async function mountHarness() { test("bottom intent follows arbitrarily late virtual geometry changes", async () => { const { content, refs, root, scroller, writes } = await mountHarness(); + assert.equal(refs.api.current.hasBottomIntent(), false); refs.api.current.settle(); + assert.equal(refs.api.current.hasBottomIntent(), true); assert.deepEqual(writes, [{ index: 4, options: { align: "end" } }]); const geometryObserver = resizeObservers.find((observer) => @@ -195,6 +197,7 @@ for (const eventType of ["pointerdown", "touchmove", "wheel", "keydown"]) { : undefined, type: eventType, }); + assert.equal(refs.api.current.hasBottomIntent(), false); resizeObservers .find((observer) => observer.targets?.includes(content)) diff --git a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts index 8c0535779f..6aaf3bfa88 100644 --- a/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts +++ b/desktop/src/features/messages/ui/useVirtualizedBottomSettle.ts @@ -125,6 +125,8 @@ export function useVirtualizedBottomSettle( pinToBottom(); }, [cancelFrame, pinToBottom]); + const hasBottomIntent = React.useCallback(() => bottomIntentRef.current, []); + React.useEffect(() => cancel, [cancel]); - return { cancel, settle }; + return { cancel, hasBottomIntent, settle }; }