From be9cf8ee178b21b819fa92eb6352a8c7589fd7ef Mon Sep 17 00:00:00 2001 From: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Date: Thu, 9 Jul 2026 09:00:53 -0400 Subject: [PATCH] Spike Virtuoso timeline one-writer integration Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> --- desktop/package.json | 1 + .../features/messages/ui/MessageTimeline.tsx | 27 ++- .../messages/ui/TimelineMessageList.tsx | 213 +++++++++++++++++- .../features/messages/ui/useAnchoredScroll.ts | 122 ++++++++-- pnpm-lock.yaml | 17 ++ 5 files changed, 350 insertions(+), 30 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index ed17b84e3..0a9e9b9db 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -70,6 +70,7 @@ "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.10", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "shiki": "^4.0.2", diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index e007e3f91..fc2d19c76 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -21,6 +21,7 @@ import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; +import type { TimelineVirtualizerApi } from "./TimelineMessageList"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; @@ -191,6 +192,10 @@ const MessageTimelineBase = React.forwardRef< const scrollContainerRef = externalScrollRef ?? internalScrollRef; const contentRef = React.useRef(null); const topSentinelRef = React.useRef(null); + const [virtualizerScrollParent, setVirtualizerScrollParent] = + React.useState(null); + const [timelineVirtualizerApi, setTimelineVirtualizerApi] = + React.useState(null); // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the @@ -230,6 +235,13 @@ const MessageTimelineBase = React.forwardRef< // painted at a stale offset until the user's next scroll event forces layout. const scrollContainerDomKey = channelId ?? "none"; + React.useLayoutEffect(() => { + // Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node. + void scrollContainerDomKey; + setVirtualizerScrollParent(scrollContainerRef.current); + setTimelineVirtualizerApi(null); + }, [scrollContainerRef, scrollContainerDomKey]); + const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, isLoading: isLoading || isDeferredSnapshotStale, @@ -245,6 +257,7 @@ const MessageTimelineBase = React.forwardRef< scrollToBottom, scrollToBottomOnNextUpdate, scrollToMessage, + onVirtualizerAtBottomStateChange, } = useAnchoredScroll({ channelId, contentRef, @@ -253,6 +266,9 @@ const MessageTimelineBase = React.forwardRef< onTargetReached, scrollContainerRef, targetMessageId, + virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, + virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, + virtualizerOwnsPrependAnchoring: virtualizerScrollParent !== null, }); const timelineIntroSurface = selectTimelineIntroSurface({ @@ -358,8 +374,13 @@ const MessageTimelineBase = React.forwardRef< } }, [deferredMessages, jumpToMessage, showTimelineSkeleton]); + const loadOlderViaVirtualizer = React.useCallback(() => { + if (!fetchOlder || showTimelineSkeleton || !hasOlderMessages) return; + void fetchOlder(); + }, [fetchOlder, hasOlderMessages, showTimelineSkeleton]); + useLoadOlderOnScroll({ - fetchOlder, + fetchOlder: virtualizerScrollParent ? undefined : fetchOlder, hasOlderMessages, isLoading: showTimelineSkeleton, scrollContainerRef, @@ -606,12 +627,16 @@ const MessageTimelineBase = React.forwardRef< onReply={onReply} isSendingVideoReviewComment={isSendingVideoReviewComment} onSendVideoReviewComment={onSendVideoReviewComment} + onStartReached={loadOlderViaVirtualizer} onToggleReaction={onToggleReaction} + onVirtualizerApiChange={setTimelineVirtualizerApi} + onAtBottomStateChange={onVirtualizerAtBottomStateChange} personaLookup={personaLookup} profiles={profiles} searchActiveMessageId={searchActiveMessageId} searchMatchingMessageIds={searchMatchingMessageIds} searchQuery={searchQuery} + scrollParent={virtualizerScrollParent} threadUnreadCounts={threadUnreadCounts} unfollowThreadById={unfollowThreadById} /> diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fdc967910..5623019de 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +import { GroupedVirtuoso } from "react-virtuoso"; +import type { Components, GroupedVirtuosoHandle } from "react-virtuoso"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; @@ -6,6 +8,7 @@ import { buildTimelineDayGroups, buildTimelineItems, getTimelineItemKey, + type TimelineDayGroup, type TimelineNonDayItem, } from "@/features/messages/lib/timelineItems"; import { THREAD_REPLY_ROW_MARGIN_INLINE_REM } from "@/features/messages/lib/threadTreeLayout"; @@ -28,6 +31,14 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; +export type TimelineVirtualizerApi = { + scrollToBottom: (behavior?: ScrollBehavior) => void; + scrollToMessage: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; +}; + type TimelineMessageListProps = { agentPubkeys?: ReadonlySet; channelId?: string | null; @@ -81,6 +92,11 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + /** Existing scroll container, reused by the virtualizer spike so MessageTimeline owns the scroll node. */ + scrollParent?: HTMLElement | null; + onStartReached?: () => void; + onAtBottomStateChange?: (atBottom: boolean) => void; + onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -114,6 +130,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, + scrollParent = null, + onStartReached, + onAtBottomStateChange, + onVirtualizerApiChange, }: TimelineMessageListProps) { const entries = React.useMemo( () => @@ -255,6 +275,19 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ], ); + if (scrollParent) { + return ( + + ); + } + return (
{dayGroups.map((group) => ( @@ -276,13 +309,9 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ )} {group.items.map((item) => ( -
+ {renderItem(item)} -
+ ))} ))} @@ -290,6 +319,178 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ); }); +const FIRST_ITEM_INDEX_BASE = 1_000_000; + +type VirtualizedTimelineRowsProps = { + dayGroups: TimelineDayGroup[]; + onAtBottomStateChange?: (atBottom: boolean) => void; + onStartReached?: () => void; + onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + renderItem: (item: TimelineNonDayItem) => React.ReactNode; + scrollParent: HTMLElement; +}; + +function VirtualizedTimelineRows({ + dayGroups, + onAtBottomStateChange, + onStartReached, + onVirtualizerApiChange, + renderItem, + scrollParent, +}: VirtualizedTimelineRowsProps) { + const virtuosoRef = React.useRef(null); + const firstItemIndexStateRef = React.useRef({ + firstItemIndex: FIRST_ITEM_INDEX_BASE, + keys: [], + }); + const timelineModel = React.useMemo(() => { + const items = dayGroups.flatMap((group) => group.items); + return { + firstItemIndex: getStableFirstItemIndex( + firstItemIndexStateRef.current, + items, + ), + flattenedItems: items, + groupCounts: dayGroups.map((group) => group.items.length), + }; + }, [dayGroups]); + const { firstItemIndex, flattenedItems, groupCounts } = timelineModel; + const messageVirtualIndexById = React.useMemo(() => { + const byId = new Map(); + flattenedItems.forEach((item, index) => { + if (item.kind === "message" || item.kind === "system") { + byId.set(item.entry.message.id, index); + } + }); + return byId; + }, [flattenedItems]); + + React.useLayoutEffect(() => { + if (!onVirtualizerApiChange) return; + const api: TimelineVirtualizerApi = { + scrollToBottom(behavior = "auto") { + virtuosoRef.current?.scrollToIndex({ + align: "end", + behavior: behavior === "smooth" ? "smooth" : "auto", + index: "LAST", + }); + }, + scrollToMessage(messageId, options = {}) { + const index = messageVirtualIndexById.get(messageId); + if (index === undefined) return false; + virtuosoRef.current?.scrollToIndex({ + align: "center", + behavior: options.behavior === "smooth" ? "smooth" : "auto", + index, + }); + return true; + }, + }; + onVirtualizerApiChange(api); + return () => onVirtualizerApiChange(null); + }, [messageVirtualIndexById, onVirtualizerApiChange]); + + return ( + + ref={virtuosoRef} + className="min-h-full" + components={virtuosoComponents} + computeItemKey={(index, item) => + item === undefined + ? `timeline-missing-${index}` + : getTimelineItemKey(item) + } + customScrollParent={scrollParent} + data={flattenedItems} + defaultItemHeight={72} + firstItemIndex={firstItemIndex} + followOutput="auto" + atBottomStateChange={onAtBottomStateChange} + initialTopMostItemIndex={{ align: "end", index: "LAST" }} + groupContent={(groupIndex) => { + const group = dayGroups[groupIndex]; + return ( +
+ {group.headingTimestamp === null ? null : ( + + )} +
+ ); + }} + groupCounts={groupCounts} + itemContent={(_index, _groupIndex, item) => ( + {renderItem(item)} + )} + startReached={onStartReached ? () => onStartReached() : undefined} + /> + ); +} + +const virtuosoComponents: Components = { + List: React.forwardRef>( + function TimelineVirtuosoList({ className, ...props }, ref) { + return ( +
+ ); + }, + ), +}; + +function TimelineRowShell({ + children, + item, +}: { + children: React.ReactNode; + item: TimelineNonDayItem; +}) { + return ( +
+ {children} +
+ ); +} + +type StableFirstItemIndexState = { + firstItemIndex: number; + keys: string[]; +}; + +function getStableFirstItemIndex( + state: StableFirstItemIndexState, + items: TimelineNonDayItem[], +): number { + const keys = items.map(getTimelineItemKey); + let firstItemIndex = state.firstItemIndex; + + if (state.keys.length === 0 || keys.length === 0) { + firstItemIndex = FIRST_ITEM_INDEX_BASE; + } else { + const previousFirstKey = state.keys[0]; + const previousFirstIndex = keys.indexOf(previousFirstKey); + if (previousFirstIndex > 0) { + firstItemIndex -= previousFirstIndex; + } else if (previousFirstIndex === -1) { + firstItemIndex = FIRST_ITEM_INDEX_BASE; + } + } + + state.firstItemIndex = firstItemIndex; + state.keys = keys; + return firstItemIndex; +} + function SystemRow({ currentPubkey, entry, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index 2c0176df7..ad7c4931c 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -49,6 +49,14 @@ type UseAnchoredScrollOptions = { /** When set, scroll to and highlight this message on mount and on change. */ targetMessageId?: string | null; onTargetReached?: (messageId: string) => void; + virtualScrollToMessage?: ( + messageId: string, + options?: { behavior?: ScrollBehavior }, + ) => boolean; + /** Imperative virtualizer-owned bottom jump, used only when virtualizer mode is active. */ + virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; + /** Spike mode: let the virtualizer preserve prepend position via its own firstItemIndex path. */ + virtualizerOwnsPrependAnchoring?: boolean; }; type UseAnchoredScrollResult = { @@ -72,6 +80,8 @@ type UseAnchoredScrollResult = { messageId: string, options?: { highlight?: boolean; behavior?: ScrollBehavior }, ) => boolean; + /** Syncs the hook's bottom affordances from a virtualizer-owned scroller. */ + onVirtualizerAtBottomStateChange: (atBottom: boolean) => void; }; function isAtBottomNow( @@ -150,6 +160,9 @@ export function useAnchoredScroll({ targetMessageId = null, onTargetReached, + virtualScrollToMessage, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring = false, }: UseAnchoredScrollOptions): UseAnchoredScrollResult { // Anchor lives in a ref because it must survive renders and is updated // both on scroll (commit-time read) and in the layout effect (post-render @@ -222,11 +235,19 @@ export function useAnchoredScroll({ // every imperative bottom jump so `onScroll` holds the at-bottom anchor // until it can snap to the true floor. settlingRef.current = true; - container.scrollTo({ top: container.scrollHeight, behavior }); + if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { + virtualScrollToBottom(behavior); + } else { + container.scrollTo({ top: container.scrollHeight, behavior }); + } setIsAtBottom(true); setNewMessageCount(0); }, - [scrollContainerRef], + [ + scrollContainerRef, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring, + ], ); // Arm a one-shot: the next append snaps to bottom regardless of where the @@ -236,6 +257,19 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = true; }, []); + const highlightMessage = React.useCallback((messageId: string) => { + if (highlightTimeoutRef.current !== null) { + window.clearTimeout(highlightTimeoutRef.current); + } + setHighlightedMessageId(messageId); + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + highlightTimeoutRef.current = null; + }, 2_000); + }, []); + const scrollToMessageImperative = React.useCallback( ( messageId: string, @@ -246,7 +280,19 @@ export function useAnchoredScroll({ const el = container.querySelector( `[data-message-id="${messageId}"]`, ); - if (!el) return false; + if (!el) { + if ( + virtualScrollToMessage?.(messageId, { + behavior: options.behavior ?? "auto", + }) + ) { + anchorRef.current = { kind: "message", messageId, topOffset: 0 }; + setIsAtBottom(false); + if (options.highlight) highlightMessage(messageId); + return true; + } + return false; + } const rect = el.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); @@ -279,21 +325,10 @@ export function useAnchoredScroll({ }; setIsAtBottom(maxScrollTop - targetScrollTop <= AT_BOTTOM_THRESHOLD_PX); - if (options.highlight) { - if (highlightTimeoutRef.current !== null) { - window.clearTimeout(highlightTimeoutRef.current); - } - setHighlightedMessageId(messageId); - highlightTimeoutRef.current = window.setTimeout(() => { - setHighlightedMessageId((current) => - current === messageId ? null : current, - ); - highlightTimeoutRef.current = null; - }, 2_000); - } + if (options.highlight) highlightMessage(messageId); return true; }, - [scrollContainerRef], + [highlightMessage, scrollContainerRef, virtualScrollToMessage], ); // Scroll handler: recompute anchor + bottom state from the current @@ -311,6 +346,9 @@ export function useAnchoredScroll({ if (settleProgrammaticBottomPin(container)) { settlingRef.current = false; } else { + if (virtualizerOwnsPrependAnchoring) { + settlingRef.current = false; + } return; } } @@ -320,7 +358,7 @@ export function useAnchoredScroll({ if (atBottom) { setNewMessageCount(0); } - }, [scrollContainerRef]); + }, [scrollContainerRef, virtualizerOwnsPrependAnchoring]); // --------------------------------------------------------------------------- // Anchor restoration: after every render, stick to the bottom if the user is @@ -396,7 +434,11 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = false; anchorRef.current = { kind: "at-bottom" }; settlingRef.current = true; - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + if (virtualizerOwnsPrependAnchoring && virtualScrollToBottom) { + virtualScrollToBottom("auto"); + } else { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } setIsAtBottom(true); setNewMessageCount(0); prevLastMessageIdRef.current = lastMessage?.id; @@ -407,10 +449,13 @@ export function useAnchoredScroll({ } if (anchor.kind === "at-bottom") { - // Stick to bottom across the append. - container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + // Stick to bottom across the append. In virtualized mode, Virtuoso's + // followOutput owns this write; do not slam the shared scroll parent. + if (!virtualizerOwnsPrependAnchoring) { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } if (newLatestArrived) setNewMessageCount(0); - } else if (messagesArrived > 0) { + } else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) { // Anchored mid-history. An older-history prepend grows the content above // the reading row; the browser's native scroll anchoring does NOT correct // this at the top edge (no anchor node above the viewport when scrollTop @@ -449,6 +494,8 @@ export function useAnchoredScroll({ scrollToBottomImperative, scrollToMessageImperative, targetMessageId, + virtualScrollToBottom, + virtualizerOwnsPrependAnchoring, ]); // --------------------------------------------------------------------------- @@ -466,13 +513,21 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "at-bottom") { + if ( + anchorRef.current.kind === "at-bottom" && + !virtualizerOwnsPrependAnchoring + ) { container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); } }); observer.observe(content); return () => observer.disconnect(); - }, [channelId, contentRef, scrollContainerRef]); + }, [ + channelId, + contentRef, + scrollContainerRef, + virtualizerOwnsPrependAnchoring, + ]); // --------------------------------------------------------------------------- // Target message handling (deep link, jump-to-reply, etc.). Distinct from @@ -500,6 +555,13 @@ export function useAnchoredScroll({ const el = container.querySelector( `[data-message-id="${targetMessageId}"]`, ); + if (!el && virtualizerOwnsPrependAnchoring) { + if (scrollToMessageImperative(targetMessageId, { highlight: true })) { + handledTargetIdRef.current = targetMessageId; + onTargetReached?.(targetMessageId); + } + return; + } if (!el) { // Row not in the DOM yet. A cold deep-link target is fetched by id and // spliced into `messages` a render or two later; this effect re-runs on @@ -516,6 +578,7 @@ export function useAnchoredScroll({ scrollContainerRef, scrollToMessageImperative, targetMessageId, + virtualizerOwnsPrependAnchoring, ]); React.useEffect(() => { @@ -526,6 +589,18 @@ export function useAnchoredScroll({ }; }, []); + const onVirtualizerAtBottomStateChange = React.useCallback( + (atBottom: boolean) => { + if (!virtualizerOwnsPrependAnchoring) return; + if (atBottom) { + anchorRef.current = { kind: "at-bottom" }; + setNewMessageCount(0); + } + setIsAtBottom(atBottom); + }, + [virtualizerOwnsPrependAnchoring], + ); + return { onScroll, isAtBottom, @@ -534,5 +609,6 @@ export function useAnchoredScroll({ scrollToBottom: scrollToBottomImperative, scrollToBottomOnNextUpdate, scrollToMessage: scrollToMessageImperative, + onVirtualizerAtBottomStateChange, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b678ba734..0d0cf20a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-virtuoso: + specifier: ^4.18.10 + version: 4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) remark-breaks: specifier: ^4.0.0 version: 4.0.0 @@ -286,6 +289,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) + react-virtuoso: + specifier: ^4.18.10 + version: 4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -2809,6 +2815,12 @@ packages: '@types/react': optional: true + react-virtuoso@4.18.10: + resolution: {integrity: sha512-P6GIZ7kWAPOYB2H16yRQNgy+VF9pJOuTFw1EUc1EAtCj5WxVSAF1Sql3x3fbLwaLeBFsiPnu+3U9o6sIOyTdFw==, tarball: https://global.block-artifacts.com/artifactory/api/npm/square-npm/react-virtuoso/-/react-virtuoso-4.18.10.tgz} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -5680,6 +5692,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + react-virtuoso@4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} readable-stream@4.7.0: