fix(timeline): virtualize channel intro as terminal row 0

Move the channel intro into the virtualized row model so it only mounts
when scrolled to the genuine history top, eliminating the header/list
sandwich during first-load and deferred render streaming.

- buildVirtualTimelineRows.ts: add optional intro row type and includeIntro option
- MessageTimeline.tsx: gate introReady on deferred settle and hasOlderMessages; render via VirtualizedTimelineList
- VirtualizedTimelineList.tsx: add renderIntro prop for intro row dispatch
- useVirtualTimelineScroll.ts: remove introRevealed opacity-gate state
- timelineSnapshot.ts: remove isAtTop helpers superseded by virtual row gating
- useVirtualScrollMargin.ts: stop treating intro as above-list scroll-margin content
- Add unit/DOM tests for intro row ordering, lookup, and rendering

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-06-16 22:48:13 -07:00
parent f1846e06cb
commit 81dfd8473e
9 changed files with 237 additions and 231 deletions
@@ -26,6 +26,34 @@ test("empty snapshot produces no rows", () => {
assert.deepEqual(buildVirtualTimelineRows([]), []);
});
test("empty snapshot with includeIntro emits only the intro row", () => {
const rows = buildVirtualTimelineRows([], { includeIntro: true });
assert.equal(rows.length, 1);
assert.equal(rows[0].kind, "intro");
assert.equal(rows[0].key, "channel-intro");
});
test("includeIntro prepends the intro row before dividers and messages", () => {
const rows = buildVirtualTimelineRows([message({ id: "a" })], {
includeIntro: true,
});
assert.deepEqual(
rows.map((row) => row.kind),
["intro", "day-divider", "message"],
);
});
test("findVirtualRowIndexForMessage still resolves after an intro row is prepended", () => {
const messages = [
message({ id: "a", createdAt: DAY_1 }),
message({ id: "b", createdAt: DAY_2 }),
];
const rows = buildVirtualTimelineRows(messages, { includeIntro: true });
// layout: [intro, div, a, div, b]
assert.equal(findVirtualRowIndexForMessage(rows, "a", messages), 2);
assert.equal(findVirtualRowIndexForMessage(rows, "b", messages), 4);
});
test("single message emits one divider then the message row", () => {
const rows = buildVirtualTimelineRows([message({ id: "a" })]);
assert.equal(rows.length, 2);
@@ -14,7 +14,8 @@
* reuses `buildDayGroupBoundaries` so divider placement is byte-identical to the
* current render — no second source of truth for "where does a day start".
*
* Two row kinds come out, in render order:
* Row kinds come out, in render order:
* - { kind: "intro", key: "channel-intro" } — optional terminal header row
* - { kind: "day-divider", key, label-source timestamp, messageIndex: -1 }
* - { kind: "message", key, messageIndex } — index back into the snapshot
*
@@ -30,6 +31,21 @@
import type { TimelineMessage } from "@/features/messages/types";
import { buildDayGroupBoundaries } from "./timelineSnapshot";
/** Options for flattening a snapshot into virtual rows. */
export type BuildVirtualTimelineRowsOptions = {
/** When true, prepend a channel-intro row before dividers and messages. */
includeIntro?: boolean;
};
/** The channel intro header — the terminal row at the true top of history. */
export type VirtualIntroRow = {
kind: "intro";
/** Stable virtualizer key. */
key: "channel-intro";
/** Always -1 — the intro does not map to a message. */
messageIndex: -1;
};
/** A divider row — one per calendar-day boundary. Carries no message. */
export type VirtualDayDividerRow = {
kind: "day-divider";
@@ -50,10 +66,13 @@ export type VirtualMessageRow = {
messageIndex: number;
};
export type VirtualTimelineRow = VirtualDayDividerRow | VirtualMessageRow;
export type VirtualTimelineRow =
| VirtualIntroRow
| VirtualDayDividerRow
| VirtualMessageRow;
/**
* Flatten a message snapshot into ordered virtual rows (dividers + messages).
* Flatten a message snapshot into ordered virtual rows (intro + dividers + messages).
*
* Walks the snapshot once; emits a divider row at each day-group start index
* (computed by `buildDayGroupBoundaries`, the same helper the live render uses),
@@ -63,8 +82,18 @@ export type VirtualTimelineRow = VirtualDayDividerRow | VirtualMessageRow;
*/
export function buildVirtualTimelineRows(
messages: readonly TimelineMessage[],
options?: BuildVirtualTimelineRowsOptions,
): VirtualTimelineRow[] {
const rows: VirtualTimelineRow[] = [];
if (options?.includeIntro) {
rows.push({
kind: "intro",
key: "channel-intro",
messageIndex: -1,
});
}
const dayStartIndices = new Set(
buildDayGroupBoundaries(messages).map((boundary) => boundary.startIndex),
);
@@ -3,9 +3,7 @@ import test from "node:test";
import {
BOTTOM_THRESHOLD_PX,
TOP_THRESHOLD_PX,
buildDayGroupBoundaries,
isAtTopMetrics,
isNearBottomMetrics,
resolveDeepLinkTarget,
selectDeferredListRenderState,
@@ -79,20 +77,6 @@ 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(
@@ -43,32 +43,6 @@ 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<ScrollMetrics, "scrollTop">,
): 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
@@ -32,6 +32,7 @@ import { VirtualizedTimelineList } from "./VirtualizedTimelineList";
// no fixed-height assumption.
const ESTIMATED_MESSAGE_HEIGHT = 64;
const ESTIMATED_DIVIDER_HEIGHT = 32;
const ESTIMATED_INTRO_HEIGHT = 320;
const VIRTUAL_OVERSCAN = 8;
// Fallback escape hatch for find-in-page: when find is open, optionally bypass
@@ -169,7 +170,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(null);
// Wraps the virtualized list; its offset within the scroll container is the
// virtualizer's `scrollMargin` (content above it: sentinel, spinner, intro).
// virtualizer's `scrollMargin` (content above it: sentinel, spinner).
const listOuterRef = React.useRef<HTMLDivElement>(null);
// Gate the heavy timeline render (each row runs a synchronous
@@ -201,9 +202,18 @@ export const MessageTimeline = React.memo(function MessageTimeline({
() => entries.map((entry) => entry.message),
[entries],
);
// The channel intro is the terminal header at the true top of history. Include
// it as virtual row 0 only once the deferred list has settled AND we've reached
// the genuine history boundary (no older pages remain).
const introReady =
!isLoading &&
channelIntro !== null &&
directMessageIntro === null &&
!isRenderPending &&
(deferredMessages.length === 0 || !hasOlderMessages);
const rows = React.useMemo(
() => buildVirtualTimelineRows(entryMessages),
[entryMessages],
() => buildVirtualTimelineRows(entryMessages, { includeIntro: introReady }),
[entryMessages, introReady],
);
// When the render-all escape hatch is enabled AND find is open, expand the
@@ -216,9 +226,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
: VIRTUAL_OVERSCAN;
// Offset of the virtualized list within the scroll container — content above
// it (sentinel, "load older" spinner, intro banner) lives in the SAME
// scrollable element, so the virtualizer must know that offset or rows paint
// at the wrong scrollTop (header/list sandwich + anchor drift on fill).
// it (sentinel, "load older" spinner) lives in the SAME scrollable element,
// so the virtualizer must know that offset or rows paint at the wrong
// scrollTop (header/list sandwich + anchor drift on fill).
const scrollMargin = useVirtualScrollMargin(
scrollContainerRef,
listOuterRef,
@@ -226,7 +236,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
isLoading,
isFetchingOlder,
deferredMessages.length,
channelIntro,
directMessageIntro,
rows.length,
],
@@ -235,24 +244,32 @@ export const MessageTimeline = React.memo(function MessageTimeline({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: (index) =>
rows[index]?.kind === "day-divider"
? ESTIMATED_DIVIDER_HEIGHT
: ESTIMATED_MESSAGE_HEIGHT,
estimateSize: (index) => {
const row = rows[index];
if (!row) {
return ESTIMATED_MESSAGE_HEIGHT;
}
if (row.kind === "day-divider") {
return ESTIMATED_DIVIDER_HEIGHT;
}
if (row.kind === "intro") {
return ESTIMATED_INTRO_HEIGHT;
}
return ESTIMATED_MESSAGE_HEIGHT;
},
// Stable per-row identity. THIS is what lets a top-prepend (older page)
// retain scroll position natively — surviving rows keep their key, so the
// measurement cache survives and the virtualizer re-anchors itself. No
// before/after scrollHeight delta math, no double-rAF correction.
getItemKey: (index) => rows[index]?.key ?? index,
overscan,
// Account for the sentinel/spinner/intro above the list inside the same
// scroll container, so item offsets line up with where they actually paint.
// Account for the sentinel/spinner above the list inside the same scroll
// container, so item offsets line up with where they actually paint.
scrollMargin: scrollMargin.value,
});
const {
highlightedMessageId,
introRevealed,
isAtBottom,
newMessageCount,
scrollToBottom,
@@ -318,6 +335,13 @@ export const MessageTimeline = React.memo(function MessageTimeline({
[renderContext],
);
const renderChannelIntro = React.useCallback(() => {
if (!channelIntro) {
return null;
}
return <ChannelIntroHeader intro={channelIntro} />;
}, [channelIntro]);
// Pagination trigger only — the virtualizer holds scroll position on prepend
// natively (stable keys), so there is no position-restore plumbing to pass.
useLoadOlderOnScroll({
@@ -329,27 +353,17 @@ export const MessageTimeline = React.memo(function MessageTimeline({
});
const showDirectMessageIntro = !isLoading && directMessageIntro !== null;
const showChannelIntro =
!isLoading && channelIntro !== null && directMessageIntro === null;
const showIntro = showDirectMessageIntro || showChannelIntro;
// The channel intro is the TERMINAL header of a bottom-anchored list — the
// thing you reach only once you've genuinely arrived at the true top. It must
// NOT be force-painted flush at the viewport top during first-load, because
// at that point "where the top is" isn't even determined yet (the list is
// still streaming in from the bottom). So the intro lives in-flow ABOVE the
// virtualized list and shares the bottom-pin (`min-h-full` + `mt-auto`): the
// intro+list block fills the container and pins to the bottom, leaving the
// intro scrolled off the top until the user scrolls up to it or the top
// naturally loads into view. The header is then EARNED by reaching the top,
// not asserted up front. (The earlier `topAlignIntro` flush-top flag was a
// different flavor of the step-5 lie — asserting "this is the top" before the
// top was determined — and is gone.)
const showGenericEmpty =
!isLoading &&
deferredMessages.length === 0 &&
directMessageIntro === null &&
channelIntro === null;
const showMessageList = !isLoading && deferredMessages.length > 0;
const showVirtualList =
!isLoading && (deferredMessages.length > 0 || introReady);
const useMinHeightFill =
showDirectMessageIntro ||
showGenericEmpty ||
(introReady && deferredMessages.length === 0);
const timelineSkeletonRows = useTimelineSkeletonRows({
channelId,
isLoading,
@@ -373,7 +387,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
className={cn(
"flex w-full flex-col gap-2",
channelChrome.contentPadding,
(showIntro || showGenericEmpty) && "min-h-full",
useMinHeightFill && "min-h-full",
)}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
@@ -387,12 +401,12 @@ export const MessageTimeline = React.memo(function MessageTimeline({
<SkeletonReveal
className={cn(
"min-h-[18rem]",
(showIntro || showGenericEmpty) && "min-h-full",
showMessageList && !showIntro && "mt-auto",
useMinHeightFill && "min-h-full",
showVirtualList && !showDirectMessageIntro && "mt-auto",
)}
contentClassName={cn(
"flex flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
useMinHeightFill && "min-h-full",
)}
loading={isLoading}
skeleton={<TimelineSkeleton rows={timelineSkeletonRows} />}
@@ -418,107 +432,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
{showChannelIntro ? (
<div
aria-hidden={!introRevealed}
className={cn(
"mb-0.5 mt-auto flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left transition-opacity",
// Reserve the intro's space (feeds scrollMargin) but only
// REVEAL it once the bottom pin has landed AND we're at the
// genuine top — never painted up front while the list
// streams in from the bottom.
introRevealed ? "opacity-100" : "opacity-0",
)}
data-testid="message-channel-intro"
>
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
>
{channelIntro.icon ?? (
<Hash aria-hidden className="h-7 w-7" />
)}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{channelIntro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{channelIntro.channelKindLabel}
</span>
.
</p>
{channelIntro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{channelIntro.description}
</p>
) : null}
{channelIntro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{channelIntro.actions.map((action) => {
const hasDescription = Boolean(action.description);
return (
<button
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5",
)}
data-testid={
action.testId
? `${action.testId}-icon`
: undefined
}
>
{action.icon}
</span>
<span className="mt-auto min-w-0">
<span
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
data-testid={
action.testId
? `${action.testId}-title`
: undefined
}
>
{action.label}
</span>
{action.description ? (
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
data-testid={
action.testId
? `${action.testId}-description`
: undefined
}
>
{action.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
{showGenericEmpty ? (
<div
className="mt-auto rounded-3xl border border-dashed border-border/80 bg-card/70 px-6 py-10 text-center shadow-xs"
@@ -533,11 +446,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
{showMessageList ? (
{showVirtualList ? (
<div
className={cn(
"flex flex-col",
!showIntro && "mt-auto",
// While a deferred render is in flight the painted
// list lags the latest `messages`. Dim it slightly so the
// streaming-in feels intentional instead of frozen.
@@ -549,6 +461,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
<VirtualizedTimelineList
entries={entries}
renderEntry={renderEntry}
renderIntro={introReady ? renderChannelIntro : undefined}
rows={rows}
scrollMargin={scrollMargin.value}
virtualizer={virtualizer}
@@ -584,7 +497,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
) : null}
{showMessageList ? (
{showVirtualList ? (
<TimelineDebugOverlay
highlightedMessageId={highlightedMessageId}
isAtBottom={isAtBottom}
@@ -602,6 +515,95 @@ export const MessageTimeline = React.memo(function MessageTimeline({
);
});
function ChannelIntroHeader({ intro }: { intro: ChannelIntro }) {
return (
<div
className="mb-0.5 flex w-full max-w-2xl flex-col items-start px-3 py-2 text-left"
data-testid="message-channel-intro"
>
<div
className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
data-testid="message-channel-intro-icon"
>
{intro.icon ?? <Hash aria-hidden className="h-7 w-7" />}
</div>
<p className="mt-4 max-w-full truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
#{intro.channelName}
</p>
<p className="mt-1 max-w-full text-sm leading-5 text-muted-foreground">
This is the beginning of the{" "}
<span className="font-medium text-foreground">
{intro.channelKindLabel}
</span>
.
</p>
{intro.description ? (
<p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
{intro.description}
</p>
) : null}
{intro.actions?.length ? (
<div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
{intro.actions.map((action) => {
const hasDescription = Boolean(action.description);
return (
<button
className={cn(
"flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
hasDescription
? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
: "h-28 w-64 flex-col rounded-xl p-4",
)}
data-testid={action.testId}
key={action.label}
onClick={action.onClick}
type="button"
>
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
hasDescription
? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
: "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5",
)}
data-testid={
action.testId ? `${action.testId}-icon` : undefined
}
>
{action.icon}
</span>
<span className="mt-auto min-w-0">
<span
className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
data-testid={
action.testId ? `${action.testId}-title` : undefined
}
>
{action.label}
</span>
{action.description ? (
<span
className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
data-testid={
action.testId
? `${action.testId}-description`
: undefined
}
>
{action.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
function DirectMessageIntroAvatarStack({
participants,
}: {
@@ -60,6 +60,10 @@ function renderEntryStub(entry: MainTimelineEntry) {
return <span data-testid="entry">{entry.message.body}</span>;
}
function renderIntroStub() {
return <div data-testid="message-channel-intro">intro</div>;
}
function divider(timestamp: number): VirtualTimelineRow {
return {
kind: "day-divider",
@@ -169,3 +173,21 @@ test("positions rows at start minus scrollMargin (content-above offset)", () =>
// 200 - 128 = 72
assert.match(wrapper.style.transform, /translateY\(72px\)/);
});
test("renders an intro row through the injected intro renderer", () => {
const rows: VirtualTimelineRow[] = [
{ kind: "intro", key: "channel-intro", messageIndex: -1 },
divider(DAY_1),
];
render(
<VirtualizedTimelineList
entries={[]}
renderEntry={renderEntryStub}
renderIntro={renderIntroStub}
scrollMargin={0}
rows={rows}
virtualizer={fakeVirtualizer(rows)}
/>,
);
assert.ok(screen.getByTestId("message-channel-intro"));
});
@@ -13,7 +13,7 @@ type VirtualizedTimelineListProps = {
entries: MainTimelineEntry[];
/**
* The virtualizer's `scrollMargin` — the list's offset within the scroll
* container (content above it: sentinel, spinner, intro). `virtualItem.start`
* container (content above it: sentinel, spinner). `virtualItem.start`
* is in scroll-element coords (includes this margin), so rows are positioned
* at `start - scrollMargin` within the spacer, which sits at that offset.
*/
@@ -25,6 +25,8 @@ type VirtualizedTimelineListProps = {
* `renderTimelineEntry` bound to its render context.
*/
renderEntry: (entry: MainTimelineEntry) => React.ReactNode;
/** Renders the channel intro header when an intro row is in the virtual list. */
renderIntro?: () => React.ReactNode;
};
/**
@@ -43,6 +45,7 @@ export const VirtualizedTimelineList = React.memo(
entries,
scrollMargin,
renderEntry,
renderIntro,
}: VirtualizedTimelineListProps) {
const virtualItems = virtualizer.getVirtualItems();
@@ -74,7 +77,9 @@ export const VirtualizedTimelineList = React.memo(
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
{row.kind === "day-divider" ? (
{row.kind === "intro" ? (
renderIntro?.()
) : row.kind === "day-divider" ? (
<DayDivider label={formatDayHeading(row.headingTimestamp)} />
) : entry ? (
renderEntry(entry)
@@ -5,16 +5,16 @@ import * as React from "react";
* scrollable content, to feed `useVirtualizer({ scrollMargin })`.
*
* The main timeline's scroll container holds content ABOVE the virtualized list
* inside the SAME scrollable element: the pagination sentinel, the
* "load older" spinner, and the channel/DM intro banner. `@tanstack/react-virtual`
* inside the SAME scrollable element: the pagination sentinel and the
* "load older" spinner. `@tanstack/react-virtual`
* positions items at `paddingStart + scrollMargin`, so without this the
* virtualizer assumes row 0 sits at scrollTop 0 — but it's actually painted
* `scrollMargin` px lower. That mismatch is what makes freshly-loaded rows
* sandwich into the header/list seam and the viewport drift while rows fill.
*
* We re-measure whenever the above-content can change height (intro mount/
* unmount, spinner toggle) AND via a ResizeObserver on the scroll container, so
* the margin stays correct as content streams in.
* We re-measure whenever the above-content can change height (spinner toggle)
* AND via a ResizeObserver on the scroll container, so the margin stays correct
* as content streams in.
*
* Returns both the margin and a `measured` flag. The flag matters because a
* legitimate margin can be `0` (nothing above the list), so callers that must
@@ -71,7 +71,7 @@ export function useVirtualScrollMargin(
return;
}
// The above-content lives inside the container; observe the container so a
// height change in the sentinel/spinner/intro re-measures the margin.
// height change in the sentinel/spinner re-measures the margin.
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
@@ -6,7 +6,6 @@ import {
type VirtualTimelineRow,
} from "@/features/messages/lib/buildVirtualTimelineRows";
import {
isAtTop,
isNearBottom,
resolveDeepLinkTarget,
selectLatestMessageKey,
@@ -79,14 +78,6 @@ export function useVirtualTimelineScroll({
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
string | null
>(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;
@@ -119,19 +110,8 @@ 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(() => {
@@ -145,8 +125,7 @@ export function useVirtualTimelineScroll({
if (atBottom) {
setNewMessageCount(0);
}
syncIntroRevealed();
}, [scrollContainerRef, syncIntroRevealed]);
}, [scrollContainerRef]);
const latestMessage =
messages.length > 0 ? messages[messages.length - 1] : undefined;
@@ -173,10 +152,6 @@ 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;
}
@@ -216,7 +191,6 @@ export function useVirtualTimelineScroll({
messages.length,
scrollMarginReady,
scrollToBottom,
syncIntroRevealed,
targetMessageId,
]);
@@ -245,18 +219,7 @@ export function useVirtualTimelineScroll({
}
lastPinnedTotalSizeRef.current = totalSize;
virtualizer.scrollToIndex(lastRowIndex, { align: "end" });
// 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,
]);
}, [totalSize, isLoading, targetMessageId, lastRowIndex, virtualizer]);
// Deep-link jump-to-message. Drives the virtualizer to mount and center the
// target row, replacing the bespoke querySelector + scrollIntoView path that
@@ -340,7 +303,6 @@ export function useVirtualTimelineScroll({
return {
highlightedMessageId,
introRevealed,
isAtBottom,
newMessageCount,
scrollToBottom,