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-i18next": "^15.1.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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<string, { scrollTop: number; stickToBottom: boolean }>();
|
||||
// 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<string, { topmostIndex: number; stickToBottom: boolean }>();
|
||||
|
||||
export function ConversationPage() {
|
||||
const { t } = useTranslation(['app', 'errors']);
|
||||
@@ -263,8 +277,8 @@ export function ConversationPage() {
|
||||
},
|
||||
[send],
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const topmostIndexRef = useRef<number>(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(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<string, DecryptedMessage>();
|
||||
@@ -390,15 +400,87 @@ export function ConversationPage() {
|
||||
return out;
|
||||
}, [messages, buildQuoted]);
|
||||
|
||||
const jumpToMessage = useCallback((targetId: string) => {
|
||||
const el = scrollRef.current?.querySelector<HTMLElement>(
|
||||
'[data-message-id="' + CSS.escape(targetId) + '"]',
|
||||
);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// 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<VirtuosoRow[]>(() => {
|
||||
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);
|
||||
}, []);
|
||||
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<string | null>(null);
|
||||
const isRestoringRef = useRef(false);
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
isRestoringRef.current = false;
|
||||
|
||||
// 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, messages.length]);
|
||||
|
||||
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]);
|
||||
},
|
||||
[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,18 +967,17 @@ export function ConversationPage() {
|
||||
{incomingHere && conversation && <IncomingCallPanel conversation={conversation} />}
|
||||
{conversation && <InCallPanel conversation={conversation} />}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="discord-chat-surface flex-1 overflow-y-auto bg-surface-3 px-5 py-4"
|
||||
>
|
||||
<div className="discord-chat-surface flex min-h-0 flex-1 flex-col bg-surface-3">
|
||||
{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" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-5 py-4">
|
||||
<Banner>{error}</Banner>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="px-5 py-4">
|
||||
<EmptyState
|
||||
icon={<SendIcon className="h-8 w-8" />}
|
||||
title={t('app:chats.conv_empty_title', { defaultValue: 'Sag Hallo 👋' })}
|
||||
@@ -898,20 +985,51 @@ export function ConversationPage() {
|
||||
defaultValue: 'Hier ist noch nichts. Schreibe die erste Nachricht.',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{displayCount < messages.length && (
|
||||
<li>
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
className="flex items-center justify-center py-2 text-xs text-fg-muted"
|
||||
>
|
||||
<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).
|
||||
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>
|
||||
</li>
|
||||
)}
|
||||
{messages.slice(Math.max(0, messages.length - displayCount)).map((m, sliceIdx) => {
|
||||
const idx = Math.max(0, messages.length - displayCount) + sliceIdx;
|
||||
);
|
||||
}
|
||||
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 nextRaw = messages[idx + 1];
|
||||
const prevIsCallEvent =
|
||||
@@ -925,7 +1043,7 @@ export function ConversationPage() {
|
||||
const senderProfile =
|
||||
memberProfile ?? (m.senderId !== myId ? (conversation?.peer ?? null) : null);
|
||||
return (
|
||||
<li key={m.id}>
|
||||
<div className="px-5">
|
||||
{firstUnreadId === m.id && (
|
||||
<div
|
||||
aria-label="Neue Nachrichten"
|
||||
@@ -972,19 +1090,10 @@ export function ConversationPage() {
|
||||
isPinned={pinnedIds.has(m.id)}
|
||||
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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
Generated
+14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user