diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index fc2d19c762..d5080cb4d1 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -194,8 +194,19 @@ const MessageTimelineBase = React.forwardRef< const topSentinelRef = React.useRef(null); const [virtualizerScrollParent, setVirtualizerScrollParent] = React.useState(null); + const [virtualizerRenderVersion, bumpVirtualizerRenderVersion] = + React.useReducer((version: number) => version + 1, 0); const [timelineVirtualizerApi, setTimelineVirtualizerApi] = React.useState(null); + const useTimelineVirtualizer = true; + const activeScrollContainerRef = React.useMemo( + () => ({ + get current() { + return virtualizerScrollParent ?? scrollContainerRef.current; + }, + }), + [scrollContainerRef, virtualizerScrollParent], + ); // Gate the heavy timeline render (each row runs a synchronous // react-markdown parse) behind React concurrency. `useDeferredValue` lets the @@ -238,7 +249,9 @@ const MessageTimelineBase = React.forwardRef< React.useLayoutEffect(() => { // Re-read after `scrollContainerDomKey` swaps the keyed scroll DOM node. void scrollContainerDomKey; - setVirtualizerScrollParent(scrollContainerRef.current); + if (!useTimelineVirtualizer) { + setVirtualizerScrollParent(scrollContainerRef.current); + } setTimelineVirtualizerApi(null); }, [scrollContainerRef, scrollContainerDomKey]); @@ -264,11 +277,12 @@ const MessageTimelineBase = React.forwardRef< isLoading: showTimelineSkeleton, messages: deferredMessages, onTargetReached, - scrollContainerRef, + scrollContainerRef: activeScrollContainerRef, targetMessageId, virtualScrollToMessage: timelineVirtualizerApi?.scrollToMessage, virtualScrollToBottom: timelineVirtualizerApi?.scrollToBottom, - virtualizerOwnsPrependAnchoring: virtualizerScrollParent !== null, + virtualizerOwnsPrependAnchoring: useTimelineVirtualizer, + virtualizerRenderVersion, }); const timelineIntroSurface = selectTimelineIntroSurface({ @@ -282,11 +296,18 @@ const MessageTimelineBase = React.forwardRef< const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; - const activeDirectMessageIntro = showDirectMessageIntro - ? directMessageIntro - : null; - const activeChannelIntro = showChannelIntro ? channelIntro : null; - const showIntro = showDirectMessageIntro || showChannelIntro; + const activeDirectMessageIntro = + showDirectMessageIntro && + (!useTimelineVirtualizer || timelineBodySurface !== "list") + ? directMessageIntro + : null; + const activeChannelIntro = + showChannelIntro && + (!useTimelineVirtualizer || timelineBodySurface !== "list") + ? channelIntro + : null; + const showIntro = + activeDirectMessageIntro !== null || activeChannelIntro !== null; const showGenericEmpty = timelineBodySurface === "empty" && !showIntro; const showMessageList = timelineBodySurface === "list"; @@ -365,14 +386,19 @@ const MessageTimelineBase = React.forwardRef< } }, [jumpToMessage, searchActiveMessageId, showTimelineSkeleton]); - // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages is the intentional retry trigger — a search hit outside the initial window is spliced into messages asynchronously, and the DOM scroll should retry when that row commits. + // biome-ignore lint/correctness/useExhaustiveDependencies: deferredMessages and virtualizerRenderVersion are intentional retry triggers — a search hit may be spliced into messages asynchronously, and in virtualized mode a phase-1 index jump only realizes the row; retry when the rendered range changes so the DOM-visible path can center and highlight it. React.useEffect(() => { const target = pendingSearchTargetRef.current; if (!target || showTimelineSkeleton) return; if (jumpToMessage(target, { behavior: "auto" })) { pendingSearchTargetRef.current = null; } - }, [deferredMessages, jumpToMessage, showTimelineSkeleton]); + }, [ + deferredMessages, + jumpToMessage, + showTimelineSkeleton, + virtualizerRenderVersion, + ]); const loadOlderViaVirtualizer = React.useCallback(() => { if (!fetchOlder || showTimelineSkeleton || !hasOlderMessages) return; @@ -380,10 +406,10 @@ const MessageTimelineBase = React.forwardRef< }, [fetchOlder, hasOlderMessages, showTimelineSkeleton]); useLoadOlderOnScroll({ - fetchOlder: virtualizerScrollParent ? undefined : fetchOlder, + fetchOlder: useTimelineVirtualizer ? undefined : fetchOlder, hasOlderMessages, isLoading: showTimelineSkeleton, - scrollContainerRef, + scrollContainerRef: activeScrollContainerRef, sentinelRef: topSentinelRef, }); @@ -393,6 +419,49 @@ const MessageTimelineBase = React.forwardRef< messages: showTimelineSkeleton ? EMPTY_MESSAGES : deferredMessages, }); + const timelineList = showMessageList ? ( + + ) : null; + return (
@@ -429,221 +498,205 @@ const MessageTimelineBase = React.forwardRef< ) : null}
-
-
- - {/* Fixed-height slot: an always-mounted height keeps the virtual - spacer's offset stable across the load-older fetch toggle, so - `scrollMargin` doesn't shift mid-fetch and yank the restore. The - visible fetch spinner lives in the absolute overlay above, which - does not occupy inline flow. */} -
- + {useTimelineVirtualizer && timelineList ? ( +
+ {timelineList} +
+ ) : (
- {showTimelineSkeleton ? ( - - ) : null} - {activeDirectMessageIntro ? ( -
- -

- {activeDirectMessageIntro.displayName} -

-

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

-
- ) : null} +
- {activeChannelIntro ? ( -
+ {/* Fixed-height slot: an always-mounted height keeps the virtual + spacer's offset stable across the load-older fetch toggle, so + `scrollMargin` doesn't shift mid-fetch and yank the restore. The + visible fetch spinner lives in the absolute overlay above, which + does not occupy inline flow. */} +
+ +
+ {showTimelineSkeleton ? ( + + ) : null} + {activeDirectMessageIntro ? (
- {activeChannelIntro.icon ?? ( - - )} -
-

- #{activeChannelIntro.channelName} -

-

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

- {activeChannelIntro.description ? ( -

- {activeChannelIntro.description} + +

+ {activeDirectMessageIntro.displayName}

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

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

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

- {emptyTitle} -

-

- {emptyDescription} -

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

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} - {showMessageList ? ( -
- -
- ) : null} + {showMessageList ? ( +
+ {timelineList} +
+ ) : null} +
-
+ )}
{!isAtBottom ? ( diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 5623019dee..3e483239fd 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -1,6 +1,11 @@ import * as React from "react"; import { GroupedVirtuoso } from "react-virtuoso"; -import type { Components, GroupedVirtuosoHandle } from "react-virtuoso"; +import type { + Components, + GroupedVirtuosoHandle, + ListRange, + ScrollerProps, +} from "react-virtuoso"; import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; @@ -93,10 +98,12 @@ type TimelineMessageListProps = { /** 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; + useVirtualizer?: boolean; onStartReached?: () => void; onAtBottomStateChange?: (atBottom: boolean) => void; onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + onVirtualizerRangeChanged?: () => void; + onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; }; export const TimelineMessageList = React.memo(function TimelineMessageList({ @@ -130,10 +137,12 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ searchQuery, threadUnreadCounts, unfollowThreadById, - scrollParent = null, + useVirtualizer = false, onStartReached, onAtBottomStateChange, onVirtualizerApiChange, + onVirtualizerRangeChanged, + onVirtualizerScrollerChange, }: TimelineMessageListProps) { const entries = React.useMemo( () => @@ -275,15 +284,16 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ ], ); - if (scrollParent) { + if (useVirtualizer) { return ( ); } @@ -326,8 +336,9 @@ type VirtualizedTimelineRowsProps = { onAtBottomStateChange?: (atBottom: boolean) => void; onStartReached?: () => void; onVirtualizerApiChange?: (api: TimelineVirtualizerApi | null) => void; + onVirtualizerRangeChanged?: () => void; + onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void; renderItem: (item: TimelineNonDayItem) => React.ReactNode; - scrollParent: HTMLElement; }; function VirtualizedTimelineRows({ @@ -335,8 +346,9 @@ function VirtualizedTimelineRows({ onAtBottomStateChange, onStartReached, onVirtualizerApiChange, + onVirtualizerRangeChanged, + onVirtualizerScrollerChange, renderItem, - scrollParent, }: VirtualizedTimelineRowsProps) { const virtuosoRef = React.useRef(null); const firstItemIndexStateRef = React.useRef({ @@ -390,17 +402,30 @@ function VirtualizedTimelineRows({ return () => onVirtualizerApiChange(null); }, [messageVirtualIndexById, onVirtualizerApiChange]); + const handleScrollerRef = React.useCallback( + (ref: HTMLElement | Window | null) => { + onVirtualizerScrollerChange?.(ref instanceof HTMLDivElement ? ref : null); + }, + [onVirtualizerScrollerChange], + ); + + const handleRangeChanged = React.useCallback( + (_range: ListRange) => { + onVirtualizerRangeChanged?.(); + }, + [onVirtualizerRangeChanged], + ); + return ( ref={virtuosoRef} - className="min-h-full" + className="h-full min-h-0 w-full" components={virtuosoComponents} computeItemKey={(index, item) => item === undefined ? `timeline-missing-${index}` : getTimelineItemKey(item) } - customScrollParent={scrollParent} data={flattenedItems} defaultItemHeight={72} firstItemIndex={firstItemIndex} @@ -433,12 +458,32 @@ function VirtualizedTimelineRows({ itemContent={(_index, _groupIndex, item) => ( {renderItem(item)} )} + rangeChanged={handleRangeChanged} + scrollerRef={handleScrollerRef} startReached={onStartReached ? () => onStartReached() : undefined} /> ); } const virtuosoComponents: Components = { + Scroller: React.forwardRef< + HTMLDivElement, + ScrollerProps & { className?: string } + >(function TimelineVirtuosoScroller({ className, ...props }, ref) { + return ( +
+ ); + }), List: React.forwardRef>( function TimelineVirtuosoList({ className, ...props }, ref) { return ( diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index ad7c4931cc..e58a988444 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -57,6 +57,8 @@ type UseAnchoredScrollOptions = { virtualScrollToBottom?: (behavior?: ScrollBehavior) => void; /** Spike mode: let the virtualizer preserve prepend position via its own firstItemIndex path. */ virtualizerOwnsPrependAnchoring?: boolean; + /** Bumps when a virtualized range changes, so pending target/search retries can re-check newly mounted DOM. */ + virtualizerRenderVersion?: number; }; type UseAnchoredScrollResult = { @@ -163,6 +165,7 @@ export function useAnchoredScroll({ virtualScrollToMessage, virtualScrollToBottom, virtualizerOwnsPrependAnchoring = false, + virtualizerRenderVersion = 0, }: 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 @@ -288,8 +291,11 @@ export function useAnchoredScroll({ ) { anchorRef.current = { kind: "message", messageId, topOffset: 0 }; setIsAtBottom(false); - if (options.highlight) highlightMessage(messageId); - return true; + // In virtualized mode, this is only the phase-1 realization request: + // the target row is not in the DOM yet, so centering can still re-aim + // once Virtuoso mounts/measures it. Start the highlight clock only on + // the later DOM-visible path, otherwise it can fade before the row has + // settled in view. } return false; } @@ -579,6 +585,7 @@ export function useAnchoredScroll({ scrollToMessageImperative, targetMessageId, virtualizerOwnsPrependAnchoring, + virtualizerRenderVersion, ]); React.useEffect(() => {