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