mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 01:33:01 +02:00
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:
Generated
+30
-32
@@ -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": [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user