feat(desktop): use MessageList in ConversationPage (replace react-virtuoso)
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user