feat(desktop): TanStack-Virtual MessageList with deferred reveal
This commit is contained in:
@@ -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<MessageListHandle, MessageListProps>(function MessageList(
|
||||
{
|
||||
rows,
|
||||
renderRow,
|
||||
computeKey,
|
||||
initialAnchor,
|
||||
ready,
|
||||
estimateRowHeight = 64,
|
||||
atBottomThreshold = 64,
|
||||
onReachTop,
|
||||
onAtBottomChange,
|
||||
onTopRowChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const scrollElRef = useRef<HTMLDivElement>(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<string | null>(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 (
|
||||
<div
|
||||
ref={scrollElRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-full overflow-y-auto"
|
||||
style={{ opacity: revealed ? 1 : 0, position: 'relative' }}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: 'relative', width: '100%' }}>
|
||||
{items.map((vi) => (
|
||||
<div
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
{renderRow(vi.index, rows[vi.index]!)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 12px bottom breathing space (matches the old Virtuoso Footer). */}
|
||||
<div style={{ height: 12 }} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user