Virtualize the message list

A conversation rendered every message it had loaded. Nothing bounded that
work, so cost grew with history: at 2000 messages the list mounted ~20k DOM
nodes, took ~285ms to mount, and ~212ms to re-render on any update. While
that runs, nothing else on the main thread gets a turn, typing included.

Only the visible window is mounted now, measured in Chromium with the real
component tree:

  history    mount            re-render        DOM nodes
    2000     285ms -> 4ms     212ms -> 2.2ms   22038 -> 225

Rendered rows stay at ~17 regardless of how much history is loaded.

Row heights are measured rather than assumed, since messages vary from one line
to a wrapped paragraph with path badges, so the scroll behaviors that used
to do height arithmetic now anchor by index instead, which is exact even
when off-screen rows still carry estimated heights:

- opening a conversation, and following new messages, anchor to the last row
- loading older messages anchors to the message that was on top
- jump-to-message and jump-to-unread scroll by index, since the target row
  may not be mounted yet

Two degenerate cases are handled explicitly: a viewport that measures zero
(before first layout, hidden tab) falls back to a nominal height instead of
collapsing the window to nothing, and a row that measures zero keeps its
estimate instead of ballooning the window.

Most of the diff is re-indentation from the new wrapper element; the real
change is 112 added / 36 removed lines (git diff -w).
This commit is contained in:
Ryan Gregg
2026-07-25 00:40:47 -07:00
parent 889bb19ef5
commit fab331e67e
4 changed files with 403 additions and 310 deletions
+30 -32
View File
@@ -1,12 +1,12 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"version": "3.16.0", "version": "3.17.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"version": "3.16.0", "version": "3.17.0",
"dependencies": { "dependencies": {
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
@@ -17,6 +17,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-virtual": "^3.14.8",
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -1543,9 +1544,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1563,9 +1561,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1583,9 +1578,6 @@
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1603,9 +1595,6 @@
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1623,9 +1612,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1643,9 +1629,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1744,6 +1727,33 @@
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.8.tgz",
"integrity": "sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.6",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz",
"integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4504,9 +4514,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4528,9 +4535,6 @@
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4552,9 +4556,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -4576,9 +4577,6 @@
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
+1
View File
@@ -25,6 +25,7 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-virtual": "^3.14.8",
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+354 -278
View File
@@ -1,5 +1,4 @@
import { import {
Fragment,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useRef, useRef,
@@ -31,6 +30,7 @@ import { PathModal } from './PathModal';
import { RawPacketInspectorDialog } from './RawPacketDetailModal'; import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { useVirtualizer } from '@tanstack/react-virtual';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface MessageListProps { interface MessageListProps {
@@ -138,6 +138,15 @@ function renderMeshcoreOpenPayload(
return null; return null;
} }
/**
* Starting guess for an unmeasured row: a single-line message with its header.
* Rows are measured for real once they scroll into view.
*/
const ESTIMATED_MESSAGE_HEIGHT = 64;
/** Stand-in viewport height for when the scroll container cannot be measured. */
const FALLBACK_VIEWPORT_HEIGHT = 800;
// URL regex for linkifying plain text // URL regex for linkifying plain text
const URL_PATTERN = const URL_PATTERN =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g; /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
@@ -474,11 +483,65 @@ export function MessageList({
} }
}, []); }, []);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.observeMessage()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() =>
preSorted
? messages
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages, preSorted]
);
/**
* Only the visible window of messages is mounted. A long channel history otherwise
* costs a full render of every message on any update — hundreds of milliseconds once
* a conversation has a few thousand messages, which stalls everything else on the
* main thread, typing included.
*
* Heights are measured, not assumed: messages vary wildly (one line, a wrapped
* paragraph, path badges, the unread divider), so `estimateSize` is only the starting
* guess for rows that have not been on screen yet.
*/
const virtualizer = useVirtualizer({
count: sortedMessages.length,
getScrollElement: () => listRef.current,
estimateSize: () => ESTIMATED_MESSAGE_HEIGHT,
getItemKey: (index) => sortedMessages[index]?.id ?? index,
overscan: 8,
// A row that measures zero has not really been laid out yet (hidden pane, images
// still loading). Keep the estimate instead, or the window balloons to compensate.
measureElement: (element) => element.getBoundingClientRect().height || ESTIMATED_MESSAGE_HEIGHT,
// A viewport that measures zero (before first layout, a hidden tab, jsdom) would
// otherwise collapse the window to nothing and render an empty list. Fall back to a
// nominal height so we always mount a plausible screenful.
observeElementRect: (instance, cb) => {
const element = instance.scrollElement;
if (!element) return;
const report = () => {
const rect = element.getBoundingClientRect();
cb({ width: rect.width, height: rect.height || FALLBACK_VIEWPORT_HEIGHT });
};
report();
const observer = new ResizeObserver(report);
observer.observe(element);
return () => observer.disconnect();
},
});
const virtualRows = virtualizer.getVirtualItems();
const scrollToIndex = useCallback(
(index: number, align: 'start' | 'center' | 'end') => {
if (index < 0) return;
virtualizer.scrollToIndex(index, { align });
},
[virtualizer]
);
// Handle scroll position AFTER render // Handle scroll position AFTER render
useLayoutEffect(() => { useLayoutEffect(() => {
if (!listRef.current) return; if (!listRef.current) return;
const list = listRef.current;
const messagesAdded = messages.length - prevMessagesLengthRef.current; const messagesAdded = messages.length - prevMessagesLengthRef.current;
// Detect if messages are from a different conversation (handles the case where // Detect if messages are from a different conversation (handles the case where
@@ -489,40 +552,40 @@ export function MessageList({
if (convKey !== null) prevConvKeyRef.current = convKey; if (convKey !== null) prevConvKeyRef.current = convKey;
if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) { if ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) {
// Initial load or conversation switch - scroll to bottom // Initial load or conversation switch - scroll to bottom.
list.scrollTop = list.scrollHeight; // Anchored to the last row rather than scrollHeight: rows that have never been
// on screen still carry estimated heights, so the total is not yet exact.
scrollToIndex(sortedMessages.length - 1, 'end');
isInitialLoadRef.current = false; isInitialLoadRef.current = false;
} else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) { } else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) {
// Messages were added - use scroll state captured before the update if (scrollStateRef.current.wasNearTop) {
const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight; // User was near top (loading older) - keep the message that was on top in place.
// Prepended rows are unmeasured, so anchoring by index beats height arithmetic.
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) { scrollToIndex(messagesAdded, 'start');
// User was near top (loading older) - preserve position by adding the height diff
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
} else if (scrollStateRef.current.wasNearBottom && !hasNewerMessagesRef.current) { } else if (scrollStateRef.current.wasNearBottom && !hasNewerMessagesRef.current) {
// User was near bottom - scroll to bottom for new messages (including sent). // User was near bottom - scroll to bottom for new messages (including sent).
// Skip when browsing mid-history (hasNewerMessages) so that forward-pagination // Skip when browsing mid-history (hasNewerMessages) so that forward-pagination
// appends in place instead of chasing the bottom in an infinite load loop. // appends in place instead of chasing the bottom in an infinite load loop.
list.scrollTop = list.scrollHeight; scrollToIndex(sortedMessages.length - 1, 'end');
} }
} }
prevMessagesLengthRef.current = messages.length; prevMessagesLengthRef.current = messages.length;
}, [messages]); }, [messages, sortedMessages.length, scrollToIndex]);
// Scroll to target message and highlight it // Scroll to target message and highlight it
useLayoutEffect(() => { useLayoutEffect(() => {
if (!targetMessageId || targetScrolledRef.current || messages.length === 0) return; if (!targetMessageId || targetScrolledRef.current || messages.length === 0) return;
const el = listRef.current?.querySelector(`[data-message-id="${targetMessageId}"]`); const targetIndex = sortedMessages.findIndex((msg) => msg.id === targetMessageId);
if (!el) return; if (targetIndex === -1) return;
// Prevent the initial-load layout effect from overriding our scroll // Prevent the initial-load layout effect from overriding our scroll
isInitialLoadRef.current = false; isInitialLoadRef.current = false;
el.scrollIntoView({ block: 'center' }); scrollToIndex(targetIndex, 'center');
setHighlightedMessageId(targetMessageId); setHighlightedMessageId(targetMessageId);
targetScrolledRef.current = true; targetScrolledRef.current = true;
onTargetReached?.(); onTargetReached?.();
}, [messages, targetMessageId, onTargetReached]); }, [messages, sortedMessages, targetMessageId, onTargetReached, scrollToIndex]);
// Reset target scroll tracking when targetMessageId changes // Reset target scroll tracking when targetMessageId changes
useEffect(() => { useEffect(() => {
@@ -597,16 +660,6 @@ export function MessageList({
}; };
}, [messages, onResendChannelMessage]); }, [messages, onResendChannelMessage]);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.observeMessage()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() =>
preSorted
? messages
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages, preSorted]
);
const unreadMarkerIndex = useMemo(() => { const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) { if (unreadMarkerLastReadAt === undefined) {
return -1; return -1;
@@ -722,10 +775,8 @@ export function MessageList({
onJumpToBottom(); onJumpToBottom();
return; return;
} }
if (listRef.current) { scrollToIndex(sortedMessages.length - 1, 'end');
listRef.current.scrollTop = listRef.current.scrollHeight; }, [hasNewerMessages, onJumpToBottom, scrollToIndex, sortedMessages.length]);
}
}, [hasNewerMessages, onJumpToBottom]);
// Sender info for outgoing messages (used by path modal on own messages) // Sender info for outgoing messages (used by path modal on own messages)
const selfSenderInfo = useMemo<SenderInfo>( const selfSenderInfo = useMemo<SenderInfo>(
@@ -894,7 +945,7 @@ export function MessageList({
return ( return (
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<div <div
className="h-full overflow-y-auto p-4 flex flex-col gap-0.5" className="h-full overflow-y-auto p-4 flex flex-col"
ref={listRef} ref={listRef}
onScroll={handleScroll} onScroll={handleScroll}
> >
@@ -908,231 +959,215 @@ export function MessageList({
Scroll up for older messages Scroll up for older messages
</div> </div>
)} )}
{sortedMessages.map((msg, index) => { <div
// For DMs, look up contact; for channel messages, use parsed sender className="relative w-full flex-shrink-0"
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null; style={{ height: virtualizer.getTotalSize() }}
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM; >
{virtualRows.map((virtualRow) => {
const index = virtualRow.index;
const msg = sortedMessages[index];
// For DMs, look up contact; for channel messages, use parsed sender
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
// Only parse "sender: text" prefix for channel messages — DMs never carry // Only parse "sender: text" prefix for channel messages — DMs never carry
// an in-text sender prefix, so parsing them would incorrectly strip // an in-text sender prefix, so parsing them would incorrectly strip
// user text that happens to contain a colon (e.g. "TEST1: TEST2"). // user text that happens to contain a colon (e.g. "TEST1: TEST2").
const { sender, content } = const { sender, content } =
msg.type === 'PRIV' msg.type === 'PRIV'
? { sender: null, content: msg.text } ? { sender: null, content: msg.text }
: parseSenderFromText(msg.text); : parseSenderFromText(msg.text);
const directSenderName = const directSenderName =
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null; msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null; const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
const channelSenderContact = const channelSenderContact =
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null; msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender); const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
const displaySender = msg.outgoing const displaySender = msg.outgoing
? 'You' ? 'You'
: directSenderName || : directSenderName ||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) || (isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
contact?.name || contact?.name ||
channelSenderName || channelSenderName ||
(isCorruptChannelMessage (isCorruptChannelMessage
? CORRUPT_SENDER_LABEL ? CORRUPT_SENDER_LABEL
: msg.conversation_key?.slice(0, 8) || 'Unknown'); : msg.conversation_key?.slice(0, 8) || 'Unknown');
const canClickSender = const canClickSender =
!msg.outgoing && !msg.outgoing &&
onSenderClick && onSenderClick &&
displaySender !== 'Unknown' && displaySender !== 'Unknown' &&
displaySender !== CORRUPT_SENDER_LABEL; displaySender !== CORRUPT_SENDER_LABEL;
// Determine if we should show avatar (first message in a chunk from same sender) // Determine if we should show avatar (first message in a chunk from same sender)
const currentSenderKey = getSenderKey( const currentSenderKey = getSenderKey(
msg, msg,
directSenderName || channelSenderName, directSenderName || channelSenderName,
isCorruptChannelMessage isCorruptChannelMessage
); );
const prevMsg = sortedMessages[index - 1]; const prevMsg = sortedMessages[index - 1];
const prevParsedSender = const prevParsedSender =
prevMsg && prevMsg.type === 'CHAN' ? parseSenderFromText(prevMsg.text).sender : null; prevMsg && prevMsg.type === 'CHAN' ? parseSenderFromText(prevMsg.text).sender : null;
const prevSenderKey = prevMsg const prevSenderKey = prevMsg
? getSenderKey( ? getSenderKey(
prevMsg, prevMsg,
prevMsg.type === 'PRIV' && prevMsg.type === 'PRIV' &&
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
? prevMsg.sender_name ? prevMsg.sender_name
: prevMsg.type === 'CHAN' : prevMsg.type === 'CHAN'
? prevMsg.sender_name || prevParsedSender ? prevMsg.sender_name || prevParsedSender
: prevParsedSender, : prevParsedSender,
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender) isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
) )
: null; : null;
const isFirstInGroup = currentSenderKey !== prevSenderKey; const isFirstInGroup = currentSenderKey !== prevSenderKey;
const showAvatar = !msg.outgoing && isFirstInGroup; const showAvatar = !msg.outgoing && isFirstInGroup;
const isFirstMessage = index === 0; const isFirstMessage = index === 0;
// Get avatar info for incoming messages // Get avatar info for incoming messages
let avatarName: string | null = null; let avatarName: string | null = null;
let avatarKey: string = ''; let avatarKey: string = '';
let avatarVariant: 'default' | 'corrupt' = 'default'; let avatarVariant: 'default' | 'corrupt' = 'default';
if (!msg.outgoing) { if (!msg.outgoing) {
if (msg.type === 'PRIV' && msg.conversation_key) { if (msg.type === 'PRIV' && msg.conversation_key) {
if (isRoomServer) { if (isRoomServer) {
avatarName = directSenderName; avatarName = directSenderName;
avatarKey = avatarKey =
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key); msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
} else {
avatarName = contact?.name || null;
avatarKey = msg.conversation_key;
}
} else if (isCorruptChannelMessage) {
avatarName = CORRUPT_SENDER_LABEL;
avatarKey = `corrupt:${msg.id}`;
avatarVariant = 'corrupt';
} else { } else {
avatarName = contact?.name || null; // Channel message: use stored sender identity first, then parsed/fallback display name
avatarKey = msg.conversation_key; avatarName =
channelSenderName || (displaySender !== 'Unknown' ? displaySender : null);
avatarKey =
msg.sender_key ||
channelSenderContact?.public_key ||
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
} }
} else if (isCorruptChannelMessage) {
avatarName = CORRUPT_SENDER_LABEL;
avatarKey = `corrupt:${msg.id}`;
avatarVariant = 'corrupt';
} else {
// Channel message: use stored sender identity first, then parsed/fallback display name
avatarName =
channelSenderName || (displaySender !== 'Unknown' ? displaySender : null);
avatarKey =
msg.sender_key ||
channelSenderContact?.public_key ||
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
} }
} const avatarActionLabel =
const avatarActionLabel = avatarName && avatarName !== 'Unknown'
avatarName && avatarName !== 'Unknown' ? `View info for ${avatarName}`
? `View info for ${avatarName}` : `View info for ${avatarKey.slice(0, 12)}`;
: `View info for ${avatarKey.slice(0, 12)}`;
return ( return (
<Fragment key={msg.id}> // Absolutely positioned so the scroll container keeps a stable total height
{unreadMarkerIndex === index && // while only the visible window is mounted. `flex flex-col` matters: it makes
(onDismissUnreadMarker ? ( // child margins (group spacing, the unread divider) part of the measured height.
<button
ref={setUnreadMarkerElement}
type="button"
className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onDismissUnreadMarker}
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
<span className="h-px flex-1 bg-border" />
</button>
) : (
<div
ref={setUnreadMarkerElement}
className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
<span className="h-px flex-1 bg-border" />
</div>
))}
<div <div
data-message-id={msg.id} key={msg.id}
className={cn( data-index={index}
'flex items-start max-w-[85%]', ref={virtualizer.measureElement}
msg.outgoing && 'flex-row-reverse self-end', className="absolute left-0 top-0 flex w-full flex-col pb-0.5"
isFirstInGroup && !isFirstMessage && 'mt-3' style={{ transform: `translateY(${virtualRow.start}px)` }}
)}
> >
{!msg.outgoing && ( {unreadMarkerIndex === index &&
<div className="w-10 flex-shrink-0 flex items-start pt-0.5"> (onDismissUnreadMarker ? (
{showAvatar && <button
avatarKey && ref={setUnreadMarkerElement}
(onOpenContactInfo ? ( type="button"
<button className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button" onClick={onDismissUnreadMarker}
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" >
aria-label={avatarActionLabel} <span className="h-px flex-1 bg-border" />
onClick={() => <span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
onOpenContactInfo( Unread messages
avatarKey, </span>
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer) <span className="h-px flex-1 bg-border" />
) </button>
} ) : (
> <div
<ContactAvatar ref={setUnreadMarkerElement}
name={avatarName} className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
publicKey={avatarKey} >
size={32} <span className="h-px flex-1 bg-border" />
clickable <span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
variant={avatarVariant} Unread messages
/> </span>
</button> <span className="h-px flex-1 bg-border" />
) : ( </div>
<span> ))}
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<div <div
data-message-id={msg.id}
className={cn( className={cn(
'py-1.5 px-3 rounded-lg min-w-0', 'flex items-start max-w-[85%]',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming', msg.outgoing && 'flex-row-reverse self-end',
highlightedMessageId === msg.id && 'message-highlight' isFirstInGroup && !isFirstMessage && 'mt-3'
)} )}
> >
{showAvatar && ( {!msg.outgoing && (
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5"> <div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{canClickSender ? ( {showAvatar &&
<span avatarKey &&
className="cursor-pointer hover:text-primary transition-colors" (onOpenContactInfo ? (
role="button" <button
tabIndex={0} type="button"
onKeyDown={handleKeyboardActivate} className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onSenderClick(displaySender)} aria-label={avatarActionLabel}
title={`Mention ${displaySender}`} onClick={() =>
> onOpenContactInfo(
{displaySender} avatarKey,
</span> msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
) : ( )
displaySender }
)} >
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]"> <ContactAvatar
{formatTime(msg.received_at)} name={avatarName}
</span> publicKey={avatarKey}
{!msg.outgoing && msg.paths && msg.paths.length > 0 && ( size={32}
<HopCountBadge clickable
paths={msg.paths} variant={avatarVariant}
variant="header" />
onClick={() => </button>
setSelectedPath({ ) : (
paths: msg.paths!, <span>
senderInfo: getSenderInfo(msg, contact, directSenderName || sender), <ContactAvatar
messageId: msg.id, name={avatarName}
packetId: msg.packet_id, publicKey={avatarKey}
}) size={32}
} variant={avatarVariant}
/> />
)} </span>
{msg.region && <RegionBadge region={msg.region} />} ))}
</div> </div>
)} )}
<div className="break-words whitespace-pre-wrap"> <div
{(renderRichPayloads && className={cn(
renderMeshcoreOpenPayload(content, radioName, onChannelReferenceClick)) || 'py-1.5 px-3 rounded-lg min-w-0',
content.split('\n').map((line, i, arr) => ( msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
<span key={i}> highlightedMessageId === msg.id && 'message-highlight'
{renderTextWithMentions(line, radioName, onChannelReferenceClick)} )}
{i < arr.length - 1 && <br />} >
</span> {showAvatar && (
))} <div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
{!showAvatar && ( {canClickSender ? (
<> <span
<span className="text-[0.625rem] text-muted-foreground ml-2"> className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
{formatTime(msg.received_at)} {formatTime(msg.received_at)}
</span> </span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && ( {!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge <HopCountBadge
paths={msg.paths} paths={msg.paths}
variant="inline" variant="header"
onClick={() => onClick={() =>
setSelectedPath({ setSelectedPath({
paths: msg.paths!, paths: msg.paths!,
@@ -1144,11 +1179,68 @@ export function MessageList({
/> />
)} )}
{msg.region && <RegionBadge region={msg.region} />} {msg.region && <RegionBadge region={msg.region} />}
</> </div>
)} )}
{msg.outgoing && <div className="break-words whitespace-pre-wrap">
(msg.acked > 0 ? ( {(renderRichPayloads &&
msg.paths && msg.paths.length > 0 ? ( renderMeshcoreOpenPayload(content, radioName, onChannelReferenceClick)) ||
content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[0.625rem] text-muted-foreground ml-2">
{formatTime(msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(
msg,
contact,
directSenderName || sender
),
messageId: msg.id,
packetId: msg.packet_id,
})
}
/>
)}
{msg.region && <RegionBadge region={msg.region} />}
</>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
senderInfo: selfSenderInfo,
messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span <span
className="text-muted-foreground cursor-pointer hover:text-primary" className="text-muted-foreground cursor-pointer hover:text-primary"
role="button" role="button"
@@ -1157,53 +1249,32 @@ export function MessageList({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedPath({ setSelectedPath({
paths: msg.paths!, paths: [],
senderInfo: selfSenderInfo, senderInfo: selfSenderInfo,
messageId: msg.id, messageId: msg.id,
packetId: msg.packet_id, packetId: msg.packet_id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage, isOutgoingChan: true,
}); });
}} }}
title="View echo paths" title="Message status"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`} aria-label="No echoes yet — view message status"
>{`${msg.acked > 1 ? msg.acked : ''}`}</span> >
{' '}
?
</span>
) : ( ) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span> <span className="text-muted-foreground" title="No repeats heard yet">
) {' '}
) : onResendChannelMessage && msg.type === 'CHAN' ? ( ?
<span </span>
className="text-muted-foreground cursor-pointer hover:text-primary" ))}
role="button" </div>
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
packetId: msg.packet_id,
isOutgoingChan: true,
});
}}
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
</div> </div>
</div> </div>
</div> </div>
</Fragment> );
); })}
})} </div>
{loadingNewer && ( {loadingNewer && (
<div className="text-center py-2 text-muted-foreground text-sm" role="status"> <div className="text-center py-2 text-muted-foreground text-sm" role="status">
Loading newer messages... Loading newer messages...
@@ -1223,7 +1294,12 @@ export function MessageList({
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' }); if (unreadMarkerRef.current?.scrollIntoView) {
unreadMarkerRef.current.scrollIntoView({ block: 'center' });
} else {
// The marker row is outside the rendered window — scroll by index.
scrollToIndex(unreadMarkerIndex, 'center');
}
setJumpToUnreadDismissed(true); setJumpToUnreadDismissed(true);
setShowJumpToUnread(false); setShowJumpToUnread(false);
}} }}
+18
View File
@@ -472,4 +472,22 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('Unread messages')).toBeInTheDocument(); expect(screen.getByText('Unread messages')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
}); });
it('mounts only a window of rows for a long history', () => {
const messages = Array.from({ length: 500 }, (_, i) =>
createMessage({
id: i + 1,
text: `Alice: message ${i}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000001 + i,
})
);
const { container } = render(<MessageList messages={messages} contacts={[]} loading={false} />);
// jsdom reports no layout, so the list falls back to a nominal viewport. The point
// is that the window is bounded: a 500-message history must not mount 500 rows.
const mounted = container.querySelectorAll('[data-message-id]').length;
expect(mounted).toBeGreaterThan(0);
expect(mounted).toBeLessThan(100);
});
}); });