feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)

This commit is contained in:
byGalax
2026-06-02 20:32:08 +02:00
parent 8b8d71bc4d
commit 271d6fff5c
+43 -89
View File
@@ -3,7 +3,7 @@ import { extractErrorCode } from '@chat-app/shared/i18n';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Virtuoso, type VirtuosoHandle, type IndexLocationWithAlign } from 'react-virtuoso'; import { MessageList, type MessageListHandle } from '../components/MessageList';
import { ComposerActionsMenu } from '../components/ComposerActionsMenu'; import { ComposerActionsMenu } from '../components/ComposerActionsMenu';
import { ConversationHeader } from '../components/ConversationHeader'; import { ConversationHeader } from '../components/ConversationHeader';
@@ -158,8 +158,22 @@ export function ConversationPage() {
byMessage: reactionsByMessage, byMessage: reactionsByMessage,
toggle: toggleReaction, toggle: toggleReaction,
voteExclusive: votePoll, voteExclusive: votePoll,
ready: reactionsReady,
} = useMessageReactions(messageIds, session?.user.id); } = useMessageReactions(messageIds, session?.user.id);
// Deferred-reveal gate for MessageList: keep the list hidden until messages
// AND their reactions (the main post-paint height changer) are loaded, so the
// chat opens already-stable instead of flickering through the load cascade.
// A 300 ms max-timeout ensures a slow/empty reactions fetch never hangs it.
const [revealTimedOut, setRevealTimedOut] = useState(false);
useEffect(() => {
setRevealTimedOut(false);
if (!id || loading || messages.length === 0) return;
const tmo = window.setTimeout(() => setRevealTimedOut(true), 300);
return () => window.clearTimeout(tmo);
}, [id, loading, messages.length]);
const listReady = !loading && messages.length > 0 && (reactionsReady || revealTimedOut);
const myId = session?.user.id; const myId = session?.user.id;
const ownMessageIds = useMemo( const ownMessageIds = useMemo(
@@ -318,7 +332,7 @@ export function ConversationPage() {
}, },
[send], [send],
); );
const virtuosoRef = useRef<VirtuosoHandle>(null); const listRef = useRef<MessageListHandle>(null);
const topmostIndexRef = useRef<number>(0); const topmostIndexRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLTextAreaElement>(null); const composerRef = useRef<HTMLTextAreaElement>(null);
@@ -486,40 +500,21 @@ export function ConversationPage() {
// previous visit to this chat AND the user wasn't sticking to the // previous visit to this chat AND the user wasn't sticking to the
// bottom, restore the saved row index (clamped to the current row // bottom, restore the saved row index (clamped to the current row
// count in case the cache was trimmed). // count in case the cache was trimmed).
const initialTopMostIndex = useMemo<number | IndexLocationWithAlign>(() => { const initialAnchor = useMemo<{ type: 'bottom' } | { type: 'row'; index: number }>(() => {
const saved = savedPositionRef.current; const saved = savedPositionRef.current;
if (saved && !saved.stickToBottom) { if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex };
// Restore the row the user was reading, pinned to the TOP of the return { type: 'bottom' };
// viewport — that's the anchor the index was captured at }, []);
// (handleRangeChanged stores range.startIndex).
const idx = Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1));
return { index: idx, align: 'start' };
}
// Bottom case (the common one): anchor the LAST row to the END (bottom)
// edge of the viewport. This is the fix for the "jumps once on chat
// switch" bug: a plain numeric index aligns the row to the TOP, so
// react-virtuoso paints with estimated row heights, then measures the
// real (taller) heights of the dynamic bubbles (avatars, attachments,
// multi-line text, reactions) and corrects scrollTop — a visible jump on
// every mount. `align: 'end'` pins the bottom edge instead, so the
// post-measurement height growth happens above the fold and the viewport
// stays put. This is react-virtuoso's canonical "start at the bottom" form.
return { index: 'LAST', align: 'end' };
// virtuosoRows.length flipping 0 -> >0 is the intentional trigger so a
// freshly-loaded chat anchors on first paint; we deliberately don't
// re-derive on every row append — Virtuoso owns scroll position after.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [virtuosoRows.length > 0]);
// SCROLL_DEBUG: log every render with the height-affecting inputs so the // SCROLL_DEBUG: log every render with the height-affecting inputs so the
// post-mount cascade (reactions/pins/receipts/divider/refresh) is visible. // post-mount cascade (reactions/pins/receipts/divider/refresh) is visible.
useEffect(() => { useEffect(() => {
dbgLog( dbgLog(
`[scroll] render t=${dbgNow()} id=${(id ?? '').slice(0, 6)} loading=${loading}` + `[scroll] render t=${dbgNow()} id=${(id ?? '').slice(0, 6)} loading=${loading}` +
` msgs=${messages.length} rows=${virtuosoRows.length}` + ` msgs=${messages.length} rows=${virtuosoRows.length} ready=${listReady}` +
` reactions=${reactionsByMessage.size} pins=${pins.length}` + ` reactions=${reactionsByMessage.size} pins=${pins.length}` +
` firstUnread=${firstUnreadId ? 'set' : '-'}` + ` firstUnread=${firstUnreadId ? 'set' : '-'}` +
` displayCount=${displayCount} initIdx=${JSON.stringify(initialTopMostIndex)}`, ` displayCount=${displayCount} anchor=${JSON.stringify(initialAnchor)}`,
); );
}); });
@@ -548,11 +543,7 @@ export function ConversationPage() {
// resolve the target row. Without this, the scroll either no-ops or // resolve the target row. Without this, the scroll either no-ops or
// lands on a stale row. // lands on a stale row.
requestAnimationFrame(() => { requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({ listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
index: rowIndex,
align: 'center',
behavior: 'smooth',
});
}); });
setHighlightedId(targetId); setHighlightedId(targetId);
window.setTimeout( window.setTimeout(
@@ -762,11 +753,7 @@ export function ConversationPage() {
); );
const jumpToBottom = useCallback(() => { const jumpToBottom = useCallback(() => {
virtuosoRef.current?.scrollToIndex({ listRef.current?.scrollToBottom('smooth');
index: 'LAST',
align: 'end',
behavior: 'smooth',
});
setStickToBottom(true); setStickToBottom(true);
setNewMessagesWhileAway(0); setNewMessagesWhileAway(0);
}, []); }, []);
@@ -786,11 +773,7 @@ export function ConversationPage() {
const lastPendingCountRef = useRef(pending.length); const lastPendingCountRef = useRef(pending.length);
useEffect(() => { useEffect(() => {
if (pending.length > lastPendingCountRef.current) { if (pending.length > lastPendingCountRef.current) {
virtuosoRef.current?.scrollToIndex({ listRef.current?.scrollToBottom('auto');
index: 'LAST',
align: 'end',
behavior: 'auto',
});
} }
lastPendingCountRef.current = pending.length; lastPendingCountRef.current = pending.length;
}, [pending.length]); }, [pending.length]);
@@ -805,11 +788,7 @@ export function ConversationPage() {
// targets the correct bottom edge. // targets the correct bottom edge.
const snapToBottom = useCallback(() => { const snapToBottom = useCallback(() => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({ listRef.current?.scrollToBottom('auto');
index: 'LAST',
align: 'end',
behavior: 'auto',
});
}); });
}, []); }, []);
@@ -1094,49 +1073,24 @@ export function ConversationPage() {
/> />
</div> </div>
) : ( ) : (
<Virtuoso <MessageList
ref={virtuosoRef} ref={listRef}
className="flex-1" rows={virtuosoRows}
style={{ height: '100%' }} // Deferred reveal: the list stays hidden until messages + reactions
data={virtuosoRows} // + the unread divider are loaded, then anchors and reveals — so the
computeItemKey={(_idx, row) => row.key} // post-paint height cascade is never visible (no chat-switch flicker).
// Initial position: either restored from per-conv memory, or ready={listReady}
// pinned to the bottom for fresh entry. Virtuoso applies this computeKey={(row) => row.key}
// synchronously before its first paint so the user doesn't see initialAnchor={initialAnchor}
// a "loaded at top, then jumped" flicker (matches the layout- // 250px at-bottom tolerance — a tall appended row (image, voice note,
// effect behavior we used in the non-virtualized version). // grouped attachments) shouldn't push the user out of the at-bottom zone.
initialTopMostItemIndex={initialTopMostIndex}
// followOutput auto-scrolls only when the user was already at
// the bottom; returning `false` from the callback when they're
// scrolled up preserves their reading position when realtime
// messages arrive (critical UX: do NOT jerk the user).
//
// We deliberately use 'auto' (instant) rather than 'smooth':
// with a smooth scroll animation, atBottomStateChange fires
// `false` mid-animation (scrollTop is briefly above the new
// bottom) and then `true` after settle — that flips
// stickToBottom twice, flashing the "Zum neuesten" pill and
// re-rendering the whole list. Instant scroll has zero
// mid-animation state so the cascade never happens.
followOutput={(isAtBottom) => (isAtBottom ? 'auto' : false)}
atBottomStateChange={handleAtBottomStateChange}
// 250 px tolerance — large enough that appending a tall row
// (image, voice note, grouped attachments) doesn't push the
// user out of the at-bottom zone. The previous 80 px flipped
// stickToBottom on nearly every typical message arrival.
atBottomThreshold={250} atBottomThreshold={250}
rangeChanged={handleRangeChanged} onReachTop={handleStartReached}
startReached={handleStartReached} onAtBottomChange={handleAtBottomStateChange}
// Render rows just outside the viewport so fast scrolling onTopRowChange={(topIndex) =>
// doesn't briefly flash empty space. handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
increaseViewportBy={400} }
// Visual breathing space below the last message so a bubble renderRow={(_index, row) => {
// bottom doesn't sit flush against the composer top — matches
// Discord's chat-pane bottom padding.
components={{
Footer: () => <div style={{ height: '12px' }} />,
}}
itemContent={(_index, row) => {
if (row.kind === 'loader') { if (row.kind === 'loader') {
return ( return (
<div className="flex items-center justify-center py-2 text-xs text-fg-muted"> <div className="flex items-center justify-center py-2 text-xs text-fg-muted">