From 807c761bca760fca3e9234ba37d029ac279402a0 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 15 Jun 2026 23:49:29 -0700 Subject: [PATCH] fix(timeline): opacity-gate channel intro until pinned and at top The header-first flash survived three CSS rewrites because no overflow-container CSS reorder fixes it: a standard scroll container rests at scrollTop 0 during the whole estimate->measure->settle window, so the in-flow intro is painted at the top before the bottom pin (a post-paint layout effect) drives it off-top. tho watches that intermediate frame. Keep the intro reserving its space (scrollMargin math unchanged) but gate its VISUAL reveal on hasInitialized && isAtTop: hidden on first paint, hidden while pinned off-top, revealed only when the user genuinely reaches the top (or a short channel where top is also bottom). New pure isAtTopMetrics/isAtTop helpers carry unit coverage (jsdom can't catch the layout bug; the threshold math is testable). Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../messages/lib/timelineSnapshot.test.mjs | 16 +++++++ .../features/messages/lib/timelineSnapshot.ts | 26 ++++++++++++ .../features/messages/ui/MessageTimeline.tsx | 11 ++++- .../messages/ui/useVirtualTimelineScroll.ts | 42 ++++++++++++++++++- 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index f16c6e7a9..0f7a02f13 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -3,7 +3,9 @@ import test from "node:test"; import { BOTTOM_THRESHOLD_PX, + TOP_THRESHOLD_PX, buildDayGroupBoundaries, + isAtTopMetrics, isNearBottomMetrics, resolveDeepLinkTarget, selectDeferredListRenderState, @@ -77,6 +79,20 @@ test("isNearBottomMetrics: false when scrolled up beyond the threshold", () => { ); }); +// --- earned channel-intro header (at-top reveal gate) ------------------------- + +test("isAtTopMetrics: true when within threshold of the top", () => { + assert.equal(isAtTopMetrics({ scrollTop: 4 }), true); +}); + +test("isAtTopMetrics: true exactly at the threshold boundary", () => { + assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX }), true); +}); + +test("isAtTopMetrics: false when scrolled down beyond the threshold", () => { + assert.equal(isAtTopMetrics({ scrollTop: TOP_THRESHOLD_PX + 1 }), false); +}); + test("selectLatestMessageKey: prefers renderKey, falls back to id, undefined when empty", () => { assert.equal(selectLatestMessageKey([]), undefined); assert.equal( diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index c0fdff16d..5eeedf947 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -43,6 +43,32 @@ export function isNearBottom(container: HTMLDivElement): boolean { }); } +/** Distance (px) from the top within which the timeline counts as "at top". */ +export const TOP_THRESHOLD_PX = 8; + +/** + * Is the timeline scrolled close enough to the top to count as "at top"? + * + * This gates the channel-intro header's VISUAL reveal. The intro is the + * terminal header of a bottom-anchored list — it must surface only once the + * user has genuinely arrived at the true top, never get painted up front while + * the list is still streaming in from the bottom (a standard overflow container + * rests at scrollTop 0 during the estimate→measure→settle window, so "scrollTop + * is 0" alone is NOT a trustworthy at-top signal until the first-load bottom pin + * has landed). Pure over geometry so the threshold math is unit-testable without + * a DOM — the surrounding flexbox layout is not, jsdom does no layout. + */ +export function isAtTopMetrics( + metrics: Pick, +): boolean { + return metrics.scrollTop <= TOP_THRESHOLD_PX; +} + +/** Reads live scroll geometry off a container and applies the top-threshold rule. */ +export function isAtTop(container: HTMLDivElement): boolean { + return isAtTopMetrics({ scrollTop: container.scrollTop }); +} + /** * Identity of the last message in a snapshot, used to detect "a new latest * message arrived" for autoscroll. Prefers `renderKey` (stable across optimistic diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index a9b5ec367..ca3f0f28d 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -252,6 +252,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ const { highlightedMessageId, + introRevealed, isAtBottom, newMessageCount, scrollToBottom, @@ -419,7 +420,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {showChannelIntro ? (
(null); + // Drives the channel-intro header's VISUAL reveal. The intro is the terminal + // header of a bottom-anchored list: it reserves its space (scrollMargin), but + // must only become VISIBLE once the first-load bottom pin has landed AND the + // user has genuinely arrived at the true top — never painted up front while + // the list streams in from the bottom. A standard overflow container rests at + // scrollTop 0 during the estimate→measure→settle window, so we gate on + // `hasInitialized && isAtTop`, not "scrollTop is 0" alone. + const [introRevealed, setIntroRevealed] = React.useState(false); const lastRowIndex = rows.length - 1; @@ -110,8 +119,19 @@ export function useVirtualTimelineScroll({ setIsAtBottom(true); setNewMessageCount(0); setHighlightedMessageId(null); + setIntroRevealed(false); }, [channelId]); + // Recompute whether the channel intro should be visible: only once the + // first-load pin has landed (`hasInitialized`) AND the container is genuinely + // at the top. Cheap geometry read, only flips state on a real change. + const syncIntroRevealed = React.useCallback(() => { + const container = scrollContainerRef.current; + const revealed = + hasInitializedRef.current && container !== null && isAtTop(container); + setIntroRevealed((current) => (current === revealed ? current : revealed)); + }, [scrollContainerRef]); + // Track bottom-pinned state off the native scroll event. The virtualizer owns // the scrollTop; we only read it to decide whether to keep auto-following. const syncScrollState = React.useCallback(() => { @@ -125,7 +145,8 @@ export function useVirtualTimelineScroll({ if (atBottom) { setNewMessageCount(0); } - }, [scrollContainerRef]); + syncIntroRevealed(); + }, [scrollContainerRef, syncIntroRevealed]); const latestMessage = messages.length > 0 ? messages[messages.length - 1] : undefined; @@ -152,6 +173,10 @@ export function useVirtualTimelineScroll({ hasInitializedRef.current = true; previousLastMessageKeyRef.current = latestMessageKey; previousMessageCountRef.current = messages.length; + // The first-load pin just landed: recompute reveal so a short channel + // (everything fits → top is genuinely also the bottom) surfaces its intro, + // while a long channel pinned off-top stays hidden. + syncIntroRevealed(); return; } @@ -191,6 +216,7 @@ export function useVirtualTimelineScroll({ messages.length, scrollMarginReady, scrollToBottom, + syncIntroRevealed, targetMessageId, ]); @@ -219,7 +245,18 @@ export function useVirtualTimelineScroll({ } lastPinnedTotalSizeRef.current = totalSize; virtualizer.scrollToIndex(lastRowIndex, { align: "end" }); - }, [totalSize, isLoading, targetMessageId, lastRowIndex, virtualizer]); + // Re-anchor changed the scroll position: recompute reveal so the intro + // tracks the new resting place (stays hidden off-top, surfaces only if the + // grown content still leaves us genuinely at the top). + syncIntroRevealed(); + }, [ + totalSize, + isLoading, + targetMessageId, + lastRowIndex, + syncIntroRevealed, + virtualizer, + ]); // Deep-link jump-to-message. Drives the virtualizer to mount and center the // target row, replacing the bespoke querySelector + scrollIntoView path that @@ -303,6 +340,7 @@ export function useVirtualTimelineScroll({ return { highlightedMessageId, + introRevealed, isAtBottom, newMessageCount, scrollToBottom,