Merge pull request #340 from rgregg/perf/virtualize-message-list

Virtualize the message list
This commit is contained in:
Jack Kingsman
2026-07-25 23:16:25 -07:00
committed by GitHub
28 changed files with 1288 additions and 556 deletions
+2 -2
View File
@@ -376,7 +376,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/packets/region-backfill` | Re-resolve region scope for stored channel messages with retained raw packets |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, `last_read_ats` boundaries, and `first_unread_ids` (unread-divider anchor) |
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
@@ -441,7 +441,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
- Bulk update via `POST /api/read-state/mark-all-read`
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, `last_read_ats`, and `first_unread_ids`)
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
- Channels: `channel-{channel_key}`
+3 -2
View File
@@ -127,7 +127,8 @@ app/
### Read/unread state
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, `last_read_ats`, and `first_unread_ids`.
- `first_unread_ids` maps stateKey -> id of the oldest unread message, so the client can anchor the unread divider (and jump to it) without paging back through history. It is computed with `ROW_NUMBER() OVER (PARTITION BY type, conversation_key ORDER BY received_at, id)` — deliberately not `MIN(received_at)` with a bare id, because sender timestamps are whole seconds and same-second ties are routine, and not `MIN(id)`, because historical decryption inserts old messages with new ids.
### DM ingest + ACKs
@@ -302,7 +303,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
- `POST /packets/maintenance`
### Read state
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, `last_read_ats`, and `first_unread_ids`
- `POST /read-state/mark-all-read`
### Settings
+7
View File
@@ -960,6 +960,13 @@ class UnreadCounts(BaseModel):
last_message_times: dict[str, int] = Field(
default_factory=dict, description="Map of stateKey -> last message timestamp"
)
first_unread_ids: dict[str, int | None] = Field(
default_factory=dict,
description=(
"Map of stateKey -> id of the oldest unread message. Lets the client place "
"the unread divider (and jump to it) without paging back through history."
),
)
last_read_ats: dict[str, int | None] = Field(
default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary"
)
+39 -1
View File
@@ -720,12 +720,15 @@ class MessageRepository:
blocked_names: Display names whose messages should be excluded from counts.
Returns:
Dict with 'counts', 'mentions', 'last_message_times', and 'last_read_ats' keys.
Dict with 'counts', 'mentions', 'last_message_times', 'last_read_ats',
and 'first_unread_ids' keys.
"""
counts: dict[str, int] = {}
mention_flags: dict[str, bool] = {}
last_message_times: dict[str, int] = {}
last_read_ats: dict[str, int | None] = {}
# id of the oldest unread message per conversation.
first_unread_ids: dict[str, int | None] = {}
mention_token = f"@[{name}]" if name else None
@@ -816,6 +819,40 @@ class MessageRepository:
for row in rows:
last_read_ats[f"contact-{row['public_key']}"] = row["last_read_at"]
# Oldest unread message per conversation. ROW_NUMBER rather than
# MIN(received_at) with a bare id: sender timestamps are whole seconds
# (a protocol constraint, see AGENTS.md), so several unread messages
# routinely share the oldest second and SQLite's bare-column rule only
# promises *a* row holding the minimum. Ordering by (received_at, id)
# picks the same message the client's own ordering does.
async with conn.execute(
f"""
WITH ranked AS (
SELECT m.type, m.conversation_key, m.id,
ROW_NUMBER() OVER (
PARTITION BY m.type, m.conversation_key
ORDER BY m.received_at ASC, m.id ASC
) AS rn
FROM messages m
LEFT JOIN channels c ON m.type = 'CHAN' AND m.conversation_key = c.key
LEFT JOIN contacts ct ON m.type = 'PRIV' AND m.conversation_key = ct.public_key
WHERE m.outgoing = 0
AND m.received_at > COALESCE(
CASE WHEN m.type = 'CHAN' THEN c.last_read_at ELSE ct.last_read_at END,
0
)
AND (m.type <> 'CHAN' OR COALESCE(c.muted, 0) = 0)
{blocked_sql}
)
SELECT type, conversation_key, id FROM ranked WHERE rn = 1
""",
blocked_params,
) as cursor:
rows = await cursor.fetchall()
for row in rows:
prefix = "channel" if row["type"] == "CHAN" else "contact"
first_unread_ids[f"{prefix}-{row['conversation_key']}"] = row["id"]
async with conn.execute(
f"""
SELECT type, conversation_key, MAX(received_at) as last_message_time
@@ -841,6 +878,7 @@ class MessageRepository:
"mentions": mention_flags,
"last_message_times": last_message_times,
"last_read_ats": last_read_ats,
"first_unread_ids": first_unread_ids,
}
@staticmethod
+17 -3
View File
@@ -254,7 +254,7 @@ High-level state is delegated to hooks:
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, server `last_read_ats`, and `first_unread_ids` (the unread-divider anchor)
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
@@ -307,6 +307,16 @@ That gives the store a load-bearing invariant: **no ancestor of `MessageList` ma
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
### Virtualization (`MessageList`)
The message list is windowed with `@tanstack/react-virtual`; only the visible rows are mounted, so render cost no longer scales with conversation length. Three details are load-bearing and easy to break:
- **`scrollMargin`** is measured from the virtual spacer's offset within the scroll container, because the container carries `p-4` and can show an "older messages" banner above the rows. Without it every `scrollToIndex` with `start`/`center` lands 1648px high, and the error shifts as the banner appears during pagination. Rows must subtract it back out in their `translateY`.
- **The bottom-pin is deferred and re-asserted** across a bounded run of frames rather than performed once, because row heights start as estimates and a single `scrollToIndex` gets undone as they converge (completely so under StrictMode's double-invoked effects). It is cancelled by a pending `targetMessageId` and by any deliberate scroll gesture.
- **`getItemKey` returns a string sentinel** for indices past the end of a shrunken list; a bare index would collide with the numeric message-id keyspace and poison the measurement cache.
jsdom has no layout engine, so none of this is observable from the vitest suite — it needs a real browser.
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
@@ -390,7 +400,11 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, `last_read_ats`, and `first_unread_ids`.
The unread divider is anchored to `first_unread_ids` — the id of the oldest unread message per conversation — not to a timestamp. `MessageList` locates it with `findIndex(msg.id === unreadMarkerMessageId)`, which returns `-1` when that message is not in the loaded window; that is the signal to offer "Jump to unread" (routed through the `targetMessageId`/`getMessagesAround` path) rather than render a divider. Locating by timestamp instead would return index 0 whenever the boundary sits further back than the loaded window, silently placing the divider on the wrong message.
Counts are incremented live over WebSocket while `first_unread_ids` only arrives with a full `/read-state/unreads` fetch, so `useUnreadCounts.incrementUnread` seeds the boundary itself on the read→unread transition. A channel going unread while the app is open would otherwise have a count but no boundary, and no divider at all.
## Contact Info Pane
@@ -450,7 +464,7 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page.
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` resolves the target to an index and calls `virtualizer.scrollToIndex(...)`, then applies a `message-highlight` CSS animation. A pending target suppresses the bottom-pin (see Virtualization below), since the around-load clears the list first and would otherwise be yanked to the newest message.
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
+131
View File
@@ -0,0 +1,131 @@
/**
* Minimal CDP driver: launches the Playwright-cached Chromium, loads the harness,
* and evaluates assertions in-page. No dependencies Node 22+ ships fetch/WebSocket.
*
* Usage: node harness/drive.mjs <harness-url>
*/
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const URL_ARG = process.argv[2] ?? 'http://localhost:5199/';
const CHROME =
process.env.CHROME_BIN ??
`${process.env.HOME}/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome`;
const PORT = 9333;
const chrome = spawn(
CHROME,
[
'--headless=new',
`--remote-debugging-port=${PORT}`,
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--window-size=1000,700',
// Start blank so the driver can enable Runtime before anything renders,
// otherwise early console output is missed.
'about:blank',
],
{ stdio: 'ignore' }
);
let ws;
let nextId = 1;
const pending = new Map();
function send(method, params = {}) {
const id = nextId++;
ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
}
async function evaluate(expression) {
const res = await send('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
});
if (res.exceptionDetails) {
throw new Error(
res.exceptionDetails.exception?.description ?? JSON.stringify(res.exceptionDetails)
);
}
return res.result.value;
}
async function connect() {
for (let i = 0; i < 100; i++) {
try {
const list = await fetch(`http://127.0.0.1:${PORT}/json/list`).then((r) => r.json());
const page = list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl);
if (page) return page.webSocketDebuggerUrl;
} catch {
/* not up yet */
}
await sleep(100);
}
throw new Error('Chromium did not expose a debugging target');
}
async function main() {
const wsUrl = await connect();
ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true });
ws.addEventListener('error', reject, { once: true });
});
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
}
});
const consoleLines = [];
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.method === 'Runtime.consoleAPICalled') {
consoleLines.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
}
});
globalThis.__consoleLines = consoleLines;
await send('Runtime.enable');
await send('Page.enable');
await send('Page.navigate', { url: URL_ARG });
// Wait for the harness to mount and settle.
for (let i = 0; i < 100; i++) {
const ready = await evaluate(
`!!document.querySelector('[data-message-id]') && !!window.__setHarness`
).catch(() => false);
if (ready) break;
await sleep(100);
}
await evaluate(`new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))`);
const script = process.env.HARNESS_SCRIPT;
// A wedged page (render loop) would otherwise leave evaluate pending forever.
const out = await Promise.race([
evaluate(script),
sleep(20000).then(() => {
throw new Error('page did not respond within 20s (main thread wedged?)');
}),
]);
console.log(JSON.stringify(out, null, 2));
if (process.env.HARNESS_CONSOLE === '1') {
console.log('--- page console ---');
for (const line of globalThis.__consoleLines) console.log(' ' + line);
}
ws.close();
chrome.kill();
}
main().catch((err) => {
console.error('DRIVER ERROR:', err.message);
chrome.kill();
process.exit(1);
});
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>MessageList layout harness</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
/**
* Real-layout harness for MessageList.
*
* jsdom has no layout engine, so windowing and scroll anchoring cannot be
* verified there. This mounts the real component in a real browser at a fixed
* viewport and exposes a control surface on `window` for a CDP driver.
*/
import { createRoot } from 'react-dom/client';
import { StrictMode, useMemo, useState } from 'react';
import { MessageList } from '../src/components/MessageList';
import type { Message } from '../src/types';
import '../src/index.css';
const CHANNEL_KEY = 'AA'.repeat(16);
function makeMessage(i: number, opts: { long?: boolean } = {}): Message {
const body = opts.long
? `message ${i} ` + 'wrapped filler text to force multi-line layout '.repeat(4)
: `message ${i}`;
return {
id: i + 1,
type: 'CHAN',
conversation_key: CHANNEL_KEY,
text: `Alice: ${body}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000000 + i,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
};
}
function buildMessages(count: number, longEvery = 0): Message[] {
return Array.from({ length: count }, (_, i) =>
makeMessage(i, { long: longEvery > 0 && i % longEvery === 0 })
);
}
interface HarnessState {
count: number;
longEvery: number;
hasOlderMessages: boolean;
loadingOlder: boolean;
targetMessageId: number | null;
unreadMarkerLastReadAt: number | null | undefined;
prepended: number;
}
function Harness() {
const q = new URLSearchParams(location.search);
const [state, setState] = useState<HarnessState>({
count: Number(q.get('count') ?? 500),
longEvery: Number(q.get('longEvery') ?? 0),
hasOlderMessages: q.get('hasOlder') === '1',
loadingOlder: false,
targetMessageId: null,
// ?unread=1 puts the unread boundary before all loaded history, like a channel
// with thousands unread.
unreadMarkerLastReadAt: q.get('unread') === '1' ? 1600000000 : undefined,
prepended: 0,
});
// Prepending shifts ids negative so existing rows keep their identity, which is
// what the real app does when older history loads in. Memoized so the array
// identity is stable across unrelated re-renders, matching the real app.
const messages = useMemo(() => {
const base = buildMessages(state.count, state.longEvery);
const older = Array.from({ length: state.prepended }, (_, i) => {
const m = makeMessage(-(state.prepended - i));
m.id = -(state.prepended - i);
m.received_at = 1700000000 - (state.prepended - i);
m.sender_timestamp = m.received_at;
return m;
});
return [...older, ...base];
}, [state.count, state.longEvery, state.prepended]);
(window as unknown as Record<string, unknown>).__setHarness = (next: Partial<HarnessState>) =>
setState((prev) => ({ ...prev, ...next }));
(window as unknown as Record<string, unknown>).__messages = messages;
return (
<div style={{ height: '600px', width: '900px', display: 'flex', flexDirection: 'column' }}>
<MessageList
messages={messages}
contacts={[]}
loading={false}
hasOlderMessages={state.hasOlderMessages}
loadingOlder={state.loadingOlder}
targetMessageId={state.targetMessageId}
unreadMarkerLastReadAt={state.unreadMarkerLastReadAt}
radioName="TestNode"
/>
</div>
);
}
const params = new URLSearchParams(location.search);
// Churn recorder. Installed before the first render so it captures the initial
// mount, which is where a large history does its flickering.
{
const churn = { rowEvents: 0, windows: new Set<string>(), frames: 0 };
(window as unknown as Record<string, unknown>).__churn = churn;
const obs = new MutationObserver((recs) => {
for (const r of recs) churn.rowEvents += r.addedNodes.length + r.removedNodes.length;
});
obs.observe(document.getElementById('root')!, { childList: true, subtree: true });
const t0 = performance.now();
const sample = () => {
churn.frames++;
const ids = [...document.querySelectorAll('[data-message-id]')].map((e) =>
e.getAttribute('data-message-id')
);
if (ids.length) churn.windows.add(ids[0] + ':' + ids[ids.length - 1]);
if (performance.now() - t0 < 4000) requestAnimationFrame(sample);
else obs.disconnect();
};
requestAnimationFrame(sample);
}
// ?strict=0 isolates StrictMode's double-mount (dev-only) from real behavior.
const useStrict = params.get('strict') !== '0';
createRoot(document.getElementById('root')!).render(
useStrict ? (
<StrictMode>
<Harness />
</StrictMode>
) : (
<Harness />
)
);
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
root: path.resolve(__dirname),
plugins: [react()],
resolve: { alias: { '@': path.resolve(__dirname, '../src') } },
server: {
port: 5199,
fs: { allow: [path.resolve(__dirname, '..')] },
// git checkout/stash swaps inodes and loses the default watcher.
watch: { usePolling: true, interval: 300 },
},
});
+30 -32
View File
@@ -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": [
+1
View File
@@ -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",
+46 -74
View File
@@ -32,7 +32,8 @@ import { shouldAutoFocusInput } from './utils/autoFocusInput';
interface ChannelUnreadMarker {
channelId: string;
lastReadAt: number | null;
/** Id of the oldest unread message, straight from the server. */
messageId: number | null;
}
interface NewMessagePrefillRequest {
@@ -41,42 +42,32 @@ interface NewMessagePrefillRequest {
nonce: number;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
}
/**
* Which message the unread divider should sit on.
*
* Normally the server's first-unread id. The exception is a channel that has
* never been read: its true boundary is the first message ever sent there, so
* offering to jump would haul the reader to the start of history for no gain.
* Everything loaded is unread in that case, so the divider belongs at the top of
* the window which is what the pre-id behaviour did, and it is genuinely the
* more useful answer.
*/
export function resolveUnreadMarkerId(
boundaryId: number | null,
lastReadAt: number | null,
messages: Message[]
): number | null {
if (boundaryId === null) return null;
if (lastReadAt !== null) return boundaryId;
if (messages.length === 0) return boundaryId;
if (messages.some((msg) => msg.id === boundaryId)) return boundaryId;
export function getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
}: UnreadBoundaryBackfillParams): string | null {
if (activeConversation?.type !== 'channel') return null;
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
if (unreadMarker.lastReadAt === null) return null;
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
const oldestLoadedMessage = messages.reduce(
(oldest, msg) => {
if (!oldest) return msg;
if (msg.received_at < oldest.received_at) return msg;
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
return oldest;
},
null as Message | null
);
if (!oldestLoadedMessage) return null;
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
const oldestLoaded = messages.reduce((oldest, msg) => {
if (msg.received_at < oldest.received_at) return msg;
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
return oldest;
}, messages[0]);
return oldestLoaded.id;
}
export function App() {
@@ -92,7 +83,6 @@ export function App() {
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
notificationsSupported,
notificationsPermission,
@@ -347,6 +337,7 @@ export function App() {
mentions,
lastMessageTimes,
unreadLastReadAts,
firstUnreadIds,
recordMessageEvent,
renameConversationState,
removeConversationState,
@@ -379,49 +370,25 @@ export function App() {
const activeChannelId = activeConversation.id;
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
const boundaryId = firstUnreadIds[getStateKey('channel', activeChannelId)] ?? null;
setChannelUnreadMarker((prev) => {
if (prev?.channelId === activeChannelId) {
// Same channel: hold the marker steady so it does not move under the
// reader, except to fill in a boundary we did not have yet. A marker
// created before /unreads resolved would otherwise stay blank for as long
// as the user stays put.
if (prev.messageId === null && boundaryId !== null) {
return { channelId: activeChannelId, messageId: boundaryId };
}
return prev;
}
if (activeChannelUnreadCount <= 0) {
return null;
}
return {
channelId: activeChannelId,
lastReadAt: unreadLastReadAts[getStateKey('channel', activeChannelId)] ?? null,
};
return { channelId: activeChannelId, messageId: boundaryId };
});
}, [activeConversation, unreadCounts, unreadLastReadAts]);
useEffect(() => {
lastUnreadBackfillAttemptRef.current = null;
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
useEffect(() => {
const backfillKey = getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker: channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
});
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
return;
}
lastUnreadBackfillAttemptRef.current = backfillKey;
void fetchOlderMessages();
}, [
activeConversation,
channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
fetchOlderMessages,
]);
}, [activeConversation, unreadCounts, firstUnreadIds]);
const wsHandlers = useRealtimeAppState({
prevHealthRef,
@@ -597,11 +564,16 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt:
unreadMarkerMessageId:
activeConversation?.type === 'channel' &&
channelUnreadMarker?.channelId === activeConversation.id
? channelUnreadMarker.lastReadAt
? resolveUnreadMarkerId(
channelUnreadMarker.messageId,
unreadLastReadAts[getStateKey('channel', activeConversation.id)] ?? null,
messages
)
: undefined,
onNavigateToUnread: (messageId: number) => setTargetMessageId(messageId),
targetMessageId,
hasNewerMessages,
loadingNewer,
+9 -4
View File
@@ -46,7 +46,8 @@ interface ConversationPaneProps {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
unreadMarkerLastReadAt?: number | null;
unreadMarkerMessageId?: number | null;
onNavigateToUnread?: (messageId: number) => void;
targetMessageId: number | null;
hasNewerMessages: boolean;
loadingNewer: boolean;
@@ -131,7 +132,8 @@ export function ConversationPane({
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt,
unreadMarkerMessageId,
onNavigateToUnread,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -329,8 +331,11 @@ export function ConversationPane({
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
unreadMarkerMessageId={
activeConversation.type === 'channel' ? unreadMarkerMessageId : undefined
}
onNavigateToUnread={
activeConversation.type === 'channel' ? onNavigateToUnread : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
+504 -290
View File
@@ -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 {
@@ -40,8 +40,11 @@ interface MessageListProps {
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
unreadMarkerLastReadAt?: number | null;
/** Id of the oldest unread message, from the server. Null when nothing is unread. */
unreadMarkerMessageId?: number | null;
onDismissUnreadMarker?: () => void;
/** Called when the unread boundary is not in loaded history and must be jumped to. */
onNavigateToUnread?: (messageId: number) => void;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
@@ -138,6 +141,22 @@ 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;
/**
* Frames a pending bottom-pin may re-assert itself for. Row heights start as
* estimates and converge over the first few measurement passes, so the pin has to
* outlive them; the budget stops a list that can never reach the bottom (a
* container stuck at zero height) from re-scrolling forever.
*/
const BOTTOM_SCROLL_FRAME_BUDGET = 20;
// 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;
@@ -371,8 +390,9 @@ export function MessageList({
loading,
loadingOlder = false,
hasOlderMessages = false,
unreadMarkerLastReadAt,
unreadMarkerMessageId,
onDismissUnreadMarker,
onNavigateToUnread,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
@@ -392,6 +412,21 @@ export function MessageList({
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
// A pending request to pin the list to the newest message.
//
// Rows are measured lazily, so the total size at mount is a guess built from
// estimates. A single scrollToIndex against that guess gets undone by the
// measurement passes that follow — and under StrictMode's double-invoked
// effects it is undone completely, leaving the view stranded at the top. So
// the request is held open and re-asserted as measurements land, rather than
// fired once and marked done.
const pendingBottomScrollRef = useRef(false);
const [bottomScrollNonce, setBottomScrollNonce] = useState(0);
const virtualSpacerRef = useRef<HTMLDivElement>(null);
// Distance from the scroll container's content origin down to the first row.
// Non-zero because the container carries p-4 and can show a loading/older
// banner above the rows; see the scrollMargin note on the virtualizer.
const [scrollMargin, setScrollMargin] = useState(0);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [selectedPath, setSelectedPath] = useState<{
paths: MessagePath[];
@@ -474,11 +509,137 @@ 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,
// Rows do not start at the scroll container's origin: the container has p-4
// padding and may render an "older messages" banner above them. Without this
// the virtualizer's offsets are short by that distance, so every
// scrollToIndex with 'start'/'center' lands high by 16-48px — and the error
// moves as the banner appears and disappears during pagination.
scrollMargin,
// String sentinel for the transient window past the end of a shrunken list:
// a bare index would share the keyspace with message ids and poison the
// measurement cache for whichever message happens to have that id.
getItemKey: (index) => sortedMessages[index]?.id ?? `__idx:${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();
// Re-measured whenever something above the rows can change height.
useLayoutEffect(() => {
const spacer = virtualSpacerRef.current;
const list = listRef.current;
if (!spacer || !list) return;
// Relative to the scroll container's *content* origin, so it is independent
// of the current scroll position. offsetTop is not usable here: the two
// elements can resolve to different offsetParents.
const next = Math.round(
spacer.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop
);
setScrollMargin((prev) => (prev === next ? prev : next));
}, [loadingOlder, hasOlderMessages, messages.length]);
const scrollToIndex = useCallback(
(index: number, align: 'start' | 'center' | 'end') => {
if (index < 0) return;
virtualizer.scrollToIndex(index, { align });
},
[virtualizer]
);
const requestBottomScroll = useCallback(() => {
pendingBottomScrollRef.current = true;
setBottomScrollNonce((n) => n + 1);
}, []);
// Drives a pending bottom-pin across a bounded run of frames. Deliberately not
// keyed on the virtualizer's total size: that churns on every measurement pass,
// which would re-enter this effect continuously. A fixed frame budget converges
// as rows are measured and then stops on its own.
useEffect(() => {
if (!pendingBottomScrollRef.current) return;
if (sortedMessages.length === 0) return;
let frames = 0;
let raf = 0;
let lastAppliedTop: number | null = null;
const step = () => {
const list = listRef.current;
if (!pendingBottomScrollRef.current || !list) return;
// Something other than us moved the scroll (a programmatic scrollTop, a
// restored position, assistive tech). Gesture handlers only catch a human
// at the wheel, so also bail when the position we last set has been moved
// upward — the pin must never fight another writer.
if (lastAppliedTop !== null && list.scrollTop < lastAppliedTop - 1) {
pendingBottomScrollRef.current = false;
return;
}
scrollToIndex(sortedMessages.length - 1, 'end');
lastAppliedTop = list.scrollTop;
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= 1;
if (atBottom || (frames += 1) >= BOTTOM_SCROLL_FRAME_BUDGET) {
pendingBottomScrollRef.current = false;
return;
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [bottomScrollNonce, sortedMessages.length, scrollToIndex]);
// Any deliberate scroll gesture cancels the pending pin, so the retry loop can
// never fight a user who has started reading back through history.
const cancelBottomScroll = useCallback(() => {
pendingBottomScrollRef.current = false;
}, []);
// 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 +650,49 @@ 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 - pin to the newest message. Requested
// rather than performed here; see pendingBottomScrollRef.
//
// Unless we are loading *at* a specific message: jump-to-message and
// jump-to-unread clear the list before fetching a window around their
// target, which trips both the initial-load and conversation-changed
// branches. Pinning to the bottom here would then discard the target scroll
// a frame later, stranding the user at the newest message instead.
if (!targetMessageId) {
requestBottomScroll();
}
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).
// User was near bottom - follow 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;
requestBottomScroll();
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages]);
}, [messages, sortedMessages.length, scrollToIndex, requestBottomScroll, targetMessageId]);
// 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
// Prevent the initial-load layout effect from overriding our scroll, and drop
// any bottom pin already queued by an earlier pass over the same commit.
isInitialLoadRef.current = false;
el.scrollIntoView({ block: 'center' });
pendingBottomScrollRef.current = false;
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,27 +767,32 @@ 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]
);
/**
* Located by message id, not by timestamp. The previous `received_at > boundary`
* scan returned 0 the top of the loaded window whenever the real boundary was
* further back than anything loaded, so the divider silently pointed at the wrong
* message. Matching on identity returns -1 in that case, which is the truth: the
* boundary is elsewhere, and `boundaryOutsideWindow` below offers to go to it.
*/
const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) {
return -1;
}
if (unreadMarkerMessageId == null) return -1;
return sortedMessages.findIndex((msg) => msg.id === unreadMarkerMessageId);
}, [sortedMessages, unreadMarkerMessageId]);
const boundary = unreadMarkerLastReadAt ?? 0;
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
}, [sortedMessages, unreadMarkerLastReadAt]);
// Unread exists, but the message it starts at has not been loaded.
const boundaryOutsideWindow = unreadMarkerMessageId != null && unreadMarkerIndex === -1;
const syncJumpToUnreadVisibility = useCallback(() => {
if (unreadMarkerIndex === -1 || jumpToUnreadDismissed) {
if (jumpToUnreadDismissed) {
setShowJumpToUnread(false);
return;
}
// Boundary is real but out of the loaded window: always offer the jump.
if (boundaryOutsideWindow) {
setShowJumpToUnread(true);
return;
}
if (unreadMarkerIndex === -1) {
setShowJumpToUnread(false);
return;
}
@@ -649,7 +824,7 @@ export function MessageList({
markerRect.right <= listRect.right;
setShowJumpToUnread(!markerVisible);
}, [jumpToUnreadDismissed, unreadMarkerIndex]);
}, [jumpToUnreadDismissed, unreadMarkerIndex, boundaryOutsideWindow]);
// Refs for scroll handler to read without causing callback recreation
const onLoadOlderRef = useRef(onLoadOlder);
@@ -722,10 +897,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<SenderInfo>(
@@ -894,9 +1067,12 @@ export function MessageList({
return (
<div className="flex-1 overflow-hidden relative">
<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}
onScroll={handleScroll}
onWheel={cancelBottomScroll}
onTouchStart={cancelBottomScroll}
onKeyDown={cancelBottomScroll}
>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
@@ -908,231 +1084,223 @@ export function MessageList({
Scroll up for older messages
</div>
)}
{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;
<div
ref={virtualSpacerRef}
className="relative w-full flex-shrink-0"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualRows.map((virtualRow) => {
const index = virtualRow.index;
const msg = sortedMessages[index];
// The virtualizer can briefly hold indices from a longer previous list
// (conversation switch, blocked-sender refilter). Rendering ahead of
// that would dereference undefined and blank the whole chat pane.
if (!msg) return null;
// 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 (
<Fragment key={msg.id}>
{unreadMarkerIndex === index &&
(onDismissUnreadMarker ? (
<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>
))}
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.
<div
data-message-id={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
key={msg.id}
data-index={index}
ref={virtualizer.measureElement}
className="absolute left-0 top-0 flex w-full flex-col pb-0.5"
// start is measured from the scroll container's origin, which
// scrollMargin accounts for; the spacer already sits that far
// down, so subtract it back out when positioning within it.
style={{ transform: `translateY(${virtualRow.start - scrollMargin}px)` }}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
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}
onClick={() =>
onOpenContactInfo(
avatarKey,
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
)
}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
{unreadMarkerIndex === index &&
(onDismissUnreadMarker ? (
<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
data-message-id={msg.id}
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{showAvatar && (
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
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)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="header"
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 && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
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}
onClick={() =>
onOpenContactInfo(
avatarKey,
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
)
}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<div className="break-words whitespace-pre-wrap">
{(renderRichPayloads &&
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">
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
)}
>
{showAvatar && (
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
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)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
@@ -1144,11 +1312,68 @@ export function MessageList({
/>
)}
{msg.region && <RegionBadge region={msg.region} />}
</>
</div>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<div className="break-words whitespace-pre-wrap">
{(renderRichPayloads &&
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
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
@@ -1157,53 +1382,32 @@ export function MessageList({
onClick={(e) => {
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 : ''}`}</span>
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
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>
))}
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
</div>
</div>
</div>
</div>
</Fragment>
);
})}
);
})}
</div>
{loadingNewer && (
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
Loading newer messages...
@@ -1223,7 +1427,17 @@ export function MessageList({
<button
type="button"
onClick={() => {
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
if (boundaryOutsideWindow && unreadMarkerMessageId != null) {
// Not in loaded history: hand off to the jump-to-message path,
// which loads a window around the boundary instead of paging
// everything between here and there.
onNavigateToUnread?.(unreadMarkerMessageId);
} else 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);
setShowJumpToUnread(false);
}}
+42 -11
View File
@@ -24,6 +24,8 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
unreadLastReadAts: Record<string, number | null>;
/** stateKey -> id of the oldest unread message, for placing the unread divider. */
firstUnreadIds: Record<string, number | null>;
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
@@ -45,6 +47,7 @@ export function useUnreadCounts(
const [mentions, setMentions] = useState<Record<string, boolean>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
const [unreadLastReadAts, setUnreadLastReadAts] = useState<Record<string, number | null>>({});
const [firstUnreadIds, setFirstUnreadIds] = useState<Record<string, number | null>>({});
// Track active conversation via ref so applyUnreads can filter without
// destabilizing the callback chain (avoids re-creating fetchUnreads on
@@ -71,6 +74,7 @@ export function useUnreadCounts(
}
setUnreadLastReadAts(data.last_read_ats);
setFirstUnreadIds(data.first_unread_ids ?? {});
if (Object.keys(data.last_message_times).length > 0) {
for (const [key, ts] of Object.entries(data.last_message_times)) {
@@ -160,18 +164,29 @@ export function useUnreadCounts(
}
}, [activeConversation]);
const incrementUnread = useCallback((stateKey: string, hasMention?: boolean) => {
setUnreadCounts((prev) => ({
...prev,
[stateKey]: (prev[stateKey] || 0) + 1,
}));
if (hasMention) {
setMentions((prev) => ({
const incrementUnread = useCallback(
(stateKey: string, messageId: number, hasMention?: boolean) => {
setUnreadCounts((prev) => ({
...prev,
[stateKey]: true,
[stateKey]: (prev[stateKey] || 0) + 1,
}));
}
}, []);
// Counts move live over the socket, but first_unread_ids only arrives with a
// full /unreads fetch. Without seeding it here, a conversation that goes from
// read to unread while the app is open has a count but no boundary, and the
// divider silently never renders. Only the transition matters: once a
// boundary exists, later messages are not the *first* unread.
setFirstUnreadIds((prev) =>
prev[stateKey] != null ? prev : { ...prev, [stateKey]: messageId }
);
if (hasMention) {
setMentions((prev) => ({
...prev,
[stateKey]: true,
}));
}
},
[]
);
const recordMessageEvent = useCallback(
({
@@ -201,7 +216,7 @@ export function useUnreadCounts(
setLastMessageTimes(updated);
if (!isActiveConversation && !msg.outgoing && isNewMessage) {
incrementUnread(stateKey, hasMention);
incrementUnread(stateKey, msg.id, hasMention);
}
},
[incrementUnread]
@@ -226,6 +241,14 @@ export function useUnreadCounts(
return next;
});
setFirstUnreadIds((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = next[newStateKey] ?? next[oldStateKey];
delete next[oldStateKey];
return next;
});
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
@@ -242,6 +265,12 @@ export function useUnreadCounts(
delete next[stateKey];
return next;
});
setFirstUnreadIds((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setUnreadLastReadAts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
@@ -257,6 +286,7 @@ export function useUnreadCounts(
setUnreadCounts({});
setMentions({});
setUnreadLastReadAts({});
setFirstUnreadIds({});
// Persist to server with single bulk request
api.markAllRead().catch((err) => {
@@ -269,6 +299,7 @@ export function useUnreadCounts(
mentions,
lastMessageTimes,
unreadLastReadAts,
firstUnreadIds,
recordMessageEvent,
renameConversationState,
removeConversationState,
+1
View File
@@ -104,6 +104,7 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
firstUnreadIds: {},
recordMessageEvent: mocks.hookFns.recordMessageEvent,
renameConversationState: vi.fn(),
markAllRead: mocks.hookFns.markAllRead,
@@ -81,6 +81,7 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
firstUnreadIds: {},
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
removeConversationState: vi.fn(),
+1
View File
@@ -53,6 +53,7 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
firstUnreadIds: {},
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
@@ -47,6 +47,7 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
firstUnreadIds: {},
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
+4 -4
View File
@@ -132,7 +132,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
unreadMarkerLastReadAt: undefined,
unreadMarkerMessageId: undefined,
targetMessageId: null,
hasNewerMessages: false,
loadingNewer: false,
@@ -311,7 +311,7 @@ describe('ConversationPane', () => {
id: channel.key,
name: channel.name,
},
unreadMarkerLastReadAt: 1700000000,
unreadMarkerMessageId: 4242,
})}
/>
);
@@ -324,7 +324,7 @@ describe('ConversationPane', () => {
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const channelCall = channelCallArgs?.[0] as Record<string, unknown> | undefined;
expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000);
expect(channelCall?.unreadMarkerMessageId).toBe(4242);
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
render(
@@ -335,7 +335,7 @@ describe('ConversationPane', () => {
id: 'cc'.repeat(32),
name: 'Alice',
},
unreadMarkerLastReadAt: 1700000000,
unreadMarkerMessageId: 4242,
})}
/>
);
+65 -23
View File
@@ -324,6 +324,47 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('TEST1: TEST2')).toBeInTheDocument();
});
it('offers a jump instead of a divider when the unread boundary is not loaded', async () => {
const user = userEvent.setup();
const onNavigateToUnread = vi.fn();
// Boundary id 999 is not among the loaded messages: the real first-unread is
// further back than this window. The divider must not be invented at the top.
render(
<MessageList
messages={[
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
]}
contacts={[]}
loading={false}
unreadMarkerMessageId={999}
onNavigateToUnread={onNavigateToUnread}
/>
);
expect(screen.queryByText('Unread messages')).not.toBeInTheDocument();
const jump = await screen.findByRole('button', { name: 'Jump to unread' });
await user.click(jump);
// Hands off to the jump-to-message path rather than scrolling to a wrong row.
expect(onNavigateToUnread).toHaveBeenCalledWith(999);
});
it('shows no unread affordance at all when nothing is unread', () => {
render(
<MessageList
messages={[createMessage({ id: 1, received_at: 1700000001, text: 'Alice: hi' })]}
contacts={[]}
loading={false}
unreadMarkerMessageId={null}
/>
);
expect(screen.queryByText('Unread messages')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
@@ -332,17 +373,15 @@ describe('MessageList channel sender rendering', () => {
];
function DismissibleUnreadMarkerList() {
const [unreadMarkerLastReadAt, setUnreadMarkerLastReadAt] = useState<number | undefined>(
1700000005
);
const [unreadMarkerMessageId, setUnreadMarkerMessageId] = useState<number | undefined>(2);
return (
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={unreadMarkerLastReadAt}
onDismissUnreadMarker={() => setUnreadMarkerLastReadAt(undefined)}
unreadMarkerMessageId={unreadMarkerMessageId}
onDismissUnreadMarker={() => setUnreadMarkerMessageId(undefined)}
/>
);
}
@@ -367,12 +406,7 @@ describe('MessageList channel sender rendering', () => {
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
<MessageList messages={messages} contacts={[]} loading={false} unreadMarkerMessageId={2} />
);
const jumpButton = screen.getByRole('button', { name: 'Jump to unread' });
@@ -394,12 +428,7 @@ describe('MessageList channel sender rendering', () => {
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
<MessageList messages={messages} contacts={[]} loading={false} unreadMarkerMessageId={2} />
);
await user.click(screen.getByRole('button', { name: 'Dismiss jump to unread' }));
@@ -461,15 +490,28 @@ describe('MessageList channel sender rendering', () => {
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
<MessageList messages={messages} contacts={[]} loading={false} unreadMarkerMessageId={2} />
);
expect(screen.getByText('Unread messages')).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);
});
});
@@ -1,101 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getUnreadBoundaryBackfillKey } from '../App';
import type { Conversation, Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'channel-1',
text: 'Alice: hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const channelConversation: Conversation = {
type: 'channel',
id: 'channel-1',
name: 'Busy room',
};
describe('getUnreadBoundaryBackfillKey', () => {
it('returns a fetch key when the unread boundary is older than the loaded window', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBe('channel-1:1700000000:20');
});
it('does not backfill when the loaded window already reaches the unread boundary', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000200,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
it('does not backfill when there is no older history to fetch', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
})
).toBeNull();
});
it('does not backfill for channels where everything is unread', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: null,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
});
@@ -0,0 +1,56 @@
/**
* The unread divider is anchored to a message id from the server. These cover the
* one case where that id is deliberately overridden: a channel that has never
* been read, whose true boundary is the start of history and therefore a useless
* jump target.
*/
import { describe, expect, it } from 'vitest';
import { resolveUnreadMarkerId } from '../App';
import type { Message } from '../types';
function msg(id: number, receivedAt: number): Message {
return {
id,
type: 'CHAN',
conversation_key: 'CHAN1',
text: `Alice: m${id}`,
sender_timestamp: receivedAt,
received_at: receivedAt,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
};
}
describe('resolveUnreadMarkerId', () => {
const loaded = [msg(50, 1700000050), msg(51, 1700000051), msg(52, 1700000052)];
it('uses the server boundary when the channel has been read before', () => {
expect(resolveUnreadMarkerId(9, 1700000000, loaded)).toBe(9);
});
it('uses the server boundary when it is inside the loaded window', () => {
expect(resolveUnreadMarkerId(51, null, loaded)).toBe(51);
});
it('anchors a never-read channel to the top of the loaded window', () => {
// Boundary 1 is the first message ever sent; jumping there would dump the
// reader at the start of history. Everything loaded is unread, so the top of
// the window is both true and useful.
expect(resolveUnreadMarkerId(1, null, loaded)).toBe(50);
});
it('picks the oldest loaded message regardless of array order', () => {
expect(resolveUnreadMarkerId(1, null, [loaded[2], loaded[0], loaded[1]])).toBe(50);
});
it('passes through when there is no boundary or no messages', () => {
expect(resolveUnreadMarkerId(null, null, loaded)).toBeNull();
expect(resolveUnreadMarkerId(7, null, [])).toBe(7);
});
});
+81
View File
@@ -103,6 +103,7 @@ describe('useUnreadCounts', () => {
counts: {},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
mocks.markChannelRead.mockResolvedValue({ status: 'ok', key: '' });
@@ -134,6 +135,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
mentions: { [`channel-${CHANNEL_KEY}`]: true },
last_message_times: {},
first_unread_ids: {},
last_read_ats: { [`channel-${CHANNEL_KEY}`]: 1234 },
});
@@ -159,6 +161,7 @@ describe('useUnreadCounts', () => {
counts: { [`contact-${CONTACT_KEY}`]: 3 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: { [`contact-${CONTACT_KEY}`]: 2345 },
});
@@ -185,6 +188,7 @@ describe('useUnreadCounts', () => {
},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -235,6 +239,7 @@ describe('useUnreadCounts', () => {
[getStateKey('channel', CHANNEL_KEY)]: true,
},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -277,6 +282,7 @@ describe('useUnreadCounts', () => {
counts: {},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -291,6 +297,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 7 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: { [`channel-${CHANNEL_KEY}`]: 3456 },
});
@@ -318,6 +325,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${addedChannelKey}`]: 2 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -347,6 +355,7 @@ describe('useUnreadCounts', () => {
counts: { [`contact-${addedContactKey}`]: 1 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -367,6 +376,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -385,6 +395,7 @@ describe('useUnreadCounts', () => {
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
@@ -468,4 +479,74 @@ describe('useUnreadCounts', () => {
expect(result.current.lastMessageTimes[getStateKey('contact', CONTACT_KEY)]).toBe(1700002000);
expect(result.current.lastMessageTimes[getStateKey('channel', CHANNEL_KEY)]).toBe(1700002001);
});
it('seeds the first-unread boundary when a conversation goes unread over the socket', async () => {
// Counts move live over WS but first_unread_ids only arrives with a full
// fetch. Without seeding here, a channel that goes unread while the app is
// open has a count but no boundary, so the divider never renders.
const mocks = await getMockedApi();
mocks.getUnreads.mockResolvedValue({
counts: {},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
const { result } = renderWith({ channels: [makeChannel(CHANNEL_KEY, 'Test')] });
await act(async () => {
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
});
const key = getStateKey('channel', CHANNEL_KEY);
act(() => {
result.current.recordMessageEvent({
msg: makeMessage({ id: 4711, type: 'CHAN', conversation_key: CHANNEL_KEY }),
activeConversation: false,
isNewMessage: true,
});
});
expect(result.current.firstUnreadIds[key]).toBe(4711);
// A later message must not move the boundary — it is not the *first* unread.
act(() => {
result.current.recordMessageEvent({
msg: makeMessage({ id: 4712, type: 'CHAN', conversation_key: CHANNEL_KEY }),
activeConversation: false,
isNewMessage: true,
});
});
expect(result.current.firstUnreadIds[key]).toBe(4711);
expect(result.current.unreadCounts[key]).toBe(2);
});
it('drops first-unread boundaries on mark-all-read', async () => {
const mocks = await getMockedApi();
mocks.getUnreads.mockResolvedValue({
counts: {},
mentions: {},
last_message_times: {},
first_unread_ids: {},
last_read_ats: {},
});
const { result } = renderWith({ channels: [makeChannel(CHANNEL_KEY, 'Test')] });
await act(async () => {
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
});
act(() => {
result.current.recordMessageEvent({
msg: makeMessage({ id: 99, type: 'CHAN', conversation_key: CHANNEL_KEY }),
activeConversation: false,
isNewMessage: true,
});
});
expect(result.current.firstUnreadIds[getStateKey('channel', CHANNEL_KEY)]).toBe(99);
await act(async () => {
await result.current.markAllRead();
});
expect(result.current.firstUnreadIds).toEqual({});
});
});
+2
View File
@@ -642,6 +642,8 @@ export interface UnreadCounts {
mentions: Record<string, boolean>;
last_message_times: Record<string, number>;
last_read_ats: Record<string, number | null>;
/** stateKey -> id of the oldest unread message. Locates the unread divider. */
first_unread_ids: Record<string, number | null>;
}
interface BusyChannel {
@@ -37,8 +37,10 @@ test.describe('Channel info pane', () => {
await page.goto(`/#channel/${channelKey}/flightless`);
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
// Wait for messages to load
await expect(page.getByText('seed-0')).toBeVisible({ timeout: 15_000 });
// Wait for messages to load. Assert on the newest seeded message, not the
// oldest: the list is virtualized, so it opens at the bottom and only the
// visible window is in the DOM — seed-0 is 30 rows up and legitimately absent.
await expect(page.getByText(`seed-${SEED_COUNT - 1}`)).toBeVisible({ timeout: 15_000 });
// Click the channel name in the header to open info pane
const headerTitle = page.locator('h2').filter({ hasText: '#flightless' });
+14 -7
View File
@@ -25,13 +25,20 @@ test.describe('Message pagination ordering/dedup', () => {
const list = page.locator('div.h-full.overflow-y-auto').first();
// Scroll to top to trigger older fetch
await list.evaluate((el) => {
el.scrollTop = 0;
});
// Wait for oldest message to appear after pagination
await expect(page.getByText('seed-0')).toBeVisible({ timeout: 15_000 });
// Scroll to top repeatedly until the oldest message renders.
//
// One scroll is not enough with a virtualized list: reaching the top fetches
// the older page, and the list then anchors to the message that was on top so
// the reader keeps their place, which leaves seed-0 fifty rows further up and
// unmounted. A reader scrolls again to get there; so does this. (Before
// virtualization every loaded row was in the DOM, so a single scroll made
// seed-0 findable regardless of where the viewport actually was.)
await expect(async () => {
await list.evaluate((el) => {
el.scrollTop = 0;
});
await expect(page.getByText('seed-0')).toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 20_000 });
// Spot-check ordering: seed-249 appears above seed-200; seed-50 above seed-10
// Fetch from API to validate ordering and dedup
+65
View File
@@ -1005,6 +1005,71 @@ class TestReadStateEndpoints:
assert result["last_read_ats"][f"channel-{chan_key}"] == 1000
assert result["last_read_ats"][f"contact-{contact_key}"] == 1000
# The oldest unread message per conversation, used by the client to place
# the unread divider without paging back through history.
first_chan = await MessageRepository.get_by_content(
msg_type="CHAN",
conversation_key=chan_key,
text="Bob: hello",
sender_timestamp=1001,
)
assert result["first_unread_ids"][f"channel-{chan_key}"] == first_chan.id
first_dm = await MessageRepository.get_by_content(
msg_type="PRIV",
conversation_key=contact_key,
text="hi @[TeStUsEr] there",
sender_timestamp=1005,
)
assert result["first_unread_ids"][f"contact-{contact_key}"] == first_dm.id
@pytest.mark.asyncio
async def test_first_unread_id_breaks_same_second_ties_by_id(self, test_db):
"""Sender timestamps are whole seconds, so the oldest unread second is
routinely shared. The boundary must be the first of those messages, not
an arbitrary one."""
chan_key = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2"
await ChannelRepository.upsert(key=chan_key, name="Busy")
await ChannelRepository.update_last_read_at(chan_key, 1000)
# Three unread messages all landing in the same second.
ids = []
for text in ("Bob: first", "Bob: second", "Bob: third"):
ids.append(
await MessageRepository.create(
msg_type="CHAN",
text=text,
received_at=1001,
conversation_key=chan_key,
sender_timestamp=1001,
)
)
result = await MessageRepository.get_unread_counts(None)
assert result["counts"][f"channel-{chan_key}"] == 3
assert result["first_unread_ids"][f"channel-{chan_key}"] == min(ids)
@pytest.mark.asyncio
async def test_first_unread_id_ignores_muted_and_outgoing(self, test_db):
"""Muted channels are excluded from unread counts, so they must not
report a boundary either."""
chan_key = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC3"
await ChannelRepository.upsert(key=chan_key, name="Muted")
await ChannelRepository.update_last_read_at(chan_key, 1000)
await ChannelRepository.set_muted(chan_key, True)
await MessageRepository.create(
msg_type="CHAN",
text="Bob: unread but muted",
received_at=1001,
conversation_key=chan_key,
sender_timestamp=1001,
)
result = await MessageRepository.get_unread_counts(None)
assert f"channel-{chan_key}" not in result["counts"]
assert result["first_unread_ids"].get(f"channel-{chan_key}") is None
@pytest.mark.asyncio
async def test_get_unreads_no_name_skips_mentions(self, test_db):
"""Unreads without a radio name returns counts but no mention flags."""