mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Merge pull request #340 from rgregg/perf/virtualize-message-list
Virtualize the message list
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 />
|
||||
)
|
||||
);
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
Generated
+30
-32
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user