diff --git a/AGENTS.md b/AGENTS.md index 1232bdb..86ae9f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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}` diff --git a/app/AGENTS.md b/app/AGENTS.md index 667274c..d2c4ef7 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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 diff --git a/app/models.py b/app/models.py index 6507682..a9efb39 100644 --- a/app/models.py +++ b/app/models.py @@ -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" ) diff --git a/app/repository/messages.py b/app/repository/messages.py index cd66549..e394d84 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 7ba86bc..77d9c15 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 16–48px 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). diff --git a/frontend/harness/drive.mjs b/frontend/harness/drive.mjs new file mode 100644 index 0000000..1789ad0 --- /dev/null +++ b/frontend/harness/drive.mjs @@ -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 + */ +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); +}); diff --git a/frontend/harness/index.html b/frontend/harness/index.html new file mode 100644 index 0000000..4106610 --- /dev/null +++ b/frontend/harness/index.html @@ -0,0 +1,11 @@ + + + + + MessageList layout harness + + +
+ + + diff --git a/frontend/harness/main.tsx b/frontend/harness/main.tsx new file mode 100644 index 0000000..5c2b6d2 --- /dev/null +++ b/frontend/harness/main.tsx @@ -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({ + 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).__setHarness = (next: Partial) => + setState((prev) => ({ ...prev, ...next })); + (window as unknown as Record).__messages = messages; + + return ( +
+ +
+ ); +} + +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(), frames: 0 }; + (window as unknown as Record).__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 ? ( + + + + ) : ( + + ) +); diff --git a/frontend/harness/vite.config.ts b/frontend/harness/vite.config.ts new file mode 100644 index 0000000..6e1a3b6 --- /dev/null +++ b/frontend/harness/vite.config.ts @@ -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 }, + }, +}); 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/App.tsx b/frontend/src/App.tsx index 71e62a7..f2821af 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState(null); const [visibilityVersion, setVisibilityVersion] = useState(0); - const lastUnreadBackfillAttemptRef = useRef(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, diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 3b8ec19..64bc07f 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -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 diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 4b2c63d..a1b2338 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 { @@ -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(null); const prevMessagesLengthRef = useRef(0); const isInitialLoadRef = useRef(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(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( @@ -894,9 +1067,12 @@ export function MessageList({ return (
{loadingOlder && (
@@ -908,231 +1084,223 @@ 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]; + // 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 ( - - {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 +1312,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 +1427,17 @@ export function MessageList({