mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(timeline): virtualize the main message timeline
Phase 2: render only the viewport (+overscan) of the main timeline instead of all ~200+ rows, lifting the ~2,000-row ceiling. Main timeline ONLY — the thread pane (and its useTimelineScrollManager) is untouched (Phase 3). What changed: - MessageTimeline now drives @tanstack/react-virtual off the same deferred snapshot the rows render from, flattening day-grouped entries into index-addressable rows via buildVirtualTimelineRows. Rows are built from the FILTERED main-timeline entries (buildMainTimelineEntries), so dropped thread replies never desync the row->entry mapping. - useVirtualTimelineScroll REPLACES the bespoke 427-line useTimelineScrollManager for the main timeline. The virtualizer owns the scroll container and all measurement/anchoring; this hook is a thin wrapper keeping sticky-bottom autoscroll, accent smooth-scroll, the newMessageCount pill, isAtBottom, and deep-link + find-in-page jumps (via findVirtualRowIndexForMessage -> scrollToIndex). No scrollTop locking, no ResizeObserver re-pinning. - useLoadOlderOnScroll is gutted to a trigger: the double-requestAnimationFrame scrollTop correction and restoreScrollPosition plumbing are DELETED. Stable per-row keys (getItemKey) let the virtualizer hold scroll position on prepend natively — the band-aid is gone at the root. - cmd+F find drives scroll-to-row through the same scrollToIndex bridge; a render-all-while-searching escape hatch exists as an off-by-default fallback (the in-app path is the default, keeping the perf win). - The per-entry render (3 row variants + video-review context) is extracted to renderTimelineEntry + useVideoReviewContextById, a single source of truth injected into VirtualizedTimelineList. The old nested TimelineMessageList is deleted (orphaned by the rewire). Must-keeps preserved: sticky-bottom autoscroll, day dividers (first-class variable-height flat rows), jump-to-message deep links, no-tearing (same deferredMessages snapshot drives both rows and scroll logic). Coverage: 8 jsdom/testing-library DOM tests cover row dispatch (divider vs message, flat-index->entry mapping), init autoscroll, accent smooth-scroll, and deep-link scrollToIndex. jsdom has no layout engine, so react-virtual's pixel measurement/scroll math stays on a manual/visual verification pass. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
Taylor Ho
parent
fc2302b5e4
commit
374c829619
@@ -1,7 +1,10 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown, Hash } from "lucide-react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
|
||||
import { buildVirtualTimelineRows } from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
@@ -13,9 +16,28 @@ import { SkeletonReveal } from "@/shared/ui/skeleton";
|
||||
import { TooltipProvider } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
|
||||
import { TimelineMessageList } from "./TimelineMessageList";
|
||||
import {
|
||||
renderTimelineEntry,
|
||||
type TimelineEntryRenderContext,
|
||||
} from "./timelineEntryRender";
|
||||
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
|
||||
import { useTimelineScrollManager } from "./useTimelineScrollManager";
|
||||
import { useVideoReviewContextById } from "./useVideoReviewContextById";
|
||||
import { useVirtualTimelineScroll } from "./useVirtualTimelineScroll";
|
||||
import { VirtualizedTimelineList } from "./VirtualizedTimelineList";
|
||||
|
||||
// Initial row-size guesses only — `measureElement` corrects each row to its
|
||||
// real height after first paint, so variable-height messages and dividers need
|
||||
// no fixed-height assumption.
|
||||
const ESTIMATED_MESSAGE_HEIGHT = 64;
|
||||
const ESTIMATED_DIVIDER_HEIGHT = 32;
|
||||
const VIRTUAL_OVERSCAN = 8;
|
||||
|
||||
// Fallback escape hatch for find-in-page: when find is open, optionally bypass
|
||||
// virtualization and render every row so native browser cmd+F can see all
|
||||
// matches. OFF by default — the in-app find path (scroll-to-row via
|
||||
// `findVirtualRowIndexForMessage`) is the default and keeps the perf win.
|
||||
// Flip via the `renderAllWhileSearching` prop only if QA finds a gap.
|
||||
const RENDER_ALL_WHILE_SEARCHING_DEFAULT = false;
|
||||
|
||||
type MessageTimelineProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
@@ -70,6 +92,9 @@ type MessageTimelineProps = {
|
||||
searchMatchingMessageIds?: Set<string>;
|
||||
/** The current find-in-channel query string. */
|
||||
searchQuery?: string;
|
||||
/** Escape hatch: render all rows while find-in-page is open so native
|
||||
* browser cmd+F can see every match. Defaults off (in-app find is default). */
|
||||
renderAllWhileSearching?: boolean;
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
};
|
||||
@@ -134,6 +159,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
searchActiveMessageId = null,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
renderAllWhileSearching = RENDER_ALL_WHILE_SEARCHING_DEFAULT,
|
||||
targetMessageId = null,
|
||||
onTargetReached,
|
||||
}: MessageTimelineProps) {
|
||||
@@ -158,53 +184,115 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}`
|
||||
: `message-timeline:${channelId ?? "none"}`;
|
||||
|
||||
// Filtered main-timeline entries + flat virtual rows, both off the SAME
|
||||
// deferred snapshot the rows render from (the no-tearing property from
|
||||
// Phase 1). `entryMessages` is what the virtual rows index into, so dropped
|
||||
// thread replies never desync the row→entry mapping.
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(deferredMessages),
|
||||
[deferredMessages],
|
||||
);
|
||||
const entryMessages = React.useMemo(
|
||||
() => entries.map((entry) => entry.message),
|
||||
[entries],
|
||||
);
|
||||
const rows = React.useMemo(
|
||||
() => buildVirtualTimelineRows(entryMessages),
|
||||
[entryMessages],
|
||||
);
|
||||
|
||||
// When the render-all escape hatch is enabled AND find is open, expand the
|
||||
// overscan to the whole list so native browser cmd+F can see every match.
|
||||
// Default path keeps the lean fixed overscan.
|
||||
const isSearchOpen = Boolean(searchQuery || searchActiveMessageId);
|
||||
const overscan =
|
||||
renderAllWhileSearching && isSearchOpen && rows.length > 0
|
||||
? rows.length
|
||||
: VIRTUAL_OVERSCAN;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: (index) =>
|
||||
rows[index]?.kind === "day-divider"
|
||||
? ESTIMATED_DIVIDER_HEIGHT
|
||||
: 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,
|
||||
});
|
||||
|
||||
const {
|
||||
bottomAnchorRef,
|
||||
contentRef,
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
restoreScrollPosition,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
} = useTimelineScrollManager({
|
||||
} = useVirtualTimelineScroll({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages: deferredMessages,
|
||||
onTargetReached,
|
||||
messages: entryMessages,
|
||||
rows,
|
||||
scrollContainerRef,
|
||||
virtualizer,
|
||||
targetMessageId,
|
||||
onTargetReached,
|
||||
searchActiveMessageId,
|
||||
});
|
||||
|
||||
// Scroll to the active search match when it changes.
|
||||
const prevSearchActiveRef = React.useRef<string | null>(null);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!searchActiveMessageId ||
|
||||
searchActiveMessageId === prevSearchActiveRef.current
|
||||
) {
|
||||
prevSearchActiveRef.current = searchActiveMessageId;
|
||||
return;
|
||||
}
|
||||
prevSearchActiveRef.current = searchActiveMessageId;
|
||||
const videoReviewContextById = useVideoReviewContextById({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment,
|
||||
messages: deferredMessages,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
const renderContext: TimelineEntryRenderContext = {
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
followThreadById,
|
||||
highlightedMessageId,
|
||||
isFollowingThreadById,
|
||||
messageFooters,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
personaLookup,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
searchActiveMessageId,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
unfollowThreadById,
|
||||
videoReviewContextById,
|
||||
};
|
||||
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
`[data-message-id="${searchActiveMessageId}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
}, [searchActiveMessageId]);
|
||||
const renderEntry = React.useCallback(
|
||||
(entry: Parameters<typeof renderTimelineEntry>[0]) =>
|
||||
renderTimelineEntry(entry, renderContext),
|
||||
// renderContext is rebuilt every render from the same inputs; the entry
|
||||
// render reads its current values, so depending on the bundle directly
|
||||
// keeps the callback in sync without a stale closure.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: renderContext fields are the real deps
|
||||
[renderContext],
|
||||
);
|
||||
|
||||
// Pagination trigger only — the virtualizer holds scroll position on prepend
|
||||
// natively (stable keys), so there is no position-restore plumbing to pass.
|
||||
useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef: topSentinelRef,
|
||||
});
|
||||
@@ -244,7 +332,6 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
channelChrome.contentPadding,
|
||||
(showIntro || showGenericEmpty) && "min-h-full",
|
||||
)}
|
||||
ref={contentRef}
|
||||
>
|
||||
<div ref={topSentinelRef} aria-hidden className="h-px" />
|
||||
|
||||
@@ -398,7 +485,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
{showMessageList ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
"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
|
||||
@@ -407,36 +494,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
)}
|
||||
data-render-pending={isRenderPending ? "true" : undefined}
|
||||
>
|
||||
<TimelineMessageList
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
channelName={channelName}
|
||||
channelType={channelType}
|
||||
currentPubkey={currentPubkey}
|
||||
followThreadById={followThreadById}
|
||||
highlightedMessageId={highlightedMessageId}
|
||||
isFollowingThreadById={isFollowingThreadById}
|
||||
messageFooters={messageFooters}
|
||||
messages={deferredMessages}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
searchActiveMessageId={searchActiveMessageId}
|
||||
searchMatchingMessageIds={searchMatchingMessageIds}
|
||||
searchQuery={searchQuery}
|
||||
unfollowThreadById={unfollowThreadById}
|
||||
<VirtualizedTimelineList
|
||||
entries={entries}
|
||||
renderEntry={renderEntry}
|
||||
rows={rows}
|
||||
virtualizer={virtualizer}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</SkeletonReveal>
|
||||
|
||||
<div aria-hidden className="h-px" ref={bottomAnchorRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewContextForMessage,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import { buildDayGroupBoundaries } from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
type TimelineMessageListProps = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
channelId?: string | null;
|
||||
channelName?: string;
|
||||
channelType?: ChannelType | null;
|
||||
currentPubkey?: string;
|
||||
followThreadById?: (rootId: string) => void;
|
||||
highlightedMessageId?: string | null;
|
||||
isFollowingThreadById?: (rootId: string) => boolean;
|
||||
messageFooters?: Record<string, React.ReactNode>;
|
||||
messages: TimelineMessage[];
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
isSendingVideoReviewComment?: boolean;
|
||||
onSendVideoReviewComment?: (
|
||||
message: TimelineMessage,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
parentEventId?: string,
|
||||
) => Promise<void>;
|
||||
unfollowThreadById?: (rootId: string) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
/** Map from lowercase pubkey → persona display name for bot members. */
|
||||
personaLookup?: Map<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
/** The message ID of the currently active find-in-channel match. */
|
||||
searchActiveMessageId?: string | null;
|
||||
/** Set of message IDs that match the current find-in-channel query. */
|
||||
searchMatchingMessageIds?: Set<string>;
|
||||
/** The current find-in-channel query string. */
|
||||
searchQuery?: string;
|
||||
};
|
||||
|
||||
export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
agentPubkeys,
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
currentPubkey,
|
||||
followThreadById,
|
||||
highlightedMessageId = null,
|
||||
isFollowingThreadById,
|
||||
messageFooters,
|
||||
messages,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onReply,
|
||||
isSendingVideoReviewComment = false,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
personaLookup,
|
||||
profiles,
|
||||
searchActiveMessageId = null,
|
||||
searchMatchingMessageIds,
|
||||
searchQuery,
|
||||
unfollowThreadById,
|
||||
}: TimelineMessageListProps) {
|
||||
const entries = React.useMemo(
|
||||
() => buildMainTimelineEntries(messages),
|
||||
[messages],
|
||||
);
|
||||
const reviewCommentsByRootId = React.useMemo(
|
||||
() => buildVideoReviewCommentsByRootId(messages),
|
||||
[messages],
|
||||
);
|
||||
// Contexts are memoized per message id so MessageRow/Markdown memo
|
||||
// comparisons hold across unrelated timeline re-renders (typing
|
||||
// indicators, presence updates) — a fresh context object per render would
|
||||
// defeat the memo and re-render every video message on every pass.
|
||||
const videoReviewContextById = React.useMemo(() => {
|
||||
const contexts = new Map<
|
||||
string,
|
||||
NonNullable<ReturnType<typeof buildVideoReviewContextForMessage>>
|
||||
>();
|
||||
for (const message of messages) {
|
||||
const comments = reviewCommentsByRootId.get(message.id) ?? [];
|
||||
const context = buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
comments,
|
||||
isSendingVideoReviewComment,
|
||||
message,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
if (context) {
|
||||
contexts.set(message.id, context);
|
||||
}
|
||||
}
|
||||
return contexts;
|
||||
}, [
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment,
|
||||
messages,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
reviewCommentsByRootId,
|
||||
]);
|
||||
const dayGroups: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
elements: React.ReactNode[];
|
||||
}> = [];
|
||||
let currentDayGroup: (typeof dayGroups)[number] | null = null;
|
||||
|
||||
// Day-divider decision delegated to a pure, lib-tested helper: a new group
|
||||
// starts at index 0 and whenever a message falls on a different calendar day
|
||||
// than the one before it. We index the boundary start positions so the render
|
||||
// loop below stays a straight walk while the grouping logic lives in `lib/`.
|
||||
const dayGroupStartIndices = new Set(
|
||||
buildDayGroupBoundaries(entries.map((entry) => entry.message)).map(
|
||||
(boundary) => boundary.startIndex,
|
||||
),
|
||||
);
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const { message, summary } = entries[i];
|
||||
const messageRenderKey = message.renderKey ?? message.id;
|
||||
|
||||
if (dayGroupStartIndices.has(i)) {
|
||||
currentDayGroup = {
|
||||
key: `day-${message.createdAt}`,
|
||||
label: formatDayHeading(message.createdAt),
|
||||
elements: [],
|
||||
};
|
||||
dayGroups.push(currentDayGroup);
|
||||
}
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
currentDayGroup?.elements.push(
|
||||
<div key={messageRenderKey} className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
agentPubkeys={agentPubkeys}
|
||||
currentPubkey={currentPubkey}
|
||||
onToggleReaction={onToggleReaction}
|
||||
personaLookup={personaLookup}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else if (summary && onReply) {
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
currentDayGroup?.elements.push(
|
||||
<div
|
||||
key={messageRenderKey}
|
||||
className={cn(
|
||||
"group/message relative -mx-1 flex flex-col gap-0 rounded-2xl px-1 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
isFollowingThreadById
|
||||
? isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
unfollowThreadById
|
||||
? () => unfollowThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
profiles={profiles}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={onReply}
|
||||
showDepthGuides={false}
|
||||
summary={summary}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
} else {
|
||||
const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === searchActiveMessageId;
|
||||
const footer = messageFooters?.[message.id] ?? null;
|
||||
|
||||
currentDayGroup?.elements.push(
|
||||
<div key={messageRenderKey} className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
agentPubkeys={agentPubkeys}
|
||||
channelId={channelId}
|
||||
highlighted={message.id === highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
onDelete && currentPubkey && message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && currentPubkey && message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
searchQuery={isSearchMatch ? searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return dayGroups.map((group) => (
|
||||
<section
|
||||
className="relative flex flex-col gap-2.5 before:absolute before:inset-x-0 before:top-[15px] before:h-px before:bg-border/35 before:content-['']"
|
||||
key={group.key}
|
||||
>
|
||||
<DayDivider label={group.label} />
|
||||
{group.elements}
|
||||
</section>
|
||||
));
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { VirtualTimelineRow } from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import { VirtualizedTimelineList } from "./VirtualizedTimelineList";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const DAY = 24 * 60 * 60;
|
||||
const DAY_1 = Math.floor(new Date(2026, 0, 1, 12, 0, 0).getTime() / 1000);
|
||||
const DAY_2 = DAY_1 + DAY;
|
||||
|
||||
// jsdom has no layout engine, so we inject a fake virtualizer that reports all
|
||||
// rows as "in view". This exercises the real row-dispatch logic — divider vs
|
||||
// message, flat-index → entry mapping, the absolutely-positioned row wrappers,
|
||||
// and that the injected renderEntry receives the correct entry — at the DOM
|
||||
// layer, which is the part VirtualizedTimelineList owns. Pixel measurement and
|
||||
// scroll math stay on the manual verification pass.
|
||||
function fakeVirtualizer(
|
||||
rows: VirtualTimelineRow[],
|
||||
): Virtualizer<HTMLDivElement, Element> {
|
||||
return {
|
||||
getTotalSize: () => rows.length * 64,
|
||||
getVirtualItems: () =>
|
||||
rows.map((_row, index) => ({
|
||||
index,
|
||||
key: rows[index].key,
|
||||
start: index * 64,
|
||||
size: 64,
|
||||
end: index * 64 + 64,
|
||||
lane: 0,
|
||||
})),
|
||||
measureElement: () => {},
|
||||
} as unknown as Virtualizer<HTMLDivElement, Element>;
|
||||
}
|
||||
|
||||
function messageEntry(id: string, createdAt: number): MainTimelineEntry {
|
||||
return {
|
||||
message: {
|
||||
id,
|
||||
createdAt,
|
||||
author: "tester",
|
||||
time: "",
|
||||
body: `body-${id}`,
|
||||
depth: 0,
|
||||
kind: 9,
|
||||
},
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Trivial injected renderer — proves the dispatch without dragging in the heavy
|
||||
// real MessageRow subtree (emoji-mart, shiki, tiptap). MessageTimeline binds the
|
||||
// real `renderTimelineEntry` here in production.
|
||||
function renderEntryStub(entry: MainTimelineEntry) {
|
||||
return <span data-testid="entry">{entry.message.body}</span>;
|
||||
}
|
||||
|
||||
function divider(timestamp: number): VirtualTimelineRow {
|
||||
return {
|
||||
kind: "day-divider",
|
||||
key: `day-${timestamp}`,
|
||||
headingTimestamp: timestamp,
|
||||
messageIndex: -1,
|
||||
};
|
||||
}
|
||||
|
||||
test("renders a day divider row with its formatted label", () => {
|
||||
const rows = [divider(DAY_1)];
|
||||
render(
|
||||
<VirtualizedTimelineList
|
||||
entries={[]}
|
||||
renderEntry={renderEntryStub}
|
||||
rows={rows}
|
||||
virtualizer={fakeVirtualizer(rows)}
|
||||
/>,
|
||||
);
|
||||
assert.equal(screen.getAllByTestId("message-timeline-day-divider").length, 1);
|
||||
});
|
||||
|
||||
test("dispatches message rows to their mapped entry, interleaved with dividers", () => {
|
||||
const entries = [messageEntry("a", DAY_1), messageEntry("b", DAY_2)];
|
||||
// layout: [div(day1), a, div(day2), b]
|
||||
const rows: VirtualTimelineRow[] = [
|
||||
divider(DAY_1),
|
||||
{ kind: "message", key: "a", messageIndex: 0 },
|
||||
divider(DAY_2),
|
||||
{ kind: "message", key: "b", messageIndex: 1 },
|
||||
];
|
||||
const { container } = render(
|
||||
<VirtualizedTimelineList
|
||||
entries={entries}
|
||||
renderEntry={renderEntryStub}
|
||||
rows={rows}
|
||||
virtualizer={fakeVirtualizer(rows)}
|
||||
/>,
|
||||
);
|
||||
// Two dividers, strictly interleaved, and each message routed to its entry.
|
||||
assert.equal(screen.getAllByTestId("message-timeline-day-divider").length, 2);
|
||||
assert.ok(screen.getByText("body-a"));
|
||||
assert.ok(screen.getByText("body-b"));
|
||||
// One absolutely-positioned wrapper per virtual row (4 total).
|
||||
assert.equal(container.querySelectorAll("[data-index]").length, 4);
|
||||
});
|
||||
|
||||
test("renders nothing for an empty row list", () => {
|
||||
const { container } = render(
|
||||
<VirtualizedTimelineList
|
||||
entries={[]}
|
||||
renderEntry={renderEntryStub}
|
||||
rows={[]}
|
||||
virtualizer={fakeVirtualizer([])}
|
||||
/>,
|
||||
);
|
||||
assert.equal(container.querySelectorAll("[data-index]").length, 0);
|
||||
});
|
||||
|
||||
test("renders a message row's wrapper even if its entry is missing (no throw)", () => {
|
||||
// A message row whose messageIndex outruns the entries array (transient
|
||||
// snapshot skew) must not throw — it renders an empty wrapper.
|
||||
const rows: VirtualTimelineRow[] = [
|
||||
{ kind: "message", key: "ghost", messageIndex: 0 },
|
||||
];
|
||||
const { container } = render(
|
||||
<VirtualizedTimelineList
|
||||
entries={[]}
|
||||
renderEntry={renderEntryStub}
|
||||
rows={rows}
|
||||
virtualizer={fakeVirtualizer(rows)}
|
||||
/>,
|
||||
);
|
||||
assert.equal(container.querySelectorAll("[data-index]").length, 1);
|
||||
assert.equal(screen.queryByTestId("entry"), null);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import * as React from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import type { VirtualTimelineRow } from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
import { formatDayHeading } from "@/features/messages/lib/dateFormatters";
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
|
||||
type VirtualizedTimelineListProps = {
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>;
|
||||
rows: VirtualTimelineRow[];
|
||||
/** Filtered main-timeline entries, indexed by `VirtualMessageRow.messageIndex`. */
|
||||
entries: MainTimelineEntry[];
|
||||
/**
|
||||
* Renders one message entry's content. Injected (rather than imported) so the
|
||||
* heavy `MessageRow` subtree stays out of this component's concern and the
|
||||
* list is testable in isolation. `MessageTimeline` passes the real
|
||||
* `renderTimelineEntry` bound to its render context.
|
||||
*/
|
||||
renderEntry: (entry: MainTimelineEntry) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Presentational virtualized main timeline. Renders only the rows in view
|
||||
* (+overscan) from the flat `rows` list, reusing `DayDivider` and the injected
|
||||
* `renderEntry`, so the painted output matches the classic nested list exactly.
|
||||
*
|
||||
* The virtualizer and its scroll behavior are owned by the parent
|
||||
* (`MessageTimeline` + `useVirtualTimelineScroll`); this component just paints
|
||||
* the spacer and absolutely-positioned rows the virtualizer reports.
|
||||
*/
|
||||
export const VirtualizedTimelineList = React.memo(
|
||||
function VirtualizedTimelineList({
|
||||
virtualizer,
|
||||
rows,
|
||||
entries,
|
||||
renderEntry,
|
||||
}: VirtualizedTimelineListProps) {
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const row = rows[virtualItem.index];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const entry =
|
||||
row.kind === "message" ? entries[row.messageIndex] : undefined;
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{row.kind === "day-divider" ? (
|
||||
<DayDivider label={formatDayHeading(row.headingTimestamp)} />
|
||||
) : entry ? (
|
||||
renderEntry(entry)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
|
||||
import type { buildVideoReviewContextForMessage } from "@/features/messages/lib/videoReviewContext";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
|
||||
import { SystemMessageRow } from "./SystemMessageRow";
|
||||
|
||||
type VideoReviewContext = NonNullable<
|
||||
ReturnType<typeof buildVideoReviewContextForMessage>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Everything a single timeline entry needs to render itself, independent of how
|
||||
* the surrounding list is laid out. Both the nested day-grouped list
|
||||
* (`TimelineMessageList`) and the flat virtualized list
|
||||
* (`VirtualizedTimelineList`) build this once and reuse `renderTimelineEntry`,
|
||||
* so the three row variants (system / thread-summary / plain) and their
|
||||
* highlight + search styling stay a single source of truth.
|
||||
*/
|
||||
export type TimelineEntryRenderContext = {
|
||||
agentPubkeys?: ReadonlySet<string>;
|
||||
channelId?: string | null;
|
||||
currentPubkey?: string;
|
||||
followThreadById?: (rootId: string) => void;
|
||||
highlightedMessageId?: string | null;
|
||||
isFollowingThreadById?: (rootId: string) => boolean;
|
||||
messageFooters?: Record<string, React.ReactNode>;
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
personaLookup?: Map<string, string>;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
profiles?: UserProfileLookup;
|
||||
searchActiveMessageId?: string | null;
|
||||
searchMatchingMessageIds?: Set<string>;
|
||||
searchQuery?: string;
|
||||
unfollowThreadById?: (rootId: string) => void;
|
||||
videoReviewContextById: Map<string, VideoReviewContext>;
|
||||
/** Present only when the list passes `channelType` through to video review. */
|
||||
channelType?: ChannelType | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the row content for one main-timeline entry. Returns the inner element
|
||||
* (no day-group wrapper) so callers control list layout — nested `<section>`s in
|
||||
* the classic list, absolutely-positioned virtual rows in the virtualized list.
|
||||
*/
|
||||
export function renderTimelineEntry(
|
||||
entry: MainTimelineEntry,
|
||||
ctx: TimelineEntryRenderContext,
|
||||
): React.ReactNode {
|
||||
const { message, summary } = entry;
|
||||
const footer = ctx.messageFooters?.[message.id] ?? null;
|
||||
|
||||
if (message.kind === KIND_SYSTEM_MESSAGE) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<SystemMessageRow
|
||||
message={message}
|
||||
agentPubkeys={ctx.agentPubkeys}
|
||||
currentPubkey={ctx.currentPubkey}
|
||||
onToggleReaction={ctx.onToggleReaction}
|
||||
personaLookup={ctx.personaLookup}
|
||||
profiles={ctx.profiles}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary && ctx.onReply) {
|
||||
const isHighlighted = message.id === ctx.highlightedMessageId;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/message relative -mx-1 flex flex-col gap-0 rounded-2xl px-1 py-1 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
isHighlighted &&
|
||||
"-mx-4 px-4 before:absolute before:-inset-y-1.5 before:inset-x-0 before:animate-[route-target-highlight-fade_2s_ease-out_forwards] before:bg-primary/10 before:content-[''] motion-reduce:before:animate-none sm:-mx-6 sm:px-6",
|
||||
)}
|
||||
>
|
||||
<MessageRow
|
||||
agentPubkeys={ctx.agentPubkeys}
|
||||
channelId={ctx.channelId}
|
||||
highlighted={false}
|
||||
hoverBackground={false}
|
||||
isFollowingThread={
|
||||
ctx.isFollowingThreadById
|
||||
? ctx.isFollowingThreadById(message.id)
|
||||
: undefined
|
||||
}
|
||||
message={message}
|
||||
onDelete={
|
||||
ctx.onDelete &&
|
||||
ctx.currentPubkey &&
|
||||
message.pubkey === ctx.currentPubkey
|
||||
? ctx.onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
ctx.onEdit &&
|
||||
ctx.currentPubkey &&
|
||||
message.pubkey === ctx.currentPubkey
|
||||
? ctx.onEdit
|
||||
: undefined
|
||||
}
|
||||
onFollowThread={
|
||||
ctx.followThreadById
|
||||
? () => ctx.followThreadById?.(message.id)
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={ctx.onMarkUnread}
|
||||
onToggleReaction={ctx.onToggleReaction}
|
||||
onReply={ctx.onReply}
|
||||
onUnfollowThread={
|
||||
ctx.unfollowThreadById
|
||||
? () => ctx.unfollowThreadById?.(message.id)
|
||||
: undefined
|
||||
}
|
||||
profiles={ctx.profiles}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={ctx.videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
<MessageThreadSummaryRow
|
||||
depth={message.depth}
|
||||
message={message}
|
||||
onOpenThread={ctx.onReply}
|
||||
showDepthGuides={false}
|
||||
summary={summary}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSearchMatch = ctx.searchMatchingMessageIds?.has(message.id) ?? false;
|
||||
const isSearchActive = message.id === ctx.searchActiveMessageId;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<MessageRow
|
||||
agentPubkeys={ctx.agentPubkeys}
|
||||
channelId={ctx.channelId}
|
||||
highlighted={message.id === ctx.highlightedMessageId || isSearchActive}
|
||||
message={message}
|
||||
onDelete={
|
||||
ctx.onDelete &&
|
||||
ctx.currentPubkey &&
|
||||
message.pubkey === ctx.currentPubkey
|
||||
? ctx.onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
ctx.onEdit &&
|
||||
ctx.currentPubkey &&
|
||||
message.pubkey === ctx.currentPubkey
|
||||
? ctx.onEdit
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={ctx.onMarkUnread}
|
||||
onToggleReaction={ctx.onToggleReaction}
|
||||
onReply={ctx.onReply}
|
||||
profiles={ctx.profiles}
|
||||
searchQuery={isSearchMatch ? ctx.searchQuery : undefined}
|
||||
showDepthGuides={false}
|
||||
videoReviewContext={ctx.videoReviewContextById.get(message.id)}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,29 +4,28 @@ type UseLoadOlderOnScrollOptions = {
|
||||
fetchOlder?: () => Promise<void>;
|
||||
hasOlderMessages: boolean;
|
||||
isLoading: boolean;
|
||||
restoreScrollPosition: (scrollTop: number) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers `fetchOlder` when a sentinel element near the top of the scroll
|
||||
* container enters the viewport, then restores the scroll position so the
|
||||
* visible content doesn't jump.
|
||||
* container enters the viewport, then re-arms once the fetch settles.
|
||||
*
|
||||
* No scroll-position restoration: the virtualizer keys every row by stable
|
||||
* identity, so when an older page splices in at the top the surviving rows keep
|
||||
* their measurement and the viewport re-anchors natively. The old
|
||||
* `previousHeight`/`previousScrollTop` snapshot + double-`requestAnimationFrame`
|
||||
* `scrollTop` correction is GONE — stable-key retention replaces it (see
|
||||
* `buildVirtualTimelineRows`).
|
||||
*/
|
||||
export function useLoadOlderOnScroll({
|
||||
fetchOlder,
|
||||
hasOlderMessages,
|
||||
isLoading,
|
||||
restoreScrollPosition,
|
||||
scrollContainerRef,
|
||||
sentinelRef,
|
||||
}: UseLoadOlderOnScrollOptions) {
|
||||
const restoreScrollPositionRef = React.useRef(restoreScrollPosition);
|
||||
React.useEffect(() => {
|
||||
restoreScrollPositionRef.current = restoreScrollPosition;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -56,19 +55,10 @@ export function useLoadOlderOnScroll({
|
||||
|
||||
currentObserver?.disconnect();
|
||||
|
||||
const previousHeight = container.scrollHeight;
|
||||
const previousScrollTop = container.scrollTop;
|
||||
void fetchOlder().then(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newHeight = container.scrollHeight;
|
||||
const delta = newHeight - previousHeight;
|
||||
if (delta > 0) {
|
||||
restoreScrollPositionRef.current(previousScrollTop + delta);
|
||||
}
|
||||
observe();
|
||||
});
|
||||
});
|
||||
// Re-arm for the next page. The virtualizer holds scroll position
|
||||
// across the prepend on its own, so there is nothing to restore.
|
||||
observe();
|
||||
});
|
||||
},
|
||||
{ root: container, rootMargin: "200px 0px 0px 0px" },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
buildVideoReviewCommentsByRootId,
|
||||
buildVideoReviewContextForMessage,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
|
||||
type VideoReviewContext = NonNullable<
|
||||
ReturnType<typeof buildVideoReviewContextForMessage>
|
||||
>;
|
||||
|
||||
type UseVideoReviewContextByIdOptions = {
|
||||
channelId?: string | null;
|
||||
channelName?: string;
|
||||
channelType?: ChannelType | null;
|
||||
isSendingVideoReviewComment?: boolean;
|
||||
messages: TimelineMessage[];
|
||||
onSendVideoReviewComment?: (
|
||||
message: TimelineMessage,
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
parentEventId?: string,
|
||||
) => Promise<void>;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
remove: boolean,
|
||||
) => Promise<void>;
|
||||
profiles?: UserProfileLookup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-message video-review context map, memoized by message id so
|
||||
* MessageRow/Markdown memo comparisons hold across unrelated timeline
|
||||
* re-renders (typing indicators, presence). A fresh context object per render
|
||||
* would defeat the memo and re-render every video message on every pass.
|
||||
*
|
||||
* Extracted so the classic nested list and the virtualized list build it
|
||||
* identically — one source of truth for the video-review wiring.
|
||||
*/
|
||||
export function useVideoReviewContextById({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment = false,
|
||||
messages,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
}: UseVideoReviewContextByIdOptions): Map<string, VideoReviewContext> {
|
||||
const reviewCommentsByRootId = React.useMemo(
|
||||
() => buildVideoReviewCommentsByRootId(messages),
|
||||
[messages],
|
||||
);
|
||||
|
||||
return React.useMemo(() => {
|
||||
const contexts = new Map<string, VideoReviewContext>();
|
||||
for (const message of messages) {
|
||||
const comments = reviewCommentsByRootId.get(message.id) ?? [];
|
||||
const context = buildVideoReviewContextForMessage({
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
comments,
|
||||
isSendingVideoReviewComment,
|
||||
message,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
});
|
||||
if (context) {
|
||||
contexts.set(message.id, context);
|
||||
}
|
||||
}
|
||||
return contexts;
|
||||
}, [
|
||||
channelId,
|
||||
channelName,
|
||||
channelType,
|
||||
isSendingVideoReviewComment,
|
||||
messages,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
profiles,
|
||||
reviewCommentsByRootId,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, mock, test } from "node:test";
|
||||
|
||||
import type * as React from "react";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { buildVirtualTimelineRows } from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { useVirtualTimelineScroll } from "./useVirtualTimelineScroll";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const DAY_1 = Math.floor(new Date(2026, 0, 1, 12, 0, 0).getTime() / 1000);
|
||||
|
||||
function message(
|
||||
overrides: Partial<TimelineMessage> & { id: string },
|
||||
): TimelineMessage {
|
||||
return {
|
||||
createdAt: DAY_1,
|
||||
author: "tester",
|
||||
time: "",
|
||||
body: `body-${overrides.id}`,
|
||||
depth: 0,
|
||||
kind: 9,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Records scrollToIndex calls so we can assert the virtualizer is driven
|
||||
// correctly. The hook never reads layout off the virtualizer directly.
|
||||
function makeVirtualizer() {
|
||||
const scrollToIndex =
|
||||
mock.fn<
|
||||
(index: number, opts?: { align?: string; behavior?: string }) => void
|
||||
>();
|
||||
return {
|
||||
scrollToIndex,
|
||||
virtualizer: { scrollToIndex } as unknown as Virtualizer<
|
||||
HTMLDivElement,
|
||||
Element
|
||||
>,
|
||||
};
|
||||
}
|
||||
|
||||
// A fake scroll container whose scroll metrics we control. `isNearBottom`
|
||||
// reads scrollHeight/clientHeight/scrollTop; defaults (0) read as "at bottom".
|
||||
function makeContainerRef(atBottom: boolean) {
|
||||
const el = {
|
||||
scrollHeight: atBottom ? 100 : 1000,
|
||||
clientHeight: 100,
|
||||
scrollTop: atBottom ? 0 : 0,
|
||||
} as unknown as HTMLDivElement;
|
||||
return { current: el } as React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
test("on init with no deep-link target, scrolls to the last row (sticky bottom)", () => {
|
||||
const messages = [message({ id: "a" }), message({ id: "b" })];
|
||||
const rows = buildVirtualTimelineRows(messages);
|
||||
const { scrollToIndex, virtualizer } = makeVirtualizer();
|
||||
|
||||
renderHook(() =>
|
||||
useVirtualTimelineScroll({
|
||||
channelId: "c1",
|
||||
isLoading: false,
|
||||
messages,
|
||||
rows,
|
||||
scrollContainerRef: makeContainerRef(true),
|
||||
virtualizer,
|
||||
}),
|
||||
);
|
||||
|
||||
// layout: [div, a, b] -> last index 2
|
||||
assert.ok(scrollToIndex.mock.calls.length >= 1);
|
||||
const [index, opts] = scrollToIndex.mock.calls[0].arguments;
|
||||
assert.equal(index, rows.length - 1);
|
||||
assert.equal(opts?.align, "end");
|
||||
});
|
||||
|
||||
test("a new latest message while pinned autoscrolls; accent uses smooth", () => {
|
||||
const initial = [message({ id: "a" })];
|
||||
const rows1 = buildVirtualTimelineRows(initial);
|
||||
const { scrollToIndex, virtualizer } = makeVirtualizer();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ messages, rows }) =>
|
||||
useVirtualTimelineScroll({
|
||||
channelId: "c1",
|
||||
isLoading: false,
|
||||
messages,
|
||||
rows,
|
||||
scrollContainerRef: makeContainerRef(true),
|
||||
virtualizer,
|
||||
}),
|
||||
{ initialProps: { messages: initial, rows: rows1 } },
|
||||
);
|
||||
|
||||
scrollToIndex.mock.resetCalls();
|
||||
|
||||
const next = [message({ id: "a" }), message({ id: "b", accent: true })];
|
||||
const rows2 = buildVirtualTimelineRows(next);
|
||||
act(() => {
|
||||
rerender({ messages: next, rows: rows2 });
|
||||
});
|
||||
|
||||
assert.ok(scrollToIndex.mock.calls.length >= 1);
|
||||
const lastCall = scrollToIndex.mock.calls.at(-1);
|
||||
assert.equal(lastCall?.arguments[0], rows2.length - 1);
|
||||
assert.equal(lastCall?.arguments[1]?.behavior, "smooth");
|
||||
});
|
||||
|
||||
test("a deep-link target scrolls to that message's flat row and centers it", () => {
|
||||
const messages = [
|
||||
message({ id: "a" }),
|
||||
message({ id: "b" }),
|
||||
message({ id: "c" }),
|
||||
];
|
||||
const rows = buildVirtualTimelineRows(messages);
|
||||
const { scrollToIndex, virtualizer } = makeVirtualizer();
|
||||
const onTargetReached = mock.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useVirtualTimelineScroll({
|
||||
channelId: "c1",
|
||||
isLoading: false,
|
||||
messages,
|
||||
rows,
|
||||
scrollContainerRef: makeContainerRef(false),
|
||||
virtualizer,
|
||||
targetMessageId: "b",
|
||||
onTargetReached,
|
||||
}),
|
||||
);
|
||||
|
||||
// layout: [div, a, b, c] -> 'b' is flat index 2
|
||||
const centerCall = scrollToIndex.mock.calls.find(
|
||||
(call) => call.arguments[1]?.align === "center",
|
||||
);
|
||||
assert.ok(centerCall, "expected a centered scroll to the deep-link target");
|
||||
assert.equal(centerCall?.arguments[0], 2);
|
||||
assert.equal(onTargetReached.mock.calls.length, 1);
|
||||
assert.equal(onTargetReached.mock.calls[0].arguments[0], "b");
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as React from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import {
|
||||
findVirtualRowIndexForMessage,
|
||||
type VirtualTimelineRow,
|
||||
} from "@/features/messages/lib/buildVirtualTimelineRows";
|
||||
import {
|
||||
isNearBottom,
|
||||
resolveDeepLinkTarget,
|
||||
selectLatestMessageKey,
|
||||
} from "@/features/messages/lib/timelineSnapshot";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
type UseVirtualTimelineScrollOptions = {
|
||||
channelId?: string | null;
|
||||
isLoading: boolean;
|
||||
/** The deferred message snapshot the virtual rows are built from. */
|
||||
messages: TimelineMessage[];
|
||||
/** Flat virtual rows (dividers + messages) the virtualizer renders. */
|
||||
rows: VirtualTimelineRow[];
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
virtualizer: Virtualizer<HTMLDivElement, Element>;
|
||||
targetMessageId?: string | null;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
/** The currently active find-in-channel match, drives scroll-to-row. */
|
||||
searchActiveMessageId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll behavior for the virtualized main timeline. This REPLACES the bespoke
|
||||
* `useTimelineScrollManager`: the virtualizer owns the scroll container and all
|
||||
* measurement/anchoring, so the 400-line scrollTop-locking machinery
|
||||
* (`lockedScrollTopRef`, double-rAF restore, ResizeObserver re-pinning) is gone.
|
||||
*
|
||||
* What this hook keeps as a THIN wrapper layered on the virtualizer:
|
||||
* - sticky-bottom autoscroll (`scrollToIndex(last, end)` when pinned)
|
||||
* - `accent` smooth-scroll for highlighted messages
|
||||
* - the `newMessageCount` "N new messages" pill when scrolled up
|
||||
* - `isAtBottom` for the jump-to-latest affordance
|
||||
* - deep-link + find-in-page jumps via `findVirtualRowIndexForMessage`
|
||||
*
|
||||
* Native key-stable retention (see `buildVirtualTimelineRows`) handles
|
||||
* scroll-up prepend, so there is no position-restore plumbing here.
|
||||
*/
|
||||
export function useVirtualTimelineScroll({
|
||||
channelId,
|
||||
isLoading,
|
||||
messages,
|
||||
rows,
|
||||
scrollContainerRef,
|
||||
virtualizer,
|
||||
targetMessageId,
|
||||
onTargetReached,
|
||||
searchActiveMessageId,
|
||||
}: UseVirtualTimelineScrollOptions) {
|
||||
const stickToBottomRef = React.useRef(true);
|
||||
const hasInitializedRef = React.useRef(false);
|
||||
const previousLastMessageKeyRef = React.useRef<string | undefined>(undefined);
|
||||
const previousMessageCountRef = React.useRef(0);
|
||||
const handledTargetMessageIdRef = React.useRef<string | null>(null);
|
||||
const handledSearchActiveIdRef = React.useRef<string | null>(null);
|
||||
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = React.useState(0);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const lastRowIndex = rows.length - 1;
|
||||
|
||||
const scrollToBottom = React.useCallback(
|
||||
(behavior: ScrollBehavior) => {
|
||||
if (lastRowIndex < 0) {
|
||||
return;
|
||||
}
|
||||
stickToBottomRef.current = true;
|
||||
setNewMessageCount(0);
|
||||
setIsAtBottom(true);
|
||||
virtualizer.scrollToIndex(lastRowIndex, { align: "end", behavior });
|
||||
},
|
||||
[lastRowIndex, virtualizer],
|
||||
);
|
||||
|
||||
// Reset all scroll state when the channel changes.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger
|
||||
React.useLayoutEffect(() => {
|
||||
hasInitializedRef.current = false;
|
||||
stickToBottomRef.current = true;
|
||||
previousLastMessageKeyRef.current = undefined;
|
||||
previousMessageCountRef.current = 0;
|
||||
handledTargetMessageIdRef.current = null;
|
||||
handledSearchActiveIdRef.current = null;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
setHighlightedMessageId(null);
|
||||
}, [channelId]);
|
||||
|
||||
// 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(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const atBottom = isNearBottom(container);
|
||||
stickToBottomRef.current = atBottom;
|
||||
setIsAtBottom((current) => (current === atBottom ? current : atBottom));
|
||||
if (atBottom) {
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
const latestMessage =
|
||||
messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
const latestMessageKey = selectLatestMessageKey(messages);
|
||||
|
||||
// Initial pin + new-message autoscroll. On first commit, jump to bottom (or
|
||||
// stay put for a deep-link). Afterwards, when a NEW latest message arrives:
|
||||
// autoscroll if pinned or accented, otherwise bump the "N new messages" pill.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!hasInitializedRef.current) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!targetMessageId) {
|
||||
scrollToBottom("auto");
|
||||
}
|
||||
hasInitializedRef.current = true;
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
const previousLastMessageKey = previousLastMessageKeyRef.current;
|
||||
const previousMessageCount = previousMessageCountRef.current;
|
||||
const hasNewLatestMessage =
|
||||
latestMessage !== undefined &&
|
||||
latestMessageKey !== previousLastMessageKey;
|
||||
|
||||
if (!hasNewLatestMessage) {
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!targetMessageId &&
|
||||
(stickToBottomRef.current || latestMessage.accent)
|
||||
) {
|
||||
scrollToBottom(latestMessage.accent ? "smooth" : "auto");
|
||||
} else {
|
||||
setNewMessageCount((current) => {
|
||||
const addedMessages = Math.max(
|
||||
1,
|
||||
messages.length - previousMessageCount,
|
||||
);
|
||||
return current + addedMessages;
|
||||
});
|
||||
}
|
||||
|
||||
previousLastMessageKeyRef.current = latestMessageKey;
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, [
|
||||
isLoading,
|
||||
latestMessage,
|
||||
latestMessageKey,
|
||||
messages.length,
|
||||
scrollToBottom,
|
||||
targetMessageId,
|
||||
]);
|
||||
|
||||
// Deep-link jump-to-message. Drives the virtualizer to mount and center the
|
||||
// target row, replacing the bespoke querySelector + scrollIntoView path that
|
||||
// breaks under virtualization (the row may be unmounted).
|
||||
React.useEffect(() => {
|
||||
if (!targetMessageId) {
|
||||
handledTargetMessageIdRef.current = null;
|
||||
setHighlightedMessageId(null);
|
||||
return;
|
||||
}
|
||||
if (handledTargetMessageIdRef.current === targetMessageId || isLoading) {
|
||||
return;
|
||||
}
|
||||
// Only attempt once the target exists in THIS snapshot — same "bail and wait
|
||||
// for the next snapshot" contract the classic manager honored, which closes
|
||||
// the tearing race.
|
||||
if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) {
|
||||
return;
|
||||
}
|
||||
const rowIndex = findVirtualRowIndexForMessage(
|
||||
rows,
|
||||
targetMessageId,
|
||||
messages,
|
||||
);
|
||||
if (rowIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledTargetMessageIdRef.current = targetMessageId;
|
||||
stickToBottomRef.current = false;
|
||||
setIsAtBottom(false);
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
setNewMessageCount(0);
|
||||
virtualizer.scrollToIndex(rowIndex, { align: "center" });
|
||||
onTargetReached?.(targetMessageId);
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setHighlightedMessageId((current) =>
|
||||
current === targetMessageId ? null : current,
|
||||
);
|
||||
}, 2_000);
|
||||
return () => {
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [
|
||||
isLoading,
|
||||
messages,
|
||||
onTargetReached,
|
||||
rows,
|
||||
targetMessageId,
|
||||
virtualizer,
|
||||
]);
|
||||
|
||||
// Find-in-page: scroll the active search match into existence. Same
|
||||
// scrollToIndex bridge as deep-link; the row may be unmounted under
|
||||
// virtualization, so native browser find can't reach it.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!searchActiveMessageId ||
|
||||
searchActiveMessageId === handledSearchActiveIdRef.current
|
||||
) {
|
||||
handledSearchActiveIdRef.current = searchActiveMessageId ?? null;
|
||||
return;
|
||||
}
|
||||
handledSearchActiveIdRef.current = searchActiveMessageId;
|
||||
|
||||
const rowIndex = findVirtualRowIndexForMessage(
|
||||
rows,
|
||||
searchActiveMessageId,
|
||||
messages,
|
||||
);
|
||||
if (rowIndex === -1) {
|
||||
return;
|
||||
}
|
||||
stickToBottomRef.current = false;
|
||||
virtualizer.scrollToIndex(rowIndex, {
|
||||
align: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [messages, rows, searchActiveMessageId, virtualizer]);
|
||||
|
||||
return {
|
||||
highlightedMessageId,
|
||||
isAtBottom,
|
||||
newMessageCount,
|
||||
scrollToBottom,
|
||||
syncScrollState,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,19 @@ export function resolve(specifier, context, nextResolve) {
|
||||
}
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (url.endsWith(".json")) {
|
||||
// Node's ESM loader requires an explicit `with { type: "json" }` import
|
||||
// attribute for JSON, but esbuild emits bare `import x from "./f.json"`.
|
||||
// Rather than rewrite every importer, synthesize an ESM module that
|
||||
// default-exports the parsed JSON so the bare import resolves.
|
||||
const filename = fileURLToPath(url);
|
||||
const json = await readFile(filename, "utf8");
|
||||
return {
|
||||
format: "module",
|
||||
source: `export default ${json};`,
|
||||
shortCircuit: true,
|
||||
};
|
||||
}
|
||||
if (url.endsWith(".ts") || url.endsWith(".tsx")) {
|
||||
const filename = fileURLToPath(url);
|
||||
const source = await readFile(filename, "utf8");
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.dom.test.tsx"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user