diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c5bd629..7c4c231 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "remoteterm-meshcore-frontend", - "version": "3.16.0", + "version": "3.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "remoteterm-meshcore-frontend", - "version": "3.16.0", + "version": "3.17.0", "dependencies": { "@codemirror/lang-python": "^6.2.1", "@codemirror/theme-one-dark": "^6.1.3", @@ -17,6 +17,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-virtual": "^3.14.8", "@uiw/react-codemirror": "^4.25.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1543,9 +1544,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1563,9 +1561,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1583,9 +1578,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1603,9 +1595,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1623,9 +1612,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1643,9 +1629,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1744,6 +1727,33 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "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": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -4504,9 +4514,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4528,9 +4535,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4552,9 +4556,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4576,9 +4577,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/package.json b/frontend/package.json index 777492f..fa07c99 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@tanstack/react-virtual": "^3.14.8", "@uiw/react-codemirror": "^4.25.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 4b2c63d..415777f 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -1,5 +1,4 @@ import { - Fragment, useEffect, useLayoutEffect, useRef, @@ -31,6 +30,7 @@ import { PathModal } from './PathModal'; import { RawPacketInspectorDialog } from './RawPacketDetailModal'; import { toast } from './ui/sonner'; import { handleKeyboardActivate } from '../utils/a11y'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { cn } from '@/lib/utils'; interface MessageListProps { @@ -138,6 +138,15 @@ function renderMeshcoreOpenPayload( 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 const URL_PATTERN = /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 useLayoutEffect(() => { if (!listRef.current) return; - const list = listRef.current; const messagesAdded = messages.length - prevMessagesLengthRef.current; // 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 ((isInitialLoadRef.current || conversationChanged) && messages.length > 0) { - // Initial load or conversation switch - scroll to bottom - list.scrollTop = list.scrollHeight; + // Initial load or conversation switch - scroll to bottom. + // 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; } else if (messagesAdded > 0 && prevMessagesLengthRef.current > 0) { - // Messages were added - use scroll state captured before the update - const scrollHeightDiff = list.scrollHeight - scrollStateRef.current.scrollHeight; - - if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) { - // User was near top (loading older) - preserve position by adding the height diff - list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff; + if (scrollStateRef.current.wasNearTop) { + // 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. + scrollToIndex(messagesAdded, 'start'); } else if (scrollStateRef.current.wasNearBottom && !hasNewerMessagesRef.current) { // User was near bottom - scroll to bottom for new messages (including sent). // Skip when browsing mid-history (hasNewerMessages) so that forward-pagination // 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; - }, [messages]); + }, [messages, sortedMessages.length, scrollToIndex]); // Scroll to target message and highlight it useLayoutEffect(() => { if (!targetMessageId || targetScrolledRef.current || messages.length === 0) return; - const el = listRef.current?.querySelector(`[data-message-id="${targetMessageId}"]`); - if (!el) return; + const targetIndex = sortedMessages.findIndex((msg) => msg.id === targetMessageId); + if (targetIndex === -1) return; // Prevent the initial-load layout effect from overriding our scroll isInitialLoadRef.current = false; - el.scrollIntoView({ block: 'center' }); + scrollToIndex(targetIndex, 'center'); setHighlightedMessageId(targetMessageId); targetScrolledRef.current = true; onTargetReached?.(); - }, [messages, targetMessageId, onTargetReached]); + }, [messages, sortedMessages, targetMessageId, onTargetReached, scrollToIndex]); // Reset target scroll tracking when targetMessageId changes useEffect(() => { @@ -597,16 +660,6 @@ export function MessageList({ }; }, [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(() => { if (unreadMarkerLastReadAt === undefined) { return -1; @@ -722,10 +775,8 @@ export function MessageList({ onJumpToBottom(); return; } - if (listRef.current) { - listRef.current.scrollTop = listRef.current.scrollHeight; - } - }, [hasNewerMessages, onJumpToBottom]); + scrollToIndex(sortedMessages.length - 1, 'end'); + }, [hasNewerMessages, onJumpToBottom, scrollToIndex, sortedMessages.length]); // Sender info for outgoing messages (used by path modal on own messages) const selfSenderInfo = useMemo( @@ -894,7 +945,7 @@ export function MessageList({ return (
@@ -908,231 +959,215 @@ export function MessageList({ Scroll up for older messages
)} - {sortedMessages.map((msg, 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; +
+ {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 - // an in-text sender prefix, so parsing them would incorrectly strip - // user text that happens to contain a colon (e.g. "TEST1: TEST2"). - const { sender, content } = - msg.type === 'PRIV' - ? { sender: null, content: msg.text } - : parseSenderFromText(msg.text); - const directSenderName = - msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null; - const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null; - const channelSenderContact = - msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null; - const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender); - const displaySender = msg.outgoing - ? 'You' - : directSenderName || - (isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) || - contact?.name || - channelSenderName || - (isCorruptChannelMessage - ? CORRUPT_SENDER_LABEL - : msg.conversation_key?.slice(0, 8) || 'Unknown'); + // Only parse "sender: text" prefix for channel messages — DMs never carry + // an in-text sender prefix, so parsing them would incorrectly strip + // user text that happens to contain a colon (e.g. "TEST1: TEST2"). + const { sender, content } = + msg.type === 'PRIV' + ? { sender: null, content: msg.text } + : parseSenderFromText(msg.text); + const directSenderName = + msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null; + const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null; + const channelSenderContact = + msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null; + const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender); + const displaySender = msg.outgoing + ? 'You' + : directSenderName || + (isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) || + contact?.name || + channelSenderName || + (isCorruptChannelMessage + ? CORRUPT_SENDER_LABEL + : msg.conversation_key?.slice(0, 8) || 'Unknown'); - const canClickSender = - !msg.outgoing && - onSenderClick && - displaySender !== 'Unknown' && - displaySender !== CORRUPT_SENDER_LABEL; + const canClickSender = + !msg.outgoing && + onSenderClick && + displaySender !== 'Unknown' && + displaySender !== CORRUPT_SENDER_LABEL; - // Determine if we should show avatar (first message in a chunk from same sender) - const currentSenderKey = getSenderKey( - msg, - directSenderName || channelSenderName, - isCorruptChannelMessage - ); - const prevMsg = sortedMessages[index - 1]; - const prevParsedSender = - prevMsg && prevMsg.type === 'CHAN' ? parseSenderFromText(prevMsg.text).sender : null; - const prevSenderKey = prevMsg - ? getSenderKey( - prevMsg, - prevMsg.type === 'PRIV' && - getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM - ? prevMsg.sender_name - : prevMsg.type === 'CHAN' - ? prevMsg.sender_name || prevParsedSender - : prevParsedSender, - isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender) - ) - : null; - const isFirstInGroup = currentSenderKey !== prevSenderKey; - const showAvatar = !msg.outgoing && isFirstInGroup; - const isFirstMessage = index === 0; + // Determine if we should show avatar (first message in a chunk from same sender) + const currentSenderKey = getSenderKey( + msg, + directSenderName || channelSenderName, + isCorruptChannelMessage + ); + const prevMsg = sortedMessages[index - 1]; + const prevParsedSender = + prevMsg && prevMsg.type === 'CHAN' ? parseSenderFromText(prevMsg.text).sender : null; + const prevSenderKey = prevMsg + ? getSenderKey( + prevMsg, + prevMsg.type === 'PRIV' && + getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM + ? prevMsg.sender_name + : prevMsg.type === 'CHAN' + ? prevMsg.sender_name || prevParsedSender + : prevParsedSender, + isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender) + ) + : null; + const isFirstInGroup = currentSenderKey !== prevSenderKey; + const showAvatar = !msg.outgoing && isFirstInGroup; + const isFirstMessage = index === 0; - // Get avatar info for incoming messages - let avatarName: string | null = null; - let avatarKey: string = ''; - let avatarVariant: 'default' | 'corrupt' = 'default'; - if (!msg.outgoing) { - if (msg.type === 'PRIV' && msg.conversation_key) { - if (isRoomServer) { - avatarName = directSenderName; - avatarKey = - msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key); + // Get avatar info for incoming messages + let avatarName: string | null = null; + let avatarKey: string = ''; + let avatarVariant: 'default' | 'corrupt' = 'default'; + if (!msg.outgoing) { + if (msg.type === 'PRIV' && msg.conversation_key) { + if (isRoomServer) { + avatarName = directSenderName; + avatarKey = + 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 { - avatarName = contact?.name || null; - avatarKey = msg.conversation_key; + // 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}`); } - } 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 = - avatarName && avatarName !== 'Unknown' - ? `View info for ${avatarName}` - : `View info for ${avatarKey.slice(0, 12)}`; + const avatarActionLabel = + avatarName && avatarName !== 'Unknown' + ? `View info for ${avatarName}` + : `View info for ${avatarKey.slice(0, 12)}`; - return ( - - {unreadMarkerIndex === index && - (onDismissUnreadMarker ? ( - - ) : ( -
- - - Unread messages - - -
- ))} + return ( + // Absolutely positioned so the scroll container keeps a stable total height + // while only the visible window is mounted. `flex flex-col` matters: it makes + // child margins (group spacing, the unread divider) part of the measured height.
- {!msg.outgoing && ( -
- {showAvatar && - avatarKey && - (onOpenContactInfo ? ( - - ) : ( - - - - ))} -
- )} + {unreadMarkerIndex === index && + (onDismissUnreadMarker ? ( + + ) : ( +
+ + + Unread messages + + +
+ ))}
- {showAvatar && ( -
- {canClickSender ? ( - onSenderClick(displaySender)} - title={`Mention ${displaySender}`} - > - {displaySender} - - ) : ( - displaySender - )} - - {formatTime(msg.received_at)} - - {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( - - setSelectedPath({ - paths: msg.paths!, - senderInfo: getSenderInfo(msg, contact, directSenderName || sender), - messageId: msg.id, - packetId: msg.packet_id, - }) - } - /> - )} - {msg.region && } + {!msg.outgoing && ( +
+ {showAvatar && + avatarKey && + (onOpenContactInfo ? ( + + ) : ( + + + + ))}
)} -
- {(renderRichPayloads && - renderMeshcoreOpenPayload(content, radioName, onChannelReferenceClick)) || - content.split('\n').map((line, i, arr) => ( - - {renderTextWithMentions(line, radioName, onChannelReferenceClick)} - {i < arr.length - 1 &&
} -
- ))} - {!showAvatar && ( - <> - +
+ {showAvatar && ( +
+ {canClickSender ? ( + onSenderClick(displaySender)} + title={`Mention ${displaySender}`} + > + {displaySender} + + ) : ( + displaySender + )} + {formatTime(msg.received_at)} {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( setSelectedPath({ paths: msg.paths!, @@ -1144,11 +1179,68 @@ export function MessageList({ /> )} {msg.region && } - +
)} - {msg.outgoing && - (msg.acked > 0 ? ( - msg.paths && msg.paths.length > 0 ? ( +
+ {(renderRichPayloads && + renderMeshcoreOpenPayload(content, radioName, onChannelReferenceClick)) || + content.split('\n').map((line, i, arr) => ( + + {renderTextWithMentions(line, radioName, onChannelReferenceClick)} + {i < arr.length - 1 &&
} +
+ ))} + {!showAvatar && ( + <> + + {formatTime(msg.received_at)} + + {!msg.outgoing && msg.paths && msg.paths.length > 0 && ( + + setSelectedPath({ + paths: msg.paths!, + senderInfo: getSenderInfo( + msg, + contact, + directSenderName || sender + ), + messageId: msg.id, + packetId: msg.packet_id, + }) + } + /> + )} + {msg.region && } + + )} + {msg.outgoing && + (msg.acked > 0 ? ( + msg.paths && msg.paths.length > 0 ? ( + { + 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 : ''}`} + ) : ( + {` ✓${msg.acked > 1 ? msg.acked : ''}`} + ) + ) : onResendChannelMessage && msg.type === 'CHAN' ? ( { e.stopPropagation(); setSelectedPath({ - paths: msg.paths!, + paths: [], senderInfo: selfSenderInfo, messageId: msg.id, packetId: msg.packet_id, - isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage, + isOutgoingChan: true, }); }} - title="View echo paths" - aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`} - >{` ✓${msg.acked > 1 ? msg.acked : ''}`} + title="Message status" + aria-label="No echoes yet — view message status" + > + {' '} + ? + ) : ( - {` ✓${msg.acked > 1 ? msg.acked : ''}`} - ) - ) : onResendChannelMessage && msg.type === 'CHAN' ? ( - { - 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" - > - {' '} - ? - - ) : ( - - {' '} - ? - - ))} + + {' '} + ? + + ))} +
- - ); - })} + ); + })} +
{loadingNewer && (
Loading newer messages... @@ -1223,7 +1294,12 @@ export function MessageList({