perf(P6B.T8): virtualize message list with react-virtuoso
Switches the ConversationPage chat list from a full O(N) render to windowed rendering via react-virtuoso. On long histories only the visible rows (plus a 400px overscan buffer) live in the DOM, ending the scroll jank and layout thrashing that hit conversations with >500 messages. Preserved behaviors: - Newest message visible on open via initialTopMostItemIndex. - Auto-scroll on send via a pending-count-based effect (the old setStickToBottom + useLayoutEffect pattern doesn't apply now that Virtuoso owns the scroll element). - Realtime auto-follow only when scrolled to bottom (followOutput). - 'New messages while away' counter + 'jump to newest' pill via atBottomStateChange. - Pinned-message / reply / search jumps via virtuosoRef.scrollToIndex; expands displayCount on the fly if the target is outside the rendered slice. Flash highlight unchanged. - Load-older infinite scroll via Virtuoso startReached (replaces the IntersectionObserver-on-sentinel pattern). - Per-conversation position memory now keys on row index instead of pixel scrollTop (the latter isn't meaningful under virtualization). Also wires the PinnedMessagesPanel onJump callback (previously a TODO that just closed the panel) into jumpToMessage, since virtualization made the smooth-scroll-from-pinned UX easy to deliver as a side benefit.
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-i18next": "^15.1.1",
|
"react-i18next": "^15.1.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.1"
|
"zustand": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
|
import { parseMessagePayload, pinMessage, unpinMessage } from '@chat-app/shared/chat';
|
||||||
import { extractErrorCode } from '@chat-app/shared/i18n';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import { ConversationHeader } from '../components/ConversationHeader';
|
import { ConversationHeader } from '../components/ConversationHeader';
|
||||||
import { EmojiPicker } from '../components/EmojiPicker';
|
import { EmojiPicker } from '../components/EmojiPicker';
|
||||||
@@ -77,7 +78,14 @@ import { usePeerPresence } from '../lib/usePeerPresence';
|
|||||||
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
import { usePinnedMessages } from '../lib/usePinnedMessages';
|
||||||
import { useTypingChannel } from '../lib/useTypingChannel';
|
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
|
// Stable empty-reactions sentinel. We pass this when a message has no
|
||||||
// reactions instead of `[]` literal — a fresh array per render would defeat
|
// 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
|
// of ConversationPage when the route param (`id`) changes — switching
|
||||||
// chats unmounts/remounts the page in our router setup. Session-only
|
// chats unmounts/remounts the page in our router setup. Session-only
|
||||||
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
// (lost on reload, like Discord). The `stickToBottom` flag is preserved
|
||||||
// alongside the pixel offset so a chat the user left at the bottom keeps
|
// alongside the topmost-visible row index so a chat the user left at the
|
||||||
// auto-following new messages when they return; a chat scrolled up
|
// bottom keeps auto-following new messages when they return; a chat
|
||||||
// returns to the exact spot the user was reading.
|
// scrolled up returns to roughly the same row the user was reading.
|
||||||
const scrollPositions = new Map<string, { scrollTop: number; stickToBottom: boolean }>();
|
//
|
||||||
|
// 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<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||||
|
|
||||||
export function ConversationPage() {
|
export function ConversationPage() {
|
||||||
const { t } = useTranslation(['app', 'errors']);
|
const { t } = useTranslation(['app', 'errors']);
|
||||||
@@ -263,8 +277,8 @@ export function ConversationPage() {
|
|||||||
},
|
},
|
||||||
[send],
|
[send],
|
||||||
);
|
);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
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);
|
||||||
|
|
||||||
@@ -309,21 +323,17 @@ export function ConversationPage() {
|
|||||||
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
|
previousMessageIdsRef.current = new Set(messages.map((message) => message.id));
|
||||||
}, [messages, myId, stickToBottom]);
|
}, [messages, myId, stickToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Replaces the previous IntersectionObserver-on-sentinel pattern: Virtuoso
|
||||||
const el = loadMoreSentinelRef.current;
|
// calls `startReached` when the user scrolls near the first row of the
|
||||||
if (!el) return;
|
// virtualized list. We bump `displayCount` the same way the old observer
|
||||||
if (displayCount >= messages.length) return;
|
// did. Wrapped in useCallback so Virtuoso doesn't tear down its scroll
|
||||||
const observer = new IntersectionObserver(
|
// observer on every parent re-render.
|
||||||
(entries) => {
|
const handleStartReached = useCallback(() => {
|
||||||
if (entries[0]?.isIntersecting) {
|
setDisplayCount((n) => {
|
||||||
setDisplayCount((n) => Math.min(messages.length, n * 2));
|
if (n >= messages.length) return n;
|
||||||
}
|
return Math.min(messages.length, n * 2);
|
||||||
},
|
});
|
||||||
{ root: scrollRef.current, rootMargin: '200px 0px' },
|
}, [messages.length]);
|
||||||
);
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [displayCount, messages.length]);
|
|
||||||
|
|
||||||
const messageById = useMemo(() => {
|
const messageById = useMemo(() => {
|
||||||
const m = new Map<string, DecryptedMessage>();
|
const m = new Map<string, DecryptedMessage>();
|
||||||
@@ -390,15 +400,87 @@ export function ConversationPage() {
|
|||||||
return out;
|
return out;
|
||||||
}, [messages, buildQuoted]);
|
}, [messages, buildQuoted]);
|
||||||
|
|
||||||
const jumpToMessage = useCallback((targetId: string) => {
|
// Build the discriminated-union row list Virtuoso renders. We use a
|
||||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
// single virtualized list rather than separate "messages" and "pending"
|
||||||
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
// sections so the unsent items stay at the bottom of the scroll viewport
|
||||||
);
|
// (and Virtuoso's `followOutput` still triggers correctly when a new
|
||||||
if (!el) return;
|
// outbox item is appended). Optional row 0 is the "load older" tile —
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
// matches the old IntersectionObserver-sentinel pattern.
|
||||||
setHighlightedId(targetId);
|
const virtuosoRows = useMemo<VirtuosoRow[]>(() => {
|
||||||
window.setTimeout(() => setHighlightedId((cur) => (cur === targetId ? null : cur)), 1600);
|
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) => {
|
const handleReply = useCallback((m: DecryptedMessage) => {
|
||||||
setReplyTo(m);
|
setReplyTo(m);
|
||||||
@@ -549,85 +631,91 @@ export function ConversationPage() {
|
|||||||
if (id && messages.length > 0) markRead(id);
|
if (id && messages.length > 0) markRead(id);
|
||||||
}, [id, messages.length, markRead]);
|
}, [id, messages.length, markRead]);
|
||||||
|
|
||||||
// useLayoutEffect: run synchronously after DOM commit, before the
|
// Scroll-to-bottom is handled by Virtuoso's `followOutput` prop, which
|
||||||
// browser paints. Using useEffect here let one frame of "scrollTop = 0
|
// fires whenever the rendered row count grows and auto-scrolls down only
|
||||||
// (top of list)" paint between message-list mount and the auto-scroll,
|
// if the user was already at the bottom — exactly the Discord behavior
|
||||||
// which is exactly the "flickers to a different position, then jumps"
|
// we want for both outgoing sends and incoming realtime messages.
|
||||||
// glitch users saw when re-entering a chat. Layout-effect fires while
|
// The old useLayoutEffect that wrote `scrollTop = scrollHeight` is no
|
||||||
// the message list is in the DOM but before paint, so the first frame
|
// longer needed: Virtuoso owns scroll positioning now.
|
||||||
// already shows the correct scroll position.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el || !stickToBottom) return;
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}, [messages.length, stickToBottom]);
|
|
||||||
|
|
||||||
// Restore saved scroll position once the conversation's messages have
|
// Snapshot of the saved position for this conversation, captured once on
|
||||||
// actually rendered. The earlier version fired on `[id]` alone and ran
|
// mount. Used to derive the `initialTopMostItemIndex` we hand to the
|
||||||
// before the message list populated — scrollHeight was still tiny, so
|
// Virtuoso instance below — Virtuoso applies that index synchronously
|
||||||
// `el.scrollTop = saved.scrollTop` got clamped to 0 by the browser
|
// before its first paint, so re-entering a chat shows the saved row in
|
||||||
// and the user landed at the top instead of the saved position. By
|
// one frame rather than a "starts at top, jumps" flicker.
|
||||||
// waiting for `messages.length > 0` we know the rendered scrollHeight
|
const savedPositionRef = useRef<{ topmostIndex: number; stickToBottom: boolean } | null>(
|
||||||
// is meaningful. `restoredForRef` ensures the restore runs at most
|
null,
|
||||||
// once per chat switch (subsequent message arrivals don't re-trigger).
|
);
|
||||||
const restoredForRef = useRef<string | null>(null);
|
if (savedPositionRef.current === null && id) {
|
||||||
const isRestoringRef = useRef(false);
|
savedPositionRef.current = scrollPositions.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// useLayoutEffect, same reason as above: writing scrollTop here happens
|
// Track whether the user is currently scrolled to the bottom. Virtuoso
|
||||||
// before the first paint of the freshly-mounted chat, so the user
|
// calls this whenever the bottom-state changes; we feed it into
|
||||||
// doesn't see a frame at scrollTop=0 before the jump to the saved
|
// `stickToBottom` (used by the "jump to newest" pill and by the
|
||||||
// position. Combined with the messages.length gate this means the
|
// "new messages while away" counter logic). Also clears the unread-
|
||||||
// re-entry shows the message list AT the saved scroll location in one
|
// away counter when the user actually reaches the bottom.
|
||||||
// single paint — no "loaded then jumped" effect.
|
const handleAtBottomStateChange = useCallback(
|
||||||
useLayoutEffect(() => {
|
(atBottom: boolean) => {
|
||||||
const el = scrollRef.current;
|
setStickToBottom(atBottom);
|
||||||
if (!el || !id) return;
|
if (atBottom) setNewMessagesWhileAway(0);
|
||||||
if (restoredForRef.current === id) return;
|
if (id) {
|
||||||
// Wait for the conversation's messages to populate; for a chat that
|
scrollPositions.set(id, {
|
||||||
// truly has zero messages the bottom and the top are the same anyway.
|
topmostIndex: topmostIndexRef.current,
|
||||||
if (messages.length === 0) return;
|
stickToBottom: atBottom,
|
||||||
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
|
[id],
|
||||||
// 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]);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
// Persists the topmost-visible row index per conversation so re-entering
|
||||||
const el = scrollRef.current;
|
// the chat lands roughly where the user left off (see scrollPositions
|
||||||
if (!el) return;
|
// Map). Virtuoso fires `rangeChanged` whenever the visible range shifts;
|
||||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
// we only care about the start of the range here.
|
||||||
const nextStick = distanceFromBottom < STICK_THRESHOLD;
|
const handleRangeChanged = useCallback(
|
||||||
setStickToBottom(nextStick);
|
(range: { startIndex: number; endIndex: number }) => {
|
||||||
if (nextStick) setNewMessagesWhileAway(0);
|
topmostIndexRef.current = range.startIndex;
|
||||||
// Persist position per chat so re-entering this conversation lands
|
if (id) {
|
||||||
// where the user left off (see scrollPositions module-level Map).
|
const prev = scrollPositions.get(id);
|
||||||
// Skipped during the in-flight restore so we don't immediately
|
scrollPositions.set(id, {
|
||||||
// overwrite the saved position with a clamped value.
|
topmostIndex: range.startIndex,
|
||||||
if (id && !isRestoringRef.current) {
|
stickToBottom: prev?.stickToBottom ?? true,
|
||||||
scrollPositions.set(id, { scrollTop: el.scrollTop, stickToBottom: nextStick });
|
});
|
||||||
}
|
}
|
||||||
}, [id]);
|
},
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
const jumpToBottom = useCallback(() => {
|
const jumpToBottom = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
virtuosoRef.current?.scrollToIndex({
|
||||||
if (!el) return;
|
index: 'LAST',
|
||||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
align: 'end',
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
setStickToBottom(true);
|
setStickToBottom(true);
|
||||||
setNewMessagesWhileAway(0);
|
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) {
|
async function handleSend(e?: React.FormEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if ((!text.trim() && attachments.length === 0) || sending) return;
|
if ((!text.trim() && attachments.length === 0) || sending) return;
|
||||||
@@ -879,39 +967,69 @@ export function ConversationPage() {
|
|||||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||||
{conversation && <InCallPanel conversation={conversation} />}
|
{conversation && <InCallPanel conversation={conversation} />}
|
||||||
|
|
||||||
<div
|
<div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
<div className="flex items-center gap-2 px-5 py-4 text-xs text-fg-muted">
|
||||||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<Banner>{error}</Banner>
|
<div className="px-5 py-4">
|
||||||
|
<Banner>{error}</Banner>
|
||||||
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<EmptyState
|
<div className="px-5 py-4">
|
||||||
icon={<SendIcon className="h-8 w-8" />}
|
<EmptyState
|
||||||
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
icon={<SendIcon className="h-8 w-8" />}
|
||||||
description={t('app:chats.conv_empty_desc', {
|
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
||||||
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
description={t('app:chats.conv_empty_desc', {
|
||||||
})}
|
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
||||||
/>
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5">
|
<Virtuoso
|
||||||
{displayCount < messages.length && (
|
ref={virtuosoRef}
|
||||||
<li>
|
className="flex-1"
|
||||||
<div
|
style={{ height: '100%' }}
|
||||||
ref={loadMoreSentinelRef}
|
data={virtuosoRows}
|
||||||
className="flex items-center justify-center py-2 text-xs text-fg-muted"
|
computeItemKey={(_idx, row) => row.key}
|
||||||
>
|
// Initial position: either restored from per-conv memory, or
|
||||||
Lade ältere Nachrichten…
|
// pinned to the bottom for fresh entry. Virtuoso applies this
|
||||||
</div>
|
// synchronously before its first paint so the user doesn't see
|
||||||
</li>
|
// a "loaded at top, then jumped" flicker (matches the layout-
|
||||||
)}
|
// effect behavior we used in the non-virtualized version).
|
||||||
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
initialTopMostItemIndex={initialTopMostIndex}
|
||||||
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
// 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 (
|
||||||
|
<div className="flex items-center justify-center py-2 text-xs text-fg-muted">
|
||||||
|
Lade ältere Nachrichten…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.kind === 'pending') {
|
||||||
|
return (
|
||||||
|
<PendingBubble
|
||||||
|
item={row.item}
|
||||||
|
onRetry={() => retryPending(row.item.id)}
|
||||||
|
onCancel={() => cancelPending(row.item.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const m = row.message;
|
||||||
|
const idx = row.idx;
|
||||||
const prevRaw = messages[idx - 1];
|
const prevRaw = messages[idx - 1];
|
||||||
const nextRaw = messages[idx + 1];
|
const nextRaw = messages[idx + 1];
|
||||||
const prevIsCallEvent =
|
const prevIsCallEvent =
|
||||||
@@ -925,7 +1043,7 @@ export function ConversationPage() {
|
|||||||
const senderProfile =
|
const senderProfile =
|
||||||
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||||
return (
|
return (
|
||||||
<li key={m.id}>
|
<div className="px-5">
|
||||||
{firstUnreadId === m.id && (
|
{firstUnreadId === m.id && (
|
||||||
<div
|
<div
|
||||||
aria-label="Neue Nachrichten"
|
aria-label="Neue Nachrichten"
|
||||||
@@ -972,19 +1090,10 @@ export function ConversationPage() {
|
|||||||
isPinned={pinnedIds.has(m.id)}
|
isPinned={pinnedIds.has(m.id)}
|
||||||
onTogglePin={handleTogglePin}
|
onTogglePin={handleTogglePin}
|
||||||
/>
|
/>
|
||||||
</li>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
{pending.map((p) => (
|
/>
|
||||||
<li key={p.id}>
|
|
||||||
<PendingBubble
|
|
||||||
item={p}
|
|
||||||
onRetry={() => retryPending(p.id)}
|
|
||||||
onCancel={() => cancelPending(p.id)}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1348,9 +1457,12 @@ export function ConversationPage() {
|
|||||||
open={pinnedPanelOpen}
|
open={pinnedPanelOpen}
|
||||||
pins={pins}
|
pins={pins}
|
||||||
onClose={() => setPinnedPanelOpen(false)}
|
onClose={() => setPinnedPanelOpen(false)}
|
||||||
onJump={(_messageId) => {
|
onJump={(messageId) => {
|
||||||
// Future: scroll to message. For now just close the panel.
|
// 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);
|
setPinnedPanelOpen(false);
|
||||||
|
jumpToMessage(messageId);
|
||||||
}}
|
}}
|
||||||
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
onUnpin={(messageId) => void handleTogglePin(messageId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Generated
+14
@@ -98,6 +98,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.28.0
|
specifier: ^6.28.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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:
|
zustand:
|
||||||
specifier: ^5.0.1
|
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))
|
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:
|
peerDependencies:
|
||||||
react: ^18.3.1
|
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:
|
react@18.3.1:
|
||||||
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -12820,6 +12829,11 @@ snapshots:
|
|||||||
react-shallow-renderer: 16.15.0(react@18.3.1)
|
react-shallow-renderer: 16.15.0(react@18.3.1)
|
||||||
scheduler: 0.23.2
|
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:
|
react@18.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user