From 271d6fff5c9ea1552cdbff62f79244402f767766 Mon Sep 17 00:00:00 2001 From: byGalax Date: Tue, 2 Jun 2026 20:32:08 +0200 Subject: [PATCH] feat(desktop): use MessageList in ConversationPage (replace react-virtuoso) --- apps/desktop/src/pages/ConversationPage.tsx | 132 +++++++------------- 1 file changed, 43 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index 44b4977..89e4de2 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -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(null); + const listRef = useRef(null); const topmostIndexRef = useRef(0); const fileInputRef = useRef(null); const composerRef = useRef(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(() => { + 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() { /> ) : ( - 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. +