mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user