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,