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 { useTranslation } from 'react-i18next';
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 { ConversationHeader } from '../components/ConversationHeader';
@@ -158,8 +158,22 @@ export function ConversationPage() {
byMessage: reactionsByMessage,
toggle: toggleReaction,
voteExclusive: votePoll,
ready: reactionsReady,
} = 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 ownMessageIds = useMemo(
@@ -318,7 +332,7 @@ export function ConversationPage() {
},
[send],
);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const listRef = useRef<MessageListHandle>(null);
const topmostIndexRef = useRef<number>(0);
const fileInputRef = useRef<HTMLInputElement>(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
// bottom, restore the saved row index (clamped to the current row
// 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;
if (saved && !saved.stickToBottom) {
// Restore the row the user was reading, pinned to the TOP of the
// 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]);
if (saved && !saved.stickToBottom) return { type: 'row', index: saved.topmostIndex };
return { type: 'bottom' };
}, []);
// SCROLL_DEBUG: log every render with the height-affecting inputs so the
// post-mount cascade (reactions/pins/receipts/divider/refresh) is visible.
useEffect(() => {
dbgLog(
`[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}` +
` 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
// lands on a stale row.
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: rowIndex,
align: 'center',
behavior: 'smooth',
});
listRef.current?.scrollToRow(rowIndex, 'center', 'smooth');
});
setHighlightedId(targetId);
window.setTimeout(
@@ -762,11 +753,7 @@ export function ConversationPage() {
);
const jumpToBottom = useCallback(() => {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'smooth',
});
listRef.current?.scrollToBottom('smooth');
setStickToBottom(true);
setNewMessagesWhileAway(0);
}, []);
@@ -786,11 +773,7 @@ export function ConversationPage() {
const lastPendingCountRef = useRef(pending.length);
useEffect(() => {
if (pending.length > lastPendingCountRef.current) {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'auto',
});
listRef.current?.scrollToBottom('auto');
}
lastPendingCountRef.current = pending.length;
}, [pending.length]);
@@ -805,11 +788,7 @@ export function ConversationPage() {
// targets the correct bottom edge.
const snapToBottom = useCallback(() => {
window.requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: 'LAST',
align: 'end',
behavior: 'auto',
});
listRef.current?.scrollToBottom('auto');
});
}, []);
@@ -1094,49 +1073,24 @@ export function ConversationPage() {
/>
</div>
) : (
<Virtuoso
ref={virtuosoRef}
className="flex-1"
style={{ height: '100%' }}
data={virtuosoRows}
computeItemKey={(_idx, row) => row.key}
// Initial position: either restored from per-conv memory, or
// pinned to the bottom for fresh entry. Virtuoso applies this
// synchronously before its first paint so the user doesn't see
// a "loaded at top, then jumped" flicker (matches the layout-
// effect behavior we used in the non-virtualized version).
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.
<MessageList
ref={listRef}
rows={virtuosoRows}
// Deferred reveal: the list stays hidden until messages + reactions
// + the unread divider are loaded, then anchors and reveals — so the
// post-paint height cascade is never visible (no chat-switch flicker).
ready={listReady}
computeKey={(row) => row.key}
initialAnchor={initialAnchor}
// 250px at-bottom tolerance — a tall appended row (image, voice note,
// grouped attachments) shouldn't push the user out of the at-bottom zone.
atBottomThreshold={250}
rangeChanged={handleRangeChanged}
startReached={handleStartReached}
// Render rows just outside the viewport so fast scrolling
// doesn't briefly flash empty space.
increaseViewportBy={400}
// Visual breathing space below the last message so a bubble
// 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) => {
onReachTop={handleStartReached}
onAtBottomChange={handleAtBottomStateChange}
onTopRowChange={(topIndex) =>
handleRangeChanged({ startIndex: topIndex, endIndex: topIndex })
}
renderRow={(_index, row) => {
if (row.kind === 'loader') {
return (
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">