diff --git a/apps/desktop/src/components/MessageList.tsx b/apps/desktop/src/components/MessageList.tsx new file mode 100644 index 0000000..b083dad --- /dev/null +++ b/apps/desktop/src/components/MessageList.tsx @@ -0,0 +1,182 @@ +import { useVirtualizer } from '@tanstack/react-virtual'; +import { + forwardRef, + useCallback, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from 'react'; + +import { isNearBottom, isNearTop, type Anchor } from '../lib/scrollController'; +import type { VirtuosoRow } from '../pages/ConversationPage'; + +export interface MessageListHandle { + scrollToBottom(behavior?: ScrollBehavior): void; + scrollToRow(index: number, align?: 'center' | 'end', behavior?: ScrollBehavior): void; +} + +export interface MessageListProps { + rows: VirtuosoRow[]; + renderRow: (index: number, row: VirtuosoRow) => ReactNode; + computeKey: (row: VirtuosoRow) => string; + /** Initial scroll target for a freshly-mounted list. */ + initialAnchor: { type: 'bottom' } | { type: 'row'; index: number }; + /** Reveal gate — the list stays hidden behind a spinner until true, so the + * post-paint height cascade (reactions/divider) is never visible. */ + ready: boolean; + estimateRowHeight?: number; + atBottomThreshold?: number; + onReachTop?: () => void; + onAtBottomChange?: (atBottom: boolean) => void; + onTopRowChange?: (topIndex: number) => void; +} + +export const MessageList = forwardRef(function MessageList( + { + rows, + renderRow, + computeKey, + initialAnchor, + ready, + estimateRowHeight = 64, + atBottomThreshold = 64, + onReachTop, + onAtBottomChange, + onTopRowChange, + }, + ref, +) { + const scrollElRef = useRef(null); + const [revealed, setRevealed] = useState(false); + const atBottomRef = useRef(true); + // Load-older preservation: remember the first row key + scrollHeight so a + // prepend can be detected and the viewport restored. + const prevFirstKeyRef = useRef(null); + const prevScrollHeightRef = useRef(0); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollElRef.current, + estimateSize: () => estimateRowHeight, + overscan: 8, + getItemKey: (index) => computeKey(rows[index]!), + }); + + const readMetrics = useCallback(() => { + const el = scrollElRef.current; + return el + ? { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight } + : { scrollTop: 0, scrollHeight: 0, clientHeight: 0 }; + }, []); + + const applyAnchor = useCallback( + (anchor: Anchor) => { + virtualizer.scrollToIndex(anchor.index, { align: anchor.align }); + // Re-apply next frame: dynamic measurement settles after first paint, so a + // single scrollToIndex can land a few px off. Still hidden here → invisible. + requestAnimationFrame(() => virtualizer.scrollToIndex(anchor.index, { align: anchor.align })); + }, + [virtualizer], + ); + + // Deferred reveal: when ready, anchor (before paint) then reveal. + useLayoutEffect(() => { + if (!ready || revealed || rows.length === 0) return; + const anchor: Anchor = + initialAnchor.type === 'bottom' + ? { index: rows.length - 1, align: 'end' } + : { index: Math.max(0, Math.min(initialAnchor.index, rows.length - 1)), align: 'start' }; + applyAnchor(anchor); + atBottomRef.current = initialAnchor.type === 'bottom'; + onAtBottomChange?.(atBottomRef.current); + requestAnimationFrame(() => setRevealed(true)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready, rows.length]); + + // Stick-to-bottom: when content grows and we were at the bottom, re-pin. + useLayoutEffect(() => { + if (!revealed) return; + if (atBottomRef.current) { + virtualizer.scrollToIndex(rows.length - 1, { align: 'end' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows.length, virtualizer.getTotalSize()]); + + // Load-older preservation: if rows were prepended (first key changed and the + // user is near the top), restore scrollTop by the height delta so the viewport + // stays put instead of jumping. + useLayoutEffect(() => { + const firstKey = rows.length > 0 ? computeKey(rows[0]!) : null; + const el = scrollElRef.current; + if (el && revealed && prevFirstKeyRef.current && firstKey !== prevFirstKeyRef.current) { + const delta = el.scrollHeight - prevScrollHeightRef.current; + if (delta > 0 && el.scrollTop < atBottomThreshold * 4) { + el.scrollTop += delta; + } + } + prevFirstKeyRef.current = firstKey; + prevScrollHeightRef.current = el?.scrollHeight ?? 0; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows]); + + const handleScroll = useCallback(() => { + const m = readMetrics(); + const atBottom = isNearBottom(m, atBottomThreshold); + if (atBottom !== atBottomRef.current) { + atBottomRef.current = atBottom; + onAtBottomChange?.(atBottom); + } + if (isNearTop(m, atBottomThreshold * 4)) onReachTop?.(); + const first = virtualizer.getVirtualItems()[0]; + if (first) onTopRowChange?.(first.index); + }, [atBottomThreshold, onAtBottomChange, onReachTop, onTopRowChange, readMetrics, virtualizer]); + + useImperativeHandle( + ref, + () => ({ + scrollToBottom: () => { + atBottomRef.current = true; + virtualizer.scrollToIndex(rows.length - 1, { align: 'end' }); + }, + scrollToRow: (index, align = 'center') => { + virtualizer.scrollToIndex(index, { align }); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [virtualizer, rows.length], + ); + + const items = virtualizer.getVirtualItems(); + + return ( +
+
+ {items.map((vi) => ( +
+ {renderRow(vi.index, rows[vi.index]!)} +
+ ))} +
+ {/* 12px bottom breathing space (matches the old Virtuoso Footer). */} +
+
+ ); +});