diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c332c38..9a9ab21 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,6 +35,7 @@ "react-easy-crop": "^5.5.7", "react-i18next": "^15.1.1", "react-router-dom": "^6.28.0", + "react-virtuoso": "^4.18.7", "zustand": "^5.0.1" }, "devDependencies": { diff --git a/apps/desktop/src/pages/ConversationPage.tsx b/apps/desktop/src/pages/ConversationPage.tsx index c034f3a..44a2339 100644 --- a/apps/desktop/src/pages/ConversationPage.tsx +++ b/apps/desktop/src/pages/ConversationPage.tsx @@ -1,8 +1,9 @@ import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat'; import { extractErrorCode } from '@chat-app/shared/i18n'; -import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +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 } from 'react-virtuoso'; import { ConversationHeader } from '../components/ConversationHeader'; import { EmojiPicker } from '../components/EmojiPicker'; @@ -77,7 +78,14 @@ import { usePeerPresence } from '../lib/usePeerPresence'; import { usePinnedMessages } from '../lib/usePinnedMessages'; import { useTypingChannel } from '../lib/useTypingChannel'; -const STICK_THRESHOLD = 80; +// Discriminated union for rows inside the virtualized message list. Keeping +// pending bubbles and the "load older" tile inside the same Virtuoso +// instance means scroll-to-bottom / followOutput stay coherent across both +// (we don't need a sibling scroll container for pending items). +type VirtuosoRow = + | { kind: 'loader'; key: string } + | { kind: 'message'; key: string; message: DecryptedMessage; idx: number } + | { kind: 'pending'; key: string; item: OutboxItem }; // Stable empty-reactions sentinel. We pass this when a message has no // reactions instead of `[]` literal — a fresh array per render would defeat @@ -89,10 +97,16 @@ const EMPTY_REACTIONS: AggregatedReaction[] = []; // of ConversationPage when the route param (`id`) changes — switching // chats unmounts/remounts the page in our router setup. Session-only // (lost on reload, like Discord). The `stickToBottom` flag is preserved -// alongside the pixel offset so a chat the user left at the bottom keeps -// auto-following new messages when they return; a chat scrolled up -// returns to the exact spot the user was reading. -const scrollPositions = new Map(); +// alongside the topmost-visible row index so a chat the user left at the +// bottom keeps auto-following new messages when they return; a chat +// scrolled up returns to roughly the same row the user was reading. +// +// We track the topmost-visible row index rather than a pixel `scrollTop` +// because `react-virtuoso` virtualizes the list — the underlying scroll +// element's pixel offset depends on dynamically-measured row heights and +// is not stable across re-mounts. Using a row index restores the user's +// reading position even if some rows above re-render at different heights. +const scrollPositions = new Map(); export function ConversationPage() { const { t } = useTranslation(['app', 'errors']); @@ -263,8 +277,8 @@ export function ConversationPage() { }, [send], ); - const scrollRef = useRef(null); - const loadMoreSentinelRef = useRef(null); + const virtuosoRef = useRef(null); + const topmostIndexRef = useRef(0); const fileInputRef = useRef(null); const composerRef = useRef(null); @@ -309,21 +323,17 @@ export function ConversationPage() { previousMessageIdsRef.current = new Set(messages.map((message) => message.id)); }, [messages, myId, stickToBottom]); - useEffect(() => { - const el = loadMoreSentinelRef.current; - if (!el) return; - if (displayCount >= messages.length) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - setDisplayCount((n) => Math.min(messages.length, n * 2)); - } - }, - { root: scrollRef.current, rootMargin: '200px 0px' }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [displayCount, messages.length]); + // Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso + // calls `startReached` when the user scrolls near the first row of the + // virtualized list. We bump `displayCount` the same way the old observer + // did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll + // observer on every parent re-render. + const handleStartReached = useCallback(() => { + setDisplayCount((n) => { + if (n >= messages.length) return n; + return Math.min(messages.length, n * 2); + }); + }, [messages.length]); const messageById = useMemo(() => { const m = new Map(); @@ -390,15 +400,87 @@ export function ConversationPage() { return out; }, [messages, buildQuoted]); - const jumpToMessage = useCallback((targetId: string) => { - const el = scrollRef.current?.querySelector( - '[data-message-id="' + CSS.escape(targetId) + '"]', - ); - if (!el) return; - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - setHighlightedId(targetId); - window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600); - }, []); + // Build the discriminated-union row list Virtuoso renders. We use a + // single virtualized list rather than separate "messages" and "pending" + // sections so the unsent items stay at the bottom of the scroll viewport + // (and Virtuoso's `followOutput` still triggers correctly when a new + // outbox item is appended). Optional row 0 is the "load older" tile — + // matches the old IntersectionObserver-sentinel pattern. + const virtuosoRows = useMemo(() => { + const out: VirtuosoRow[] = []; + const hasLoader = displayCount < messages.length; + if (hasLoader) { + out.push({ kind: 'loader', key: '__loader__' }); + } + const sliceStart = Math.max(0, messages.length - displayCount); + for (let i = sliceStart; i < messages.length; i++) { + const m = messages[i]; + if (!m) continue; + out.push({ kind: 'message', key: m.id, message: m, idx: i }); + } + for (const p of pending) { + out.push({ kind: 'pending', key: 'pending-' + p.id, item: p }); + } + return out; + }, [messages, pending, displayCount]); + + // Initial scroll position for the freshly-mounted Virtuoso instance. + // Default = bottom (newest message). If we have a saved position from a + // 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 saved = savedPositionRef.current; + if (saved && !saved.stickToBottom) { + return Math.max(0, Math.min(saved.topmostIndex, virtuosoRows.length - 1)); + } + return virtuosoRows.length - 1; + // virtuosoRows.length changes when the conversation loads — that's the + // intentional trigger so a freshly-loaded chat anchors to the bottom + // on first paint. We deliberately don't re-derive this on every row + // append; Virtuoso owns scroll position from that point on. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [virtuosoRows.length > 0]); + + const jumpToMessage = useCallback( + (targetId: string) => { + const msgIdx = messages.findIndex((m) => m.id === targetId); + if (msgIdx < 0) return; // pinned message outside loaded cache — no-op + // Make sure the target is actually inside the rendered slice; if the + // user has only loaded the most-recent 150 rows but is jumping to an + // older message, expand the slice so the row exists in the virtual + // list before we ask Virtuoso to scroll to it. + const needsAtLeast = messages.length - msgIdx; + if (needsAtLeast > displayCount) { + setDisplayCount(needsAtLeast); + } + // Convert message-array index into row index for the virtuoso rows + // array (see `rows` further down). The slice starts at + // `messages.length - displayCount`, and row 0 is the optional + // "load older" header. + const targetDisplayCount = Math.max(displayCount, needsAtLeast); + const sliceStart = Math.max(0, messages.length - targetDisplayCount); + const hasLoader = targetDisplayCount < messages.length; + const rowIndex = (hasLoader ? 1 : 0) + (msgIdx - sliceStart); + // requestAnimationFrame: when we just bumped `displayCount`, Virtuoso + // needs a paint to register the new rows before scrollToIndex can + // 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', + }); + }); + setHighlightedId(targetId); + window.setTimeout( + () => setHighlightedId((cur) => (cur === targetId ? null : cur)), + 1600, + ); + }, + [messages, displayCount], + ); const handleReply = useCallback((m: DecryptedMessage) => { setReplyTo(m); @@ -549,85 +631,91 @@ export function ConversationPage() { if (id && messages.length > 0) markRead(id); }, [id, messages.length, markRead]); - // useLayoutEffect: run synchronously after DOM commit, before the - // browser paints. Using useEffect here let one frame of "scrollTop = 0 - // (top of list)" paint between message-list mount and the auto-scroll, - // which is exactly the "flickers to a different position, then jumps" - // glitch users saw when re-entering a chat. Layout-effect fires while - // the message list is in the DOM but before paint, so the first frame - // already shows the correct scroll position. - useLayoutEffect(() => { - const el = scrollRef.current; - if (!el || !stickToBottom) return; - el.scrollTop = el.scrollHeight; - }, [messages.length, stickToBottom]); + // Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which + // fires whenever the rendered row count grows and auto-scrolls down only + // if the user was already at the bottom — exactly the Discord behavior + // we want for both outgoing sends and incoming realtime messages. + // The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no + // longer needed: Virtuoso owns scroll positioning now. - // Restore saved scroll position once the conversation's messages have - // actually rendered. The earlier version fired on `[id]` alone and ran - // before the message list populated — scrollHeight was still tiny, so - // `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser - // and the user landed at the top instead of the saved position. By - // waiting for `messages.length > 0` we know the rendered scrollHeight - // is meaningful. `restoredForRef` ensures the restore runs at most - // once per chat switch (subsequent message arrivals don't re-trigger). - const restoredForRef = useRef(null); - const isRestoringRef = useRef(false); + // Snapshot of the saved position for this conversation, captured once on + // mount. Used to derive the `initialTopMostItemIndex` we hand to the + // Virtuoso instance below — Virtuoso applies that index synchronously + // before its first paint, so re-entering a chat shows the saved row in + // one frame rather than a "starts at top, jumps" flicker. + const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>( + null, + ); + if (savedPositionRef.current === null && id) { + savedPositionRef.current = scrollPositions.get(id) ?? null; + } - // useLayoutEffect, same reason as above: writing scrollTop here happens - // before the first paint of the freshly-mounted chat, so the user - // doesn't see a frame at scrollTop=0 before the jump to the saved - // position. Combined with the messages.length gate this means the - // re-entry shows the message list AT the saved scroll location in one - // single paint — no "loaded then jumped" effect. - useLayoutEffect(() => { - const el = scrollRef.current; - if (!el || !id) return; - if (restoredForRef.current === id) return; - // Wait for the conversation's messages to populate; for a chat that - // truly has zero messages the bottom and the top are the same anyway. - if (messages.length === 0) return; - restoredForRef.current = id; - const saved = scrollPositions.get(id); - // Suppress handleScroll's persistence during the programmatic scroll - // below — otherwise the browser's clamp/normalisation could write a - // different scrollTop back into the Map and lose the saved position. - isRestoringRef.current = true; - if (saved && !saved.stickToBottom) { - el.scrollTop = saved.scrollTop; - setStickToBottom(false); - } else { - setStickToBottom(true); - el.scrollTop = el.scrollHeight; - } - requestAnimationFrame(() => { - isRestoringRef.current = false; - }); - }, [id, messages.length]); + // Track whether the user is currently scrolled to the bottom. Virtuoso + // calls this whenever the bottom-state changes; we feed it into + // `stickToBottom` (used by the "jump to newest" pill and by the + // "new messages while away" counter logic). Also clears the unread- + // away counter when the user actually reaches the bottom. + const handleAtBottomStateChange = useCallback( + (atBottom: boolean) => { + setStickToBottom(atBottom); + if (atBottom) setNewMessagesWhileAway(0); + if (id) { + scrollPositions.set(id, { + topmostIndex: topmostIndexRef.current, + stickToBottom: atBottom, + }); + } + }, + [id], + ); - const handleScroll = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - const nextStick = distanceFromBottom < STICK_THRESHOLD; - setStickToBottom(nextStick); - if (nextStick) setNewMessagesWhileAway(0); - // Persist position per chat so re-entering this conversation lands - // where the user left off (see scrollPositions module-level Map). - // Skipped during the in-flight restore so we don't immediately - // overwrite the saved position with a clamped value. - if (id && !isRestoringRef.current) { - scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick }); - } - }, [id]); + // Persists the topmost-visible row index per conversation so re-entering + // the chat lands roughly where the user left off (see scrollPositions + // Map). Virtuoso fires `rangeChanged` whenever the visible range shifts; + // we only care about the start of the range here. + const handleRangeChanged = useCallback( + (range: { startIndex: number; endIndex: number }) => { + topmostIndexRef.current = range.startIndex; + if (id) { + const prev = scrollPositions.get(id); + scrollPositions.set(id, { + topmostIndex: range.startIndex, + stickToBottom: prev?.stickToBottom ?? true, + }); + } + }, + [id], + ); const jumpToBottom = useCallback(() => { - const el = scrollRef.current; - if (!el) return; - el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + virtuosoRef.current?.scrollToIndex({ + index: 'LAST', + align: 'end', + behavior: 'smooth', + }); setStickToBottom(true); setNewMessagesWhileAway(0); }, []); + // Whenever an outgoing pending row appears, snap the viewport to the + // bottom so the user sees their freshly-sent message land. This replaces + // the old `setStickToBottom(true)` pattern that piggy-backed on a + // `useLayoutEffect` writing scrollTop — Virtuoso owns scroll positioning + // now, so we have to call it explicitly. Tracked via a ref so we only + // scroll when the count actually grew (not on every render where it + // happens to be > 0). + const lastPendingCountRef = useRef(0); + useEffect(() => { + if (pending.length > lastPendingCountRef.current) { + virtuosoRef.current?.scrollToIndex({ + index: 'LAST', + align: 'end', + behavior: 'auto', + }); + } + lastPendingCountRef.current = pending.length; + }, [pending.length]); + async function handleSend(e?: React.FormEvent) { e?.preventDefault(); if ((!text.trim() && attachments.length === 0) || sending) return; @@ -879,39 +967,69 @@ export function ConversationPage() { {incomingHere && conversation && } {conversation && } -
+
{loading ? ( -
+
) : error ? ( - {error} +
+ {error} +
) : messages.length === 0 ? ( - } - title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })} - description={t('app:chats.conv_empty_desc', { - defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.', - })} - /> +
+ } + title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })} + description={t('app:chats.conv_empty_desc', { + defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.', + })} + /> +
) : ( -
    - {displayCount < messages.length && ( -
  • -
    - Lade ältere Nachrichten… -
    -
  • - )} - {messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => { - const idx = Math.max(0, messages.length - displayCount) + sliceIdx; + 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). + followOutput={(isAtBottom) => (isAtBottom ? 'smooth' : false)} + atBottomStateChange={handleAtBottomStateChange} + atBottomThreshold={80} + rangeChanged={handleRangeChanged} + startReached={handleStartReached} + // Render rows just outside the viewport so fast scrolling + // doesn't briefly flash empty space. + increaseViewportBy={400} + itemContent={(_index, row) => { + if (row.kind === 'loader') { + return ( +
    + Lade ältere Nachrichten… +
    + ); + } + if (row.kind === 'pending') { + return ( + retryPending(row.item.id)} + onCancel={() => cancelPending(row.item.id)} + /> + ); + } + const m = row.message; + const idx = row.idx; const prevRaw = messages[idx - 1]; const nextRaw = messages[idx + 1]; const prevIsCallEvent = @@ -925,7 +1043,7 @@ export function ConversationPage() { const senderProfile = memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null); return ( -
  • +
    {firstUnreadId === m.id && (
    -
  • +
); - })} - {pending.map((p) => ( -
  • - retryPending(p.id)} - onCancel={() => cancelPending(p.id)} - /> -
  • - ))} - + }} + /> )}
    @@ -1348,9 +1457,12 @@ export function ConversationPage() { open={pinnedPanelOpen} pins={pins} onClose={() => setPinnedPanelOpen(false)} - onJump={(_messageId) => { - // Future: scroll to message. For now just close the panel. + onJump={(messageId) => { + // Close the panel first so the underlying message-list viewport + // is fully visible before the smooth-scroll runs (otherwise the + // panel would briefly cover the highlighted target row). setPinnedPanelOpen(false); + jumpToMessage(messageId); }} onUnpin={(messageId) => void handleTogglePin(messageId)} /> diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 830e310..3c1c3c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: react-router-dom: specifier: ^6.28.0 version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-virtuoso: + specifier: ^4.18.7 + version: 4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zustand: specifier: ^5.0.1 version: 5.0.12(@types/react@18.3.28)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) @@ -5420,6 +5423,12 @@ packages: peerDependencies: react: ^18.3.1 + react-virtuoso@4.18.7: + resolution: {integrity: sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -12820,6 +12829,11 @@ snapshots: react-shallow-renderer: 16.15.0(react@18.3.1) scheduler: 0.23.2 + react-virtuoso@4.18.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react@18.3.1: dependencies: loose-envify: 1.4.0