From 8a4ef01cff87e05adba538692e87603506db248f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 17 Jun 2026 19:42:16 -0400 Subject: [PATCH] feat(desktop): virtualize timeline + thread pane onto single scroll owner The two chat-message surfaces (MessageTimeline, MessageThreadPanel) ran on a hand-rolled scroll manager with two ResizeObservers and a double-rAF scrollTop band-aid that raced during prepend, causing the channel-jump bug. Replace it with @tanstack/react-virtual as the single scroll owner. virtual-core@3.17.0 ships the three hard behaviors natively, so they are configuration not hand-built code: anchorTo:"end" holds the viewport on load-older prepend, followOnAppend pins the bottom during a burst, and the internal reconcile loop settles deep-link scrollToIndex once the target row measures. The hook owns only what the library does not: the short-channel bottom-align pad, the at-bottom/new-message pill state, and the deep-link highlight. Channel/thread switch resets via key-remount (fresh virtualizer = zero stale measurement cache) rather than porting the old reset logic. useTimelineScrollManager + useLoadOlderOnScroll deleted, zero callers. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/channels/ui/ChannelPane.tsx | 2 + .../src/features/messages/lib/messageLink.ts | 4 +- .../messages/lib/timelineSnapshot.test.mjs | 69 +++ .../features/messages/lib/timelineSnapshot.ts | 50 +- .../src/features/messages/ui/DayDivider.tsx | 2 +- .../messages/ui/MessageThreadPanel.tsx | 339 +++++++----- .../features/messages/ui/MessageTimeline.tsx | 483 ++++++++++-------- .../messages/ui/TimelineMessageList.tsx | 338 ++++++------ .../messages/ui/useChatScrollVirtualizer.ts | 150 +++++- .../messages/ui/useLoadOlderOnScroll.ts | 92 ---- .../messages/ui/useTimelineScrollManager.ts | 436 ---------------- desktop/src/shared/ui/markdown.tsx | 4 +- 12 files changed, 913 insertions(+), 1056 deletions(-) delete mode 100644 desktop/src/features/messages/ui/useLoadOlderOnScroll.ts delete mode 100644 desktop/src/features/messages/ui/useTimelineScrollManager.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 517fa07287..b73c54a734 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -638,6 +638,7 @@ export const ChannelPane = React.memo(function ChannelPane({ { + assert.deepEqual(buildThreadReplyVirtualItems([]), []); +}); + +test("thread-items: each reply becomes one item, no day dividers", () => { + const items = buildThreadReplyVirtualItems([ + entry({ id: "a", createdAt: dayAt(2026, 6, 14) }), + entry({ id: "b", createdAt: dayAt(2026, 6, 15) }), + ]); + assert.deepEqual( + items.map((item) => item.kind), + ["reply", "reply"], + ); + assert.deepEqual( + items.map((item) => item.key), + ["a", "b"], + ); +}); + +test("thread-items: unread divider precedes the first unread reply when not index 0", () => { + const items = buildThreadReplyVirtualItems( + [entry({ id: "a" }), entry({ id: "b" }), entry({ id: "c" })], + "b", + ); + assert.deepEqual( + items.map((item) => item.kind), + ["reply", "unread", "reply", "reply"], + ); + assert.equal(items[1].key, "unread-b"); +}); + +test("thread-items: unread divider suppressed when the first unread reply is index 0", () => { + const items = buildThreadReplyVirtualItems( + [entry({ id: "a" }), entry({ id: "b" })], + "a", + ); + assert.deepEqual( + items.map((item) => item.kind), + ["reply", "reply"], + ); +}); + +test("thread-items: a reply carries its inline summary, never a separate item", () => { + const summary = { + threadHeadId: "a", + replyCount: 2, + lastReplyAt: null, + participants: [], + }; + const items = buildThreadReplyVirtualItems([entry({ id: "a" }, summary)]); + assert.equal(items.length, 1); + assert.equal(items[0].summary, summary); +}); + +test("thread-items: reply key prefers renderKey for optimistic-send identity", () => { + const items = buildThreadReplyVirtualItems([ + entry({ id: "server-id", renderKey: "local-key" }), + ]); + assert.equal(items[0].key, "local-key"); +}); diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index d007325341..356b02da2b 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -160,7 +160,7 @@ export function selectDeferredListRenderState( * the virtualizer measures and positions, so every divider that the legacy * `TimelineMessageList` rendered as a sibling node becomes its own item here. * - * - "day" → a day-boundary heading; renders the sticky `DayDivider` + * - "day" → a day-boundary heading; renders the inline `DayDivider` * - "unread" → the "New" read/unread boundary above the first unread message * - "system" → a system message (join/leave); renders `SystemMessageRow` * - "message" → a normal message; carries the thread `summary` it renders @@ -227,3 +227,51 @@ export function buildTimelineVirtualItems( return items; } + +/** + * One row in the flattened, virtualizer-ready thread reply list. The thread + * pane has a simpler shape than the main timeline — no day grouping, no system + * rows, and the head message renders OUTSIDE the virtualized region — so the + * only item kinds are an unread boundary and a reply (which carries the inline + * thread `summary` it renders beneath the row, never a separate item, to keep + * one measured unit per reply). + * + * `key` is byte-identical to the legacy reply-list keys + * (`message.renderKey ?? message.id`) so optimistic-send identity stays stable + * across the migration. + */ +export type ThreadReplyVirtualItem = + | { kind: "unread"; key: string } + | { + kind: "reply"; + key: string; + message: TimelineMessage; + summary: TimelineThreadSummary | null; + }; + +/** + * Flattens thread reply entries into the ordered virtual-item list the thread + * pane's virtualizer consumes. Mirrors the legacy reply-list render walk: an + * unread divider precedes the first unread reply (suppressed at index 0, where + * there is nothing above it to divide), and every entry becomes a `reply` item. + * Pure over the snapshot so the divider rule stays lib-tested without a DOM. + */ +export function buildThreadReplyVirtualItems( + entries: readonly MainTimelineEntry[], + firstUnreadReplyId: string | null | undefined = null, +): ThreadReplyVirtualItem[] { + const items: ThreadReplyVirtualItem[] = []; + + for (let i = 0; i < entries.length; i++) { + const { message, summary } = entries[i]; + const replyKey = message.renderKey ?? message.id; + + if (shouldRenderUnreadDivider(i, message.id, firstUnreadReplyId ?? null)) { + items.push({ kind: "unread", key: `unread-${replyKey}` }); + } + + items.push({ kind: "reply", key: replyKey, message, summary }); + } + + return items; +} diff --git a/desktop/src/features/messages/ui/DayDivider.tsx b/desktop/src/features/messages/ui/DayDivider.tsx index a76b8a7f1e..bd2bd82a13 100644 --- a/desktop/src/features/messages/ui/DayDivider.tsx +++ b/desktop/src/features/messages/ui/DayDivider.tsx @@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) { return (
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index fba4748ec3..a15e76aa3d 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -31,8 +31,12 @@ import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; -import { useTimelineScrollManager } from "./useTimelineScrollManager"; -import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; +import { useChatScrollVirtualizer } from "./useChatScrollVirtualizer"; +import { + buildThreadReplyVirtualItems, + selectDeferredListRenderState, + selectLatestMessageKey, +} from "@/features/messages/lib/timelineSnapshot"; type MessageThreadPanelProps = { agentPubkeys?: ReadonlySet; @@ -95,6 +99,10 @@ type MessageThreadPanelProps = { * `EMPTY_MESSAGES` in MessageTimeline. */ const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = []; +/** Vertical gap (px) the virtualizer inserts between reply rows — the spacing + * the legacy reply list expressed as `space-y-2.5`. */ +const THREAD_REPLY_GAP_PX = 10; + type MessageThreadPanelSkeletonProps = { isSinglePanelView?: boolean; layout?: "standalone" | "split"; @@ -317,8 +325,6 @@ export function MessageThreadPanel({ isSinglePanelView, ); - const threadHeadId = threadHead?.id ?? null; - const composerReplyTarget = replyTargetMessage && threadHead && replyTargetMessage.id !== threadHead.id ? { @@ -333,13 +339,10 @@ export function MessageThreadPanel({ // the main thread and the OS would show the busy cursor. Gate the reply render // behind `useDeferredValue`. `initialValue: []` keeps even the FIRST render on // thread-open light; the heavy list streams in on a deferred, interruptible - // commit. We deliberately drive BOTH the scroll manager and the rendered list - // off the SAME deferred value — sticky-bottom / deep-link logic reads the DOM - // (`scrollIntoView`), so it must stay consistent with what's actually painted. - // You can't scroll to a reply that hasn't committed yet. The thread pane gets - // this no-tearing guarantee for free by routing through the same - // `useTimelineScrollManager` (and its `timelineSnapshot` helpers) as the main - // timeline. + // commit. We deliberately drive BOTH the virtualizer and the rendered list + // off the SAME deferred value — the deep-link index resolves against this + // snapshot, so a jump never targets a reply the DOM has not committed yet + // (the no-tearing guard). You can't scroll to a reply that hasn't rendered. const deferredThreadReplies = React.useDeferredValue( threadReplies, EMPTY_THREAD_REPLIES, @@ -354,32 +357,130 @@ export function MessageThreadPanel({ threadReplies.length, ); - const threadMessages = React.useMemo( - () => deferredThreadReplies.map((entry) => entry.message), + // Flat virtual-item list for the reply region (head renders outside it). The + // deep-link index map below is built off this SAME list — the no-tearing + // guard — so a `scrollTargetId` jump only fires once the target reply exists + // in the committed snapshot. + const replyItems = React.useMemo( + () => + buildThreadReplyVirtualItems(deferredThreadReplies, firstUnreadReplyId), + [deferredThreadReplies, firstUnreadReplyId], + ); + const indexByMessageId = React.useMemo(() => { + const map = new Map(); + for (let i = 0; i < replyItems.length; i++) { + const item = replyItems[i]; + if (item.kind === "reply") { + map.set(item.message.id, i); + } + } + return map; + }, [replyItems]); + const latestReplyKey = React.useMemo( + () => selectLatestMessageKey(deferredThreadReplies.map((e) => e.message)), [deferredThreadReplies], ); + const getReplyItemKey = React.useCallback( + (index: number) => replyItems[index].key, + [replyItems], + ); + // Bottom-stick + deep-link only — a thread never grows at the head, so + // `anchorTo:"end"` makes prepend structurally absent (no flag needed). No + // short-channel `topPad`: replies render top-down under the head row, which + // is the legacy thread-pane layout. const { - bottomAnchorRef, - contentRef, + virtualizer, isAtBottom, newMessageCount, + highlightedMessageId, scrollToBottom, - syncScrollState, - } = useTimelineScrollManager({ - channelId: threadHeadId, - // Wait for deferred replies to commit before scroll-init (else rows mount un-scrolled). - isLoading: repliesRenderState === "pending", - messages: threadMessages, + scrollToItem, + } = useChatScrollVirtualizer({ + count: replyItems.length, + scrollRef: threadBodyRef, + getItemKey: getReplyItemKey, + gap: THREAD_REPLY_GAP_PX, + latestMessageKey: latestReplyKey, onTargetReached: onScrollTargetResolved, - scrollContainerRef: threadBodyRef, - targetMessageId: scrollTargetId, }); + // Deep-link to `scrollTargetId` once it resolves against the rendered + // snapshot. The library's reconcile loop settles the offset once the target + // reply measures — the same W1 deep-link settle the main timeline uses, with + // prepend off. + const lastJumpedTargetRef = React.useRef(null); + React.useEffect(() => { + if (!scrollTargetId) { + lastJumpedTargetRef.current = null; + return; + } + if (scrollTargetId === lastJumpedTargetRef.current) { + return; + } + const index = indexByMessageId.get(scrollTargetId); + if (index === undefined) { + return; + } + lastJumpedTargetRef.current = scrollTargetId; + scrollToItem(index, scrollTargetId); + }, [scrollTargetId, indexByMessageId, scrollToItem]); + if (!threadHead) { return null; } + const renderReplyItem = ( + item: (typeof replyItems)[number], + ): React.ReactNode => { + if (item.kind === "unread") { + return ; + } + const { message, summary } = item; + return ( +
+ + {summary ? ( + + ) : null} +
+ ); + }; + const threadScrollRegion = (
-
-
-
- onFollowThread() : undefined - } - onMarkUnread={onMarkUnread} - onToggleReaction={onToggleReaction} - onUnfollowThread={ - onUnfollowThread ? (_msg) => onUnfollowThread() : undefined - } - profiles={profiles} - videoReviewContext={threadHeadVideoReviewContext} - /> -
+
+
+ onFollowThread() : undefined + } + onMarkUnread={onMarkUnread} + onToggleReaction={onToggleReaction} + onUnfollowThread={ + onUnfollowThread ? (_msg) => onUnfollowThread() : undefined + } + profiles={profiles} + videoReviewContext={threadHeadVideoReviewContext} + />
+
-
- {repliesRenderState === "list" ? ( -
- {deferredThreadReplies.map((entry, index) => { - const showUnreadDivider = - index > 0 && entry.message.id === firstUnreadReplyId; - return ( -
- {showUnreadDivider ? : null} - - {entry.summary ? ( - - ) : null} -
- ); - })} -
- ) : repliesRenderState === "empty" ? ( - // Only show the empty state when the thread is GENUINELY empty. - // Keying off `deferredThreadReplies` would flash "No replies" for a - // frame while a non-empty list streams in on the deferred commit. -
-

- No replies in this branch yet -

-

- Reply in the thread to continue this branch. -

-
- ) : // "pending": deferred list is empty but the live list has content — - // rows are streaming in on the deferred commit. Paint nothing rather - // than flashing the empty state. - null} -
-
+
+ {repliesRenderState === "list" ? ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ {renderReplyItem(replyItems[virtualRow.index])} +
+ ))} +
+ ) : repliesRenderState === "empty" ? ( + // Only show the empty state when the thread is GENUINELY empty. + // Keying off `deferredThreadReplies` would flash "No replies" for a + // frame while a non-empty list streams in on the deferred commit. +
+

+ No replies in this branch yet +

+

+ Reply in the thread to continue this branch. +

+
+ ) : // "pending": deferred list is empty but the live list has content — + // rows are streaming in on the deferred commit. Paint nothing rather + // than flashing the empty state. + null}
); diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 9b4aa3ac95..a46f66a46a 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -2,6 +2,11 @@ import * as React from "react"; import { ArrowDown, ArrowUp, Hash } from "lucide-react"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; +import { + buildTimelineVirtualItems, + selectLatestMessageKey, +} 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"; @@ -14,8 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; -import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll"; -import { useTimelineScrollManager } from "./useTimelineScrollManager"; +import { useChatScrollVirtualizer } from "./useChatScrollVirtualizer"; type MessageTimelineProps = { agentPubkeys?: ReadonlySet; @@ -101,6 +105,10 @@ type ChannelIntro = { * message list. Must be module-level so its identity never changes. */ const EMPTY_MESSAGES: TimelineMessage[] = []; +/** Vertical gap (px) the virtualizer inserts between rows — the spacing the + * legacy list expressed as the `gap-2.5` between its top-level row wrappers. */ +const TIMELINE_ROW_GAP_PX = 10; + type DirectMessageIntroParticipant = { avatarUrl: string | null; displayName: string; @@ -167,25 +175,79 @@ export const MessageTimeline = React.memo(function MessageTimeline({ ? `message-timeline:${channelId ?? "none"}:target:${targetMessageId}` : `message-timeline:${channelId ?? "none"}`; + // Flatten the deferred snapshot into the ordered virtual-item list the + // virtualizer measures. Deep-link/search index lookups below resolve against + // this SAME snapshot — a jump must target a row the DOM has committed, never + // a fresher message that hasn't rendered yet (the no-tearing guard). + const entries = React.useMemo( + () => buildMainTimelineEntries(deferredMessages), + [deferredMessages], + ); + const items = React.useMemo( + () => buildTimelineVirtualItems(entries, firstUnreadMessageId), + [entries, firstUnreadMessageId], + ); + // Map message id -> its flat-item index for deep-link jumps. Built off the + // same `items` so the index the virtualizer scrolls to and the row it renders + // are always the same snapshot. + const indexByMessageId = React.useMemo(() => { + const map = new Map(); + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind === "message" || item.kind === "system") { + map.set(item.message.id, i); + } + } + return map; + }, [items]); + + const latestMessageKey = React.useMemo( + () => selectLatestMessageKey(deferredMessages), + [deferredMessages], + ); + const getItemKey = React.useCallback( + (index: number) => items[index].key, + [items], + ); + const { - bottomAnchorRef, - contentRef, - highlightedMessageId, + virtualizer, + topPad, isAtBottom, newMessageCount, - restoreScrollPosition, + highlightedMessageId, scrollToBottom, - scrollToMessage, - syncScrollState, - } = useTimelineScrollManager({ - channelId, - isLoading, - messages: deferredMessages, + scrollToItem, + } = useChatScrollVirtualizer({ + count: items.length, + scrollRef: scrollContainerRef, + getItemKey, + gap: TIMELINE_ROW_GAP_PX, + latestMessageKey, onTargetReached, - scrollContainerRef, - targetMessageId, }); + // Deep-link to `targetMessageId` once it resolves against the rendered + // snapshot. `resolveDeepLinkTarget` reads the same `deferredMessages` the + // rows came from, so the jump never fires against an uncommitted row; the + // library's reconcile loop settles the offset once the target measures. + const lastJumpedTargetRef = React.useRef(null); + React.useEffect(() => { + if (!targetMessageId) { + lastJumpedTargetRef.current = null; + return; + } + if (targetMessageId === lastJumpedTargetRef.current) { + return; + } + const index = indexByMessageId.get(targetMessageId); + if (index === undefined) { + return; + } + lastJumpedTargetRef.current = targetMessageId; + scrollToItem(index, targetMessageId); + }, [targetMessageId, indexByMessageId, scrollToItem]); + // The unread pill is a transient, per-open affordance: dismiss it once the // user acts on it (jumps to the oldest unread) or catches up by reaching the // bottom of the timeline. Reset when the channel changes so a freshly opened @@ -214,14 +276,19 @@ export const MessageTimeline = React.memo(function MessageTimeline({ if (showUnreadPill) hasShownPillRef.current = true; const handleJumpToOldestUnread = React.useCallback(() => { setIsUnreadPillDismissed(true); - if (firstUnreadMessageId) { - scrollToMessage(firstUnreadMessageId); + if (!firstUnreadMessageId) { + return; } - }, [firstUnreadMessageId, scrollToMessage]); + const index = indexByMessageId.get(firstUnreadMessageId); + if (index !== undefined) { + scrollToItem(index, firstUnreadMessageId); + } + }, [firstUnreadMessageId, indexByMessageId, scrollToItem]); - // Scroll to the active search match when it changes. + // Scroll to the active find-in-channel match when it changes. Resolving the + // index off `indexByMessageId` (not `querySelector`) is required under + // virtualization — an off-screen match has no DOM node to scroll into view. const prevSearchActiveRef = React.useRef(null); - // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref React.useEffect(() => { if ( !searchActiveMessageId || @@ -231,31 +298,50 @@ export const MessageTimeline = React.memo(function MessageTimeline({ return; } prevSearchActiveRef.current = searchActiveMessageId; - - const container = scrollContainerRef.current; - if (!container) return; - - const el = container.querySelector( - `[data-message-id="${searchActiveMessageId}"]`, - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); + const index = indexByMessageId.get(searchActiveMessageId); + if (index !== undefined) { + scrollToItem(index, searchActiveMessageId); } - }, [searchActiveMessageId]); + }, [searchActiveMessageId, indexByMessageId, scrollToItem]); - useLoadOlderOnScroll({ + // Load older messages when the user scrolls near the top. The virtualizer's + // `anchorTo:"end"` holds the viewport across the prepend — unlike the legacy + // manager, this trigger does NOT touch scrollTop, killing the + // measure-and-restore race. The sentinel is the topmost rendered row. + React.useEffect(() => { + const sentinel = topSentinelRef.current; + const container = scrollContainerRef.current; + if ( + !sentinel || + !container || + !fetchOlder || + isLoading || + isFetchingOlder || + !hasOlderMessages + ) { + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + void fetchOlder(); + } + }, + { root: container, rootMargin: "200px 0px 0px 0px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [ fetchOlder, hasOlderMessages, isLoading, - restoreScrollPosition, + isFetchingOlder, scrollContainerRef, - sentinelRef: topSentinelRef, - }); + ]); const showDirectMessageIntro = !isLoading && directMessageIntro !== null; const showChannelIntro = !isLoading && channelIntro !== null && directMessageIntro === null; - const showIntro = showDirectMessageIntro || showChannelIntro; const showGenericEmpty = !isLoading && deferredMessages.length === 0 && @@ -298,16 +384,10 @@ export const MessageTimeline = React.memo(function MessageTimeline({ )} data-scroll-restoration-id={scrollRestorationId} data-testid="message-timeline" - onScroll={syncScrollState} ref={scrollContainerRef} >
@@ -317,191 +397,190 @@ export const MessageTimeline = React.memo(function MessageTimeline({
) : null} - } - > - {showDirectMessageIntro ? ( -
- -

- {directMessageIntro.displayName} -

-

- This is the beginning of your direct message with{" "} - - {directMessageIntro.displayName} - - . -

-
- ) : null} - - {showChannelIntro ? ( -
+ {/* Intro/empty/skeleton are NOT virtual rows — they render as plain + siblings of the virtualized spacer, gated so exactly one of them + or the list shows. `min-h-full` keeps the empty/intro states + filling the viewport (bottom-aligned via `mt-auto`); the message + list bottom-aligns through the virtualizer's `topPad` instead. */} + {showMessageList ? null : ( + } + > + {showDirectMessageIntro ? (
- {channelIntro.icon ?? ( - - )} -
-

- #{channelIntro.channelName} -

-

- This is the beginning of the{" "} - - {channelIntro.channelKindLabel} - - . -

- {channelIntro.description ? ( -

- {channelIntro.description} + +

+ {directMessageIntro.displayName}

- ) : null} - {channelIntro.actions?.length ? ( -
- {channelIntro.actions.map((action) => { - const hasDescription = Boolean(action.description); +

+ This is the beginning of your direct message with{" "} + + {directMessageIntro.displayName} + + . +

+
+ ) : null} - return ( - - ); - })} -
- ) : null} -
- ) : null} + {action.description ? ( + + {action.description} + + ) : null} + + + ); + })} +
+ ) : null} +
+ ) : null} - {showGenericEmpty ? ( -
-

- {emptyTitle} -

-

- {emptyDescription} -

-
- ) : null} + {showGenericEmpty ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} + + )} - {showMessageList ? ( -
- -
- ) : null} - - -
+ {showMessageList ? ( +
+ +
+ ) : null}
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 9f66b161e0..a0d4ba6aa3 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,20 +1,16 @@ import * as React from "react"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; -import { - buildMainTimelineEntries, - shouldRenderUnreadDivider, -} from "@/features/messages/lib/threadPanel"; +import type { TimelineVirtualItem } from "@/features/messages/lib/timelineSnapshot"; 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 type { ChatVirtualizer } from "./useChatScrollVirtualizer"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; @@ -63,6 +59,20 @@ type TimelineMessageListProps = { searchQuery?: string; /** Per-thread unread counts keyed by thread root id. */ threadUnreadCounts?: ReadonlyMap; + /** + * The virtualizer that owns this surface's scroll. Built by + * `useChatScrollVirtualizer` at the timeline level so the single scroll owner + * also owns measurement; this component is a pure renderer of its rows. + */ + virtualizer: ChatVirtualizer; + /** The flat virtual-item list the virtualizer's `count` mirrors. */ + items: TimelineVirtualItem[]; + /** + * Top padding (px) that bottom-aligns a channel shorter than the viewport. + * Always `0` once content fills the viewport. Applied to the row spacer so it + * pushes the absolute rows down without entering the measured total. + */ + topPad: number; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -71,7 +81,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ channelName, channelType, currentPubkey, - firstUnreadMessageId = null, followThreadById, highlightedMessageId = null, isFollowingThreadById, @@ -90,11 +99,10 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, + virtualizer, + items, + topPad, }: TimelineMessageListProps) { - const entries = React.useMemo( - () => buildMainTimelineEntries(messages), - [messages], - ); const reviewCommentsByRootId = React.useMemo( () => buildVideoReviewCommentsByRootId(messages), [messages], @@ -137,161 +145,167 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ 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, - ), - ); + const renderItem = (item: TimelineVirtualItem): React.ReactNode => { + switch (item.kind) { + case "day": + // The day separator scrolls inline with the rows (it is a normal + // virtual item, not sticky — sticky cannot work on an absolute row). + // The thin connector line that the legacy day-`
` drew behind + // the centered pill is re-homed onto this row so the divider keeps its + // rule-behind-label look without the group wrapper. + return ( +
+ +
+ ); + case "unread": + return ; + case "system": { + const footer = messageFooters?.[item.message.id] ?? null; + return ( +
+ + {footer} +
+ ); + } + case "message": { + const { message, summary } = item; + const footer = messageFooters?.[message.id] ?? null; - for (let i = 0; i < entries.length; i++) { - const { message, summary } = entries[i]; - const messageRenderKey = message.renderKey ?? message.id; + if (summary && onReply) { + const isHighlighted = message.id === highlightedMessageId; + return ( +
+ 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} +
+ ); + } - if (dayGroupStartIndices.has(i)) { - currentDayGroup = { - key: `day-${message.createdAt}`, - label: formatDayHeading(message.createdAt), - elements: [], - }; - dayGroups.push(currentDayGroup); + const isSearchMatch = + searchMatchingMessageIds?.has(message.id) ?? false; + const isSearchActive = message.id === searchActiveMessageId; + return ( +
+ + {footer} +
+ ); + } } + }; - // The unread "New" divider only marks a read/unread boundary when there is - // a message above the first unread. When the first unread is the first - // rendered top-level entry (fresh/never-read channel), there is nothing - // above to separate from, so it is suppressed. - if (shouldRenderUnreadDivider(i, message.id, firstUnreadMessageId)) { - currentDayGroup?.elements.push( - , - ); - } - - 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) => ( -
0 ? `${topPad}px` : undefined, + }} > - - {group.elements} -
- )); + {virtualizer.getVirtualItems().map((virtualRow) => ( +
+ {renderItem(items[virtualRow.index])} +
+ ))} + + ); }); diff --git a/desktop/src/features/messages/ui/useChatScrollVirtualizer.ts b/desktop/src/features/messages/ui/useChatScrollVirtualizer.ts index c0ef3feb74..8369ce860c 100644 --- a/desktop/src/features/messages/ui/useChatScrollVirtualizer.ts +++ b/desktop/src/features/messages/ui/useChatScrollVirtualizer.ts @@ -1,8 +1,13 @@ import { type Virtualizer, useVirtualizer } from "@tanstack/react-virtual"; import * as React from "react"; +import { BOTTOM_THRESHOLD_PX } from "@/features/messages/lib/timelineSnapshot"; + export type ChatVirtualizer = Virtualizer; +/** How long (ms) a deep-linked row stays highlighted before the glow fades. */ +const HIGHLIGHT_DURATION_MS = 2_000; + /** * Single scroll owner for the chat surfaces (main timeline + thread pane). * @@ -30,6 +35,12 @@ export type ChatVirtualizer = Virtualizer; * the LIVE `getTotalSize()` on every measurement pass (via `onChange`) so * it collapses to 0 the instant content exceeds the viewport — see the * ordering note below. + * - **The "at bottom" / new-message-count UI state** the scroll-to-latest + * pill reads. The library knows the geometry (`isAtEnd`); this hook lifts + * it to React state and counts messages that arrive while scrolled up. + * - **Deep-link highlight + completion callback.** `scrollToItem` drives the + * library jump, lights the target row for a beat, and fires + * `onTargetReached` once. */ export type ChatScrollVirtualizerOptions = { @@ -41,13 +52,18 @@ export type ChatScrollVirtualizerOptions = { getItemKey: (index: number) => string; /** Estimated row height (px) before measurement. */ estimateSize?: number; + /** Vertical gap (px) the virtualizer inserts between rows. */ + gap?: number; /** Rows rendered outside the viewport on each side. */ overscan?: number; /** - * Bottom-stick mode. The main timeline and thread pane both want to follow - * new messages, so both pass `"auto"`; pass `false` to disable following. + * Key of the latest message in the rendered snapshot. When it changes while + * the user is scrolled up, `newMessageCount` ticks up — drives the pill's + * "N new messages" label. Pass `undefined` for an empty surface. */ - followOnAppend?: boolean | "auto" | "smooth" | "instant"; + latestMessageKey?: string; + /** Called once when a deep-link jump lands on its target. */ + onTargetReached?: (messageId: string) => void; }; export type ChatScrollVirtualizer = { @@ -58,6 +74,20 @@ export type ChatScrollVirtualizer = { * once content fills the viewport. */ topPad: number; + /** True while the surface is scrolled to (or near) the bottom. */ + isAtBottom: boolean; + /** Messages that arrived while scrolled up; reset on reaching the bottom. */ + newMessageCount: number; + /** Message id currently highlighted by a deep-link jump, or null. */ + highlightedMessageId: string | null; + /** Jump to the end (the pill action). */ + scrollToBottom: (behavior: ScrollBehavior) => void; + /** + * Jump to a flat-item index, highlight `messageId`, and fire + * `onTargetReached` once the library settles. The caller resolves the index + * against the SAME snapshot the rows render from (the no-tearing guard). + */ + scrollToItem: (index: number, messageId: string) => void; }; export function useChatScrollVirtualizer({ @@ -65,8 +95,10 @@ export function useChatScrollVirtualizer({ scrollRef, getItemKey, estimateSize = 80, + gap, overscan = 6, - followOnAppend = "auto", + latestMessageKey, + onTargetReached, }: ChatScrollVirtualizerOptions): ChatScrollVirtualizer { // Read the element lazily so the virtualizer binds once the ref attaches; // capturing `ref.current` at render time would freeze it at the first-render @@ -77,17 +109,22 @@ export function useChatScrollVirtualizer({ ); const [topPad, setTopPad] = React.useState(0); + const [isAtBottom, setIsAtBottom] = React.useState(true); + const [newMessageCount, setNewMessageCount] = React.useState(0); + const [highlightedMessageId, setHighlightedMessageId] = React.useState< + string | null + >(null); - // Recompute the short-channel pad off the LIVE total whenever a row's - // measured size changes. virtual-core fires `onChange` directly from + // The bottom-state and pad recompute both read live geometry off the + // virtualizer on each `onChange`. virtual-core fires `onChange` directly from // `resizeItem` on any size delta (not only on a visible-range change), which // is exactly when `getTotalSize()` moves — so a short channel whose rows - // measure taller/shorter than the estimate re-pads with the settled total. - // Running it here, before paint and before the library re-applies its - // end-anchoring/follow in the same pass, orders "recompute pad -> re-pin - // bottom" and avoids a one-frame sliver when a short channel grows past the - // viewport. - const recomputePad = React.useCallback( + // measure taller/shorter than the estimate re-pads (and re-evaluates "at + // bottom") with the settled total. Running here, before paint and before the + // library re-applies its end-anchoring/follow in the same pass, orders + // "recompute pad -> re-pin bottom" and avoids a one-frame sliver when a short + // channel grows past the viewport. + const onChange = React.useCallback( (instance: ChatVirtualizer) => { const scrollEl = scrollRef.current; if (!scrollEl) { @@ -95,6 +132,14 @@ export function useChatScrollVirtualizer({ } const pad = Math.max(0, scrollEl.clientHeight - instance.getTotalSize()); setTopPad((prev) => (prev === pad ? prev : pad)); + + // The app's "at bottom" rule is looser than the library's 1px default; + // reuse the timeline's 72px threshold so the pill matches the old feel. + const atBottom = instance.isAtEnd(BOTTOM_THRESHOLD_PX); + setIsAtBottom((prev) => (prev === atBottom ? prev : atBottom)); + if (atBottom) { + setNewMessageCount((prev) => (prev === 0 ? prev : 0)); + } }, [scrollRef], ); @@ -105,26 +150,89 @@ export function useChatScrollVirtualizer({ estimateSize: () => estimateSize, getItemKey, overscan, + gap, // Hold the viewport on prepend and pin to the bottom anchor for follow — // the two library-native behaviors that replace the manual scroll manager. + // Both surfaces follow new messages, so `followOnAppend` is always on; the + // library only re-pins when the user is already at the end. anchorTo: "end", - followOnAppend, - onChange: recomputePad, + followOnAppend: "auto", + onChange, }); - // The pad also depends on the viewport height, which `onChange` does not - // track — a window/pane resize that changes `clientHeight` without resizing a - // row would leave a short channel's pad stale. Observe the container so the - // bottom-align holds across resizes too. + // The pad/bottom-state also depend on the viewport height, which `onChange` + // does not track — a window/pane resize that changes `clientHeight` without + // resizing a row would leave them stale. Observe the container so the + // bottom-align and pill state hold across resizes too. React.useEffect(() => { const scrollEl = scrollRef.current; if (!scrollEl) { return; } - const observer = new ResizeObserver(() => recomputePad(virtualizer)); + const observer = new ResizeObserver(() => onChange(virtualizer)); observer.observe(scrollEl); return () => observer.disconnect(); - }, [scrollRef, recomputePad, virtualizer]); + }, [scrollRef, onChange, virtualizer]); - return { virtualizer, topPad }; + // Count messages that arrive while the user is scrolled up. When at the + // bottom `followOnAppend` keeps us pinned and `onChange` zeroes the count, so + // the increment only fires for genuinely-missed messages. Skips the first + // observed key so opening a channel doesn't show a phantom count. + const previousLatestKeyRef = React.useRef(undefined); + React.useEffect(() => { + const previous = previousLatestKeyRef.current; + previousLatestKeyRef.current = latestMessageKey; + if ( + previous === undefined || + latestMessageKey === undefined || + latestMessageKey === previous || + isAtBottom + ) { + return; + } + setNewMessageCount((prev) => prev + 1); + }, [latestMessageKey, isAtBottom]); + + const scrollToBottom = React.useCallback( + (behavior: ScrollBehavior) => { + virtualizer.scrollToIndex(count - 1, { align: "end", behavior }); + }, + [virtualizer, count], + ); + + const highlightTimeoutRef = React.useRef(undefined); + const scrollToItem = React.useCallback( + (index: number, messageId: string) => { + // align:center + the library's reconcile loop re-targets the index until + // the offset is stable once the (possibly never-measured) row measures — + // the W1 deep-link settle, library-owned. + virtualizer.scrollToIndex(index, { align: "center" }); + setHighlightedMessageId(messageId); + setNewMessageCount(0); + onTargetReached?.(messageId); + + window.clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = window.setTimeout(() => { + setHighlightedMessageId((current) => + current === messageId ? null : current, + ); + }, HIGHLIGHT_DURATION_MS); + }, + [virtualizer, onTargetReached], + ); + + React.useEffect( + () => () => window.clearTimeout(highlightTimeoutRef.current), + [], + ); + + return { + virtualizer, + topPad, + isAtBottom, + newMessageCount, + highlightedMessageId, + scrollToBottom, + scrollToItem, + }; } diff --git a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts b/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts deleted file mode 100644 index 73efbd3fc7..0000000000 --- a/desktop/src/features/messages/ui/useLoadOlderOnScroll.ts +++ /dev/null @@ -1,92 +0,0 @@ -import * as React from "react"; - -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. - */ -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; - if ( - !sentinel || - !container || - !fetchOlder || - isLoading || - !hasOlderMessages - ) { - return; - } - - let disposed = false; - let currentObserver: IntersectionObserver | null = null; - - const observe = () => { - if (disposed) { - return; - } - - currentObserver = new IntersectionObserver( - ([entry]) => { - if (!entry.isIntersecting || disposed) { - return; - } - - 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(); - }); - }); - }); - }, - { root: container, rootMargin: "200px 0px 0px 0px" }, - ); - - currentObserver.observe(sentinel); - }; - - observe(); - return () => { - disposed = true; - currentObserver?.disconnect(); - }; - }, [ - fetchOlder, - hasOlderMessages, - isLoading, - scrollContainerRef, - sentinelRef, - ]); -} diff --git a/desktop/src/features/messages/ui/useTimelineScrollManager.ts b/desktop/src/features/messages/ui/useTimelineScrollManager.ts deleted file mode 100644 index 4dcdf25892..0000000000 --- a/desktop/src/features/messages/ui/useTimelineScrollManager.ts +++ /dev/null @@ -1,436 +0,0 @@ -import * as React from "react"; - -import { - isNearBottom, - resolveDeepLinkTarget, - selectLatestMessageKey, -} from "@/features/messages/lib/timelineSnapshot"; -import type { TimelineMessage } from "@/features/messages/types"; - -type UseTimelineScrollManagerOptions = { - channelId?: string | null; - isLoading: boolean; - messages: TimelineMessage[]; - onTargetReached?: (messageId: string) => void; - scrollContainerRef: React.RefObject; - targetMessageId?: string | null; -}; - -type PinToBottomOptions = { - clearNewMessageCount?: boolean; -}; - -export function useTimelineScrollManager({ - channelId, - isLoading, - messages, - onTargetReached, - scrollContainerRef, - targetMessageId, -}: UseTimelineScrollManagerOptions) { - const timelineRef = scrollContainerRef; - const contentRef = React.useRef(null); - const bottomAnchorRef = React.useRef(null); - const hasInitializedRef = React.useRef(false); - const shouldStickToBottomRef = React.useRef(true); - const isAtBottomRef = React.useRef(true); - const isProgrammaticBottomScrollRef = React.useRef(false); - const previousTimelineHeightRef = React.useRef(null); - const previousScrollTopRef = React.useRef(0); - const lockedScrollTopRef = React.useRef(null); - const previousLastMessageKeyRef = React.useRef(undefined); - const previousMessageCountRef = React.useRef(0); - const handledTargetMessageIdRef = React.useRef(null); - const [isAtBottom, setIsAtBottom] = React.useState(true); - const [highlightedMessageId, setHighlightedMessageId] = React.useState< - string | null - >(null); - const [newMessageCount, setNewMessageCount] = React.useState(0); - - const resetScrollTracking = React.useCallback(() => { - hasInitializedRef.current = false; - shouldStickToBottomRef.current = true; - isAtBottomRef.current = true; - isProgrammaticBottomScrollRef.current = false; - previousTimelineHeightRef.current = null; - previousScrollTopRef.current = 0; - lockedScrollTopRef.current = null; - previousLastMessageKeyRef.current = undefined; - previousMessageCountRef.current = 0; - handledTargetMessageIdRef.current = null; - setIsAtBottom(true); - setHighlightedMessageId(null); - setNewMessageCount(0); - }, []); - - const pinToBottom = React.useCallback( - ({ clearNewMessageCount = false }: PinToBottomOptions = {}) => { - shouldStickToBottomRef.current = true; - isAtBottomRef.current = true; - setIsAtBottom((current) => (current ? current : true)); - - if (clearNewMessageCount) { - setNewMessageCount(0); - } - }, - [], - ); - - const setObservedBottomState = React.useCallback((atBottom: boolean) => { - shouldStickToBottomRef.current = atBottom; - isAtBottomRef.current = atBottom; - setIsAtBottom((current) => (current === atBottom ? current : atBottom)); - - if (atBottom) { - setNewMessageCount(0); - } - }, []); - - const unpinFromBottom = React.useCallback((scrollTop: number) => { - shouldStickToBottomRef.current = false; - isAtBottomRef.current = false; - isProgrammaticBottomScrollRef.current = false; - previousScrollTopRef.current = scrollTop; - setIsAtBottom(false); - }, []); - - // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is intentionally the sole trigger — we reset all scroll state when the channel changes - React.useLayoutEffect(() => { - resetScrollTracking(); - }, [channelId, resetScrollTracking]); - - const latestMessage = - messages.length > 0 ? messages[messages.length - 1] : undefined; - const latestMessageKey = selectLatestMessageKey(messages); - - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref passed from the parent — its identity never changes - const syncScrollState = React.useCallback(() => { - const timeline = timelineRef.current; - if (!timeline) { - return; - } - - const scrollTop = lockedScrollTopRef.current ?? timeline.scrollTop; - const atBottom = isNearBottom(timeline); - const movedAwayFromBottom = scrollTop + 1 < previousScrollTopRef.current; - - if (isProgrammaticBottomScrollRef.current) { - previousScrollTopRef.current = scrollTop; - - if (movedAwayFromBottom) { - isProgrammaticBottomScrollRef.current = false; - } else if (!atBottom) { - pinToBottom(); - return; - } else { - isProgrammaticBottomScrollRef.current = false; - pinToBottom({ clearNewMessageCount: true }); - return; - } - } - - if (shouldStickToBottomRef.current && !atBottom && !movedAwayFromBottom) { - previousScrollTopRef.current = scrollTop; - pinToBottom({ clearNewMessageCount: true }); - return; - } - - previousScrollTopRef.current = scrollTop; - setObservedBottomState(atBottom); - }, [pinToBottom, setObservedBottomState]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes - const restoreScrollPosition = React.useCallback( - (scrollTop: number) => { - const timeline = timelineRef.current; - - if (!timeline) { - return; - } - - isProgrammaticBottomScrollRef.current = false; - lockedScrollTopRef.current = scrollTop; - - const restore = (remainingFrames: number) => { - timeline.scrollTop = scrollTop; - - if (remainingFrames > 0) { - requestAnimationFrame(() => { - restore(remainingFrames - 1); - }); - return; - } - - lockedScrollTopRef.current = null; - previousScrollTopRef.current = timeline.scrollTop; - syncScrollState(); - }; - - restore(2); - }, - [syncScrollState], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes - const scrollToBottom = React.useCallback( - (behavior: ScrollBehavior) => { - const timeline = timelineRef.current; - - if (!timeline) { - return; - } - - isProgrammaticBottomScrollRef.current = true; - - const alignToBottom = (nextBehavior: ScrollBehavior) => { - bottomAnchorRef.current?.scrollIntoView({ - block: "end", - behavior: nextBehavior, - }); - timeline.scrollTo({ - top: timeline.scrollHeight, - behavior: nextBehavior, - }); - }; - - alignToBottom(behavior); - lockedScrollTopRef.current = null; - previousScrollTopRef.current = timeline.scrollTop; - pinToBottom({ clearNewMessageCount: true }); - - if (behavior === "smooth") { - requestAnimationFrame(() => { - previousScrollTopRef.current = timeline.scrollTop; - syncScrollState(); - }); - return; - } - - const settleAlignment = (remainingFrames: number) => { - requestAnimationFrame(() => { - alignToBottom("auto"); - previousScrollTopRef.current = timeline.scrollTop; - - if (remainingFrames > 0) { - settleAlignment(remainingFrames - 1); - return; - } - - syncScrollState(); - }); - }; - - settleAlignment(2); - }, - [pinToBottom, syncScrollState], - ); - - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes - React.useEffect(() => { - const timeline = timelineRef.current; - - if (!timeline || typeof ResizeObserver === "undefined") { - return; - } - - previousTimelineHeightRef.current = timeline.clientHeight; - previousScrollTopRef.current = timeline.scrollTop; - - const observer = new ResizeObserver(([entry]) => { - const previousTimelineHeight = previousTimelineHeightRef.current; - const nextTimelineHeight = entry.contentRect.height; - previousTimelineHeightRef.current = nextTimelineHeight; - - if ( - previousTimelineHeight === null || - Math.abs(nextTimelineHeight - previousTimelineHeight) < 1 - ) { - return; - } - - if (shouldStickToBottomRef.current || isAtBottomRef.current) { - scrollToBottom("auto"); - return; - } - - restoreScrollPosition(previousScrollTopRef.current); - }); - - observer.observe(timeline); - - return () => { - observer.disconnect(); - }; - }, [restoreScrollPosition, scrollToBottom]); - - React.useEffect(() => { - const content = contentRef.current; - - if (!content || typeof ResizeObserver === "undefined") { - return; - } - - const observer = new ResizeObserver(() => { - if (shouldStickToBottomRef.current) { - scrollToBottom("auto"); - return; - } - - syncScrollState(); - }); - - observer.observe(content); - - return () => { - observer.disconnect(); - }; - }, [scrollToBottom, syncScrollState]); - - React.useLayoutEffect(() => { - if (!hasInitializedRef.current) { - if (isLoading) { - return; - } - - if (targetMessageId) { - const timeline = timelineRef.current; - unpinFromBottom(timeline?.scrollTop ?? 0); - } else { - 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 && - (shouldStickToBottomRef.current || - isAtBottomRef.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, - timelineRef, - unpinFromBottom, - ]); - - // biome-ignore lint/correctness/useExhaustiveDependencies: timelineRef is a stable React ref — its identity never changes - const scrollToMessage = React.useCallback( - (messageId: string) => { - const timeline = timelineRef.current; - if (!timeline) { - return false; - } - - const targetElement = timeline.querySelector( - `[data-message-id="${messageId}"]`, - ); - if (!targetElement) { - return false; - } - - unpinFromBottom(timeline.scrollTop); - setHighlightedMessageId(messageId); - setNewMessageCount(0); - - const alignToTarget = (remainingFrames: number) => { - targetElement.scrollIntoView({ - block: "center", - behavior: "auto", - }); - previousScrollTopRef.current = timeline.scrollTop; - - if (remainingFrames > 0) { - requestAnimationFrame(() => { - alignToTarget(remainingFrames - 1); - }); - return; - } - - onTargetReached?.(messageId); - }; - - alignToTarget(2); - - window.setTimeout(() => { - setHighlightedMessageId((current) => - current === messageId ? null : current, - ); - }, 2_000); - - return true; - }, - [onTargetReached, unpinFromBottom], - ); - - React.useEffect(() => { - if (!targetMessageId) { - handledTargetMessageIdRef.current = null; - setHighlightedMessageId(null); - return; - } - - if (handledTargetMessageIdRef.current === targetMessageId || isLoading) { - return; - } - - // Deep-link decision delegated to a pure, lib-tested helper: only attempt the - // jump once the target actually exists in THIS (deferred) snapshot. If it - // doesn't, the row hasn't committed yet — bail and let the next snapshot that - // includes it drive the jump. This reads the same `messages` snapshot the - // list rendered, which closes the tearing race. - if (!resolveDeepLinkTarget(messages, targetMessageId).resolved) { - return; - } - - if (!scrollToMessage(targetMessageId)) { - return; - } - - handledTargetMessageIdRef.current = targetMessageId; - }, [isLoading, messages, scrollToMessage, targetMessageId]); - - return { - bottomAnchorRef, - contentRef, - highlightedMessageId, - isAtBottom, - newMessageCount, - restoreScrollPosition, - scrollToBottom, - scrollToMessage, - syncScrollState, - }; -} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 516ffceb4b..5a458b1495 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -2093,8 +2093,8 @@ function MarkdownInner({ const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { // Always route through `goChannel` with `messageId` set: the channel - // route already handles scroll-into-view + highlight via - // `useTimelineScrollManager` + `getEventById` backfill, and works for + // route already handles scroll-into-view + highlight via the timeline's + // `useChatScrollVirtualizer` + `getEventById` backfill, and works for // both stream-message replies and forum threads. Detecting "the thread // root is a forum post" up front would require an event lookup we don't // currently have synchronously; the brief explicitly allows skipping