diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 27f025bc8..35f54401f 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -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; @@ -70,6 +92,9 @@ type MessageTimelineProps = { searchMatchingMessageIds?: Set; /** 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(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( - `[data-message-id="${searchActiveMessageId}"]`, - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); - } - }, [searchActiveMessageId]); + const renderEntry = React.useCallback( + (entry: Parameters[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} >
@@ -398,7 +485,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ {showMessageList ? (
-
) : null} - -
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx deleted file mode 100644 index caf6a42eb..000000000 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ /dev/null @@ -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; - channelId?: string | null; - channelName?: string; - channelType?: ChannelType | null; - currentPubkey?: string; - followThreadById?: (rootId: string) => void; - highlightedMessageId?: string | null; - isFollowingThreadById?: (rootId: string) => boolean; - messageFooters?: Record; - 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; - unfollowThreadById?: (rootId: string) => void; - onToggleReaction?: ( - message: TimelineMessage, - emoji: string, - remove: boolean, - ) => Promise; - /** Map from lowercase pubkey → persona display name for bot members. */ - personaLookup?: Map; - 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; - /** 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> - >(); - 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( -
- - {footer} -
, - ); - } else if (summary && onReply) { - const footer = messageFooters?.[message.id] ?? null; - const isHighlighted = message.id === highlightedMessageId; - currentDayGroup?.elements.push( -
- 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)} - /> - - {footer} -
, - ); - } else { - const isSearchMatch = searchMatchingMessageIds?.has(message.id) ?? false; - const isSearchActive = message.id === searchActiveMessageId; - const footer = messageFooters?.[message.id] ?? null; - - currentDayGroup?.elements.push( -
- - {footer} -
, - ); - } - } - - return dayGroups.map((group) => ( -
- - {group.elements} -
- )); -}); diff --git a/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx b/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx new file mode 100644 index 000000000..30e4c7a72 --- /dev/null +++ b/desktop/src/features/messages/ui/VirtualizedTimelineList.dom.test.tsx @@ -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 { + 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; +} + +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 {entry.message.body}; +} + +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( + , + ); + 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( + , + ); + // 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( + , + ); + 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( + , + ); + assert.equal(container.querySelectorAll("[data-index]").length, 1); + assert.equal(screen.queryByTestId("entry"), null); +}); diff --git a/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx b/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx new file mode 100644 index 000000000..1e4f64936 --- /dev/null +++ b/desktop/src/features/messages/ui/VirtualizedTimelineList.tsx @@ -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; + 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 ( +
+ {virtualItems.map((virtualItem) => { + const row = rows[virtualItem.index]; + if (!row) { + return null; + } + const entry = + row.kind === "message" ? entries[row.messageIndex] : undefined; + return ( +
+ {row.kind === "day-divider" ? ( + + ) : entry ? ( + renderEntry(entry) + ) : null} +
+ ); + })} +
+ ); + }, +); diff --git a/desktop/src/features/messages/ui/timelineEntryRender.tsx b/desktop/src/features/messages/ui/timelineEntryRender.tsx new file mode 100644 index 000000000..79c9717fd --- /dev/null +++ b/desktop/src/features/messages/ui/timelineEntryRender.tsx @@ -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 +>; + +/** + * 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; + channelId?: string | null; + currentPubkey?: string; + followThreadById?: (rootId: string) => void; + highlightedMessageId?: string | null; + isFollowingThreadById?: (rootId: string) => boolean; + messageFooters?: Record; + onDelete?: (message: TimelineMessage) => void; + onEdit?: (message: TimelineMessage) => void; + onMarkUnread?: (message: TimelineMessage) => void; + onReply?: (message: TimelineMessage) => void; + personaLookup?: Map; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; + profiles?: UserProfileLookup; + searchActiveMessageId?: string | null; + searchMatchingMessageIds?: Set; + searchQuery?: string; + unfollowThreadById?: (rootId: string) => void; + videoReviewContextById: Map; + /** 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 `
`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 ( +
+ + {footer} +
+ ); + } + + if (summary && ctx.onReply) { + const isHighlighted = message.id === ctx.highlightedMessageId; + return ( +
+ 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)} + /> + + {footer} +
+ ); + } + + const isSearchMatch = ctx.searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === ctx.searchActiveMessageId; + return ( +
+ + {footer} +
+ ); +} diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts index 73efbd3fc..701938969 100644 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts @@ -4,29 +4,28 @@ type UseLoadOlderOnScrollOptions = { fetchOlder?: () => Promise; hasOlderMessages: boolean; isLoading: boolean; - restoreScrollPosition: (scrollTop: number) => void; scrollContainerRef: React.RefObject; sentinelRef: React.RefObject; }; /** * 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" }, diff --git a/desktop/src/features/messages/ui/useVideoReviewContextById.ts b/desktop/src/features/messages/ui/useVideoReviewContextById.ts new file mode 100644 index 000000000..aefab6fdd --- /dev/null +++ b/desktop/src/features/messages/ui/useVideoReviewContextById.ts @@ -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 +>; + +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; + onToggleReaction?: ( + message: TimelineMessage, + emoji: string, + remove: boolean, + ) => Promise; + 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 { + const reviewCommentsByRootId = React.useMemo( + () => buildVideoReviewCommentsByRootId(messages), + [messages], + ); + + return React.useMemo(() => { + const contexts = new Map(); + 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, + ]); +} diff --git a/desktop/src/features/messages/ui/useVirtualTimelineScroll.dom.test.tsx b/desktop/src/features/messages/ui/useVirtualTimelineScroll.dom.test.tsx new file mode 100644 index 000000000..63a7a6c07 --- /dev/null +++ b/desktop/src/features/messages/ui/useVirtualTimelineScroll.dom.test.tsx @@ -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 & { 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; +} + +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"); +}); diff --git a/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts b/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts new file mode 100644 index 000000000..b9341316a --- /dev/null +++ b/desktop/src/features/messages/ui/useVirtualTimelineScroll.ts @@ -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; + virtualizer: Virtualizer; + 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(undefined); + const previousMessageCountRef = React.useRef(0); + const handledTargetMessageIdRef = React.useRef(null); + const handledSearchActiveIdRef = React.useRef(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, + }; +} diff --git a/desktop/test-dom-loader-hooks.mjs b/desktop/test-dom-loader-hooks.mjs index 5c5ec0a24..75edede09 100644 --- a/desktop/test-dom-loader-hooks.mjs +++ b/desktop/test-dom-loader-hooks.mjs @@ -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"); diff --git a/desktop/tsconfig.json b/desktop/tsconfig.json index e8f442a3e..fe51e3e85 100644 --- a/desktop/tsconfig.json +++ b/desktop/tsconfig.json @@ -25,5 +25,6 @@ "noFallthroughCasesInSwitch": true }, "include": ["src"], + "exclude": ["src/**/*.dom.test.tsx"], "references": [{ "path": "./tsconfig.node.json" }] }