From 8ab6d20156fcd4be41e7fb75e5c21236770eba39 Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Sat, 12 Sep 2026 10:53:00 -0600 Subject: [PATCH 01/17] fix(read-state): accept legacy receive envelopes without widening writes Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- dev/read-state-broker.test.mjs | 117 ++++++++++++++++++++++++++++++++- dev/read-state.mjs | 21 ++++-- dev/read-state.test.mjs | 27 ++++++++ dev/relay-broker.mjs | 4 +- docs/unread.md | 10 ++- 5 files changed, 171 insertions(+), 8 deletions(-) diff --git a/dev/read-state-broker.test.mjs b/dev/read-state-broker.test.mjs index 168a6a14..7781a551 100644 --- a/dev/read-state-broker.test.mjs +++ b/dev/read-state-broker.test.mjs @@ -5,11 +5,14 @@ import { finalizeEvent, generateSecretKey, getPublicKey, + nip44, verifyEvent, } from "nostr-tools"; import { relayBrokerPlugin } from "./relay-broker.mjs"; import { connectBrokerTransport } from "../src/features/relay/transport.ts"; import { createRelayReader } from "../src/features/relay/reader.ts"; +import { createReadState } from "../src/features/relay/read-state.ts"; +import { readJournal } from "../src/features/relay/read-state-storage.ts"; import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; const disposals = []; @@ -75,7 +78,9 @@ async function harness(discovered = true) { events: published ? [published] : [], }, ); - return Response.json(published ? [published] : []); + return Response.json( + Array.isArray(response) ? response : published ? [published] : [], + ); }, }); await plugin.configureServer({ @@ -150,6 +155,116 @@ it("real broker discovery -> reader snapshot and scoped encrypted signing/public { kinds: [30078], authors: [h.viewer], read_state_snapshot: 1 }, ]); }); +it.each([false, true])( + "reconciles legacy maximum-size NIP-44 records without widening writes (snapshot: %s)", + async (discovered) => { + const h = await harness(discovered); + const blob = { + v: 1, + client_id: "legacy", + contexts: Object.fromEntries( + Array.from({ length: 650 }, (_, i) => [ + `msg:${i.toString(16).padStart(64, "0")}`, + 1786662839, + ]), + ), + }; + const key = nip44.v2.utils.getConversationKey(h.key, h.viewer); + const expected = { ...blob.contexts }; + const events = Array.from({ length: 5 }, (_, i) => { + const contexts = { ...blob.contexts, [`slot-${i}`]: 1786662839 + i }; + Object.assign(expected, contexts); + // Pad valid JSON to the original NIP-44 maximum, with unique evidence + // per slot so dropping a record or the second batch cannot pass. + const plaintext = JSON.stringify({ ...blob, contexts }).padEnd( + 65535, + " ", + ); + expect(Buffer.byteLength(plaintext)).toBe(65535); + return finalizeEvent( + { + kind: 30078, + created_at: 1786662839, + tags: [ + ["d", `read-state:${i.toString(16).padStart(32, "0")}`], + ["t", "read-state"], + ], + content: nip44.v2.encrypt(plaintext, key), + }, + h.key, + ); + }); + key.fill(0); + expect(events[0].content.length).toBe(87472); + expect(Buffer.byteLength(JSON.stringify(events[0]))).toBe(87888); + h.reply(discovered ? h.envelope(events) : events); + let journal; + const owner = createReadState({ + viewer: h.viewer, + reader: h.reader, + host: h.transport.readState, + storage: { + async update(change) { + journal = readJournal(change(journal), h.viewer); + return journal; + }, + close() {}, + }, + }); + disposals.push(() => owner.dispose()); + await owner.refresh(); + expect(owner.snapshot()).toMatchObject({ + status: "reconciled", + completeness: discovered ? "snapshot" : "bounded", + }); + expect(journal.state.frontiers).toEqual(expected); + expect(owner.state().frontiers).toEqual(expected); + // The receive exception is not permission to sign or republish large records. + expect( + ( + await h.post("read-state-sign", { + slot: "a".repeat(32), + createdAt: Math.floor(Date.now() / 1000), + blob, + }) + ).status, + ).toBe(400); + expect((await h.post("read-state-publish", events[0])).status).toBe(413); + // A Unicode envelope can fit the HTTP character cap yet exceed the strict + // publication byte cap. Exercise the actual publish validator, not only HTTP. + const small = await h.transport.readState.sign( + { + slot: "a".repeat(32), + createdAt: Math.floor(Date.now() / 1000), + blob: { v: 1, client_id: "fixture", contexts: { room: 12 } }, + }, + new AbortController().signal, + ); + const unicodeEnvelope = finalizeEvent( + { + ...small, + tags: [ + ...small.tags, + ["padding", "界".repeat(10000) + "x".repeat(40000)], + ], + }, + h.key, + ); + expect(JSON.stringify(unicodeEnvelope).length).toBeLessThan(65536); + expect(Buffer.byteLength(JSON.stringify(unicodeEnvelope))).toBeGreaterThan( + 65536, + ); + expect((await h.post("read-state-decode", [unicodeEnvelope])).status).toBe( + 200, + ); + expect((await h.post("read-state-publish", unicodeEnvelope)).status).toBe( + 400, + ); + expect(h.calls.map((call) => new URL(call.url).pathname)).toEqual([ + "/query", + ]); + }, +); it("absent discovery does not grant complete enumeration and the broker rejects malformed extension filters before upstream", async () => { const old = await harness(false); expect(old.transport.readStateSnapshot).toBeUndefined(); diff --git a/dev/read-state.mjs b/dev/read-state.mjs index 0fb021a4..ee2e4dc7 100644 --- a/dev/read-state.mjs +++ b/dev/read-state.mjs @@ -11,25 +11,38 @@ import { } from "../src/features/relay/read-state-model.ts"; export const READ_STATE_DECODE_BYTES = 512 * 1024; +// Original NIP-44 can expand 65,535 plaintext bytes to 87,472 base64 characters. +// Receive old client records without raising our 64 KiB publication event cap. +const READ_STATE_RECEIVE_EVENT_BYTES = 96 * 1024; /** Validate and copy wire bytes: never trust nostr-tools' cached verification symbol. */ -export function validReadStateEvent(raw, secret) { +export function validReadStateEvent( + raw, + secret, + maxBytes = READ_STATE_EVENT_BYTES, +) { const event = eventDto(raw); if ( event.pubkey !== getPublicKey(secret) || !readCoordinate(event) || - Buffer.byteLength(JSON.stringify(event)) > READ_STATE_EVENT_BYTES + Buffer.byteLength(JSON.stringify(event)) > maxBytes ) throw new Error("Invalid read-state event"); return event; } -export function decodeReadState(events, secret) { +export function decodeReadState( + events, + secret, + maxEventBytes = READ_STATE_RECEIVE_EVENT_BYTES, +) { if ( !Array.isArray(events) || events.length > 16 || Buffer.byteLength(JSON.stringify(events)) > READ_STATE_DECODE_BYTES ) throw new Error("Read-state decode capacity exceeded"); - const verified = events.map((event) => validReadStateEvent(event, secret)); + const verified = events.map((event) => + validReadStateEvent(event, secret, maxEventBytes), + ); const key = nip44.v2.utils.getConversationKey(secret, getPublicKey(secret)); try { return verified.map((event) => { diff --git a/dev/read-state.test.mjs b/dev/read-state.test.mjs index 408f8bd3..53da3fe2 100644 --- a/dev/read-state.test.mjs +++ b/dev/read-state.test.mjs @@ -65,6 +65,33 @@ describe("host-only read-state codec", () => { } key.fill(0); }); + it("keeps explicit receive-event and aggregate bounds while publication validation stays smaller", () => { + const small = signReadState(intent, secret, 100); + const base = { ...small, tags: [...small.tags, ["padding", ""]] }; + const remaining = 96 * 1024 - Buffer.byteLength(JSON.stringify(base)); + const padded = (length) => + finalizeEvent( + { ...small, tags: [...small.tags, ["padding", "x".repeat(length)]] }, + secret, + ); + const event = padded(remaining); + expect(Buffer.byteLength(JSON.stringify(event))).toBe(96 * 1024); + expect(decodeReadState(Array(4).fill(event), secret)).toEqual( + Array(4).fill({ eventId: event.id, blob }), + ); + expect(() => decodeReadState([padded(remaining + 1)], secret)).toThrow( + "Invalid read-state event", + ); + expect(() => decodeReadState(Array(6).fill(event), secret)).toThrow( + "capacity", + ); + expect(() => validReadStateEvent(event, secret)).toThrow( + "Invalid read-state event", + ); + expect(() => + decodeReadState([{ ...event, content: "changed" }], secret), + ).toThrow(); + }); it("bounds work before decrypting/signing", () => { expect(() => decodeReadState(Array(17).fill({}), secret)).toThrow( "capacity", diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 9ec90c45..d7878299 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -3,6 +3,7 @@ import { signReadState, READ_STATE_DECODE_BYTES, } from "./read-state.mjs"; +import { READ_STATE_EVENT_BYTES } from "../src/features/relay/read-state-model.ts"; import { isReadSnapshotFilter, readSnapshotText, @@ -780,7 +781,8 @@ export function relayBrokerPlugin({ if (readSigning) return json(res, 200, signReadState(filters, key)); // A valid own signature alone is not permission to publish arbitrary kind-30078 data. - decodeReadState([filters], key); + // Receive-only compatibility must not widen publication admission. + decodeReadState([filters], key, READ_STATE_EVENT_BYTES); } catch { return json(res, 400, { error: "Read-state operation rejected", diff --git a/docs/unread.md b/docs/unread.md index cb8da608..68623fcd 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -141,8 +141,14 @@ message-history completeness, a CAS revision, or a global cryptographic communit identity. Absent discovery permits only bounded ordinary marker observation. Resource bounds: 4,096 snapshot events / 8 MiB encoded event array; envelope stream -is capped before parsing at 8 MiB + 4 KiB; individual recognized read-state events -are limited to 64 KiB and blobs to 10,000 keys. Unknown/undecryptable recognized +is capped before parsing at 8 MiB + 4 KiB. Recognized read-state events can be up +to 96 KiB **on receive**, accommodating older clients' original NIP-44 maximum +plaintext (65,535 bytes → 87,472 base64 characters plus the signed envelope). +The four-event decode batches fit the unchanged 512 KiB HTTP decode budget. +New signing retains its stricter 40 KiB plaintext budget, and both signing and +direct publication retain the 64 KiB event limit. The larger receive budget +does not authorize republishing legacy records. +Blobs remain capped at 10,000 keys. Unknown/undecryptable recognized coordinates fail marker loading rather than masquerading as empty state. Access revocation denies projections before any subscriber can inspect another one; durable account-owned intent survives without exposing revoked context projections. From 0d9eb2e17509eece33049b92ee08873169d6189d Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Sat, 12 Sep 2026 10:53:25 -0600 Subject: [PATCH 02/17] feat(notifications): add shared browser and standard desktop alerts Use existing live/unread policy for account-local notification settings, plugin categories, and sender/conversation previews. Deliver desktop banners through the official Tauri plugin; retain verified browser exact-message opening without a custom native click or delivery subsystem. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- Cargo.lock | 117 +++++ dev/relay-broker.mjs | 5 +- docs/channels.md | 18 +- docs/notifications.md | 79 ++++ docs/plugin-architecture.md | 12 +- docs/relay-queries.md | 17 + package.json | 1 + pnpm-lock.yaml | 13 + src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 5 +- src-tauri/src/lib.rs | 1 + src/app/App.tsx | 1 + src/app/NotificationSettings.tsx | 154 +++++++ src/app/Settings.tsx | 10 +- src/app/navigation.ts | 4 +- src/app/services.ts | 14 + src/bundled/channels/ChannelsPage.tsx | 37 +- .../messages/ChannelTimeline.test.tsx | 15 + src/features/messages/MessageDetailPanel.tsx | 205 +++++++++ src/features/messages/ThreadPanel.test.tsx | 11 + src/features/messages/use-message-reveal.ts | 64 +++ src/features/messages/use-reading.test.ts | 33 +- src/features/messages/use-reading.ts | 13 +- src/features/notifications/content.test.ts | 79 ++++ src/features/notifications/content.ts | 65 +++ src/features/notifications/desktop.test.ts | 164 +++++++ src/features/notifications/messages.test.ts | 413 ++++++++++++++++++ src/features/notifications/messages.ts | 165 +++++++ src/features/notifications/platform.test.ts | 125 ++++++ src/features/notifications/platform.ts | 109 +++++ src/features/notifications/preferences.ts | 135 ++++++ .../notifications/presentation.test.ts | 22 + src/features/notifications/presentation.ts | 22 + src/features/notifications/service.test.ts | 277 ++++++++++++ src/features/notifications/service.ts | 398 +++++++++++++++++ src/features/relay/broker-live.test.ts | 58 ++- src/features/relay/broker-live.ts | 15 +- src/features/relay/incoming.ts | 10 + src/features/relay/live.test.ts | 44 +- src/features/relay/live.ts | 35 +- src/features/relay/message-detail.test.ts | 308 +++++++++++++ src/features/relay/message-detail.ts | 254 +++++++++++ src/features/relay/session.ts | 123 +++++- src/features/relay/unread.test.ts | 103 +++++ src/features/relay/unread.ts | 133 +++++- src/plugins/author.ts | 6 + tests/browser/fixture.mjs | 82 +++- tests/browser/message-detail.spec.mjs | 300 +++++++++++++ tests/browser/notifications.spec.mjs | 389 +++++++++++++++++ tests/browser/plugin-fixtures.tsx | 19 + 50 files changed, 4611 insertions(+), 72 deletions(-) create mode 100644 docs/notifications.md create mode 100644 src/app/NotificationSettings.tsx create mode 100644 src/features/messages/MessageDetailPanel.tsx create mode 100644 src/features/messages/use-message-reveal.ts create mode 100644 src/features/notifications/content.test.ts create mode 100644 src/features/notifications/content.ts create mode 100644 src/features/notifications/desktop.test.ts create mode 100644 src/features/notifications/messages.test.ts create mode 100644 src/features/notifications/messages.ts create mode 100644 src/features/notifications/platform.test.ts create mode 100644 src/features/notifications/platform.ts create mode 100644 src/features/notifications/preferences.ts create mode 100644 src/features/notifications/presentation.test.ts create mode 100644 src/features/notifications/presentation.ts create mode 100644 src/features/notifications/service.test.ts create mode 100644 src/features/notifications/service.ts create mode 100644 src/features/relay/incoming.ts create mode 100644 src/features/relay/message-detail.test.ts create mode 100644 src/features/relay/message-detail.ts create mode 100644 tests/browser/message-detail.spec.mjs create mode 100644 tests/browser/notifications.spec.mjs diff --git a/Cargo.lock b/Cargo.lock index 8558beb7..1c71fc36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,6 +342,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tauri-plugin-opener", ] @@ -2108,6 +2109,20 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -2228,6 +2243,20 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2713,6 +2742,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2811,6 +2849,35 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3691,6 +3758,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2fd40946aef810c4be9fd33a2d1b9b397cb79042b2d21c81a0a8f204354fd1" +dependencies = [ + "log", + "notify-rust", + "rand", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.20", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.5" @@ -3813,6 +3899,17 @@ dependencies = [ "toml 1.1.5+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -5175,6 +5272,26 @@ dependencies = [ "serde", ] +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index d7878299..9d096b06 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -604,8 +604,9 @@ export function relayBrokerPlugin({ async (event) => finalizeEvent(event, key), viewer, { - receive: (events) => { - for (const event of events) write("", event); + receive: (events, provenance) => { + for (const event of events) + write("traffic", { event, provenance }); }, state: (state) => write("state", state), established: (channelId) => write("established", { channelId }), diff --git a/docs/channels.md b/docs/channels.md index bd008267..28ba781c 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -157,7 +157,8 @@ The footer reuses `MessageComposer` and sends direct replies to the resolved roo through `session.messages.reply`. Channel and thread drafts are separate and survive reconnection; failed replies remain inline with the shared retry action. Read-only connections keep the existing composer capability notice; missing/revoked roots do -not expose a composer. There is no jump-to-specific-reply navigation yet. +not expose a composer. Message-addressed navigation opens a separate exact detail, +not a position inside this bounded thread panel. Replies use ascending timestamp/event-ID order, including nested replies. Retry appears only after a failed read; there is no routine Refresh control. Names are @@ -254,3 +255,18 @@ after dwell; no automatic channel-prefix advance hides unseen siblings. Conversa options exposes local-only manual unread, explicit mark-through and sync recovery. Older synchronized hints may expire under bounded retention. Synced manual-unread and OS notifications are not enabled by this feature. + + +## Opening an exact message + +Message-addressed conversations show **Message detail** with only the selected +verified row. They do not fetch an optional original thread message or traverse +surrounding history. **Open channel** returns to normal reading with its saved +geometry and composer intact. + +`MessageDetailPanel` acknowledges navigation only after the target is visible and +focused. Reclick/Back reveals again; live/profile updates do not steal focus. +The shared row preserves Markdown, profile links and background enrichment. +Opening never marks read directly: the ordinary focus/visibility/dwell hook applies. +Missing/deleted targets, access loss and failed reads expose failure/retry instead +of falling back to the channel head. See [the evidence contract](relay-queries.md#exact-message-detail). diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 00000000..5ced8986 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,79 @@ +# Notifications + +The host provides one `notifications` service for built-in messages and trusted +plugins. Settings → Notifications stores account-local choices: alerts are on +by default, subject to system permission; master off preserves category choices. +Browser sound uses the Notification API. Desktop sound is managed in OS settings; +there is no separate audio player. + +```ts +export const inject = ["notifications"]; +export function apply(ctx) { + const updates = ctx.notifications.register({ id: "updates", label: "Updates" }); + // In response to a real domain event: + // await updates.submit({ sourceKey: event.id, target: typedOpenTarget }); +} +``` + +Categories use existing installation ownership. Disabled/replaced plugins cannot +submit new alerts. A browser notification click belongs to the host; opening never enables +a missing destination plugin. `submit()` means a candidate was accepted for policy +checks, not that an OS banner was displayed or read. + +## Running-app behavior + +- Built-in mentions, DMs and participating-thread replies consume the selected + community's verified live traffic and existing unread/visibility facts. No new + socket, unread engine or background-community subscription is added. +- History, initial/reconnect replay and own messages stay quiet. Candidates older + than two minutes (or over 30 seconds in the future) are ignored. Unknown read + readiness waits; off/access loss cancels pending candidates. Visibility is checked + after UI presentation, without publishing read intent. +- Permission is requested explicitly from Settings where a browser needs a user + gesture. A fresh pending candidate is reconsidered after Allow; a newer off + choice still wins. Observable API errors are reported, never auto-retried. +- Running-session dedup is bounded to 2,048 source identities/two minutes; pending + candidates are capped at 128. Browser presentation retains at most 128 active + alerts, closing the oldest before retiring its callback. These are not durable + exactly-once or cross-window guarantees. +- Browser clicks use the existing typed, account/community-scoped navigation path. It owns + membership/provider checks and exact opening. Changing account invalidates old + callbacks; changing community does not turn an old alert into a dead click. + Clicks never mark a message read. + +There is no notification database, Recent notifications UI, cold/reload receipt +protocol, uniform OS withdrawal subsystem, or closed-app push. Preferences are +persistent; notification candidates are not. Built-in message banners show the +sender and conversation plus a short, plain-text preview on both browser and +desktop. This sends those details to the OS, where lock-screen/preview settings +control their visibility. Titles use the current shared profile/channel cache, +with a key fragment when a name is unavailable; optional names never delay an +alert or trigger additional reads. Previews use at most the first 4,096 source +characters, flatten CommonMark to at most 200 Unicode code points, omit raw HTML +and link destinations, and label images without fetching them. Empty or overly +deep content falls back to “New message”. This is an arrival preview, not a live +copy of subsequent edits. Plugin categories without message details retain their +generic category text. + +## Current acceptance limits + +The browser adapter works only in a running tab with the Notification API. +Desktop builds use the official Tauri notification plugin for macOS, Linux and +Windows, with the same eligibility, master/category choices and default-on policy. +Only the plugin's permission and send commands are granted to the main window. + +The stock desktop plugin does not expose actual OS permission state, per-banner +sound suppression, or message-click navigation. Settings therefore describes +permission/sound as system-controlled and does not offer an ineffective desktop +sound toggle. Browser exact-message opening is unchanged. No custom native +activation or cold-start recovery is added to supply those missing capabilities. +The public desktop send API is fire-and-forget: a returned call is **not** proof +of delivery, and asynchronous native/OS failures are not observable by the host. + +Real banners still require OS permission, an available notification service and +appropriate app packaging/installation. macOS development notifications can be +attributed to Terminal; Windows development notifications may use PowerShell's +identity. Test the packaged app identity before claiming release acceptance. +Chromium/WebKit fixtures replace only the OS Notification API; adapter tests do +not prove real OS permission dialogs, appearance, sound or focus behavior. Native +build results and real banner observations must be reported per platform. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 7a775312..7d752207 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -235,7 +235,7 @@ for retry, rather than silently opening another page. Pages receive optional `navigation` in `PageProps`. Ordinary pages acknowledge a successful mount inside the render boundary. Pages declaring `handlesNavigation` acknowledge their domain presentation with `navigation.complete(...)`; Channels -waits for its requested channel window. `navigation.resolve(target)` normalizes a +waits for its requested channel window or exact-message reveal/focus. `navigation.resolve(target)` normalizes a pending default destination within the same visit, caller and original deadline; it does not start competing navigation. Normalization revokes the old request. @@ -256,11 +256,11 @@ into versioned route parameters. These are host-matched preview types through Browser `#buzz=` addresses and session history support reload and Back/Forward. `targetLink`/`parseTargetLink` define a `buzz://open` locator codec that omits the sender's viewer; `bindSharedTarget` pins it for an admitted recipient. **This slice -does not install native OS deep-link or notification-click ingress, migrate legacy -Buzz links, or locate/reveal older messages and threads.** Message-addressed -conversation targets explicitly fail as unsupported rather than claiming success -at the channel head. Those ingresses/reveal adapters must use the same validated -target and completion lifecycle when implemented. +does not install native OS deep-link or notification-click ingress or migrate legacy +Buzz links.** Message-addressed conversations show the selected verified row in a +bounded detail surface, ignoring optional `threadRootId` hints. Completion requires +the exact row to be visible and focused; unavailable targets never fall back to the +channel head. Ingress adapters must reuse this validated target/completion lifecycle. Drafts, reading geometry and sidebar view intent remain domain-owned, outside visit history. Saved sidebar preferences live in the relay session, not in the diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 552d85e0..174fac79 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -140,6 +140,23 @@ channel timeline. Failed content rows stay visible for same-event retry; failed auxiliary edits/reactions stop affecting the fold. Verified echo reconciliation and persisted signed-event retry remain the same outbox operations as channel sends. +## Exact message detail + +`session.messageDetail(channelId, messageId)` owns an isolated folded row. Its +snapshot has `status` (idle/loading/ready/unavailable/error), `target`, `error`, +and `limited`; allocate, subscribe, refresh and dispose with the consuming request. +At most three bounded reads fetch the target ID, reference overlays and deletions +of those overlays. No root-hint lookup or thread traversal is performed. +Reference queries omit `#h` for legacy edits/deletes but retain session visibility +checks. Raw responses reaching 500 events fail before filtering; retained evidence +stays below 500 events / at most 4 MiB. These are evidence bounds, not complete history. + +Known tombstones survive sparse refreshes and shared-cache eviction. Detail reads +share verification, admission, access epochs and live reconciliation without +inserting isolated rows into channel history. Explicit denial revokes the owning +channel; access loss, cache clear and disposal purge the view. It shares the existing +64-view limit and channel-establishment repair, with no new subscription or persistence. + ## Ownership and reconciliation | Internal owner | Responsibility | diff --git a/package.json b/package.json index 00103259..94837fe4 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@tabler/icons-react": "^3.46.0", "@tanstack/react-router": "^1.168.10", "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-notification": "2.4.0", "dockview-react": "8.2.0", "emoji-mart": "5.6.0", "flexlayout-react": "0.10.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d33b44dd..86cbc903 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@tauri-apps/api': specifier: ^2.11.1 version: 2.11.1 + '@tauri-apps/plugin-notification': + specifier: 2.4.0 + version: 2.4.0 dockview-react: specifier: 8.2.0 version: 8.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -178,6 +181,7 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.5.12': resolution: {integrity: sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==} @@ -442,6 +446,7 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.3': resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} @@ -566,6 +571,7 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.4': resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} @@ -590,6 +596,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-notification@2.4.0': + resolution: {integrity: sha512-xlJXMcUoKOjNupzDue5wrEsa1wytf+l/2gCAPhafHyP683Y3N7J/8clUWLZ3vpnwkpT2C1zcLMMQFjjecIG2xg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1915,6 +1924,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/plugin-notification@2.4.0': + dependencies: + '@tauri-apps/api': 2.11.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a518f09d..13d999fa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,3 +17,4 @@ tauri = { version = "2", features = [] } buzzodz-plugins = { path = "../crates/plugin-manager" } tauri-plugin-dialog = "2" tauri-plugin-opener = "2" +tauri-plugin-notification = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 868e796c..7a9d7644 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,11 +1,14 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "main-window", - "description": "Allow the main window title bar controls and external HTTP(S) links.", + "description": "Allow the main window title bar controls, external HTTP(S) links, and notifications.", "windows": ["main"], "permissions": [ "core:window:allow-start-dragging", "core:window:allow-internal-toggle-maximize", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify", { "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }, { "url": "http://*" }] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42ac4466..b15ba2b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -149,6 +149,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_notification::init()) .manage(Imports::default()) .manage(PluginManager(Manager::from_env())) .invoke_handler(tauri::generate_handler![ diff --git a/src/app/App.tsx b/src/app/App.tsx index fde428f2..bc0653b0 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -100,6 +100,7 @@ export function App({ services }: { services: AppServices }) { plugins={plugins} communities={services.communities} appearance={services.appearance} + notifications={services.notifications} navigation={route.request} onSection={(section) => void services.navigation.open({ diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx new file mode 100644 index 00000000..a768597a --- /dev/null +++ b/src/app/NotificationSettings.tsx @@ -0,0 +1,154 @@ +import { useSyncExternalStore } from "react"; +import type { NotificationsService } from "../features/notifications/service"; + +export function NotificationSettings({ + notifications, +}: { + notifications: NotificationsService; +}) { + const state = useSyncExternalStore( + notifications.subscribe, + notifications.snapshot, + ); + const { preferences, permission } = state; + return ( +
+

+ Notifications +

+
+

+ Choices are saved for this account on this device. System permission + is separate. +

+ notifications.updatePreferences({ enabled })} + /> +

+ {state.requesting + ? "Waiting for system permission…" + : permission === "granted" + ? "Permission granted. Your alert choices still apply." + : permission === "denied" + ? "Blocked. Allow notifications in your browser or system settings." + : permission === "unsupported" + ? "System notifications are unavailable in this build." + : permission === "unknown" + ? "Permission is controlled by system notification settings." + : "Allow notifications to receive alerts."} +

+
+ {permission === "default" && ( + + )} + +
+ + notifications.updatePreferences({ notifyWhileViewing }) + } + /> + {state.systemManaged ? ( +

+ Manage sound and permission in system notification settings. Desktop + banners do not open a specific message when clicked. +

+ ) : ( + <> + notifications.updatePreferences({ sound })} + /> +

+ Sound uses the system default where supported. Turning it off + keeps alerts enabled. +

+ + )} +
+ Notify me about + {state.categories.map(({ key, label }) => ( + + notifications.updatePreferences({ + categories: { ...preferences.categories, [key]: enabled }, + }) + } + /> + ))} +
+

+ Message alerts cover the selected community while Buzz is running. + Reading history and reconnecting stay quiet. +

+ {state.preferencesError && ( +
+

{state.preferencesError}

+ {" "} + +
+ )} + {state.error && ( +

+ {state.error} +

+ )} +
+
+ ); +} +function Toggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange(checked: boolean): void; +}) { + return ( + + ); +} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 5fdba59f..04a899d5 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import { RecoveryScreen } from "./RecoveryScreen"; -import { Blocks, Settings2, UserRound, Palette } from "lucide-react"; +import { Blocks, Settings2, UserRound, Palette, Bell } from "lucide-react"; import type { PluginManager } from "../plugins/manager"; import type { Communities } from "../features/communities/service"; import { PluginImport } from "./PluginImport"; @@ -8,23 +8,28 @@ import { ProfileSettings } from "./ProfileSettings"; import type { Appearance } from "../shared/theme/service"; import { AppearanceSettings } from "./AppearanceSettings"; +import { NotificationSettings } from "./NotificationSettings"; +import type { NotificationsService } from "../features/notifications/service"; const sections = [ { id: "profile", label: "Profile", icon: UserRound }, { id: "plugins", label: "Plugins", icon: Blocks }, { id: "appearance", label: "Appearance", icon: Palette }, + { id: "notifications", label: "Notifications", icon: Bell }, ] as const; export function Settings({ plugins, communities, appearance, + notifications, navigation, onSection, }: { plugins: PluginManager; communities: Communities; appearance: Appearance; + notifications: NotificationsService; navigation?: | import("../features/navigation/service").PageNavigation | undefined; @@ -90,6 +95,9 @@ export function Settings({ ))}
+ diff --git a/src/app/navigation.ts b/src/app/navigation.ts index 2a885c01..79c49dc3 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -77,7 +77,9 @@ export function useAppNavigation(services: AppServices) { if ( target.kind === "settings" && target.section && - !["profile", "plugins", "appearance"].includes(target.section) + !["profile", "plugins", "appearance", "notifications"].includes( + target.section, + ) ) failure = "unavailable"; const owner = useMemo( diff --git a/src/app/services.ts b/src/app/services.ts index 8b762229..ed644fe5 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,5 +1,10 @@ // FOUNDATION: Compose the bundled distribution, plugin runtime, and services here. import { provideNavigation } from "../features/navigation/service"; +import { NotificationsService } from "../features/notifications/service"; +import { + bindMessageNotifications, + notificationAuthorized, +} from "../features/notifications/messages"; import { ShortcutsService } from "../features/shortcuts/service"; import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; @@ -28,8 +33,17 @@ export function createServices() { import.meta.env.VITE_BUZZ_LIVE === "1", ); const relay = communities.relay; + const notifications = new NotificationsService( + ctx, + navigation, + undefined, + undefined, + (target) => notificationAuthorized(communities, target), + ); + ctx.effect(() => bindMessageNotifications(notifications, communities)); let disposal: Promise | undefined; return { + notifications, navigation, navigationHost, shortcuts, diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 6136e886..adb8b905 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -35,6 +35,7 @@ import { RelayTimings } from "./RelayTimings"; import { LiveStatus } from "./LiveStatus"; import { MessageComposer } from "../../features/messages/MessageComposer"; import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; +import { MessageDetailPanel } from "../../features/messages/MessageDetailPanel"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; @@ -62,15 +63,7 @@ export function ChannelsPage({ const sessionNavigation = navigation?.forSession(relay, session); useEffect(() => { if (!navigation || !sessionNavigation) return; - if ( - navigation.target.kind === "conversation" && - navigation.target.messageId - ) - sessionNavigation.complete({ status: "failed", reason: "unavailable" }); - else if ( - session.status === "disconnected" && - navigation.target.kind === "page" - ) + if (session.status === "disconnected" && navigation.target.kind === "page") sessionNavigation.complete({ status: "opened" }); else if (session.status === "error") sessionNavigation.complete({ status: "failed", reason: "unavailable" }); @@ -210,7 +203,12 @@ function ChannelWorkspace({ }); } }, [requestedChannel, current, list.status, navigation, viewer, scope]); - const showingThread = thread?.channelId === current?.id ? thread : undefined; + const requestedMessage = + navigation?.target.kind === "conversation" + ? navigation.target.messageId + : undefined; + const showingThread = + !requestedMessage && thread?.channelId === current?.id ? thread : undefined; useEffect(() => { if (thread && !showingThread) setThread(undefined); }, [thread, showingThread]); @@ -455,7 +453,22 @@ function ChannelWorkspace({ channelId={current?.id} partialRoster={list.coverage === "partial"} /> - {current ? ( + {current && requestedMessage && navigation ? ( + select(current.id)} + retry={() => { + void navigator?.retry(); + }} + /> + ) : current ? ( Select a channel to read it.
)} - {current && ( + {current && !requestedMessage && ( ({ useReading: vi.fn() })); const hooks = vi.hoisted(() => ({ refs: [] as { current: unknown }[], states: [] as unknown[], @@ -892,3 +896,14 @@ it("an accepted button read retires earlier blocked gesture before verification" expect(h.olderReads).toHaveBeenCalledTimes(1); h.unmount(); }); + +it("wires the shared reading hook to its owned scroller and settled position", () => { + vi.mocked(useReading).mockClear(); + setup(); + expect(useReading).toHaveBeenCalledWith({ + session: expect.any(Object), + channelId: "channel", + scroller: expect.objectContaining({ current: expect.anything() }), + settled: expect.objectContaining({ current: expect.any(Boolean) }), + }); +}); diff --git a/src/features/messages/MessageDetailPanel.tsx b/src/features/messages/MessageDetailPanel.tsx new file mode 100644 index 00000000..7f79248e --- /dev/null +++ b/src/features/messages/MessageDetailPanel.tsx @@ -0,0 +1,205 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { ConversationExtensions } from "../conversation/contracts"; +import type { PageNavigation } from "../navigation/service"; +import type { RelaySession } from "../relay/session"; +import type { MessageDetailView } from "../relay/message-detail"; +import { useRowProfiles } from "../relay/react"; +import { MessageRow } from "./MessageRow"; +import { messageViewKey } from "./view-key"; +import { useMessageReveal } from "./use-message-reveal"; +import { useReading } from "./use-reading"; +import styles from "./Messages.module.css"; + +type Props = { + session: RelaySession; + scope: string; + channelId: string; + messageId: string; + navigation: PageNavigation; + extensions?: ConversationExtensions | undefined; + onOpenLink(url: string): boolean; + canOpenLink?: ((target: string) => boolean) | undefined; + openChannel(): void; + retry(): void; +}; + +/** A bounded exact-target presentation. Normal channel/thread scrolling is independent. */ +export function MessageDetailPanel(props: Props) { + return ( + + ); +} +function OwnedDetail(props: Props) { + const { session, channelId, messageId, navigation } = props; + const [ownedView, setView] = useState<{ + request: PageNavigation; + view: MessageDetailView; + }>(); + const view = ownedView?.request === navigation ? ownedView.view : undefined; + const [error, setError] = useState(); + useEffect(() => { + if (navigation.signal.aborted) return; + setError(undefined); + try { + const owned = session.messageDetail(channelId, messageId); + const cancel = () => { + owned.dispose(); + setView(undefined); + }; + setView({ request: navigation, view: owned }); + navigation.signal.addEventListener("abort", cancel, { once: true }); + void owned.refresh(); + return () => { + navigation.signal.removeEventListener("abort", cancel); + owned.dispose(); + }; + } catch (error) { + setError(String(error)); + navigation.complete({ status: "failed", reason: "unavailable" }); + } + }, [session, channelId, messageId, navigation]); + const failed = useCallback( + (message: string, missing: boolean) => { + setError(message); + navigation.complete({ + status: "failed", + reason: missing ? "not-found" : "unavailable", + }); + }, + [navigation], + ); + return ( + <> +
+

+ Message detail · Selected message only. +

+ +
+ {view ? ( + + ) : ( +
+

+ {error ?? + (navigation.signal.aborted + ? "Message opening interrupted." + : "Locating message…")} +

+ {(error || navigation.signal.aborted) && ( + + )} +
+ )} + + ); +} +function DetailMessages({ + session, + channelId, + messageId, + navigation, + extensions, + onOpenLink, + canOpenLink, + view, + failed, + retry, +}: Props & { + view: MessageDetailView; + failed(message: string, missing: boolean): void; +}) { + const snapshot = useSyncExternalStore( + view.subscribe, + view.snapshot, + view.snapshot, + ); + const rows = useMemo( + () => (snapshot.target ? [snapshot.target] : []), + [snapshot.target], + ); + const authors = [ + ...new Set(rows.flatMap((row) => [row.authorId, ...row.mentions])), + ] + .sort() + .join(":"); + useEffect(() => { + if (authors) + void session.profiles + .ensure(authors.split(":"), "background") + .catch(() => {}); + }, [session.profiles, authors]); + const profiles = useRowProfiles(session.profiles, rows); + const scroller = useRef(null); + const settled = useRef(false); + const complete = useCallback(() => { + navigation.complete({ status: "opened" }); + }, [navigation]); + useMessageReveal({ + scroller, + settled, + messageId, + signal: navigation.signal, + ready: snapshot.status === "ready" && snapshot.target?.id === messageId, + complete, + }); + // Opening itself is not mark-read. Reuse focus, visibility and dwell policy. + useReading({ session, channelId, scroller, settled }); + useEffect(() => { + if (snapshot.status === "error" || snapshot.status === "unavailable") + failed( + snapshot.error ?? "The selected message is missing or deleted.", + snapshot.status === "unavailable", + ); + }, [snapshot.status, snapshot.error, failed]); + return ( +
+ {snapshot.target && ( + + )} + {snapshot.status === "loading" &&

Locating message…

} + {snapshot.status === "unavailable" && ( +

The selected message is missing or deleted.

+ )} + {snapshot.error &&

{snapshot.error}

} + {(snapshot.status === "error" || snapshot.status === "unavailable") && ( + + )} +
+ ); +} diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index 8ef9d60b..7642f9ba 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -1,3 +1,4 @@ +import { useReading } from "./use-reading"; import { beforeEach, expect, it, vi } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; import { ThreadPanel } from "./ThreadPanel"; @@ -10,6 +11,9 @@ import type { ChannelMessage } from "../relay/contracts"; // Shallow production-boundary checks. These invoke returned handlers and effect // lifetimes; they do not claim browser layout, focus, or React StrictMode validation. +// Reading geometry/dwell has its own real-hook boundary suite. This fixture +// deliberately supplies only the DOM shape needed for positioning. +vi.mock("./use-reading", () => ({ useReading: vi.fn() })); const hooks = vi.hoisted(() => ({ refs: [] as { current: unknown }[], ref: 0, @@ -225,7 +229,14 @@ it("loads history automatically with error-only retry and no routine history con hooks.effects = []; hooks.refs = []; hooks.states = []; + vi.mocked(useReading).mockClear(); const tree = render(); + expect(useReading).toHaveBeenCalledWith({ + session: h.session, + channelId: "channel", + scroller: expect.objectContaining({ current: null }), + settled: expect.objectContaining({ current: false }), + }); h.effects(); expect(h.ensure).toHaveBeenCalledExactlyOnceWith( [row.authorId], diff --git a/src/features/messages/use-message-reveal.ts b/src/features/messages/use-message-reveal.ts new file mode 100644 index 00000000..b7a2037f --- /dev/null +++ b/src/features/messages/use-message-reveal.ts @@ -0,0 +1,64 @@ +import { useLayoutEffect, useRef, type RefObject } from "react"; + +/** One reveal per request signal, never per row/profile/image update. */ +export function useMessageReveal({ + scroller, + settled, + messageId, + signal, + ready, + complete, +}: { + scroller: RefObject; + settled: RefObject; + messageId: string; + signal: AbortSignal; + ready: boolean; + complete(): void; +}) { + const revealed = useRef(undefined); + useLayoutEffect(() => { + if (!ready || signal.aborted || revealed.current === signal) return; + const container = scroller.current; + if (!container) return; + let frame = 0; + const cancel = () => cancelAnimationFrame(frame); + signal.addEventListener("abort", cancel, { once: true }); + frame = requestAnimationFrame(() => { + if (signal.aborted || !container.isConnected) return; + const row = [ + ...container.querySelectorAll("[data-message-id]"), + ].find((element) => element.dataset.messageId === messageId); + if (!row) return; + row.tabIndex = -1; + row.scrollIntoView({ + block: "start", + inline: "nearest", + behavior: "instant", + }); + // Position is settled before focus schedules the ordinary dwell lease. + settled.current = true; + row.focus({ preventScroll: true }); + frame = requestAnimationFrame(() => { + if (signal.aborted || !row.isConnected || !container.contains(row)) + return; + const box = row.getBoundingClientRect(); + const viewport = container.getBoundingClientRect(); + const visible = + box.height > 0 && + box.width > 0 && + box.bottom > Math.max(viewport.top, 0) && + box.top < Math.min(viewport.bottom, window.innerHeight) && + box.right > Math.max(viewport.left, 0) && + box.left < Math.min(viewport.right, window.innerWidth); + if (document.activeElement !== row || !visible) return; + revealed.current = signal; + complete(); + }); + }); + return () => { + cancel(); + signal.removeEventListener("abort", cancel); + }; + }, [scroller, settled, messageId, signal, ready, complete]); +} diff --git a/src/features/messages/use-reading.test.ts b/src/features/messages/use-reading.test.ts index 26d93fe0..5b5eb942 100644 --- a/src/features/messages/use-reading.test.ts +++ b/src/features/messages/use-reading.test.ts @@ -69,11 +69,16 @@ function setup({ supported = true, focused = true, settled = true } = {}) { }, ); const leases: { + view: ReturnType; observe: ReturnType; dispose: ReturnType; }[] = []; const reading = vi.fn(() => { - const lease = { observe: vi.fn(async () => {}), dispose: vi.fn() }; + const lease = { + view: vi.fn(), + observe: vi.fn(async () => {}), + dispose: vi.fn(), + }; leases.push(lease); return lease; }); @@ -127,8 +132,8 @@ it("reports only fully visible settled evidence after dwell, not mounted oversca vi.advanceTimersByTime(1); expect(h.leases[0]?.observe).toHaveBeenCalledExactlyOnceWith(["visible"]); }); -it.each([{ supported: false }, { focused: false }, { settled: false }])( - "does not allocate reading from unsupported/background/unsettled views: %j", +it.each([{ focused: false }, { settled: false }])( + "does not allocate reading from background/unsettled views: %j", (options) => { const h = setup(options); vi.advanceTimersByTime(1000); @@ -171,3 +176,25 @@ it("focus leaving the reading surface cancels pending evidence", () => { vi.advanceTimersByTime(1000); expect(h.leases[0]?.observe).not.toHaveBeenCalled(); }); + +it("reports qualified viewing even without read sync, but never publishes read intent", () => { + const h = setup({ supported: false }); + expect(h.leases[0]?.view).toHaveBeenCalledExactlyOnceWith( + ["visible"], + expect.any(Function), + ); + vi.advanceTimersByTime(1000); + expect(h.leases[0]?.observe).not.toHaveBeenCalled(); +}); +it("the viewing validity callback rechecks focus and settled positioning synchronously", () => { + const h = setup(); + const visible = h.leases[0]?.view.mock.calls[0]?.[1]; + expect(visible()).toBe(true); + h.position.current = false; + expect(visible()).toBe(false); + h.position.current = true; + h.doc.activeElement = new EventTarget(); + expect(visible()).toBe(false); + h.unmount(); + expect(visible()).toBe(false); +}); diff --git a/src/features/messages/use-reading.ts b/src/features/messages/use-reading.ts index 47e1b2bc..0ccfaa91 100644 --- a/src/features/messages/use-reading.ts +++ b/src/features/messages/use-reading.ts @@ -15,11 +15,7 @@ export function useReading({ settled: RefObject; }) { useEffect(() => { - if ( - !scroller.current || - session.unread.sync().capability !== "frontier-sync" - ) - return; + if (!scroller.current) return; const element: HTMLElement = scroller.current; let handle: ReadingHandle | undefined; let timer: ReturnType | undefined; @@ -63,6 +59,7 @@ export function useReading({ try { // Capture the lease BEFORE dwell: a newer manual action invalidates it. handle = session.unread.reading(channelId); + handle.view(ids, active); } catch { return; // Membership may disappear between commit and observation. } @@ -75,7 +72,11 @@ export function useReading({ const visible = new Set(visibleIds()); // A row appearing only at the end of the interval has not had a dwell. const remained = ids.filter((id) => visible.has(id)); - if (remained.length) void handle?.observe(remained).catch(() => {}); + if ( + remained.length && + session.unread.sync().capability === "frontier-sync" + ) + void handle?.observe(remained).catch(() => {}); }, 750); } for (const event of [ diff --git a/src/features/notifications/content.test.ts b/src/features/notifications/content.test.ts new file mode 100644 index 00000000..6201d3d7 --- /dev/null +++ b/src/features/notifications/content.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "vitest"; +import { messageNotificationText, messagePreview } from "./content"; + +const message = { + channelId: "room", + messageId: "b".repeat(64), + authorId: "a".repeat(64), + createdAt: 1, + previewContent: "**Hello** [Wes](https://example.com/private)", +}; +it.each([ + ["mention", "Pinky mentioned you in #new-notifications"], + ["thread", "Pinky replied in #new-notifications"], + ["direct", "Pinky sent you a direct message"], +] as const)( + "formats %s with sender, conversation and plain preview", + (category, title) => { + expect( + messageNotificationText( + message, + category, + { id: "room", name: "new-notifications" }, + { name: "Pinky" }, + ), + ).toEqual({ title, body: "Hello Wes" }); + }, +); +it("DM mentions do not expose an internal DM name; missing names use key fragments", () => { + expect( + messageNotificationText( + message, + "mention", + { id: "room", name: "internal-id", channelType: "dm" }, + undefined, + ).title, + ).toBe("aaaaaaaaaa mentioned you in a direct message"); + expect( + messageNotificationText(message, "thread", undefined, { name: " " }).title, + ).toBe("aaaaaaaaaa replied in #room"); +}); +it("flattens blocks, links, images, escapes and code without URLs or raw HTML", () => { + expect( + messagePreview( + "# Heading\n\n**hello** [world](https://example.com)\n\n- one\n- two\n\n`a_b` ![private alt](https://example.com/image)\n\n
hidden
", + ), + ).toBe("Heading hello world one two a_b [Image]"); + expect( + messagePreview("[label][ref]\n\n[ref]: https://example.com/private"), + ).toBe("label"); + expect( + messagePreview("\\*literal\\* & `code`\n\n```js\nconst a = 1;\n```"), + ).toBe("*literal* & code const a = 1;"); +}); +it("bounds source, Unicode output and nesting, with a nonempty fallback", () => { + expect(messagePreview("😀".repeat(400))).toBe(`${"😀".repeat(199)}…`); + expect(messagePreview(`${" ".repeat(4096)}not parsed`)).toBe("New message"); + expect(messagePreview(`${"> ".repeat(120)}deep`)).toBe("New message"); + expect(messagePreview("
hidden
")).toBe("New message"); +}); +it("normalizes whitespace/control characters in names and body", () => { + const text = messageNotificationText( + { ...message, previewContent: "hello\u202E\nworld" }, + "mention", + { id: "room", name: "room\nname" }, + { name: "Pinky\u202E\nMouse" }, + ); + expect(text).toEqual({ + title: "Pinky Mouse mentioned you in #room name", + body: "hello world", + }); + expect( + messageNotificationText( + message, + "mention", + { id: "room", name: "y".repeat(200) }, + { name: "x".repeat(200) }, + ).title, + ).toBe(`${"x".repeat(63)}… mentioned you in #${"y".repeat(63)}…`); +}); diff --git a/src/features/notifications/content.ts b/src/features/notifications/content.ts new file mode 100644 index 00000000..715fa234 --- /dev/null +++ b/src/features/notifications/content.ts @@ -0,0 +1,65 @@ +import type { ChannelSummary, Profile } from "../relay/contracts"; +import type { IncomingMessage } from "../relay/incoming"; +import { scanMarkdown } from "../relay/message-content"; +import type { NotificationCategory } from "./preferences"; + +export type NotificationText = Readonly<{ title: string; body: string }>; + +function shortText(value: string, limit: number) { + const points = Array.from( + value + .replace(/[\p{Cc}\p{Bidi_Control}]/gu, " ") + .replace(/\s+/gu, " ") + .trim(), + ); + return points.length > limit + ? `${points.slice(0, limit - 1).join("")}…` + : points.join(""); +} + +type TextNode = { type: string; value?: string; children?: TextNode[] }; +function prose(node: TextNode): string { + if (node.type === "html" || node.type === "definition") return ""; + if (node.type === "image" || node.type === "imageReference") return "[Image]"; + if (node.type === "break" || node.type === "thematicBreak") return " "; + if (node.value !== undefined) return node.value; + const separator = [ + "paragraph", + "heading", + "emphasis", + "strong", + "link", + "linkReference", + ].includes(node.type) + ? "" + : " "; + return (node.children ?? []).map(prose).join(separator); +} + +/** Bounded CommonMark text, not rendered HTML or a fetch of linked/attached content. */ +export function messagePreview(content: string): string { + const { tree, tooDeep } = scanMarkdown(content.slice(0, 4096)); + return (tooDeep ? "" : shortText(prose(tree), 200)) || "New message"; +} + +/** Names are optional cached enrichment; notifications never wait for profile reads. */ +export function messageNotificationText( + message: IncomingMessage, + category: NotificationCategory, + channel: ChannelSummary | undefined, + profile: Profile | undefined, +): NotificationText { + const sender = + shortText(profile?.name ?? "", 64) || message.authorId.slice(0, 10); + const destination = + channel?.channelType === "dm" + ? "a direct message" + : `#${shortText(channel?.name ?? "", 64) || message.channelId.slice(0, 8)}`; + const title = + category === "mention" + ? `${sender} mentioned you in ${destination}` + : category === "direct" + ? `${sender} sent you a direct message` + : `${sender} replied in ${destination}`; + return Object.freeze({ title, body: messagePreview(message.previewContent) }); +} diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts new file mode 100644 index 00000000..d1817215 --- /dev/null +++ b/src/features/notifications/desktop.test.ts @@ -0,0 +1,164 @@ +import { Context } from "@deepseek-ai/cordis"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { NotificationSettings } from "../../app/NotificationSettings"; +import { PluginRuntime } from "../../plugins/runtime"; +import { provideNavigation } from "../navigation/service"; +import { createNotifications } from "./platform"; +import { NotificationsService } from "./service"; +import { messageNotificationText } from "./content"; + +const sdk = vi.hoisted(() => ({ + isPermissionGranted: vi.fn(async () => true), + requestPermission: vi.fn(async () => "granted"), + sendNotification: vi.fn(), +})); +const native = vi.hoisted(() => ({ value: true })); +vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => native.value })); +vi.mock("@tauri-apps/plugin-notification", () => sdk); +vi.mock("react", async (original) => ({ + ...(await original()), + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), +})); +const contexts: Context[] = []; +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose(); + vi.resetAllMocks(); + native.value = true; +}); +function setup() { + const ctx = new Context(); + contexts.push(ctx); + const runtime = new PluginRuntime(ctx, async () => ({ apply() {} })); + ctx.effect(() => () => runtime.dispose()); + const navigation = provideNavigation(ctx).navigation; + // Exercise the production default, not an injected adapter. + const service = new NotificationsService(ctx, navigation); + service.selectViewer("a".repeat(64)); + const submit = (sourceKey: string) => + service.admit( + "mention", + "Mentions", + { sourceKey, target: { version: 1, kind: "settings" } }, + () => true, + () => true, + ); + return { service, submit }; +} +async function flush() { + for (let i = 0; i < 40; i++) await Promise.resolve(); +} + +it("the default service sends desktop banners via the official SDK and shared policy", async () => { + const { service, submit } = setup(); + await flush(); + expect(service.snapshot()).toMatchObject({ + permission: "unknown", + systemManaged: true, + preferences: { enabled: true }, + }); + expect(sdk.requestPermission).not.toHaveBeenCalled(); + await submit("first"); + await submit("first"); + await flush(); + expect(sdk.sendNotification).toHaveBeenCalledExactlyOnceWith({ + title: "Buzz", + body: "New mentions", + }); + service.updatePreferences({ enabled: false }); + await submit("off"); + service.updatePreferences({ enabled: true, categories: { mention: false } }); + await submit("category-off"); + await flush(); + expect(sdk.sendNotification).toHaveBeenCalledTimes(1); +}); + +it("an explicit permission request uses the SDK without claiming OS permission is known", async () => { + const { service, submit } = setup(); + sdk.isPermissionGranted.mockResolvedValue(false); + await service.refreshPermission(); + expect(service.snapshot().permission).toBe("default"); + await submit("pending"); + await flush(); + expect(sdk.sendNotification).not.toHaveBeenCalled(); + sdk.isPermissionGranted.mockResolvedValue(true); + await service.requestPermission(); + await flush(); + expect(sdk.requestPermission).toHaveBeenCalledOnce(); + expect(service.snapshot().permission).toBe("unknown"); + expect(sdk.sendNotification).toHaveBeenCalledOnce(); +}); + +it("observable SDK failures surface once without retry or a browser fallback", async () => { + const { service, submit } = setup(); + sdk.sendNotification.mockImplementationOnce(() => { + throw new Error("SDK unavailable"); + }); + await submit("failed"); + await flush(); + expect(service.snapshot().error).toBe("SDK unavailable"); + await submit("failed"); + await flush(); + expect(sdk.sendNotification).toHaveBeenCalledOnce(); + await submit("next"); + await flush(); + expect(sdk.sendNotification).toHaveBeenCalledTimes(2); +}); + +it("desktop settings keep master/categories but explain OS sound and click limits", async () => { + const { service } = setup(); + await flush(); + const html = renderToStaticMarkup( + createElement(NotificationSettings, { notifications: service }), + ); + expect(html).toContain("Desktop alerts"); + expect(html).toContain("Mentions"); + expect(html).toContain( + "Manage sound and permission in system notification settings", + ); + expect(html).toContain( + "Desktop banners do not open a specific message when clicked", + ); + expect(html).not.toContain("Sound"); + expect(html).not.toContain("Permission granted"); +}); + +it("non-Tauri runs select the unchanged browser adapter, never the native SDK", async () => { + native.value = false; + const platform = createNotifications(); + expect(platform.label).toBe("Browser notifications"); + expect(await platform.permission()).toBe("unsupported"); + expect(sdk.isPermissionGranted).not.toHaveBeenCalled(); + expect(sdk.sendNotification).not.toHaveBeenCalled(); +}); + +it("the production desktop adapter forwards the message title and preview unchanged", async () => { + const { service } = setup(); + await service.admit( + "mention", + "Mentions", + { sourceKey: "rich", target: { version: 1, kind: "settings" } }, + () => true, + () => true, + () => + messageNotificationText( + { + channelId: "room", + messageId: "b".repeat(64), + authorId: "c".repeat(64), + createdAt: 1, + previewContent: "Hello **Wes**", + }, + "mention", + { id: "room", name: "Room" }, + { name: "Pinky" }, + ), + ); + await flush(); + expect(sdk.sendNotification).toHaveBeenCalledExactlyOnceWith({ + title: "Pinky mentioned you in #Room", + body: "Hello Wes", + }); +}); diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts new file mode 100644 index 00000000..35bb0188 --- /dev/null +++ b/src/features/notifications/messages.test.ts @@ -0,0 +1,413 @@ +import { Context } from "@deepseek-ai/cordis"; +import { PluginRuntime } from "../../plugins/runtime"; +import { afterEach, expect, it, vi } from "vitest"; +import { createRelaySession } from "../relay/session"; +import type { LiveCallbacks } from "../relay/live"; +import type { Communities } from "../communities/service"; +import { + keypair, + message, + metadata, + profile, + roster, + signed, + flush, +} from "../relay/testing"; +import { + newReadJournal, + readJournal, + type ReadJournal, +} from "../relay/read-state-storage"; +import { NotificationsService } from "./service"; +import { createNotificationPreferences } from "./preferences"; +import { provideNavigation } from "../navigation/service"; +import { bindMessageNotifications, notificationAuthorized } from "./messages"; + +const cleanups: (() => unknown)[] = []; +afterEach(async () => { + for (const stop of cleanups.splice(0)) await stop(); + vi.restoreAllMocks(); +}); +async function setup( + readBarrier: Promise = Promise.resolve(), + readFrontier?: number, +) { + const viewer = keypair(), + peer = keypair(), + relay = keypair(); + const origin = "https://relay.example.com"; + let callbacks!: LiveCallbacks; + let readState: ReadJournal | undefined = + readFrontier === undefined + ? undefined + : { + ...newReadJournal(), + state: { frontiers: { room: readFrontier }, overrides: {} }, + }; + const query = vi.fn(async () => [] as ReturnType[]); + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relay.pubkey, + query, + media: () => undefined, + subscribe(value) { + callbacks = value; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { + readStateStorage: { + async update(change) { + await readBarrier; + readState = readJournal(change(readState), viewer.pubkey); + return readState; + }, + close() {}, + }, + }, + ); + cleanups.push(owner.dispose); + const ctx = new Context(); + const runtime = new PluginRuntime(ctx, async () => ({ apply() {} })); + ctx.effect(() => () => runtime.dispose()); + cleanups.push(() => ctx.fiber.dispose()); + const navigation = provideNavigation(ctx); + const data = new Map(); + const preferences = createNotificationPreferences({ + localStorage: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => data.set(key, value), + }, + addEventListener() {}, + removeEventListener() {}, + } as unknown as Window); + let click = () => {}; + const show = vi.fn(async (_item, activate: () => void) => { + click = activate; + }); + const permission = vi.fn(async (): Promise<"granted"> => "granted"); + const listeners = new Set<() => void>(); + let selected: string | null = origin; + const snapshot = { + status: "ready" as const, + generation: 1, + viewer: viewer.pubkey, + session: owner.session, + }; + const communities = { + snapshot: () => ({ + status: "ready", + viewer: viewer.pubkey, + selected, + memberships: [{ id: origin, name: "Example" }], + }), + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + relay: { + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + } as unknown as Communities; + const notifications = new NotificationsService( + ctx, + navigation.navigation, + { + label: "Test platform", + permission, + requestPermission: async () => "granted", + show, + dispose() {}, + }, + preferences, + (target) => notificationAuthorized(communities, target), + ); + const stop = bindMessageNotifications(notifications, communities); + cleanups.push(stop); + await flush(); + notifications.updatePreferences({ sound: false }); + const emit = ( + events: ReturnType[], + phase?: "replay" | "live", + channelId = "room", + ) => callbacks.receive(events, phase ? { phase, channelId } : undefined); + emit([ + roster(relay, "room", [viewer.pubkey]), + metadata(relay, "room", "Room"), + ]); + const make = (text: string, age = 0, author = peer) => + message(author, "room", text, Math.floor(Date.now() / 1000) - age, [ + ["p", viewer.pubkey], + ]); + return { + owner, + notifications, + navigation, + emit, + make, + show, + permission, + query, + peer, + relay, + viewer, + click: () => click(), + deselect() { + selected = null; + for (const listener of listeners) listener(); + }, + }; +} +it("only production live traffic can create a message notification, never history/replay/local observation", async () => { + const h = await setup(); + const historic = h.make("finite"); + h.query.mockResolvedValueOnce([historic]); + await h.owner.session.read([{ ids: [historic.id], limit: 1 }]); + h.emit([historic], "live"); + h.emit([h.make("legacy")]); + h.emit([h.make("replay")], "replay"); + h.emit([h.make("wrong route")], "live", "elsewhere"); + h.emit( + [h.make("stale", 121), h.make("future", -31), h.make("own", 0, h.viewer)], + "live", + ); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + const fresh = h.make("fresh"); + h.emit([fresh, fresh], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(1)); + h.click(); + expect(h.navigation.navigation.snapshot().entry.target).toMatchObject({ + messageId: fresh.id, + }); + expect(h.owner.session.unread.attention("room", fresh.id).unread).toBe(true); +}); +it("viewing suppression uses the shared lease, and suppressed candidates never become delayed alerts", async () => { + const h = await setup(); + const row = h.make("visible"); + const lease = h.owner.session.unread.reading("room"); + const view = h.owner.session.observe([ + { kinds: [9], "#h": ["room"], limit: 50 }, + ]); + view.subscribe(() => lease.view([row.id], () => true)); + h.emit([row], "live"); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + lease.dispose(); + await h.notifications.requestPermission(); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + h.notifications.updatePreferences({ notifyWhileViewing: true }); + const next = h.make("visible allowed"); + const visible = h.owner.session.unread.reading("room"); + view.subscribe(() => visible.view([next.id], () => true)); + h.emit([next], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(1)); + visible.dispose(); + view.dispose(); +}); +it("community switching stops new production but keeps prior scoped click intent", async () => { + const h = await setup(); + const row = h.make("fresh"); + h.emit([row], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(1)); + h.deselect(); + h.click(); + // The real host navigation will select/check the target community and channel; + // this service must not silently discard it because another community is selected. + expect(h.navigation.navigation.snapshot().entry.target).toMatchObject({ + kind: "conversation", + messageId: row.id, + scope: { viewer: h.viewer.pubkey }, + }); + h.emit([h.make("unselected")], "live"); + await flush(); + expect(h.show).toHaveBeenCalledTimes(1); +}); +it("authorized deletions in the same live batch cannot generate an alert", async () => { + const h = await setup(); + const row = h.make("deleted"); + h.emit( + [ + row, + signed(h.peer, { + kind: 5, + tags: [["e", row.id]], + content: "", + created_at: row.created_at, + }), + ], + "live", + ); + await flush(); + expect(h.show).not.toHaveBeenCalled(); +}); + +it("review: fresh incoming alert waits for initial unread readiness rather than becoming permanently quiet", async () => { + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const h = await setup(ready); + try { + expect(h.owner.session.unread.sync().status).toBe("loading"); + const row = h.make("arrived during startup"); + h.emit([row], "live"); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + release(); + await flush(); + expect(h.owner.session.unread.sync().status).toBe("local"); + expect(h.owner.session.unread.attention("room", row.id)).toMatchObject({ + status: "eligible", + unread: true, + }); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(1)); + } finally { + release(); + } +}); + +it.each([ + "already-read", + "expired", + "revoked", + "muted", + "viewed", + "switched", +] as const)( + "a loading live candidate stays quiet after readiness when %s", + async (condition) => { + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + const now = Date.now(); + const h = await setup( + barrier, + condition === "already-read" ? Math.floor(now / 1000) + 10 : undefined, + ); + try { + const row = h.make("pending at startup"); + h.emit([row], "live"); + await flush(); + if (condition === "expired") + vi.spyOn(Date, "now").mockReturnValue(now + 121000); + if (condition === "revoked") + h.emit([roster(h.relay, "room", [], Math.floor(now / 1000))]); + if (condition === "muted") + h.notifications.updatePreferences({ enabled: false }); + if (condition === "switched") h.deselect(); + if (condition === "viewed") { + const lease = h.owner.session.unread.reading("room"); + lease.view([row.id], () => true); + cleanups.push(lease.dispose); + } + release(); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + await h.notifications.requestPermission(); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + } finally { + release(); + } + }, +); + +it("review: channel revoke/regrant plus history restoration must not revive a pending live alert", async () => { + const h = await setup(); + let finish!: (permission: "granted") => void; + h.permission.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const row = h.make("pending before access removal"); + h.emit([row], "live"); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + const now = Math.floor(Date.now() / 1000); + h.emit([roster(h.relay, "room", [], now + 1)]); + h.emit([roster(h.relay, "room", [h.viewer.pubkey], now + 2)]); + h.query.mockResolvedValueOnce([row]); + await h.owner.session.read([{ ids: [row.id], limit: 1 }]); + expect(h.owner.session.unread.attention("room", row.id)).toMatchObject({ + status: "eligible", + unread: true, + }); + finish("granted"); + await flush(); + expect(h.show).not.toHaveBeenCalled(); +}); + +it("live message wiring supplies the signed author and body, resolving names at delivery without extra reads", async () => { + const h = await setup(); + let release!: (permission: "granted") => void; + h.permission.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const row = h.make("**Hello** [Wes](https://example.com/private)"); + h.emit([row], "live"); + await vi.waitFor(() => expect(release).toBeTypeOf("function")); + // Profile can arrive from the existing shared stream while permission is pending. + h.emit([profile(h.peer, { display_name: "Pinky" })]); + release("granted"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.show.mock.calls[0]?.[0]).toMatchObject({ + title: "Pinky mentioned you in #Room", + body: "Hello Wes", + }); + expect(h.query).not.toHaveBeenCalled(); +}); + +it.each(["direct", "thread"] as const)( + "live %s messages carry the correct title and preview", + async (category) => { + const h = await setup(); + h.emit([profile(h.peer, { name: "Pinky" })]); + const now = Math.floor(Date.now() / 1000); + const root = message(h.viewer, "room", "Own thread", now - 1); + if (category === "direct") { + h.emit([ + signed(h.relay, { + kind: 39000, + content: JSON.stringify({ + name: "internal-dm-id", + channel_type: "dm", + }), + tags: [ + ["d", "room"], + ["name", "internal-dm-id"], + ["t", "dm"], + ], + created_at: now, + }), + ]); + } else h.emit([root], "replay"); + const row = message( + h.peer, + "room", + "A **new** reply", + now, + category === "thread" ? [["e", root.id, "", "reply"]] : [], + ); + h.emit([row], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.show.mock.calls[0]?.[0]).toMatchObject({ + title: + category === "direct" + ? "Pinky sent you a direct message" + : "Pinky replied in #Room", + body: "A new reply", + }); + }, +); diff --git a/src/features/notifications/messages.ts b/src/features/notifications/messages.ts new file mode 100644 index 00000000..497ae053 --- /dev/null +++ b/src/features/notifications/messages.ts @@ -0,0 +1,165 @@ +import type { Communities } from "../communities/service"; +import { communityDestination } from "../communities/destination"; +import type { OpenTarget } from "../navigation/targets"; +import type { IncomingListener } from "../relay/incoming"; +import type { RelaySession } from "../relay/session"; +import type { NotificationsService } from "./service"; +import { messageNotificationText } from "./content"; + +const labels = { + mention: "Mentions", + direct: "Direct messages", + thread: "Thread replies", +}; +/** No new subscriptions: this first adapter covers only the selected community. */ +export function notificationAuthorized( + communities: Communities, + target: OpenTarget, +) { + if (!("scope" in target) || !target.scope) return true; + const client = communities.snapshot(); + const scope = target.scope; + if ( + client.viewer !== scope.viewer || + !client.memberships.some( + (item) => communityDestination(item.id).url === scope.communityOrigin, + ) + ) + return false; + if (target.kind !== "conversation") return true; + const relay = communities.relay.snapshot(); + return ( + !!client.selected && + communityDestination(client.selected).url === scope.communityOrigin && + relay.status === "ready" && + relay.viewer === scope.viewer && + relay.session.channels + .list() + .channels.some( + (channel) => + channel.id === target.channelId && + channel.members?.includes(scope.viewer), + ) + ); +} + +export function bindMessageNotifications( + notifications: NotificationsService, + communities: Communities, +) { + let closed = false; + let session: RelaySession | undefined; + let identity = ""; + let generation = 0; + let stopIncoming = () => {}; + let stopAccess = () => {}; + let stopSync = () => {}; + const update = () => { + const client = communities.snapshot(); + void notifications.selectViewer(client.viewer); + const relay = communities.relay.snapshot(); + const origin = client.selected + ? communityDestination(client.selected).url + : undefined; + const next = `${client.viewer ?? ""}:${origin ?? ""}:${relay.status}`; + if (session === relay.session && identity === next) return; + generation++; + stopIncoming(); + stopAccess(); + stopSync(); + notifications.revalidate(); + session = relay.session; + identity = next; + if ( + closed || + relay.status !== "ready" || + !origin || + !client.viewer || + relay.viewer !== client.viewer + ) + return; + const viewer = client.viewer; + const owned = relay.session; + const current = generation; + const valid = () => + !closed && + generation === current && + communities.relay.snapshot().session === owned; + const receive: IncomingListener = (messages) => { + for (const message of messages) { + if (!valid()) return; + const age = Date.now() - message.createdAt * 1000; + if (age < -30000 || age > 120000) continue; + const attention = owned.unread.attention( + message.channelId, + message.messageId, + ); + const category = attention.category; + if (attention.status !== "eligible" || !category) continue; + void notifications.admit( + category, + labels[category], + { + sourceKey: message.messageId, + target: { + version: 1, + kind: "conversation", + scope: { viewer, communityOrigin: origin }, + channelId: message.channelId, + messageId: message.messageId, + ...(attention.rootId ? { threadRootId: attention.rootId } : {}), + }, + }, + valid, + () => { + if (!valid() || Date.now() - message.createdAt * 1000 > 120000) + return false; + const attention = owned.unread.attention( + message.channelId, + message.messageId, + ); + const sync = owned.unread.sync(); + if ( + attention.status === "ineligible" || + (!notifications.snapshot().preferences.notifyWhileViewing && + attention.viewing) + ) + return false; + if ( + sync.status === "loading" || + sync.status === "error" || + attention.status === "unknown" + ) + return "wait"; + return attention.unread; + }, + () => + messageNotificationText( + message, + category, + owned.channels + .list() + .channels.find((item) => item.id === message.channelId), + owned.profiles.snapshot().get(message.authorId), + ), + ); + } + }; + stopIncoming = owned.subscribeIncoming(receive); + // Only reconsider retained live candidates; readiness is not an event source. + stopSync = owned.unread.subscribeSync(() => notifications.revalidate()); + stopAccess = owned.channels.subscribeList(() => notifications.revalidate()); + }; + const stop = communities.relay.subscribe(update); + const stopCommunities = communities.subscribe(update); + update(); + return () => { + closed = true; + generation++; + stop(); + stopCommunities(); + stopIncoming(); + stopAccess(); + stopSync(); + }; +} diff --git a/src/features/notifications/platform.test.ts b/src/features/notifications/platform.test.ts new file mode 100644 index 00000000..3205e657 --- /dev/null +++ b/src/features/notifications/platform.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { createBrowserNotifications } from "./platform"; + +vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => native })); +const { native: initial } = vi.hoisted(() => ({ native: false })); +let native = initial; +afterEach(() => { + native = false; +}); +function setup() { + const shown: FakeNotification[] = []; + class FakeNotification { + static permission: NotificationPermission = "granted"; + static requestPermission = vi.fn(async () => FakeNotification.permission); + onclick: (() => void) | null = null; + onclose: (() => void) | null = null; + onerror: (() => void) | null = null; + close = vi.fn(() => this.onclose?.()); + constructor( + public title: string, + public options: NotificationOptions, + ) { + shown.push(this); + } + } + const host = { + Notification: FakeNotification, + focus: vi.fn(), + } as unknown as Window; + return { + platform: createBrowserNotifications(host), + host, + shown, + FakeNotification, + }; +} +it("browser activation focuses and routes once; close/dispose releases callbacks", async () => { + const t = setup(), + activate = vi.fn(); + await t.platform.show( + { + id: "one", + title: "Pinky mentioned you in #Room", + body: "Hello Wes", + silent: true, + }, + activate, + vi.fn(), + ); + expect(t.shown[0]?.title).toBe("Pinky mentioned you in #Room"); + expect(t.shown[0]?.options).toEqual({ + body: "Hello Wes", + tag: "one", + silent: true, + }); + t.shown[0]?.onclick?.(); + expect(activate).toHaveBeenCalledTimes(1); + expect(t.host.focus).toHaveBeenCalledTimes(1); + expect(t.shown[0]?.onclick).toBeNull(); + await t.platform.show( + { id: "two", title: "Buzz", body: "New mention", silent: false }, + activate, + vi.fn(), + ); + t.platform.dispose(); + expect(t.shown[1]?.onclick).toBeNull(); + expect(t.shown[1]?.onerror).toBeNull(); + expect(t.shown[1]?.onclose).toBeNull(); + expect(t.shown[1]?.close).toHaveBeenCalledTimes(1); +}); +it("bounded presentation closes the old banner instead of stranding its target", async () => { + const t = setup(); + for (let i = 0; i < 129; i++) + await t.platform.show( + { id: String(i), title: "Buzz", body: "New mention", silent: true }, + () => {}, + vi.fn(), + ); + expect(t.shown[0]?.close).toHaveBeenCalledTimes(1); + expect(t.shown[0]?.onclick).toBeNull(); + expect(t.shown[0]?.onerror).toBeNull(); + expect(t.shown[0]?.onclose).toBeNull(); + expect(t.shown[128]?.onclick).toBeTypeOf("function"); + t.platform.dispose(); +}); +it("denial and a native WebView never silently use browser notification delivery", async () => { + const t = setup(); + t.FakeNotification.permission = "denied"; + expect(await t.platform.permission()).toBe("denied"); + await expect( + t.platform.show( + { id: "one", title: "Buzz", body: "New mention", silent: true }, + () => {}, + vi.fn(), + ), + ).rejects.toThrow("permission"); + native = true; + const other = createBrowserNotifications(t.host); + expect(await other.permission()).toBe("unsupported"); + expect(await other.requestPermission()).toBe("unsupported"); + expect(t.FakeNotification.requestPermission).not.toHaveBeenCalled(); + expect(t.shown).toHaveLength(0); +}); + +it("asynchronous browser errors report once and retire every callback without retry", async () => { + const t = setup(), + failed = vi.fn(), + activate = vi.fn(); + await t.platform.show( + { id: "failed", title: "Buzz", body: "New mention", silent: true }, + activate, + failed, + ); + t.shown[0]?.onerror?.(); + t.shown[0]?.onerror?.(); + expect(failed).toHaveBeenCalledExactlyOnceWith( + new Error("The browser could not display a notification."), + ); + expect(t.shown[0]?.onclick).toBeNull(); + expect(t.shown[0]?.onerror).toBeNull(); + expect(t.shown[0]?.onclose).toBeNull(); + expect(t.shown[0]?.close).toHaveBeenCalledTimes(1); + expect(activate).not.toHaveBeenCalled(); + expect(t.shown).toHaveLength(1); +}); diff --git a/src/features/notifications/platform.ts b/src/features/notifications/platform.ts new file mode 100644 index 00000000..5cc84680 --- /dev/null +++ b/src/features/notifications/platform.ts @@ -0,0 +1,109 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { + isPermissionGranted, + requestPermission, + sendNotification, +} from "@tauri-apps/plugin-notification"; + +export type NotificationPermissionState = + | NotificationPermission + | "unsupported" + | "unknown"; +export type NotificationPresentation = Readonly<{ + id: string; + title: string; + body: string; + silent: boolean; +}>; +export interface NotificationPlatform { + readonly label: string; + readonly systemManaged?: boolean; + permission(): Promise; + requestPermission(): Promise; + show( + item: NotificationPresentation, + activate: () => void, + failed: (error: Error) => void, + ): Promise; + dispose(): void; +} + +/** Official desktop plugin; browser callbacks are not supported by its shim. */ +export function createNotifications(): NotificationPlatform { + if (!isTauri()) return createBrowserNotifications(); + return { + label: "Desktop notifications", + systemManaged: true, + // The desktop plugin reports API availability, not the OS user's permission. + permission: async () => + (await isPermissionGranted()) ? "unknown" : "default", + async requestPermission() { + const permission = await requestPermission(); + return permission === "granted" ? "unknown" : permission; + }, + async show(item) { + // The public SDK is fire-and-forget. No click callback, delivery receipt, + // withdrawal or portable sound override is promised by this desktop path. + sendNotification({ title: item.title, body: item.body }); + }, + dispose() {}, + }; +} + +/** Running-tab alerts. Native delivery never falls back to a WebView API. */ +export function createBrowserNotifications( + host: Window | undefined = typeof window === "undefined" ? undefined : window, +): NotificationPlatform { + const api = + host && !isTauri() && "Notification" in host + ? (host as Window & { Notification: typeof Notification }).Notification + : undefined; + const active = new Map(); + function release(id: string, notification: Notification) { + notification.onclick = null; + notification.onclose = null; + notification.onerror = null; + active.delete(id); + } + function retire(id: string, notification: Notification) { + release(id, notification); + notification.close(); + } + return { + label: isTauri() + ? "Native notifications unavailable in this build" + : "Browser notifications", + permission: async () => api?.permission ?? "unsupported", + requestPermission: () => + api ? api.requestPermission() : Promise.resolve("unsupported"), + async show(item, activate, failed) { + if (api?.permission !== "granted") + throw new Error("Notification permission is not granted"); + // Closing the oldest presentation also retires its callback; never strand + // an open banner by independently evicting its only navigation target. + if (active.size >= 128) { + const first = active.entries().next().value; + if (first) retire(...first); + } + const notification = new api(item.title, { + body: item.body, + tag: item.id, + silent: item.silent, + }); + active.set(item.id, notification); + notification.onclick = () => { + host?.focus(); + activate(); + retire(item.id, notification); + }; + notification.onclose = () => release(item.id, notification); + notification.onerror = () => { + retire(item.id, notification); + failed(new Error("The browser could not display a notification.")); + }; + }, + dispose() { + for (const [id, item] of active) retire(id, item); + }, + }; +} diff --git a/src/features/notifications/preferences.ts b/src/features/notifications/preferences.ts new file mode 100644 index 00000000..0bc47e20 --- /dev/null +++ b/src/features/notifications/preferences.ts @@ -0,0 +1,135 @@ +/** Desired account-local policy is separate from system permission. */ +export const NOTIFICATION_CATEGORIES = ["mention", "direct", "thread"] as const; +export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number]; +export type NotificationPreferences = Readonly<{ + enabled: boolean; + notifyWhileViewing: boolean; + sound: boolean; + categories: Readonly>; +}>; +export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = + Object.freeze({ + enabled: true, + notifyWhileViewing: false, + sound: true, + categories: Object.freeze({ mention: true, direct: true, thread: true }), + }); +const KEY = "buzz-notification-preferences.v1"; +export function parsePreferences(raw: unknown): NotificationPreferences { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + throw new Error("Invalid notification preferences"); + const value = raw as Record; + if ( + typeof value.enabled !== "boolean" || + typeof value.notifyWhileViewing !== "boolean" || + typeof value.sound !== "boolean" || + !value.categories || + typeof value.categories !== "object" || + Array.isArray(value.categories) + ) + throw new Error("Invalid notification preferences"); + const categories = Object.entries(value.categories); + if ( + categories.length > 128 || + categories.some( + ([key, enabled]) => + !/^[a-z0-9][a-z0-9._/-]{0,255}$/.test(key) || + typeof enabled !== "boolean", + ) + ) + throw new Error("Invalid notification categories"); + return Object.freeze({ + enabled: value.enabled, + notifyWhileViewing: value.notifyWhileViewing, + sound: value.sound as boolean, + categories: Object.freeze(Object.fromEntries(categories)), + }); +} +export function createNotificationPreferences( + host: Window | undefined = typeof window === "undefined" ? undefined : window, +) { + let viewer: string | undefined; + let disposed = false; + let state = { + preferences: DEFAULT_NOTIFICATION_PREFERENCES, + error: null as string | null, + }; + const listeners = new Set<() => void>(); + const key = () => `${KEY}:${viewer ?? "device"}`; + const notify = () => { + for (const listener of listeners) listener(); + }; + function restore() { + try { + const raw = host?.localStorage.getItem(key()); + state = { + preferences: raw + ? parsePreferences(JSON.parse(raw)) + : DEFAULT_NOTIFICATION_PREFERENCES, + error: null, + }; + } catch { + // Never turn alerts on because stored off intent could not be read. + state = { + preferences: { ...DEFAULT_NOTIFICATION_PREFERENCES, enabled: false }, + error: + "Notification preferences could not be restored. Alerts are paused; retry loading your settings.", + }; + } + notify(); + } + const changed = (event: StorageEvent) => { + if (event.key !== key() && event.key !== null) return; + try { + if (event.storageArea !== host?.localStorage) return; + } catch { + /* restore reports the failure */ + } + restore(); + }; + restore(); + host?.addEventListener("storage", changed); + return { + snapshot: () => state, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + selectViewer(next: string | undefined) { + if (disposed || next === viewer) return; + if (next !== undefined && !/^[a-f0-9]{64}$/.test(next)) + throw new Error("Invalid notification viewer"); + viewer = next; + restore(); + }, + update(patch: Partial) { + if (disposed) return false; + const preferences = parsePreferences({ ...state.preferences, ...patch }); + let error: string | null = null; + try { + if (!host) throw new Error("No browser storage"); + host.localStorage.setItem(key(), JSON.stringify(preferences)); + } catch { + error = + "These notification choices are active, but could not be saved on this device. Retry saving."; + } + // An off action takes effect even if persistence fails. Permission completion never changes it. + state = { preferences, error }; + notify(); + return error === null; + }, + reload() { + if (!disposed) restore(); + }, + dispose() { + disposed = true; + host?.removeEventListener("storage", changed); + listeners.clear(); + }, + }; +} +export type NotificationPreferencesService = ReturnType< + typeof createNotificationPreferences +>; diff --git a/src/features/notifications/presentation.test.ts b/src/features/notifications/presentation.test.ts new file mode 100644 index 00000000..27decdcf --- /dev/null +++ b/src/features/notifications/presentation.test.ts @@ -0,0 +1,22 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { afterPresentation } from "./presentation"; +afterEach(() => vi.useRealTimers()); +it("a throttled document cannot indefinitely hold delivery or retain frame work", async () => { + vi.useFakeTimers(); + const cancel = vi.fn(); + const host = { + requestAnimationFrame: vi.fn(() => 1), + cancelAnimationFrame: cancel, + setTimeout, + clearTimeout, + } as unknown as Window; + const complete = vi.fn(); + const pending = afterPresentation(host).then(complete); + await vi.advanceTimersByTimeAsync(99); + expect(complete).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(complete).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith(1); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/src/features/notifications/presentation.ts b/src/features/notifications/presentation.ts new file mode 100644 index 00000000..b648d5ac --- /dev/null +++ b/src/features/notifications/presentation.ts @@ -0,0 +1,22 @@ +/** Let mounted reading consumers publish post-commit visibility before an alert. + * Two frames cover React commit and the timeline's own positioning frame. The + * timer caps the wait when frames stop; eligibility still checks the live lease. + * This yields presentation only: it neither observes nor marks a message read. + */ +export function afterPresentation( + host: Window | undefined = typeof window === "undefined" ? undefined : window, +): Promise { + if (!host) return Promise.resolve(); + return new Promise((resolve) => { + let frame = 0; + const finish = () => { + host.cancelAnimationFrame(frame); + host.clearTimeout(timer); + resolve(); + }; + const timer = host.setTimeout(finish, 100); + frame = host.requestAnimationFrame(() => { + frame = host.requestAnimationFrame(finish); + }); + }); +} diff --git a/src/features/notifications/service.test.ts b/src/features/notifications/service.test.ts new file mode 100644 index 00000000..c1af445e --- /dev/null +++ b/src/features/notifications/service.test.ts @@ -0,0 +1,277 @@ +import { Context } from "@deepseek-ai/cordis"; +import { afterEach, expect, it, vi } from "vitest"; +import { PluginRuntime } from "../../plugins/runtime"; +import type { PluginModule } from "../../plugins/api"; +import { provideNavigation } from "../navigation/service"; +import { NotificationsService, type Notifications } from "./service"; +import { createNotificationPreferences } from "./preferences"; +import type { + NotificationPlatform, + NotificationPermissionState, +} from "./platform"; + +const viewer = "a".repeat(64); +const target = { + version: 1, + kind: "settings", + section: "notifications", +} as const; +const contexts: Context[] = []; +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose(); + vi.restoreAllMocks(); +}); +function setup() { + const ctx = new Context(); + contexts.push(ctx); + let module: PluginModule = { apply() {} }; + const runtime = new PluginRuntime(ctx, async () => module); + ctx.effect(() => () => runtime.dispose()); + const values = new Map(); + const host = { + localStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }, + addEventListener() {}, + removeEventListener() {}, + } as unknown as Window; + const preferences = createNotificationPreferences(host); + const navigation = provideNavigation(ctx); + const clicks: (() => void)[] = []; + const failures: ((error: Error) => void)[] = []; + let permission: NotificationPermissionState = "granted"; + const platform: NotificationPlatform = { + label: "Test", + permission: vi.fn(async () => permission), + requestPermission: vi.fn(async () => permission), + show: vi.fn(async (_item, activate, failed) => { + clicks.push(activate); + failures.push(failed); + }), + dispose: vi.fn(), + }; + const service = new NotificationsService( + ctx, + navigation.navigation, + platform, + preferences, + ); + service.selectViewer(viewer); + return { + ctx, + service, + preferences, + platform, + navigation, + clicks, + failures, + values, + host, + permission(value: NotificationPermissionState) { + permission = value; + }, + submit(id = "event", eligible: () => boolean | "wait" = () => true) { + return service.admit( + "mention", + "Mentions", + { sourceKey: id, target }, + () => true, + eligible, + ); + }, + install(value: PluginModule) { + module = value; + runtime.reconcile([ + { + manifest: { id: "test.plugin", name: "Test plugin", apiVersion: 1 }, + enabled: true, + source: "external", + revision: "v1", + previous: null, + error: null, + }, + ]); + }, + disable() { + runtime.reconcile([]); + }, + }; +} +async function flush() { + for (let i = 0; i < 40; i++) await Promise.resolve(); +} +it("deduplicates in this running session and routes clicks through existing navigation", async () => { + const t = setup(); + expect(await t.submit()).toBe(true); + expect(await t.submit()).toBe(false); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); + t.clicks[0]?.(); + expect(t.navigation.navigation.snapshot().entry.target).toEqual(target); + expect(t.navigation.navigation.snapshot().status).toBe("opening"); +}); +it("more than 256 sequential alerts do not fill a persistent inbox or pause delivery", async () => { + const t = setup(); + for (let i = 0; i < 300; i++) { + expect(await t.submit(String(i))).toBe(true); + await flush(); + } + expect(t.platform.show).toHaveBeenCalledTimes(300); + expect(t.service.snapshot().error).toBeNull(); + expect(Object.keys(t.service.snapshot())).not.toContain("records"); +}); +it("retains a fresh candidate for explicit permission, without prompting on arrival", async () => { + const t = setup(); + t.permission("default"); + await t.submit(); + await flush(); + expect(t.platform.requestPermission).not.toHaveBeenCalled(); + expect(t.platform.show).not.toHaveBeenCalled(); + t.permission("granted"); + await t.service.requestPermission(); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); +}); +it("an off/on transition during a permission probe cancels the old candidate", async () => { + const t = setup(); + await flush(); + let release!: (value: NotificationPermissionState) => void; + vi.mocked(t.platform.permission).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + await t.submit(); + await flush(); + expect(release).toBeTypeOf("function"); + t.service.updatePreferences({ enabled: false }); + t.service.updatePreferences({ enabled: true }); + release("granted"); + await flush(); + expect(t.platform.show).not.toHaveBeenCalled(); +}); +it("readiness rechecks fresh candidates; expired candidates never reappear", async () => { + const t = setup(); + let ready = false; + await t.submit("waiting", () => (ready ? true : "wait")); + await flush(); + expect(t.platform.show).not.toHaveBeenCalled(); + ready = true; + t.service.revalidate(); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); + ready = false; + await t.submit("old", () => (ready ? true : "wait")); + await flush(); + const now = Date.now(); + vi.spyOn(Date, "now").mockReturnValue(now + 121000); + ready = true; + t.service.revalidate(); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); +}); +it("submission failure is reported once; unrelated alerts are not blocked", async () => { + const t = setup(); + vi.mocked(t.platform.show).mockRejectedValueOnce( + new Error("OS outcome unknown"), + ); + await t.submit(); + await flush(); + expect(t.service.snapshot().error).toBe("OS outcome unknown"); + t.service.revalidate(); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); + await t.submit("next"); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(2); +}); +it("old account and disposed-host callbacks cannot navigate", async () => { + const t = setup(); + await t.submit(); + await flush(); + const before = t.navigation.navigation.snapshot().entry; + t.service.selectViewer("b".repeat(64)); + t.clicks[0]?.(); + expect(t.navigation.navigation.snapshot().entry).toBe(before); + await t.submit("other"); + await flush(); + await t.ctx.fiber.dispose(); + t.clicks[1]?.(); + expect(t.navigation.navigation.snapshot().entry).toBe(before); +}); +it("installed plugins share the host policy and stale producer handles are rejected", async () => { + const t = setup(); + let producer: ReturnType | undefined; + t.install({ + inject: ["notifications"], + apply(ctx) { + producer = ctx.notifications.register({ + id: "updates", + label: "Updates", + }); + }, + }); + await vi.waitFor(() => expect(producer).toBeDefined()); + expect(await producer?.submit({ sourceKey: "one", target })).toBe(true); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); + t.disable(); + await flush(); + expect(await producer?.submit({ sourceKey: "two", target })).toBe(false); + t.clicks[0]?.(); + expect(t.navigation.navigation.snapshot().entry.target).toEqual(target); +}); +it("off takes effect despite storage failure and account preferences stay separate", () => { + const t = setup(); + t.service.updatePreferences({ categories: { mention: false }, sound: false }); + t.service.selectViewer("b".repeat(64)); + expect(t.service.snapshot().preferences.sound).toBe(true); + t.service.selectViewer(viewer); + expect(t.service.snapshot().preferences.categories.mention).toBe(false); + vi.spyOn(t.host.localStorage, "setItem").mockImplementation(() => { + throw new Error("disk"); + }); + expect(t.service.updatePreferences({ enabled: false })).toBe(false); + expect(t.service.snapshot().preferences.enabled).toBe(false); + expect(t.service.snapshot().preferencesError).toContain("could not be saved"); +}); + +it("a slow old permission probe cannot strand an alert after Allow completes", async () => { + const t = setup(); + await flush(); + t.permission("default"); + let release!: (permission: NotificationPermissionState) => void; + vi.mocked(t.platform.permission).mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + await t.submit(); + await flush(); + t.permission("granted"); + await t.service.requestPermission(); + await flush(); + release("default"); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); +}); + +it("late platform errors report without retry and stay fenced to their account lifetime", async () => { + const t = setup(); + await t.submit(); + await flush(); + t.failures[0]?.(new Error("Late display failure")); + expect(t.service.snapshot().error).toBe("Late display failure"); + t.service.revalidate(); + await flush(); + expect(t.platform.show).toHaveBeenCalledTimes(1); + t.service.selectViewer("b".repeat(64)); + t.failures[0]?.(new Error("Old account failure")); + expect(t.service.snapshot().error).toBeNull(); + await t.ctx.fiber.dispose(); + t.failures[0]?.(new Error("Disposed failure")); + expect(t.service.snapshot().error).toBeNull(); +}); diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts new file mode 100644 index 00000000..98c8b1c6 --- /dev/null +++ b/src/features/notifications/service.ts @@ -0,0 +1,398 @@ +import { Service, type Context } from "@deepseek-ai/cordis"; +import { createContributions } from "../../plugins/contributions"; +import { parseOpenTarget, type OpenTarget } from "../navigation/targets"; +import type { Navigation } from "../navigation/controller"; +import { + createNotificationPreferences, + type NotificationPreferences, +} from "./preferences"; +import { + createNotifications, + type NotificationPlatform, + type NotificationPermissionState, +} from "./platform"; +import { afterPresentation } from "./presentation"; +import type { NotificationText } from "./content"; + +export type NotificationCategoryDescriptor = Readonly<{ + id: string; + label: string; +}>; +export type NotificationInput = Readonly<{ + sourceKey: string; + target: OpenTarget; +}>; +export interface Notifications { + register( + category: NotificationCategoryDescriptor, + ): Readonly<{ submit(input: NotificationInput): Promise }>; +} +declare module "@deepseek-ai/cordis" { + interface Context { + notifications: Notifications; + } +} +export type NotificationEligibility = boolean | "wait"; +type Candidate = { + category: string; + target: OpenTarget; + viewer: string; + expires: number; + cancelled: boolean; + submitting: boolean; + valid(): boolean; + eligible(): NotificationEligibility; + text(): NotificationText; +}; +const categories = Object.freeze([ + { key: "mention", label: "Mentions" }, + { key: "direct", label: "Direct messages" }, + { key: "thread", label: "Thread replies" }, +]); +const FRESH_MS = 120_000; +export type NotificationSnapshot = Readonly<{ + viewer: string | undefined; + preferences: NotificationPreferences; + categories: readonly Readonly<{ key: string; label: string }>[]; + permission: NotificationPermissionState; + requesting: boolean; + error: string | null; + preferencesError: string | null; + platform: string; + systemManaged: boolean; +}>; + +/** Running-session delivery only: no notification journal, inbox, or recovery protocol. */ +export class NotificationsService extends Service implements Notifications { + private readonly contributions; + private readonly listeners = new Set<() => void>(); + private readonly pending = new Set(); + private readonly seen = new Map(); + private closed = false; + private generation = 0; + private permissionGeneration = 0; + private permissionRequest: Promise | undefined; + private scheduled = false; + private state: NotificationSnapshot; + constructor( + ctx: Context, + private readonly navigation: Navigation, + private readonly platform: NotificationPlatform = createNotifications(), + private readonly preferences = createNotificationPreferences(), + private readonly authorized: (target: OpenTarget) => boolean = (target) => + !("scope" in target && target.scope), + ) { + super(ctx, "notifications"); + this.contributions = + createContributions(ctx); + this.state = Object.freeze({ + viewer: undefined, + ...preferences.snapshot(), + preferencesError: preferences.snapshot().error, + categories, + permission: "default", + requesting: false, + platform: platform.label, + systemManaged: platform.systemManaged ?? false, + }); + ctx.effect(() => { + const stopPreferences = preferences.subscribe(() => { + const { preferences: value, error } = preferences.snapshot(); + this.publish({ preferences: value, preferencesError: error }); + this.revalidate(); + }); + const stopCategories = this.contributions.subscribe(() => { + this.publish({ + categories: [ + ...categories, + ...this.contributions + .snapshot() + .map(({ key, label }) => ({ key, label })), + ], + }); + this.revalidate(); + }); + const refresh = () => { + void this.refreshPermission(); + }; + if (typeof window !== "undefined") + window.addEventListener("focus", refresh); + return () => { + this.closed = true; + this.generation++; + this.permissionGeneration++; + for (const item of this.pending) item.cancelled = true; + this.pending.clear(); + this.seen.clear(); + stopPreferences(); + stopCategories(); + preferences.dispose(); + platform.dispose(); + this.listeners.clear(); + if (typeof window !== "undefined") + window.removeEventListener("focus", refresh); + }; + }); + void this.refreshPermission(); + } + snapshot = () => this.state; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + private publish(patch: Partial) { + if (this.closed) return; + this.state = Object.freeze({ ...this.state, ...patch }); + for (const listener of this.listeners) listener(); + } + reportError = (error: unknown) => + this.publish({ + error: error instanceof Error ? error.message : String(error), + }); + selectViewer(viewer: string | undefined) { + if (this.closed || this.state.viewer === viewer) return; + if (viewer !== undefined && !/^[a-f0-9]{64}$/.test(viewer)) + throw new Error("Invalid notification viewer"); + this.generation++; + for (const item of this.pending) item.cancelled = true; + this.pending.clear(); + this.seen.clear(); + this.publish({ viewer, error: null }); + this.preferences.selectViewer(viewer); + } + updatePreferences(patch: Partial) { + return this.preferences.update(patch); + } + reloadPreferences() { + this.preferences.reload(); + } + async refreshPermission() { + if (this.closed || this.permissionRequest) return; + const generation = ++this.permissionGeneration; + try { + const permission = await this.platform.permission(); + if (generation === this.permissionGeneration) { + this.publish({ permission }); + this.schedule(); + } + } catch (error) { + if (generation === this.permissionGeneration) this.reportError(error); + } + } + requestPermission() { + if (this.closed) return Promise.resolve(); + if (this.permissionRequest) return this.permissionRequest; + const generation = ++this.permissionGeneration; + this.publish({ requesting: true, error: null }); + // Keep the browser's user gesture: no asynchronous probe before the request. + try { + this.permissionRequest = this.platform + .requestPermission() + .then((permission) => { + if (generation === this.permissionGeneration) + this.publish({ permission }); + this.schedule(); + }) + .catch(this.reportError) + .finally(() => { + this.permissionRequest = undefined; + this.publish({ requesting: false }); + }); + } catch (error) { + this.reportError(error); + this.publish({ requesting: false }); + } + return this.permissionRequest ?? Promise.resolve(); + } + private allowed(item: Candidate) { + return ( + !this.closed && + !item.cancelled && + item.viewer === this.state.viewer && + item.expires > Date.now() && + item.valid() && + this.authorized(item.target) && + this.state.preferences.enabled && + this.state.preferences.categories[item.category] !== false + ); + } + revalidate() { + for (const item of this.pending) { + if (!this.allowed(item) || item.eligible() === false) { + item.cancelled = true; + this.pending.delete(item); + } + } + this.schedule(); + } + register(category: NotificationCategoryDescriptor) { + if ( + !category || + typeof category.id !== "string" || + !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(category.id) || + typeof category.label !== "string" || + !category.label.trim() || + category.label.length > 128 + ) + throw new Error("Invalid notification category"); + if (this.contributions.snapshot().length >= 125) + throw new Error("Too many notification categories"); + const owner = this.ctx.pluginOwner; + this.contributions.register(this.ctx, category); + let disposed = false; + this.ctx.effect(() => () => { + disposed = true; + }); + const entry = () => + this.contributions + .snapshot() + .find( + (item) => + item.pluginId === owner?.id && + item.revision === owner?.revision && + item.id === category.id, + ); + return Object.freeze({ + submit: (input: NotificationInput) => { + const current = entry(); + if (disposed || !current) return Promise.resolve(false); + return this.admit( + current.key, + current.label, + input, + () => !disposed && entry() === current, + ); + }, + }); + } + /** Session-owned producers supply existing attention/access facts, never new read intent. */ + async admit( + category: string, + label: string, + input: NotificationInput, + valid: () => boolean, + eligible: () => NotificationEligibility = () => true, + text: () => NotificationText = () => ({ + title: "Buzz", + body: `New ${label.toLowerCase()}`, + }), + ) { + const viewer = this.state.viewer; + if (this.closed || !viewer || !valid()) return false; + if ( + !input || + typeof input.sourceKey !== "string" || + !input.sourceKey.length || + input.sourceKey.length > 1024 + ) + throw new Error("Invalid notification source identity"); + const target = parseOpenTarget(input.target); + if ("scope" in target && target.scope && target.scope.viewer !== viewer) + return false; + const scope = + "scope" in target && target.scope + ? target.scope.communityOrigin + : "local"; + const source = categories.some((item) => item.key === category) + ? "message" + : category; + const key = JSON.stringify([scope, source, input.sourceKey]); + const now = Date.now(); + this.revalidate(); + for (const [id, expires] of this.seen) + if (expires <= now) this.seen.delete(id); + if (this.seen.has(key)) return false; + const item: Candidate = { + category, + target, + viewer, + valid, + eligible, + text, + expires: now + FRESH_MS, + cancelled: false, + submitting: false, + }; + // Visibility is rechecked after the UI commits; do not race the reading hook. + if (!this.allowed(item)) return false; + if (this.pending.size >= 128) { + this.reportError(new Error("Too many pending notifications")); + return false; + } + this.seen.set(key, now + FRESH_MS); + while (this.seen.size > 2048) { + const first = this.seen.keys().next().value; + if (first) this.seen.delete(first); + } + this.pending.add(item); + this.schedule(); + return true; + } + private schedule() { + if (this.closed || this.scheduled) return; + this.scheduled = true; + void afterPresentation() + .then(() => { + this.scheduled = false; + for (const item of this.pending) + if (!item.submitting) void this.deliver(item); + }) + .catch(this.reportError); + } + private async deliver(item: Candidate) { + if (!this.allowed(item) || item.eligible() === false) { + this.pending.delete(item); + return; + } + if (item.eligible() === "wait") return; + item.submitting = true; + const generation = this.generation; + try { + const permissionGeneration = this.permissionGeneration; + const probed = await this.platform.permission(); + const permission = + permissionGeneration === this.permissionGeneration + ? probed + : this.state.permission; + if (!this.allowed(item)) { + this.pending.delete(item); + return; + } + if (permission !== "granted" && permission !== "unknown") { + if (permission !== "default") this.pending.delete(item); + return; + } + if (item.eligible() !== true) { + if (item.eligible() === false) this.pending.delete(item); + return; + } + // One attempt. A rejected/unknown OS submission is reported, never retried. + this.pending.delete(item); + await this.platform.show( + { + id: crypto.randomUUID(), + ...item.text(), + silent: !this.state.preferences.sound, + }, + () => { + if (this.closed || generation !== this.generation) return; + // Opening may switch to an already joined community. Navigation owns + // current membership/channel access; admission's selected-session gate + // must not turn a still-valid prior notification into a dead click. + void this.navigation.open(item.target).catch(this.reportError); + }, + (error) => { + if (!this.closed && generation === this.generation) + this.reportError(error); + }, + ); + } catch (error) { + this.pending.delete(item); + this.reportError(error); + } finally { + item.submitting = false; + } + } +} diff --git a/src/features/relay/broker-live.test.ts b/src/features/relay/broker-live.test.ts index 5fd2440f..e3f33d64 100644 --- a/src/features/relay/broker-live.test.ts +++ b/src/features/relay/broker-live.test.ts @@ -1,5 +1,6 @@ // Regression controls contributed by Brain; see WS_RETRY_REVIEW_2026_09_09. import { assert, afterEach, expect, it, vi } from "vitest"; +import { keypair, message } from "./testing"; import { connectBrokerTransport } from "./transport"; function required(value: T | undefined): T { assert.exists(value); @@ -81,11 +82,18 @@ function fixture() { snapshots, accept, publish, + frame(kind: string, value: unknown) { + required(bodyControllers[0]).enqueue( + new TextEncoder().encode( + `event: ${kind}\ndata: ${JSON.stringify(value)}\n\n`, + ), + ); + }, callbacks: { state(s: unknown) { snapshots.push(s); }, - receive() {}, + receive: vi.fn(), established() {}, denied() {}, }, @@ -161,3 +169,51 @@ for (const finish of ["replacement", "dispose"] as const) owner.dispose(); } }); + +it("preserves validated replay/live provenance through production broker transport; legacy traffic stays unknown", async () => { + vi.useFakeTimers(); + const f = fixture(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)(f.callbacks); + try { + f.accept(0); + await tick(); + const event = message(keypair(), "a", "incoming", 1700000000); + f.frame("message", event); + await tick(); + expect(f.callbacks.receive).toHaveBeenLastCalledWith([event]); + for (const phase of ["replay", "live"]) { + f.frame("traffic", { event, provenance: { phase, channelId: "a" } }); + await tick(); + expect(f.callbacks.receive).toHaveBeenLastCalledWith([event], { + phase, + channelId: "a", + }); + } + expect(f.callbacks.receive).toHaveBeenCalledTimes(3); + } finally { + owner.dispose(); + } +}); +it.each([undefined, { phase: "fresh" }, { phase: "live", channelId: ["a"] }])( + "rejects malformed traffic provenance instead of calling it fresh: %j", + async (provenance) => { + vi.useFakeTimers(); + const f = fixture(); + const t = await connectBrokerTransport(); + const owner = required(t.subscribe)(f.callbacks); + try { + f.accept(0); + await tick(); + f.frame("traffic", { + event: message(keypair(), "a", "incoming", 1700000000), + provenance, + }); + await tick(); + expect(f.callbacks.receive).not.toHaveBeenCalled(); + expect(f.snapshots.at(-1)).toMatchObject({ status: "retrying" }); + } finally { + owner.dispose(); + } + }, +); diff --git a/src/features/relay/broker-live.ts b/src/features/relay/broker-live.ts index cac64c77..3555a4c4 100644 --- a/src/features/relay/broker-live.ts +++ b/src/features/relay/broker-live.ts @@ -1,6 +1,7 @@ import { eventDto } from "./events"; import { liveChannels, + liveProvenance, type LiveCallbacks, type LiveSnapshot, type LiveSubscription, @@ -114,7 +115,19 @@ export function subscribeBrokerTraffic( const data: unknown = JSON.parse(lines.join("\n")); if (!valid()) return; if (kind === "message") callbacks.receive([eventDto(data)]); - else if (kind === "state") { + else if (kind === "traffic") { + if ( + !data || + typeof data !== "object" || + !("event" in data) || + !("provenance" in data) + ) + throw new Error("Invalid live traffic envelope"); + callbacks.receive( + [eventDto(data.event)], + liveProvenance(data.provenance), + ); + } else if (kind === "state") { const snapshot = liveSnapshot(data); publish(snapshot); } else if (kind === "established") { diff --git a/src/features/relay/incoming.ts b/src/features/relay/incoming.ts new file mode 100644 index 00000000..2e0d9ca1 --- /dev/null +++ b/src/features/relay/incoming.ts @@ -0,0 +1,10 @@ +/** Verified live-route message only. Not an activity feed, replay, or read intent. */ +export type IncomingMessage = Readonly<{ + channelId: string; + messageId: string; + createdAt: number; + authorId: string; + /** At most 4,096 source characters for a preview, not the complete message. */ + previewContent: string; +}>; +export type IncomingListener = (messages: readonly IncomingMessage[]) => void; diff --git a/src/features/relay/live.test.ts b/src/features/relay/live.test.ts index ff1520df..fd20504a 100644 --- a/src/features/relay/live.test.ts +++ b/src/features/relay/live.test.ts @@ -103,7 +103,15 @@ it("uses independent explicit channel routes and self-p globals; equal interests const event = message(keypair(), "a", "incoming", 1700000000); await h.first.receive(["EVENT", request[1], event]); await h.first.receive(["EOSE", request[1]]); - expect(h.callbacks.receive).toHaveBeenCalledWith([event]); + expect(h.callbacks.receive).toHaveBeenCalledWith([event], { + phase: "replay", + channelId: "a", + }); + await h.first.receive(["EVENT", request[1], event]); + expect(h.callbacks.receive).toHaveBeenLastCalledWith([event], { + phase: "live", + channelId: "a", + }); expect(h.callbacks.established).toHaveBeenCalledWith("a"); expect( h.callbacks.state.mock.lastCall?.[0].routes.find( @@ -571,6 +579,38 @@ it("requests community emoji on the existing profile route and delivers verified ], }); await h.first.receive(["EVENT", req?.[1], event]); - expect(h.callbacks.receive).toHaveBeenCalledWith([event]); + expect(h.callbacks.receive).toHaveBeenCalledWith([event], { + phase: "replay", + }); + h.owner.dispose(); +}); + +it("a reconnect starts a new replay phase even for previously established routes", async () => { + vi.useFakeTimers(); + const h = setup(["a"]); + await h.first.auth(); + await vi.advanceTimersByTimeAsync(750); + const request = h.first.requests()[2]; + assert.exists(request); + await h.first.receive(["EOSE", request[1]]); + const event = message(keypair(), "a", "live", 1700000000); + await h.first.receive(["EVENT", request[1], event]); + expect(h.callbacks.receive).toHaveBeenLastCalledWith([event], { + phase: "live", + channelId: "a", + }); + h.first.close(); + await vi.advanceTimersByTimeAsync(500); + const socket = h.sockets[1]; + assert.exists(socket); + await socket.auth(); + await vi.advanceTimersByTimeAsync(750); + const replay = socket.requests()[2]; + assert.exists(replay); + await socket.receive(["EVENT", replay[1], event]); + expect(h.callbacks.receive).toHaveBeenLastCalledWith([event], { + phase: "replay", + channelId: "a", + }); h.owner.dispose(); }); diff --git a/src/features/relay/live.ts b/src/features/relay/live.ts index e4a6c2ab..2994994e 100644 --- a/src/features/relay/live.ts +++ b/src/features/relay/live.ts @@ -38,8 +38,33 @@ export type LiveSnapshot = Readonly<{ routes: readonly LiveRoute[]; error?: string; }>; +/** Transport provenance, not a history-completeness claim or permission to alert. */ +export type LiveProvenance = Readonly<{ + phase: "replay" | "live"; + channelId?: string; +}>; +export function liveProvenance(value: unknown): LiveProvenance { + if (!value || typeof value !== "object") + throw new Error("Invalid live provenance"); + const input = value as Record; + if (input.phase !== "replay" && input.phase !== "live") + throw new Error("Invalid live provenance"); + if ( + input.channelId !== undefined && + (typeof input.channelId !== "string" || + !/^[a-zA-Z0-9_-]{1,128}$/.test(input.channelId)) + ) + throw new Error("Invalid live provenance channel"); + return Object.freeze({ + phase: input.phase, + ...(typeof input.channelId === "string" + ? { channelId: input.channelId } + : {}), + }); +} export type LiveCallbacks = { - receive(events: readonly VerifiedEvent[]): void; + /** Legacy/missing provenance reconciles quietly; it is never implicitly fresh. */ + receive(events: readonly VerifiedEvent[], provenance?: LiveProvenance): void; state(snapshot: LiveSnapshot): void; established(channelId?: string): void; denied(channelId: string, reason: string): void; @@ -416,7 +441,13 @@ export function subscribeRelayTraffic( return; } if (route.status === "pending") route.count++; - callbacks.receive([incoming]); + callbacks.receive( + [incoming], + Object.freeze({ + phase: route.status === "live" ? "live" : "replay", + ...(route.channelId ? { channelId: route.channelId } : {}), + }), + ); } else if (data[0] === "EOSE" && route.status === "pending") { clearTimeout(route.deadline); route.status = "live"; diff --git a/src/features/relay/message-detail.test.ts b/src/features/relay/message-detail.test.ts new file mode 100644 index 00000000..d586b704 --- /dev/null +++ b/src/features/relay/message-detail.test.ts @@ -0,0 +1,308 @@ +import { afterEach, expect, it } from "vitest"; +import { createRelaySession } from "./session"; +import { DETAIL_READ_LIMIT } from "./message-detail"; +import { ReadError } from "./errors"; +import type { RelayEvent } from "./events"; +import type { LiveCallbacks } from "./live"; +import { + bounds, + flush, + keypair, + message, + roster, + scriptedTransport, + signed, +} from "./testing"; + +const relay = keypair(), + viewer = keypair(), + alice = keypair(); +const owners: ReturnType[] = []; +afterEach(() => { + for (const owner of owners.splice(0)) owner.dispose(); +}); +const root = message(alice, "a", "Old original", 1); +const reply = message(alice, "a", "Selected nested reply", 10000, [ + ["e", root.id, "", "root"], + ["e", "b".repeat(64), "", "reply"], +]); +const aux = (kind: number, target: RelayEvent, content = "", time = 10001) => + signed(alice, { kind, content, created_at: time, tags: [["e", target.id]] }); +function setup(id = root.id) { + const wire = scriptedTransport(viewer.pubkey, relay.pubkey); + let traffic!: LiveCallbacks; + const owner = createRelaySession({ + ...wire.transport, + subscribe(callbacks) { + traffic = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }); + owners.push(owner); + return { + ...wire, + ...owner, + traffic, + view: owner.session.messageDetail("a", id), + }; +} + +it("locates an old nested reply in three bounded reads without head insertion or traversal", async () => { + const h = setup(reply.id); + h.traffic.receive([roster(relay, "a", [viewer.pubkey])]); + h.session.channels.ensure("a"); + const head = message(alice, "a", "Head", 20000); + h.next().respond([ + head, + bounds(relay, "a", "head", { has_more: false, next_cursor: null }), + ]); + await flush(); + // Optional head profile fetch must not stand in for any detail request. + for (const pending of h.pending.splice(0)) pending.respond([]); + const before = h.session.channels.window("a"); + const loading = h.view.refresh(); + const selected = h.next(); + expect(selected.filters).toEqual([ + { ids: [reply.id], "#h": ["a"], limit: 1 }, + ]); + selected.respond([reply]); + await flush(); + const edit = aux(40003, reply, "Edited selected reply"); + const reaction = aux(7, reply, "+"); + const direct = h.next(); + expect(direct.filters[0]).toMatchObject({ + kinds: [39005, 40003, 5, 7, 9005], + limit: 500, + }); + expect(direct.filters[0]?.["#h"]).toBeUndefined(); + expect([...(direct.filters[0]?.["#e"] ?? [])].sort()).toEqual([reply.id]); + direct.respond([edit, reaction]); + await flush(); + const deletion = h.next(); + expect(deletion.filters[0]?.kinds).toEqual([5, 9005]); + expect([...(deletion.filters[0]?.["#e"] ?? [])].sort()).toEqual( + [edit.id, reaction.id].sort(), + ); + deletion.respond([aux(5, reaction)]); + await loading; + expect(h.view.snapshot()).toMatchObject({ + status: "ready", + target: { id: reply.id, content: "Edited selected reply", reactions: [] }, + }); + expect(h.session.channels.window("a").rows).toEqual(before.rows); + expect(h.session.channels.window("a").rows.map((row) => row.id)).toEqual([ + head.id, + ]); + expect(h.pending).toHaveLength(0); +}); + +it("folds author deletes of edits and target deletes, and retains tombstones across refresh omission", async () => { + const h = setup(); + const loading = h.view.refresh(); + h.next().respond([root]); + await flush(); + const edit = aux(40003, root, "Edited"); + h.next().respond([edit]); + await flush(); + h.next().respond([aux(9005, edit)]); + await loading; + expect(h.view.snapshot().target?.content).toBe(root.content); + h.traffic.receive([aux(5, root)]); + expect(h.view.snapshot()).toMatchObject({ + status: "unavailable", + target: undefined, + }); + const retry = h.view.refresh(); + h.next().respond([root]); + await flush(); + h.next().respond([]); + await flush(); + h.next().respond([]); + await retry; + expect(h.view.snapshot()).toMatchObject({ + status: "unavailable", + target: undefined, + }); +}); + +it("distinguishes missing from failed reads and supports a fresh retry", async () => { + const h = setup(); + const missing = h.view.refresh(); + h.next().respond([]); + await missing; + expect(h.view.snapshot().status).toBe("unavailable"); + const failed = h.view.refresh(); + h.next().fail(new Error("network down")); + await failed; + expect(h.view.snapshot()).toMatchObject({ + status: "error", + error: expect.stringContaining("network down"), + }); + const retry = h.view.refresh(); + await flush(); // The shared reader yields to the host after a failed fetch. + h.next().respond([root]); + await flush(); + h.next().respond([]); + await retry; + expect(h.view.snapshot()).toMatchObject({ + status: "ready", + target: { id: root.id }, + }); +}); + +it("opens a verified reply without fetching or substituting its original", async () => { + const h = setup(reply.id); + const reading = h.view.refresh(); + h.next().respond([reply]); + await flush(); + const overlays = h.next(); + expect(overlays.filters[0]?.ids).toBeUndefined(); + expect(overlays.filters[0]?.["#e"]).toEqual([reply.id]); + overlays.respond([]); + await reading; + expect(h.view.snapshot()).toMatchObject({ + status: "ready", + target: { id: reply.id }, + }); +}); + +it("rejects a capped raw auxiliary response before visibility filtering can hide overflow", async () => { + const h = setup(); + const reading = h.view.refresh(); + h.next().respond([root]); + await flush(); + const other = message(alice, "private", "Unavailable target", 2); + h.next().respond( + Array.from({ length: DETAIL_READ_LIMIT }, (_, i) => aux(7, other, `${i}`)), + ); + await reading; + expect(h.view.snapshot()).toMatchObject({ + status: "error", + limited: true, + target: undefined, + }); +}); + +it("retains owned evidence after shared cache eviction, including deletion of an edit", async () => { + const h = setup(); + const reading = h.view.refresh(); + h.next().respond([root]); + await flush(); + const edit = aux(40003, root, "Edited"); + h.next().respond([edit]); + await flush(); + h.next().respond([]); + await reading; + // Unrelated signed payloads exceed recent’s 8 MiB byte budget. + h.traffic.receive( + Array.from({ length: 140 }, (_, i) => + message(alice, "b", `unrelated ${i} ${"x".repeat(65536)}`, i + 20), + ), + ); + h.traffic.receive([aux(5, edit)]); + expect(h.view.snapshot().target?.content).toBe(root.content); +}); + +it("purges atomically on signed membership loss and fences late results", async () => { + const h = setup(); + h.traffic.receive([roster(relay, "a", [viewer.pubkey], 10)]); + const reading = h.view.refresh(); + h.next().respond([root]); + await flush(); + const late = h.next(); + h.traffic.receive([roster(relay, "a", [], 11)]); + expect(late.signal?.aborted).toBe(true); + expect(h.view.snapshot().target).toBeUndefined(); + h.traffic.receive([roster(relay, "a", [viewer.pubkey], 12)]); + late.respond([]); + await reading; + expect(h.view.snapshot().target).toBeUndefined(); + const retry = h.view.refresh(); + h.next().respond([root]); + await flush(); + h.next().respond([]); + await retry; + expect(h.view.snapshot().target?.id).toBe(root.id); + h.traffic.receive([roster(relay, "a", [], 13)]); + expect(h.view.snapshot().target).toBeUndefined(); +}); + +it("a denied reference-only detail read revokes the owning channel, not just its panel", async () => { + const h = setup(); + h.traffic.receive([roster(relay, "a", [viewer.pubkey]), root]); + const reading = h.view.refresh(); + h.next().respond([root]); + await flush(); + h.next().fail(new ReadError("denied", "denied")); + await reading; + expect(h.view.snapshot().target).toBeUndefined(); + expect( + h.session.channels.list().channels.find((channel) => channel.id === "a"), + ).toBeUndefined(); + const retry = h.view.refresh(); + await retry; + expect(h.pending).toHaveLength(0); +}); + +it("shares the 64-view budget and disposes pending requests and cache-cleared evidence", async () => { + const h = setup(); + const reading = h.view.refresh(); + const pending = h.next(); + h.view.dispose(); + expect(pending.signal?.aborted).toBe(true); + pending.respond([root]); + await reading; + expect(h.view.snapshot().target).toBeUndefined(); + for (let i = 0; i < 63; i++) h.session.thread("a", root.id); + const last = h.session.messageDetail("a", root.id); + expect(() => h.session.observe([{ kinds: [9], limit: 1 }])).toThrow( + "capacity", + ); + last.dispose(); + const fresh = h.session.messageDetail("a", root.id); + const load = fresh.refresh(); + h.next().respond([root]); + await flush(); + h.next().respond([]); + await load; + await h.clearCache(); + expect(fresh.snapshot().target).toBeUndefined(); +}); + +it("retains already observed tombstones when a later finite detail read omits them", async () => { + const h = setup(); + h.view.dispose(); + h.traffic.receive([root, aux(5, root)]); + const view = h.session.messageDetail("a", root.id); + const reading = view.refresh(); + h.next().respond([root]); + await flush(); + h.next().respond([]); + await flush(); + h.next().respond([]); + await reading; + expect(view.snapshot()).toMatchObject({ + status: "unavailable", + target: undefined, + }); +}); + +it("repairs through channel establishment without channel-head invalidation cancelling exact reads", async () => { + const h = setup(); + h.traffic.receive([roster(relay, "a", [viewer.pubkey])]); + const first = h.view.refresh(); + h.next().respond([root]); + await flush(); + h.next().respond([]); + await first; + h.traffic.established("a"); + const target = h.next(); + expect(target.filters[0]?.ids).toEqual([root.id]); + await flush(); + expect(target.signal?.aborted).toBe(false); + target.respond([root]); + await flush(); + h.next().respond([]); + await flush(); + expect(h.view.snapshot().status).toBe("ready"); +}); diff --git a/src/features/relay/message-detail.ts b/src/features/relay/message-detail.ts new file mode 100644 index 00000000..7c695a93 --- /dev/null +++ b/src/features/relay/message-detail.ts @@ -0,0 +1,254 @@ +import type { ChannelMessage } from "./contracts"; +import type { EventData, RelayEvent } from "./events"; +import type { LocalEvents } from "./outbox"; +import type { RelayReader } from "./reader"; +import { byteSize } from "./budget"; +import { foldMessages } from "./fold"; + +const AUX = [5, 7, 9005, 40003, 39005]; +export const DETAIL_READ_LIMIT = 500; +const MAX_BYTES = 4 * 1024 * 1024; +export class DetailLimitError extends Error { + constructor() { + super( + "Message detail exceeded its evidence limit. Open the channel or retry.", + ); + } +} +export type MessageDetailSnapshot = Readonly<{ + status: "idle" | "loading" | "ready" | "unavailable" | "error"; + target: ChannelMessage | undefined; + error: string | undefined; + limited: boolean; +}>; +export type MessageDetailView = { + snapshot(): MessageDetailSnapshot; + subscribe(listener: () => void): () => void; + refresh(): Promise; + dispose(): void; +}; + +/** An isolated exact row, not a channel window or a claim of contiguous history. */ +export function createMessageDetail({ + channelId, + messageId, + relayAuthor, + reader, + local, + canAccess, + visible, + notify, +}: { + channelId: string; + messageId: string; + relayAuthor: string; + reader: RelayReader; + local: LocalEvents | undefined; + canAccess(): boolean; + visible(events: readonly RelayEvent[]): readonly RelayEvent[]; + notify(listener: () => void): void; +}) { + let disposed = false; + let controller: AbortController | undefined; + let again = false; + let remote: readonly RelayEvent[] = []; + let readable = false; + let snapshot: MessageDetailSnapshot = Object.freeze({ + status: "idle", + target: undefined, + error: undefined, + limited: false, + }); + const listeners = new Set<() => void>(); + const content = (event: EventData) => + [9, 40002].includes(event.kind) && + event.tags.some(([name, value]) => name === "h" && value === channelId); + const union = (...batches: readonly (readonly RelayEvent[])[]) => [ + ...new Map(batches.flat().map((event) => [event.id, event])).values(), + ]; + function related(events: readonly T[]) { + const ids = new Set([messageId, ...remote.map((event) => event.id)]); + const result = new Map( + events + .filter((event) => content(event) && event.id === messageId) + .map((event) => [event.id, event]), + ); + for (let hop = 0; hop < 2; hop++) { + for (const event of events) { + if ( + AUX.includes(event.kind) && + event.tags.some( + ([name, value]) => name === "e" && ids.has(value ?? ""), + ) + ) { + result.set(event.id, event); + ids.add(event.id); + } + } + } + return [...result.values()]; + } + function publish(patch: Partial = {}) { + if (disposed) return; + const operations = canAccess() ? (local?.snapshot() ?? []) : []; + const inputs = new Map( + remote.map((event) => [event.id, event as EventData]), + ); + for (const event of related( + operations + .filter((item) => item.delivery !== "failed") + .map((item) => item.event), + )) + inputs.set(event.id, event); + const rows = + readable && canAccess() + ? foldMessages(channelId, relayAuthor, [...inputs.values()], { + includeReplies: true, + }) + : []; + const target = rows.find((row) => row.id === messageId); + const next = { ...snapshot, ...patch }; + // A live author deletion must withdraw a previously successful target too. + if (next.status === "ready" && !target) next.status = "unavailable"; + snapshot = Object.freeze({ + ...next, + target, + }); + for (const listener of listeners) notify(listener); + } + function retain(events: readonly RelayEvent[]) { + if (events.length >= DETAIL_READ_LIMIT || byteSize(events) > MAX_BYTES) + throw new DetailLimitError(); + remote = events; + } + function fail(error: unknown) { + readable = false; + if (error instanceof DetailLimitError) remote = []; + publish({ + status: "error", + error: String(error), + limited: error instanceof DetailLimitError, + }); + } + function receive(events: readonly RelayEvent[]) { + if (disposed || !canAccess()) return; + try { + const incoming = related(events); + if (!incoming.length) return; + retain(union(remote, incoming)); + publish(); + } catch (error) { + controller?.abort(); + controller = undefined; + again = false; + fail(error); + } + } + function purge(clear = false) { + controller?.abort(); + controller = undefined; + again = false; + readable = false; + if (clear || !canAccess()) { + remote = []; + } else remote = visible(remote); + publish({ + status: "error", + limited: false, + error: canAccess() + ? "Message read interrupted. Retry to continue." + : "This channel is no longer available.", + }); + } + async function refresh() { + if (disposed) return; + if (controller) { + again = true; + return; + } + if (!canAccess()) { + purge(); + return; + } + const owned = new AbortController(); + controller = owned; + const active = () => + !disposed && !owned.signal.aborted && controller === owned && canAccess(); + readable = false; + publish({ status: "loading", error: undefined, limited: false }); + try { + const selected = await reader.read( + [{ ids: [messageId], "#h": [channelId], limit: 1 }], + { signal: owned.signal }, + ); + if (!active()) return; + const target = selected.find( + (event) => event.id === messageId && content(event), + ); + if (!target) { + publish({ status: "unavailable" }); + return; + } + retain(union(remote, [target])); + // Generic ID reads have no include_aux expansion. Query supported NIP-01 + // references explicitly, without #h: legacy edits/deletes may be reference-only. + const direct = await reader.read( + [ + { + kinds: AUX, + "#e": [messageId], + limit: DETAIL_READ_LIMIT, + }, + ], + { signal: owned.signal }, + ); + if (!active()) return; + retain(union(remote, related(direct))); + const auxiliaries = remote + .filter((event) => AUX.includes(event.kind)) + .map((event) => event.id); + if (auxiliaries.length) { + const tombstones = await reader.read( + [{ kinds: [5, 9005], "#e": auxiliaries, limit: DETAIL_READ_LIMIT }], + { signal: owned.signal }, + ); + if (!active()) return; + retain(union(remote, related(tombstones))); + } + readable = true; + publish({ status: "ready", error: undefined }); + } catch (error) { + if (active()) fail(error); + } finally { + if (controller === owned) { + controller = undefined; + if (again && !disposed) { + again = false; + void refresh(); + } + } + } + } + return { + channelId, + event: (id: string) => remote.find((event) => event.id === id), + receive, + purge, + changed: () => publish(), + view: { + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + refresh, + dispose() { + listeners.clear(); + purge(true); + disposed = true; + }, + } satisfies MessageDetailView, + }; +} diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index abbac089..62f3c6b2 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -16,6 +16,7 @@ import { type ReadStateStorage, } from "./read-state-storage"; import { createUnread } from "./unread"; +import type { IncomingListener, IncomingMessage } from "./incoming"; import { readSidebarPreferences } from "./sidebar-preferences"; import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; import { createEmojiDirectory } from "./emoji-directory"; @@ -39,6 +40,11 @@ import { } from "./outbox"; import { createMessages } from "./messages"; import { createThreadView } from "./threads"; +import { + createMessageDetail, + DETAIL_READ_LIMIT, + DetailLimitError, +} from "./message-detail"; import { ByteLru } from "./budget"; import { createRelayProfiler } from "./profiling"; import { @@ -85,11 +91,13 @@ export function createRelaySession( 8 * 1024 * 1024, ); const observations = new Set<(events: readonly RelayEvent[]) => void>(); + const incomingListeners = new Set(); const timers = new Set>(); const pendingConfirmation = new Set(); const refreshers = new Set<() => Promise>(); const views = new Map<() => void, (clear?: boolean) => void>(); const threads = new Set>(); + const details = new Set>(); const writer = transport?.writer; const writes = transport && writer @@ -119,7 +127,7 @@ export function createRelaySession( : undefined; const rawLocal = () => writes?.local.snapshot() ?? []; function retainedThreadEvent(id: string) { - for (const thread of threads) { + for (const thread of [...threads, ...details]) { if (!canAccess(thread.channelId)) continue; const event = thread.event(id); if (event) return event; @@ -567,6 +575,14 @@ export function createRelaySession( notify, ); const session = Object.freeze({ + /** Verified new live-route messages, after reconciliation. Never history or local intent. */ + subscribeIncoming(listener: IncomingListener) { + if (closed) return () => {}; + incomingListeners.add(listener); + return () => { + incomingListeners.delete(listener); + }; + }, unread: unread.capability, sidebarPreferences: sidebarPreferences.queries, live, @@ -580,6 +596,61 @@ export function createRelaySession( emoji.tags, validateMentions, ), + /** Exact-target evidence stays separate from channel head/history ingestion. */ + messageDetail(channelId: string, messageId: string) { + if (closed || views.size >= 64) + throw new Error("Relay view capacity unavailable"); + if (!/^[0-9a-f]{64}$/.test(messageId)) + throw new Error("Message detail needs a valid message ID"); + const detail = createMessageDetail({ + channelId, + messageId, + relayAuthor: transport?.relayAuthor ?? "", + reader: { + async read(filters, settings) { + const epoch = accessEpoch; + let events: readonly RelayEvent[]; + try { + events = await requests.reader.read(filters, settings); + } catch (error) { + // This owned reader knows the channel even for reference-only aux. + if ( + !closed && + epoch === accessEpoch && + readErrorKind(error) === "denied" + ) + channels.denyChannel(channelId, error); + throw error; + } + if (closed || epoch !== accessEpoch) + throw new DOMException("Stale message detail", "AbortError"); + settings?.signal?.throwIfAborted(); + // Count before visibility filtering: a full raw response may have + // omitted an author tombstone. Never call that a complete fold. + if (events.length >= DETAIL_READ_LIMIT) + throw new DetailLimitError(); + return accept(events, false); + }, + }, + local: localViews, + canAccess: () => !closed && canAccess(channelId), + visible: (events) => events.filter(visibility(events)), + notify, + }); + details.add(detail); + detail.receive(recent.entries().map(([, item]) => item.event)); + observations.add(detail.receive); + const unsubscribe = localViews?.subscribe(detail.changed); + const dispose = () => { + detail.view.dispose(); + unsubscribe?.(); + observations.delete(detail.receive); + details.delete(detail); + views.delete(dispose); + }; + views.set(dispose, detail.purge); + return { ...detail.view, dispose }; + }, /** An owned bounded thread reader. Dispose on close; the session retains access/lifetime authority. */ thread(channelId: string, messageId: string) { if (closed || views.size >= 64) @@ -866,8 +937,28 @@ export function createRelaySession( } } traffic = transport?.subscribe?.({ - receive(events) { + receive(events, provenance) { if (closed) return; + const candidates = new Set( + provenance?.phase === "live" && provenance.channelId + ? events + .filter((event) => { + const destinations = event.tags.filter( + ([name]) => name === "h", + ); + return ( + event.kind === 9 && + event.pubkey !== transport.viewer && + destinations.length === 1 && + destinations[0]?.[1] === provenance.channelId && + !recent.peek(event.id) && + !unread.event(event.id) && + !rawLocal().some((item) => item.event.id === event.id) + ); + }) + .map((event) => event.id) + : [], + ); // Signed membership notifications are hints, not roster authority. Schedule // before visibility filtering, because a newly granted channel may be denied locally. if ( @@ -881,7 +972,30 @@ export function createRelaySession( ) ) refreshRoster(); - accept(events); + const visible = accept(events); + if (closed || !candidates.size || !provenance?.channelId) return; + const epoch = accessEpoch; + const delivered = new Set(); + const incoming: readonly IncomingMessage[] = Object.freeze( + visible.flatMap((event) => { + if (!candidates.has(event.id) || delivered.has(event.id)) return []; + delivered.add(event.id); + return [ + Object.freeze({ + channelId: provenance.channelId as string, + messageId: event.id, + createdAt: event.created_at, + authorId: event.pubkey, + previewContent: event.content.slice(0, 4096), + }), + ]; + }), + ); + if (!incoming.length) return; + for (const listener of incomingListeners) { + if (closed || epoch !== accessEpoch) return; + listener(incoming); + } }, state(snapshot) { if (closed) return; @@ -920,7 +1034,7 @@ export function createRelaySession( return; } if (!channels.canAccess(channelId)) return; - for (const thread of threads) + for (const thread of [...threads, ...details]) if (thread.channelId === channelId) void thread.view.refresh(); const job = { generation: liveGeneration, @@ -969,6 +1083,7 @@ export function createRelaySession( stopInterests(); traffic?.dispose(); liveListeners.clear(); + incomingListeners.clear(); observations.clear(); for (const timer of timers) clearTimeout(timer); for (const dispose of [...views.keys()]) dispose(); diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts index f05304be..826aa5ea 100644 --- a/src/features/relay/unread.test.ts +++ b/src/features/relay/unread.test.ts @@ -671,3 +671,106 @@ it.each([5, 9005])( expect(seen).toEqual([[0, 0]]); }, ); + +it("projects event attention through the same mention, DM, participation and frontier policy", async () => { + const h = setup(); + h.grant("room"); + const own = message(h.viewer, "room", "root", 11); + const reply = message(h.alice, "room", "reply", 12, [ + ["e", own.id, "", "reply"], + ]); + const mention = message(h.alice, "room", "mention", 13, [ + ["p", h.viewer.pubkey], + ]); + const ordinary = message(h.alice, "room", "ordinary", 14); + h.emit([own, reply, mention, ordinary]); + const attention = (id: string) => h.session.unread.attention("room", id); + expect(attention(own.id)).toMatchObject({ + status: "ineligible", + unread: false, + }); + expect(attention(reply.id)).toMatchObject({ + status: "eligible", + category: "thread", + rootId: own.id, + unread: true, + }); + expect(attention(mention.id)).toMatchObject({ + status: "eligible", + category: "mention", + unread: true, + }); + expect(attention(ordinary.id)).toMatchObject({ + status: "ineligible", + unread: true, + }); + expect(h.snapshot().attentionCount).toBe(2); + const lease = h.session.unread.reading("room"); + await lease.observe([reply.id]); + expect(attention(reply.id).unread).toBe(false); + expect(h.snapshot().attentionCount).toBe(1); + h.emit([ + signed(h.relay, { + kind: 39000, + created_at: 20, + content: "", + tags: [ + ["d", "room"], + ["name", "DM"], + ["t", "dm"], + ], + }), + ]); + expect(attention(ordinary.id)).toMatchObject({ + status: "eligible", + category: "direct", + }); + expect(attention(mention.id).category).toBe("mention"); + expect(h.snapshot().attentionCount).toBe(2); + expect(attention("f".repeat(64)).status).toBe("unknown"); + lease.dispose(); +}); +it("qualified viewing is lease-scoped and never writes a read marker", async () => { + const h = setup(); + h.grant("room"); + const row = message(h.alice, "room", "mention", 11, [["p", h.viewer.pubkey]]); + h.emit([row]); + let focused = true; + const lease = h.session.unread.reading("room"); + const attention = () => h.session.unread.attention("room", row.id); + lease.view([row.id, "f".repeat(64)], () => focused); + expect(attention().viewing).toBe(true); + await flush(); + expect(h.host.sign).not.toHaveBeenCalled(); + expect(attention().unread).toBe(true); + focused = false; + expect(attention().viewing).toBe(false); + focused = true; + lease.dispose(); + expect(attention().viewing).toBe(false); +}); +it("attention fails closed after deletion or access loss, and viewing cannot survive regrant", () => { + const h = setup(); + h.grant("room"); + const row = message(h.alice, "room", "mention", 11, [["p", h.viewer.pubkey]]); + h.emit([row]); + const lease = h.session.unread.reading("room"); + lease.view([row.id], () => true); + h.emit([ + signed(h.alice, { + kind: 5, + created_at: 12, + content: "", + tags: [["e", row.id]], + }), + ]); + expect(h.session.unread.attention("room", row.id)).toMatchObject({ + status: "ineligible", + viewing: false, + }); + h.emit([roster(h.relay, "room", [], 20)]); + expect(h.session.unread.attention("room", row.id).status).toBe("unknown"); + h.grant("room", 30); + h.emit([row]); + expect(h.session.unread.attention("room", row.id).viewing).toBe(false); +}); diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts index b9675c5a..9378a47d 100644 --- a/src/features/relay/unread.ts +++ b/src/features/relay/unread.ts @@ -5,6 +5,7 @@ import { overrideActive, targetKey, type ReadTarget, + type ReadState, } from "./read-state-model"; import type { createReadState, @@ -24,13 +25,24 @@ export type UnreadSnapshot = Readonly<{ manual: "none" | "local-only" | "remote"; error?: string | undefined; }>; +export type MessageAttention = Readonly<{ + status: "unknown" | "ineligible" | "eligible"; + category?: "mention" | "direct" | "thread"; + rootId?: string; + unread: boolean; + viewing: boolean; +}>; export type ReadingHandle = Readonly<{ + /** Qualified visible rows only. This publishes no read intent and ends with the lease. */ + view(messageIds: readonly string[], visible: () => boolean): void; /** Only message IDs actually visible to the active consumer; no caller timestamps. */ observe(messageIds: readonly string[]): Promise; dispose(): void; }>; export interface UnreadCapability { snapshot(target: ReadTarget): UnreadSnapshot; + /** Same verified attention/frontier policy as badges, not a notification event source. */ + attention(channelId: string, messageId: string): MessageAttention; subscribe(target: ReadTarget, listener: () => void): () => void; sync(): ReadSyncSnapshot; subscribeSync(listener: () => void): () => void; @@ -79,6 +91,10 @@ export function createUnread({ const snapshots = new Map(); const dirty = new Set(); const handles = new Set<() => void>(); + const views = new Map< + () => void, + { ids: ReadonlySet; visible: () => boolean } + >(); let bytes = 0; const allowed = (id: string) => channels @@ -154,6 +170,79 @@ export function createUnread({ root(event) === target.rootId)) ); } + function isUnread({ event, rootId }: Evidence, state: ReadState) { + const channelId = channelOf(event); + if (!channelId || event.pubkey === viewer) return false; + const frontier = effectiveFrontier( + state, + `msg:${event.id}`, + channelId, + rootId, + ); + const forced = + overrideActive(state.overrides[`msg:${event.id}`], frontier) || + overrideActive( + state.overrides[channelId], + effectiveFrontier(state, channelId), + ) || + (rootId !== undefined && + overrideActive( + state.overrides[`thread:${rootId}`], + effectiveFrontier(state, `thread:${rootId}`, channelId), + )); + return frontier === undefined || event.created_at > frontier || !!forced; + } + function category( + { rootId, mentioned }: Evidence, + dm: boolean, + ): MessageAttention["category"] { + return mentioned + ? "mention" + : dm + ? "direct" + : rootId && participants.has(rootId) + ? "thread" + : undefined; + } + function attention(channelId: string, messageId: string): MessageAttention { + const unknown = Object.freeze({ + status: "unknown", + unread: false, + viewing: false, + } as const); + if (closed || !allowed(channelId)) return unknown; + indexEvidence(); + const event = events.get(messageId); + if (!event || channelOf(event) !== channelId || !contentKind(event)) + return unknown; + const entry = byChannel + .get(channelId) + ?.find((row) => row.event.id === messageId); + if (!entry || event.pubkey === viewer) + return Object.freeze({ + status: "ineligible", + unread: false, + viewing: false, + }); + const dm = + channels.list().channels.find((channel) => channel.id === channelId) + ?.channelType === "dm"; + const kind = category(entry, dm); + const viewing = [...views.values()].some( + (view) => view.ids.has(messageId) && view.visible(), + ); + return Object.freeze({ + status: kind + ? "eligible" + : threadReference(event) && !entry.rootId + ? "unknown" + : "ineligible", + ...(kind ? { category: kind } : {}), + ...(entry.rootId ? { rootId: entry.rootId } : {}), + unread: isUnread(entry, reads.state()), + viewing, + }); + } function compute(target: ReadTarget): UnreadSnapshot { const key = targetKey(target); const accessible = @@ -188,31 +277,10 @@ export function createUnread({ .channels.find((channel) => channel.id === target.channelId) ?.channelType === "dm"; indexEvidence(); - for (const { event, rootId, mentioned } of byChannel.get( - target.channelId, - ) ?? []) { - if (event.pubkey === viewer || !inTarget(event, target)) continue; - const frontier = effectiveFrontier( - state, - `msg:${event.id}`, - target.channelId, - rootId, - ); - const forced = - overrideActive(state.overrides[`msg:${event.id}`], frontier) || - overrideActive( - state.overrides[target.channelId], - effectiveFrontier(state, target.channelId), - ) || - (rootId !== undefined && - overrideActive( - state.overrides[`thread:${rootId}`], - effectiveFrontier(state, `thread:${rootId}`, target.channelId), - )); - if (frontier !== undefined && event.created_at <= frontier && !forced) - continue; + for (const entry of byChannel.get(target.channelId) ?? []) { + if (!inTarget(entry.event, target) || !isUnread(entry, state)) continue; count++; - if (dm || mentioned || (rootId && participants.has(rootId))) attention++; + if (category(entry, dm)) attention++; } const manual = reads.localUnread(key) ? "local-only" @@ -465,6 +533,7 @@ export function createUnread({ } const capability: UnreadCapability = Object.freeze({ snapshot, + attention, subscribe(target, listener) { snapshot(target); const key = keyFor(target), @@ -498,6 +567,7 @@ export function createUnread({ const dispose = () => { active = false; handles.delete(dispose); + views.delete(dispose); }; const valid = () => active && @@ -508,6 +578,21 @@ export function createUnread({ handles.add(dispose); return Object.freeze({ dispose, + view(ids: readonly string[], visible: () => boolean) { + if (!valid() || ids.length > 128) return; + const verified = ids.filter((id) => { + try { + requireMessage({ kind: "message", channelId, messageId: id }, id); + return true; + } catch { + return false; + } + }); + views.set(dispose, { + ids: new Set(verified), + visible: () => valid() && visible(), + }); + }, async observe(ids: readonly string[]) { if (!valid() || ids.length > 128) return; for (const id of ids) { diff --git a/src/plugins/author.ts b/src/plugins/author.ts index 76e8b2b8..c9613bfb 100644 --- a/src/plugins/author.ts +++ b/src/plugins/author.ts @@ -52,3 +52,9 @@ export type { JsonValue, } from "../features/navigation/targets"; export type { PageNavigation } from "../features/navigation/service"; + +export type { + Notifications, + NotificationInput, + NotificationCategoryDescriptor, +} from "../features/notifications/service"; diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 309ae0dc..d0e0a35a 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -27,6 +27,7 @@ export const test = base.extend({ productionBroker: [false, { option: true }], readState: [false, { option: true }], threadUnread: [false, { option: true }], + exactMessages: [false, { option: true }], sidebarUnread: [false, { option: true }], savedSidebar: [false, { option: true }], expectedPageFailure: [false, { option: true }], @@ -45,6 +46,7 @@ export const test = base.extend({ productionBroker, readState, threadUnread, + exactMessages, sidebarUnread, savedSidebar, expectedPageFailure, @@ -61,7 +63,8 @@ export const test = base.extend({ const relayKey = generateSecretKey(); const userKey = generateSecretKey(); const viewer = getPublicKey(userKey); - const peerKey = dmLabels || readState ? generateSecretKey() : undefined; + const peerKey = + dmLabels || readState || exactMessages ? generateSecretKey() : undefined; const communityIds = { primary: "01234567-89ab-cdef-0123-456789abcdef", secondary: "11234567-89ab-cdef-0123-456789abcdef", @@ -147,6 +150,48 @@ export const test = base.extend({ ); for (const community of ["primary", "secondary"]) for (const id of dmIds) histories.set(`${community}/${id}`, []); + const detailEvents = []; + let exact; + if (exactMessages) { + const root = histories.get("primary/alpha")[2]; + const replies = Array.from({ length: 80 }, (_, i) => + sign( + 9, + [ + ["h", "alpha"], + ["e", root.id, "", "reply"], + ["p", getPublicKey(peerKey)], + ], + `Old thread reply ${i} · Hello @Alice Fixture`, + userKey, + root.created_at + i + 1, + ), + ); + const target = replies.at(-1); + const edit = sign( + 40003, + [["e", target.id]], + "**Exact reply edited** · Hello @Alice Fixture", + userKey, + target.created_at + 1, + ); + const reaction = sign( + 7, + [["e", target.id]], + "+", + userKey, + target.created_at + 2, + ); + const deletion = sign( + 5, + [["e", reaction.id]], + "", + userKey, + target.created_at + 3, + ); + detailEvents.push(...replies, edit, reaction, deletion); + exact = { root, target, replies, edit, reaction, deletion }; + } if (sidebarUnread) { for (const id of ["dm-030", "dm-090"]) histories.set(`primary/${id}`, [ @@ -346,10 +391,36 @@ export const test = base.extend({ ] : []), ]; - if (threadUnread && filter.ids) - return [...histories.values()] - .flat() - .filter((event) => filter.ids.includes(event.id)); + if (filter.ids) + return [...histories.entries()] + .filter(([key]) => key.startsWith(`${community}/`)) + .flatMap(([, events]) => events) + .concat(community === "primary" ? detailEvents : []) + .filter( + (event) => + filter.ids.includes(event.id) && + (!filter["#h"] || + event.tags.some( + ([key, value]) => key === "h" && filter["#h"].includes(value), + )), + ) + .slice(0, filter.limit); + if ( + filter["#e"] && + filter.kinds?.every((kind) => [5, 7, 9005, 39005, 40003].includes(kind)) + ) + return (community === "primary" ? detailEvents : []) + .filter( + (event) => + filter.kinds.includes(event.kind) && + event.tags.some( + ([key, value]) => key === "e" && filter["#e"].includes(value), + ), + ) + .toSorted( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + ) + .slice(0, filter.limit); if (threadUnread && filter.depth_limit) return (threadReplies.get(filter["#e"]?.[0]) ?? []) .filter( @@ -632,6 +703,7 @@ export const test = base.extend({ report, pending, histories, + exact, participants, viewer, relay, diff --git a/tests/browser/message-detail.spec.mjs b/tests/browser/message-detail.spec.mjs new file mode 100644 index 00000000..b280fa28 --- /dev/null +++ b/tests/browser/message-detail.spec.mjs @@ -0,0 +1,300 @@ +import { test, expect } from "./fixture.mjs"; +import { open } from "./timeline.mjs"; + +test.use({ pluginFixtures: true, exactMessages: true }); +const detail = (page) => + page.getByRole("region", { name: "Message detail", exact: true }); +const target = (app, id = app.exact.target.id) => ({ + version: 1, + kind: "conversation", + channelId: "alpha", + messageId: id, + scope: { + viewer: app.viewer, + communityOrigin: "https://primary.example", + }, + threadRootId: "f".repeat(64), +}); +const status = (page) => + page.evaluate(() => window.fixtureNavigation.snapshot().status); +async function openTarget(page, value) { + return page.evaluate((value) => window.fixtureNavigation.open(value), value); +} + +test("old root and reply beyond the first thread page open exactly; reclick and Back reveal again", async ({ + page, + app, +}) => { + await open(page, app); + const initialHeadQueries = app.report.queries.filter( + (q) => q.filter.top_level, + ).length; + // This navigation fixture deliberately registers a catch-all panel first. + // Disable it before exercising the actual Profiles provider. + await page.getByRole("button", { name: "Your profile", exact: true }).click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Plugins", exact: true }).click(); + await page + .getByRole("switch", { name: "Enable Notes fixture", exact: true }) + .click(); + for (const [mode, id] of [ + ["cold root", app.exact.root.id], + ["cold reply", app.exact.target.id], + ["warm reply", app.exact.target.id], + ]) { + const start = performance.now(); + expect(await openTarget(page, target(app, id))).toEqual({ + status: "opened", + }); + app.report.measurements.push({ + mode, + clickToOpenedMs: performance.now() - start, + }); + const row = detail(page).locator(`[data-message-id="${id}"]`); + await expect(row).toBeVisible(); + await expect(row).toBeFocused(); + expect(await status(page)).toBe("opened"); + if (id === app.exact.target.id) { + await expect(row).toContainText("Exact reply edited"); + await expect( + row.locator("strong").filter({ hasText: "Exact reply edited" }), + ).toHaveText("Exact reply edited"); + await expect( + row.getByRole("button", { + name: "View Alice Fixture profile", + exact: true, + }), + ).toHaveCount(0); // Edited-body names cannot inherit original signed recipients. + await expect( + page.getByText("Message detail · Selected message only."), + ).toBeVisible(); + expect(await detail(page).locator("[data-message-id]").count()).toBe(1); + } + } + expect(app.report.queries.filter((q) => q.filter.top_level).length).toBe( + initialHeadQueries, + ); + expect(app.report.queries.filter((q) => q.filter.depth_limit)).toHaveLength( + 0, + ); + expect(app.report.queries.filter((q) => q.filter.until)).toHaveLength(0); + // Use an unedited reply to exercise exact mention/profile identity plumbing. + expect( + await openTarget(page, target(app, app.exact.replies.at(-2).id)), + ).toEqual({ status: "opened" }); + const mention = detail(page).getByRole("button", { + name: "View Alice Fixture profile", + exact: true, + }); + await mention.click(); + await expect( + page.getByRole("region", { name: "Profile details" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Close channel panel", exact: true }) + .click(); + await expect(mention).toBeFocused(); + expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); + await page.getByRole("button", { name: "Open channel", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + await expect( + page.locator(`[data-message-id="${app.exact.target.id}"]`), + ).toHaveCount(0); + await page.getByRole("button", { name: "Go back", exact: true }).click(); + await expect( + detail(page).locator(`[data-message-id="${app.exact.target.id}"]`), + ).toBeFocused(); + await expect.poll(() => status(page)).toBe("opened"); +}); + +test("unknown target fails without channel-head success and retries in the same visit", async ({ + page, + app, +}) => { + await open(page, app); + expect(await openTarget(page, target(app, "e".repeat(64)))).toEqual({ + status: "failed", + reason: "not-found", + }); + await expect( + page.getByRole("heading", { name: "This destination couldn’t open" }), + ).toBeVisible(); + const visit = await page.evaluate( + () => window.fixtureNavigation.snapshot().entry.id, + ); + await page + .getByRole("button", { name: "Retry navigation", exact: true }) + .click(); + await expect.poll(() => status(page)).toBe("failed"); + expect( + await page.evaluate(() => window.fixtureNavigation.snapshot().entry.id), + ).toBe(visit); + await expect(page.locator("[data-message-id]")).toHaveCount(0); +}); + +test("superseding a held exact read cancels it; a late response cannot steal focus or complete the next visit", async ({ + page, + app, +}) => { + await open(page, app); + let release; + let intercepted; + const seen = new Promise((resolve) => { + intercepted = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + await page.route("**/api/relay/**/query", async (route) => { + const filters = route.request().postDataJSON(); + if (!filters?.some((filter) => filter.ids?.includes(app.exact.target.id))) + return route.continue(); + intercepted(); + await held; + await route.fulfill({ json: [app.exact.target] }).catch(() => {}); + }); + await page.evaluate((value) => { + window.exactResult = window.fixtureNavigation.open(value); + }, target(app)); + await seen; + expect(await status(page)).toBe("opening"); + await page.getByRole("button", { name: "Beta", exact: true }).click(); + expect(await page.evaluate(() => window.exactResult)).toEqual({ + status: "superseded", + }); + const composer = page.getByRole("textbox", { + name: "Message #Beta", + exact: true, + }); + await expect(composer).toBeVisible(); + await composer.focus(); + release(); + await expect(composer).toBeFocused(); + await expect(detail(page)).toHaveCount(0); + await expect.poll(() => status(page)).toBe("opened"); +}); + +test("same-scope replacement withdraws a held old session and reopens the exact row", async ({ + page, + app, +}) => { + await open(page, app); + let release; + let intercepted; + const seen = new Promise((resolve) => { + intercepted = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + let first = true; + await page.route("**/api/relay/**/query", async (route) => { + const filters = route.request().postDataJSON(); + if ( + !first || + !filters?.some((filter) => filter.ids?.includes(app.exact.target.id)) + ) + return route.continue(); + first = false; + intercepted(); + await held; + await route.fulfill({ json: [app.exact.target] }).catch(() => {}); + }); + await page.evaluate((value) => { + window.exactResult = window.fixtureNavigation.open(value); + }, target(app)); + await seen; + const generation = await page.evaluate(() => { + const generation = window.fixtureRelay.snapshot().generation; + window.fixtureRelay.disconnect(); + return generation; + }); + await expect(detail(page)).toHaveCount(0); + await page.evaluate(() => window.fixtureRelay.retry()); + release(); + await expect( + detail(page).locator(`[data-message-id="${app.exact.target.id}"]`), + ).toBeFocused(); + expect( + await page.evaluate(() => window.fixtureRelay.snapshot().generation), + ).toBeGreaterThan(generation); + expect(await page.evaluate(() => window.exactResult)).toEqual({ + status: "opened", + }); +}); + +test("post-success membership loss removes detail and live updates do not snap it back to the target", async ({ + page, + app, +}) => { + await open(page, app); + expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); + const region = detail(page); + const channelButton = page.getByRole("button", { + name: "Open channel", + exact: true, + }); + await channelButton.focus(); + const before = await region.evaluate((element) => element.scrollTop); + app.edit( + "primary", + "alpha", + { ...app.exact.target, created_at: app.exact.target.created_at + 10 }, + "Live edited exact reply", + ); + await expect(region).toContainText("Live edited exact reply"); + await expect(channelButton).toBeFocused(); + expect(await region.evaluate((element) => element.scrollTop)).toBe(before); + app.omitChannel("alpha"); + await page.getByLabel("Conversation options", { exact: true }).click(); + await page.getByText("Diagnostics", { exact: true }).click(); + await page + .getByRole("button", { name: "Refresh channels", exact: true }) + .click(); + await expect(region).toHaveCount(0); + await expect( + page.locator(`[data-message-id="${app.exact.target.id}"]`), + ).toHaveCount(0); +}); + +const readingTest = test.extend({ tallMessages: true }); +readingTest( + "exact detail leaves the ordinary channel reading anchor unchanged", + async ({ page, app }) => { + const { settle, anchor, expectAnchor } = await import("./timeline.mjs"); + await open(page, app); + const history = page.getByRole("region", { + name: "Channel message history", + }); + await history.hover(); + await page.mouse.wheel(0, -650); + await settle(page); + const reading = await anchor(page); + expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); + await page + .getByRole("button", { name: "Open channel", exact: true }) + .click(); + await expect(history).toBeVisible(); + await settle(page); + await expectAnchor(page, reading); + }, +); + +const readTest = test.extend({ productionBroker: true, readState: true }); +readTest( + "exact reveal uses ordinary dwell rather than marking read at open", + async ({ page, app }) => { + await open(page, app); + await page.evaluate(() => + window.fixtureRelay.snapshot().session.unread.ensure(), + ); + const before = app.report.readPublications.length; + expect(await openTarget(page, target(app))).toEqual({ status: "opened" }); + expect(app.report.readPublications.length).toBe(before); + await expect + .poll(() => app.report.readPublications.length) + .toBeGreaterThan(before); + }, +); diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs new file mode 100644 index 00000000..4199cc84 --- /dev/null +++ b/tests/browser/notifications.spec.mjs @@ -0,0 +1,389 @@ +import { test, expect } from "./fixture.mjs"; +import { open, settle } from "./timeline.mjs"; +import { finalizeEvent, generateSecretKey } from "nostr-tools"; + +test.use({ + productionBroker: true, + readState: true, + threadUnread: true, + pluginFixtures: true, +}); + +// Only the browser's OS boundary is replaced. The built host, session, +// verified WS → broker → SSE and navigation/Channels consumers are production. +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + window.notificationEvents = []; + window.notificationRequests = 0; + window.Notification = class { + static permission = "granted"; + static async requestPermission() { + window.notificationRequests++; + window.Notification.permission = "granted"; + return window.Notification.permission; + } + constructor(title, options) { + this.title = title; + this.options = options; + this.closed = false; + window.notificationEvents.push(this); + } + close() { + this.closed = true; + this.onclose?.(); + } + }; + }); +}); +const systemCount = (page) => + page.evaluate(() => window.notificationEvents.length); +async function settings(page) { + expect( + await page.evaluate(() => + window.fixtureNavigation.open({ + version: 1, + kind: "settings", + section: "notifications", + }), + ), + ).toEqual({ status: "opened" }); + await expect( + page.getByRole("heading", { name: "Notifications", exact: true }), + ).toBeVisible(); + await page.getByRole("switch", { name: "Sound", exact: true }).uncheck(); +} +async function ready(page, app) { + await open(page, app); + await expect + .poll(() => + page.evaluate(() => { + const session = window.fixtureRelay.snapshot().session; + return ( + session.live + .snapshot() + .routes.some( + (r) => r.channelId === "beta" && r.status === "live", + ) && session.unread.sync().completeness === "snapshot" + ); + }), + ) + .toBe(true); + await settings(page); +} +function liveMessage( + app, + content, + { age = 0, replyTo, mentioned = true } = {}, +) { + const event = finalizeEvent( + { + kind: 9, + content, + created_at: Math.floor(Date.now() / 1000) - age, + tags: [ + ["h", "beta"], + ...(mentioned ? [["p", app.viewer]] : []), + ...(replyTo ? [["e", replyTo, "", "reply"]] : []), + ], + }, + generateSecretKey(), + ); + app.histories.get("primary/beta").push(event); + app.relay.publish("primary", event); + return event; +} + +async function observed(page, id) { + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("beta", id) + .status, + id, + ), + ) + .toBe("eligible"); + // Bounded delivery is after two rendering frames, with a 100ms background cap. + await page.waitForTimeout(150); +} + +test("real live traffic alerts once; replay/reload stay quiet and choices persist", async ({ + page, + app, +}) => { + await ready(page, app); + expect(await systemCount(page)).toBe(0); + const row = liveMessage(app, "Fresh mention"); + await expect.poll(() => systemCount(page)).toBe(1); + app.relay.publish("primary", row); + await observed(page, row.id); + expect(await systemCount(page)).toBe(1); + await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); + const muted = liveMessage(app, "Muted mention"); + await observed(page, muted.id); + expect(await systemCount(page)).toBe(1); + await page.reload(); + await settings(page); + await expect( + page.getByRole("switch", { name: "Mentions", exact: true }), + ).not.toBeChecked(); + expect(await systemCount(page)).toBe(0); + expect(await page.evaluate(() => window.notificationRequests)).toBe(0); + await expect( + page.getByRole("heading", { name: "Recent notifications" }), + ).toHaveCount(0); +}); + +test("explicit Allow releases the first fresh alert; master off preserves categories", async ({ + page, + app, +}) => { + await ready(page, app); + await page.evaluate(() => { + window.Notification.permission = "default"; + }); + await page + .getByRole("button", { name: "Check permission", exact: true }) + .click(); + const row = liveMessage(app, "Permission wait"); + await observed(page, row.id); + expect(await systemCount(page)).toBe(0); + expect(await page.evaluate(() => window.notificationRequests)).toBe(0); + await page + .getByRole("button", { name: "Allow notifications", exact: true }) + .click(); + await expect.poll(() => systemCount(page)).toBe(1); + await page.getByRole("switch", { name: "Mentions", exact: true }).uncheck(); + await page + .getByRole("switch", { name: "Desktop alerts", exact: true }) + .uncheck(); + await page + .getByRole("switch", { name: "Desktop alerts", exact: true }) + .check(); + await expect( + page.getByRole("switch", { name: "Mentions", exact: true }), + ).not.toBeChecked(); + const muted = liveMessage(app, "Disabled category"); + await observed(page, muted.id); + expect(await systemCount(page)).toBe(1); +}); + +test("a fully visible incoming row stays quiet without publishing read intent", async ({ + page, + app, +}) => { + app.histories.get("primary/beta").push( + finalizeEvent( + { + kind: 9, + content: "Following row", + created_at: Math.floor(Date.now() / 1000) + 20, + tags: [["h", "beta"]], + }, + generateSecretKey(), + ), + ); + await ready(page, app); + await page.evaluate( + (viewer) => + window.fixtureNavigation.open({ + version: 1, + kind: "conversation", + channelId: "beta", + scope: { viewer, communityOrigin: "https://primary.example" }, + }), + app.viewer, + ); + const history = page.getByRole("region", { + name: "Channel message history", + exact: true, + }); + await settle(page); + await history.focus(); + const row = liveMessage(app, "Visible mention"); + await expect(history.locator(`[data-message-id="${row.id}"]`)).toBeVisible(); + await observed(page, row.id); + expect(await systemCount(page)).toBe(0); + expect( + await page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("beta", id) + .unread, + row.id, + ), + ).toBe(true); + await settings(page); + await page + .getByRole("switch", { name: "Notify while viewing", exact: true }) + .check(); + await page.evaluate( + (viewer) => + window.fixtureNavigation.open({ + version: 1, + kind: "conversation", + channelId: "beta", + scope: { viewer, communityOrigin: "https://primary.example" }, + }), + app.viewer, + ); + await settle(page); + await history.focus(); + liveMessage(app, "Allowed visible mention"); + await expect.poll(() => systemCount(page)).toBe(1); +}); + +for (const kind of ["mention", "thread reply"]) { + test(`live ${kind} notification opens only its exact focused row, then ordinary dwell reads it`, async ({ + page, + app, + }) => { + // Model a real prior contribution in relay history, not a client-side + // participation/readiness override. The incoming reply itself has no p tag. + // Keep this prior contribution inside the existing 500-event unread + // evidence window; the default 640-row Alpha fixture would crowd it out. + app.histories.set( + "primary/alpha", + app.histories.get("primary/alpha").slice(-200), + ); + const root = + kind === "thread reply" + ? app.append("primary", "beta", "My prior thread", false) + : undefined; + await ready(page, app); + if (root) + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay + .snapshot() + .session.unread.attention("beta", id).status, + root.id, + ), + ) + .toBe("ineligible"); // Own root is verified evidence, never an alert. + const incoming = liveMessage(app, `Selected **${kind}**`, { + mentioned: !root, + replyTo: root?.id, + }); + await expect.poll(() => systemCount(page)).toBe(1); + expect( + await page.evaluate(() => window.notificationEvents[0].options.body), + ).toBe(`Selected ${kind}`); + expect(await page.evaluate(() => window.notificationEvents[0].title)).toBe( + `${incoming.pubkey.slice(0, 10)} ${root ? "replied" : "mentioned you"} in #Beta`, + ); + const before = app.report.readPublications.length; + const start = performance.now(); + await page.evaluate(() => window.notificationEvents[0].onclick()); + expect(app.report.readPublications.length).toBe(before); + const detail = page.getByRole("region", { + name: "Message detail", + exact: true, + }); + const row = detail.locator(`[data-message-id="${incoming.id}"]`); + await expect(row).toBeFocused(); + await expect(row).toBeVisible(); + await expect(row.locator("strong").filter({ hasText: kind })).toHaveText( + kind, + ); + await expect + .poll(() => + page.evaluate(() => window.fixtureNavigation.snapshot().status), + ) + .toBe("opened"); + app.report.measurements.push({ + mode: `live ${kind} click to exact detail`, + clickToOpenedMs: performance.now() - start, + }); + expect(app.report.readPublications.length).toBe(before); + await expect(detail.locator("[data-message-id]")).toHaveCount(1); + await expect( + page.getByRole("textbox", { name: "Message #Beta", exact: true }), + ).toHaveCount(0); + expect(app.report.queries.filter((q) => q.filter.depth_limit)).toHaveLength( + 0, + ); + await expect + .poll(() => app.report.readPublications.length) + .toBeGreaterThan(before); + await expect + .poll(() => + page.evaluate( + (id) => + window.fixtureRelay.snapshot().session.unread.attention("beta", id) + .unread, + incoming.id, + ), + ) + .toBe(false); + }); +} + +test("an installed producer shares policy and OS click navigation, including after producer disable", async ({ + page, + app, +}) => { + await ready(page, app); + const input = { + sourceKey: "plugin-event", + target: { version: 1, kind: "settings", section: "appearance" }, + }; + expect( + await page.evaluate((input) => window.fixtureNotify(input), input), + ).toBe(true); + await expect.poll(() => systemCount(page)).toBe(1); + await page.evaluate(() => + window.fixtureNavigation.open({ + version: 1, + kind: "settings", + section: "plugins", + }), + ); + await page + .getByRole("switch", { name: "Enable Notification fixture", exact: true }) + .click(); + expect( + await page.evaluate((input) => window.fixtureNotify(input), { + ...input, + sourceKey: "stale", + }), + ).toBe(false); + await page.evaluate(() => window.notificationEvents[0].onclick()); + await expect( + page.getByRole("heading", { name: "Appearance", exact: true }), + ).toBeVisible(); + expect( + await page.evaluate(() => window.fixtureNavigation.snapshot().status), + ).toBe("opened"); +}); + +test("asynchronous browser display failure reaches Settings once without redelivery", async ({ + page, + app, +}) => { + await ready(page, app); + liveMessage(app, "Browser display error"); + await expect.poll(() => systemCount(page)).toBe(1); + await page.evaluate(() => window.notificationEvents[0].onerror?.()); + await expect(page.getByRole("alert")).toHaveText( + "The browser could not display a notification.", + ); + expect( + await page.evaluate(() => { + const item = window.notificationEvents[0]; + return { + closed: item.closed, + click: item.onclick, + error: item.onerror, + close: item.onclose, + }; + }), + ).toEqual({ closed: true, click: null, error: null, close: null }); + await page + .getByRole("button", { name: "Check permission", exact: true }) + .click(); + await page.waitForTimeout(150); + expect(await systemCount(page)).toBe(1); +}); diff --git a/tests/browser/plugin-fixtures.tsx b/tests/browser/plugin-fixtures.tsx index 72afaf10..e8b3befe 100644 --- a/tests/browser/plugin-fixtures.tsx +++ b/tests/browser/plugin-fixtures.tsx @@ -8,11 +8,13 @@ import type { PageProps } from "../../src/features/pages/service"; import type { PageNavigation } from "../../src/features/navigation/service"; import type { RelayData } from "../../src/features/relay/service"; import type { PanelProps } from "../../src/features/panels/service"; +import type { NotificationInput } from "../../src/features/notifications/service"; declare global { interface Window { stalePanelClose?: () => void; fixtureNavigation?: Navigation; + fixtureNotify?: (input: NotificationInput) => Promise; fixturePageBroken?: boolean; delayFixture?: boolean; stopFixtureDependency?: () => Promise; @@ -80,6 +82,23 @@ function RetryPage() { ); } export const fixturePlugins: readonly BundledPlugin[] = [ + { + manifest: { + id: "fixture.notifications", + name: "Notification fixture", + apiVersion: 1, + }, + module: { + inject: ["notifications"], + apply(ctx) { + const producer = ctx.notifications.register({ + id: "updates", + label: "Fixture updates", + }); + window.fixtureNotify = producer.submit; + }, + }, + }, { manifest: { id: "fixture.dependency", From 7fcbe6c01a4211c53e46391988c3a701a21f25df Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Sat, 12 Sep 2026 11:18:09 -0600 Subject: [PATCH 03/17] Test notification settings keyboard tab order Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- tests/browser/settings.spec.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index f94396f5..34eec25e 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -126,6 +126,10 @@ test("avatar Settings access dismisses cleanly and exposes Profile and Plugins", sections.getByRole("button", { name: "Appearance", exact: true }), ).toBeFocused(); await tab(); + await expect( + sections.getByRole("button", { name: "Notifications", exact: true }), + ).toBeFocused(); + await tab(); await expect( page.getByRole("textbox", { name: "Display name", exact: true }), ).toBeFocused(); From 5bf72fbcd52ae2baa51477b2fe7f2dc4b288497c Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Sat, 12 Sep 2026 11:54:20 -0600 Subject: [PATCH 04/17] Stabilize observer freshness test clock Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- dev/agent-observer.test.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dev/agent-observer.test.mjs b/dev/agent-observer.test.mjs index b113e130..55436ecc 100644 --- a/dev/agent-observer.test.mjs +++ b/dev/agent-observer.test.mjs @@ -1,4 +1,4 @@ -import { test, expect } from "vitest"; +import { test, expect, vi } from "vitest"; import { finalizeEvent, generateSecretKey, @@ -49,7 +49,12 @@ test("purpose-bound host decoder preserves raw JSON and never returns keys", () plaintext: raw, }); }); -test("rejects signature, recipient, sender, direction, cardinality, freshness, content and captured-viewer violations", () => { +test("rejects signature, recipient, sender, direction, cardinality, freshness, content and captured-viewer violations", ({ + onTestFinished, +}) => { + // A future +301s fixture becomes valid at +300s if the wall clock ticks. + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now()); + onTestFinished(() => clock.mockRestore()); const tags = frame().tags; const invalid = [ { ...frame(), sig: "0".repeat(128) }, From 0e34478ae39621dd33cafcbc95d8648b5f2b6641 Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Sun, 13 Sep 2026 07:50:57 -0600 Subject: [PATCH 05/17] fix(notifications): admit supported messages after read-state readiness Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- docs/notifications.md | 14 +- src/features/notifications/messages.test.ts | 321 +++++++++++++++++--- src/features/notifications/messages.ts | 16 +- src/features/relay/session.ts | 9 +- 4 files changed, 314 insertions(+), 46 deletions(-) diff --git a/docs/notifications.md b/docs/notifications.md index 5ced8986..a37fdfa5 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -23,12 +23,18 @@ checks, not that an OS banner was displayed or read. ## Running-app behavior - Built-in mentions, DMs and participating-thread replies consume the selected - community's verified live traffic and existing unread/visibility facts. No new - socket, unread engine or background-community subscription is added. + community's verified live kind-9 and kind-40002 traffic and existing + unread/visibility facts. Structured kind-40002 bodies use the same decoded text + as message rows. No new socket, unread engine or background-community + subscription is added. - History, initial/reconnect replay and own messages stay quiet. Candidates older than two minutes (or over 30 seconds in the future) are ignored. Unknown read - readiness waits; off/access loss cancels pending candidates. Visibility is checked - after UI presentation, without publishing read intent. + readiness waits; off/access loss cancels pending candidates. The app-global binding + starts the shared bounded unread observation even without Channels mounted. + Remote-capable hosts wait for the initial marker merge (bounded observation or + complete snapshot); local-only hosts wait only for local storage. Failed or + cancelled observation does not release alerts. Visibility is checked after UI + presentation, without publishing read intent. - Permission is requested explicitly from Settings where a browser needs a user gesture. A fresh pending candidate is reconsidered after Allow; a newer off choice still wins. Observable API errors are reported, never auto-retried. diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts index 90ce4464..ae9f82bf 100644 --- a/src/features/notifications/messages.test.ts +++ b/src/features/notifications/messages.test.ts @@ -3,6 +3,7 @@ import { PluginRuntime } from "../../plugins/runtime"; import { afterEach, expect, it, vi } from "vitest"; import { createRelaySession } from "../relay/session"; import type { LiveCallbacks } from "../relay/live"; +import type { ReadFilter } from "../relay/events"; import type { Communities } from "../communities/service"; import { keypair, @@ -31,6 +32,14 @@ afterEach(async () => { async function setup( readBarrier: Promise = Promise.resolve(), readFrontier?: number, + remote?: { + observation: "bounded" | "snapshot"; + barrier: Promise; + decodeBarrier?: Promise; + frontier: number; + channelsMounted?: boolean; + deferRoster?: boolean; + }, ) { const viewer = keypair(), peer = keypair(), @@ -44,12 +53,59 @@ async function setup( ...newReadJournal(), state: { frontiers: { room: readFrontier }, overrides: {} }, }; - const query = vi.fn(async () => [] as ReturnType[]); + const markerQuery = vi.fn(async () => { + await remote?.barrier; + return [ + signed(viewer, { + kind: 30078, + tags: [ + ["d", `read-state:${"a".repeat(32)}`], + ["t", "read-state"], + ], + content: "encrypted remote marker", + }), + ]; + }); + const decode = vi.fn( + async ( + events: readonly ReturnType[], + signal: AbortSignal, + ) => { + await remote?.decodeBarrier; + signal.throwIfAborted(); + return events.map((event) => ({ + eventId: event.id, + blob: { + v: 1, + client_id: "other-device", + contexts: { room: remote?.frontier }, + }, + })); + }, + ); + const query = vi.fn(async (filters: readonly ReadFilter[]) => + remote && filters[0]?.kinds?.includes(30078) + ? markerQuery() + : ([] as ReturnType[]), + ); const owner = createRelaySession( { viewer: viewer.pubkey, relayAuthor: relay.pubkey, query, + ...(remote + ? { + readState: { + decode, + ...(remote.observation === "snapshot" + ? { communityId: "test-community" } + : {}), + }, + ...(remote.observation === "snapshot" + ? { readStateSnapshot: markerQuery } + : {}), + } + : {}), media: () => undefined, subscribe(value) { callbacks = value; @@ -127,6 +183,16 @@ async function setup( preferences, (target) => notificationAuthorized(communities, target), ); + const discover = () => + callbacks.receive([ + roster(relay, "room", [viewer.pubkey]), + metadata(relay, "room", "Room"), + ]); + // Channels starts the same shared observation once the roster is ready. + if (remote?.channelsMounted) { + discover(); + void owner.session.unread.ensure(); + } const stop = bindMessageNotifications(notifications, communities); cleanups.push(stop); await flush(); @@ -136,10 +202,7 @@ async function setup( phase?: "replay" | "live", channelId = "room", ) => callbacks.receive(events, phase ? { phase, channelId } : undefined); - emit([ - roster(relay, "room", [viewer.pubkey]), - metadata(relay, "room", "Room"), - ]); + if (!remote?.channelsMounted && !remote?.deferRoster) discover(); const make = (text: string, age = 0, author = peer) => message(author, "room", text, Math.floor(Date.now() / 1000) - age, [ ["p", viewer.pubkey], @@ -153,6 +216,10 @@ async function setup( show, permission, query, + markerQuery, + decode, + stop, + discover, peer, relay, viewer, @@ -163,30 +230,38 @@ async function setup( }, }; } -it("only production live traffic can create a message notification, never history/replay/local observation", async () => { - const h = await setup(); - const historic = h.make("finite"); - h.query.mockResolvedValueOnce([historic]); - await h.owner.session.read([{ ids: [historic.id], limit: 1 }]); - h.emit([historic], "live"); - h.emit([h.make("legacy")]); - h.emit([h.make("replay")], "replay"); - h.emit([h.make("wrong route")], "live", "elsewhere"); - h.emit( - [h.make("stale", 121), h.make("future", -31), h.make("own", 0, h.viewer)], - "live", - ); - await flush(); - expect(h.show).not.toHaveBeenCalled(); - const fresh = h.make("fresh"); - h.emit([fresh, fresh], "live"); - await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(1)); - h.click(); - expect(h.navigation.navigation.snapshot().entry.target).toMatchObject({ - messageId: fresh.id, - }); - expect(h.owner.session.unread.attention("room", fresh.id).unread).toBe(true); -}); +it.each([9, 40002])( + "only production live kind-%s traffic can notify, never history/replay/local observation", + async (kind) => { + const h = await setup(); + const original = h.make; + h.make = (text, age = 0, author = h.peer) => + signed(author, { ...original(text, age, author), kind }); + const historic = h.make("finite"); + h.query.mockResolvedValueOnce([historic]); + await h.owner.session.read([{ ids: [historic.id], limit: 1 }]); + h.emit([historic], "live"); + h.emit([h.make("legacy")]); + h.emit([h.make("replay")], "replay"); + h.emit([h.make("wrong route")], "live", "elsewhere"); + h.emit( + [h.make("stale", 121), h.make("future", -31), h.make("own", 0, h.viewer)], + "live", + ); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + const fresh = h.make("fresh"); + h.emit([fresh, fresh], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledTimes(1)); + h.click(); + expect(h.navigation.navigation.snapshot().entry.target).toMatchObject({ + messageId: fresh.id, + }); + expect(h.owner.session.unread.attention("room", fresh.id).unread).toBe( + true, + ); + }, +); it("live membership activity and observer telemetry never become message notifications", async () => { const h = await setup(); h.emit( @@ -402,9 +477,14 @@ it("live message wiring supplies the signed author and body, resolving names at expect(h.query).not.toHaveBeenCalled(); }); -it.each(["direct", "thread"] as const)( - "live %s messages carry the correct title and preview", - async (category) => { +it.each([ + [9, "direct"], + [9, "thread"], + [40002, "direct"], + [40002, "thread"], +] as const)( + "live kind-%s %s messages carry the correct title and preview", + async (kind, category) => { const h = await setup(); h.emit([profile(h.peer, { name: "Pinky" })]); const now = Math.floor(Date.now() / 1000); @@ -426,13 +506,18 @@ it.each(["direct", "thread"] as const)( }), ]); } else h.emit([root], "replay"); - const row = message( - h.peer, - "room", - "A **new** reply", - now, - category === "thread" ? [["e", root.id, "", "reply"]] : [], - ); + const row = signed(h.peer, { + kind, + content: + kind === 40002 + ? JSON.stringify({ content: "A **new** reply" }) + : "A **new** reply", + created_at: now, + tags: [ + ["h", "room"], + ...(category === "thread" ? [["e", root.id, "", "reply"]] : []), + ], + }); h.emit([row], "live"); await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); expect(h.show.mock.calls[0]?.[0]).toMatchObject({ @@ -444,3 +529,161 @@ it.each(["direct", "thread"] as const)( }); }, ); + +function deferred() { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +it.each([ + ["bounded", false], + ["bounded", true], + ["snapshot", false], + ["snapshot", true], +] as const)( + "waits for %s remote marker merge (Channels consumer=%s), then revalidates retained live candidates", + async (observation, channelsMounted) => { + vi.spyOn(Date, "now").mockReturnValue(Date.now()); + const marker = deferred(), + merge = deferred(); + const h = await setup(Promise.resolve(), undefined, { + observation, + channelsMounted, + barrier: marker.promise, + decodeBarrier: merge.promise, + frontier: Math.floor(Date.now() / 1000) - 1, + }); + try { + // Without Channels this must be initiated by the actual app-global binding. + await vi.waitFor(() => expect(h.markerQuery).toHaveBeenCalledOnce()); + expect(h.owner.session.unread.sync()).toMatchObject({ + status: "local", + completeness: "unknown", + }); + const read = h.make("already read on another device", 1), + unread = h.make("genuinely unread"); + h.emit([read, unread], "live"); + await flush(); + expect(h.owner.session.unread.attention("room", read.id).unread).toBe( + true, + ); + expect(h.show).not.toHaveBeenCalled(); + marker.release(); + await vi.waitFor(() => expect(h.decode).toHaveBeenCalledOnce()); + await flush(); + expect(h.show).not.toHaveBeenCalled(); // Response alone is not a merged frontier. + merge.release(); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + expect(h.owner.session.unread.sync()).toMatchObject({ + status: "reconciled", + completeness: observation, + }); + expect(h.owner.session.unread.attention("room", read.id).unread).toBe( + false, + ); + expect(h.show.mock.calls[0]?.[0]).toMatchObject({ + body: "genuinely unread", + }); + expect(h.markerQuery).toHaveBeenCalledOnce(); // Shared with Channels, not a second observation. + } finally { + marker.release(); + merge.release(); + } + }, +); + +it.each(["failed", "cancelled", "switched", "disposed"] as const)( + "remote marker observation keeps candidates quiet when %s", + async (condition) => { + const marker = deferred(); + const h = await setup(Promise.resolve(), undefined, { + observation: "bounded", + barrier: marker.promise, + frontier: 0, + }); + try { + await vi.waitFor(() => expect(h.markerQuery).toHaveBeenCalledOnce()); + h.emit([h.make("pending remote state")], "live"); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + if (condition === "failed" || condition === "cancelled") + h.decode.mockRejectedValueOnce( + condition === "failed" + ? new Error("decode unavailable") + : new DOMException("cancelled", "AbortError"), + ); + if (condition === "switched") h.deselect(); + if (condition === "disposed") h.stop(); + marker.release(); + await vi.waitFor(() => + expect(h.owner.session.unread.sync().status).toBe( + condition === "failed" || condition === "cancelled" + ? "error" + : "reconciled", + ), + ); + await h.notifications.requestPermission(); + await flush(); + expect(h.show).not.toHaveBeenCalled(); + expect(h.markerQuery).toHaveBeenCalledOnce(); + } finally { + marker.release(); + } + }, +); + +it.each([ + [ + JSON.stringify({ content: "**Decoded** preview", extra: "not displayed" }), + "Decoded preview", + ], + [ + JSON.stringify({ + extra: "x".repeat(5000), + content: "Text after envelope metadata", + }), + "Text after envelope metadata", + ], + [JSON.stringify({ content: "Long ".repeat(1000) }), null], + ["Plain text fallback", "Plain text fallback"], + [JSON.stringify({ content: "" }), "New message"], +])( + "kind-40002 mentions decode their envelope before bounding the preview (case %#)", + async (content, body) => { + const h = await setup(); + const event = signed(h.peer, { ...h.make(content), kind: 40002 }); + h.emit([event], "live"); + await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce()); + const shown = h.show.mock.calls[0]?.[0]; + if (body !== null) expect(shown.body).toBe(body); + else { + expect([...shown.body].length).toBeLessThanOrEqual(200); + expect(shown.body.startsWith("Long Long")).toBe(true); + } + }, +); + +it("notification startup waits for the roster without consuming the shared evidence repair early", async () => { + const h = await setup(Promise.resolve(), undefined, { + observation: "bounded", + barrier: Promise.resolve(), + frontier: 0, + deferRoster: true, + }); + expect(h.markerQuery).not.toHaveBeenCalled(); + expect(h.query).not.toHaveBeenCalled(); + h.discover(); + await h.owner.session.unread.ensure(); + expect(h.markerQuery).toHaveBeenCalledOnce(); + const evidence = () => + h.query.mock.calls.filter(([filters]) => filters[0]?.kinds?.includes(9)); + expect(evidence()).toHaveLength(1); + expect(evidence()[0]?.[0][0]).toMatchObject({ "#h": ["room"] }); + h.discover(); + await h.owner.session.unread.ensure(); + expect(h.markerQuery).toHaveBeenCalledOnce(); + expect(evidence()).toHaveLength(1); +}); diff --git a/src/features/notifications/messages.ts b/src/features/notifications/messages.ts index 497ae053..2b86743b 100644 --- a/src/features/notifications/messages.ts +++ b/src/features/notifications/messages.ts @@ -128,6 +128,8 @@ export function bindMessageNotifications( if ( sync.status === "loading" || sync.status === "error" || + (sync.capability !== "unsupported" && + sync.completeness === "unknown") || attention.status === "unknown" ) return "wait"; @@ -148,7 +150,19 @@ export function bindMessageNotifications( stopIncoming = owned.subscribeIncoming(receive); // Only reconsider retained live candidates; readiness is not an event source. stopSync = owned.unread.subscribeSync(() => notifications.revalidate()); - stopAccess = owned.channels.subscribeList(() => notifications.revalidate()); + // App-global ownership: Channels may not be mounted. Start its shared + // observation only after discovery, so an empty startup roster cannot + // consume the unread owner's one-shot evidence repair. + const accessChanged = () => { + notifications.revalidate(); + if ( + owned.channels.list().status === "ready" && + owned.unread.sync().capability !== "unsupported" + ) + void owned.unread.ensure(); + }; + stopAccess = owned.channels.subscribeList(accessChanged); + accessChanged(); }; const stop = communities.relay.subscribe(update); const stopCommunities = communities.subscribe(update); diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index 132b3df9..05c3ac40 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -19,6 +19,7 @@ import { } from "./read-state-storage"; import { createUnread } from "./unread"; import type { IncomingListener, IncomingMessage } from "./incoming"; +import { objectBody } from "./body"; import { readSidebarPreferences } from "./sidebar-preferences"; import { createSidebarPreferencesStore } from "./sidebar-preferences-store"; import { createEmojiDirectory } from "./emoji-directory"; @@ -960,7 +961,7 @@ export function createRelaySession( ([name]) => name === "h", ); return ( - event.kind === 9 && + (event.kind === 9 || event.kind === 40002) && event.pubkey !== transport.viewer && destinations.length === 1 && destinations[0]?.[1] === provenance.channelId && @@ -993,13 +994,17 @@ export function createRelaySession( visible.flatMap((event) => { if (!candidates.has(event.id) || delivered.has(event.id)) return []; delivered.add(event.id); + const body = + event.kind === 40002 ? objectBody(event.content) : undefined; + const content = + typeof body?.content === "string" ? body.content : event.content; return [ Object.freeze({ channelId: provenance.channelId as string, messageId: event.id, createdAt: event.created_at, authorId: event.pubkey, - previewContent: event.content.slice(0, 4096), + previewContent: content.slice(0, 4096), }), ]; }), From e16865e431bb3dd1ee60366b1d8e29fe6778e472 Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Sun, 13 Sep 2026 08:45:08 -0600 Subject: [PATCH 06/17] Reuse channel timeline and thread panel for exact navigation Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- docs/channels.md | 23 +- docs/notifications.md | 4 +- docs/relay-queries.md | 26 +- src/bundled/channels/ChannelsPage.tsx | 111 ++++--- src/features/messages/ChannelTimeline.tsx | 40 ++- src/features/messages/MessageDetailPanel.tsx | 205 ------------ src/features/messages/ThreadPanel.tsx | 89 ++++- src/features/messages/use-message-reveal.ts | 51 ++- src/features/relay/message-detail.test.ts | 308 ------------------ src/features/relay/message-detail.ts | 254 --------------- src/features/relay/session.ts | 107 +++--- src/features/relay/thread-target.test.ts | 288 ++++++++++++++++ src/features/relay/threads.ts | 100 +++++- tests/browser/fixture.mjs | 66 +++- ...l.spec.mjs => message-navigation.spec.mjs} | 158 +++++++-- tests/browser/notifications.spec.mjs | 34 +- 16 files changed, 905 insertions(+), 959 deletions(-) delete mode 100644 src/features/messages/MessageDetailPanel.tsx delete mode 100644 src/features/relay/message-detail.test.ts delete mode 100644 src/features/relay/message-detail.ts create mode 100644 src/features/relay/thread-target.test.ts rename tests/browser/{message-detail.spec.mjs => message-navigation.spec.mjs} (67%) diff --git a/docs/channels.md b/docs/channels.md index 3db04404..f4e21f25 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -177,8 +177,8 @@ The footer reuses `MessageComposer` and sends direct replies to the resolved roo through `session.messages.reply`. Channel and thread drafts are separate and survive reconnection; failed replies remain inline with the shared retry action. Read-only connections keep the existing composer capability notice; missing/revoked roots do -not expose a composer. Message-addressed navigation opens a separate exact detail, -not a position inside this bounded thread panel. +not expose a composer. Exact navigation can retain and focus a selected reply +beyond the traversal range; it does not extend that range or promise complete history. Replies use ascending timestamp/event-ID order, including nested replies. Retry appears only after a failed read; there is no routine Refresh control. Names are @@ -290,14 +290,17 @@ bottom following, reading anchors and narrow layout in Chromium and WebKit. ## Opening an exact message -Message-addressed conversations show **Message detail** with only the selected -verified row. They do not fetch an optional original thread message or traverse -surrounding history. **Open channel** returns to normal reading with its saved -geometry and composer intact. +Message-addressed conversations reuse the normal timeline and thread panel. A +verified, loaded top-level target is revealed in the timeline. An off-window +message opens as the root in the existing thread panel; a reply opens there with +its actual root and bounded surrounding replies. No around-message channel query +or separate detail screen is added. The presentation choice stays fixed for that +navigation attempt; exact reads do not insert isolated old rows into channel history. -`MessageDetailPanel` acknowledges navigation only after the target is visible and -focused. Reclick/Back reveals again; live/profile updates do not steal focus. -The shared row preserves Markdown, profile links and background enrichment. +Navigation completes only after the exact folded target is visible and focused. +Reclick/Back reveals again; live/profile updates do not steal focus. The shared +rows preserve Markdown, profile links, composers and background enrichment. Opening never marks read directly: the ordinary focus/visibility/dwell hook applies. Missing/deleted targets, access loss and failed reads expose failure/retry instead -of falling back to the channel head. See [the evidence contract](relay-queries.md#exact-message-detail). +of channel-head success. An accessible reply remains visible when its root is +unavailable, without a thread composer. See [the evidence contract](relay-queries.md#exact-message-navigation). diff --git a/docs/notifications.md b/docs/notifications.md index a37fdfa5..8148e2d5 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -45,7 +45,9 @@ checks, not that an OS banner was displayed or read. - Browser clicks use the existing typed, account/community-scoped navigation path. It owns membership/provider checks and exact opening. Changing account invalidates old callbacks; changing community does not turn an old alert into a dead click. - Clicks never mark a message read. + Loaded top-level targets use the timeline; off-window targets and replies use + the existing thread panel with exact scroll/focus. Clicks never mark a message + read; normal focused, visible dwell does. There is no notification database, Recent notifications UI, cold/reload receipt protocol, uniform OS withdrawal subsystem, or closed-app push. Preferences are diff --git a/docs/relay-queries.md b/docs/relay-queries.md index 174fac79..62fac93a 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -140,22 +140,24 @@ channel timeline. Failed content rows stay visible for same-event retry; failed auxiliary edits/reactions stop affecting the fold. Verified echo reconciliation and persisted signed-event retry remain the same outbox operations as channel sends. -## Exact message detail +## Exact message navigation -`session.messageDetail(channelId, messageId)` owns an isolated folded row. Its -snapshot has `status` (idle/loading/ready/unavailable/error), `target`, `error`, -and `limited`; allocate, subscribe, refresh and dispose with the consuming request. -At most three bounded reads fetch the target ID, reference overlays and deletions -of those overlays. No root-hint lookup or thread traversal is performed. -Reference queries omit `#h` for legacy edits/deletes but retain session visibility -checks. Raw responses reaching 500 events fail before filtering; retained evidence -stays below 500 events / at most 4 MiB. These are evidence bounds, not complete history. +`session.thread(channelId, messageId, { exact: true })` retains the selected +`target` and `targetStatus` inside the existing thread owner. At most three bounded +reads fetch the target ID, reference overlays and deletions of those overlays +before exposing it. The root is resolved from signed ancestry, never a navigation +hint. Normal bounded thread traversal provides surrounding context; its cursor +never comes from the selected row. An accessible selected reply remains available +even if the original root is missing or the reply lies beyond the traversal cap. -Known tombstones survive sparse refreshes and shared-cache eviction. Detail reads +Reference queries omit `#h` for legacy edits/deletes but retain session visibility +checks. Raw target/overlay responses reaching 500 events fail before filtering. +Evidence shares the thread's 2,000-event / 4 MiB budget and 64-view ceiling. +Known tombstones survive sparse refreshes and shared-cache eviction. Exact reads share verification, admission, access epochs and live reconciliation without inserting isolated rows into channel history. Explicit denial revokes the owning -channel; access loss, cache clear and disposal purge the view. It shares the existing -64-view limit and channel-establishment repair, with no new subscription or persistence. +channel; access loss, cache clear and disposal purge the view. There is no separate +reader owner, subscription or persistence. ## Ownership and reconciliation diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index e24a94f6..19e67daf 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -37,7 +37,6 @@ import { RelayTimings } from "./RelayTimings"; import { LiveStatus } from "./LiveStatus"; import { MessageComposer } from "../../features/messages/MessageComposer"; import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; -import { MessageDetailPanel } from "../../features/messages/MessageDetailPanel"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; @@ -152,21 +151,24 @@ function ChannelWorkspace({ const [selected, setSelected] = useState(() => readView(scope, "selected-channel", undefined), ); - const select = (id: string) => { - if (navigator && viewer) { - void navigator.open({ - version: 1, - kind: "conversation", - channelId: id, - scope: { - viewer, - communityOrigin: scope.slice(0, -(viewer.length + 1)), - }, - }); - } - setSelected(id); - writeView(scope, "selected-channel", id); - }; + const select = useCallback( + (id: string) => { + if (navigator && viewer) { + void navigator.open({ + version: 1, + kind: "conversation", + channelId: id, + scope: { + viewer, + communityOrigin: scope.slice(0, -(viewer.length + 1)), + }, + }); + } + setSelected(id); + writeView(scope, "selected-channel", id); + }, + [navigator, viewer, scope], + ); const [thread, setThread] = useState<{ channelId: string; messageId: string; @@ -212,8 +214,49 @@ function ChannelWorkspace({ navigation?.target.kind === "conversation" ? navigation.target.messageId : undefined; - const showingThread = - !requestedMessage && thread?.channelId === current?.id ? thread : undefined; + const currentId = current?.id; + const [exactOpening, setExactOpening] = useState<{ + request: PageNavigation; + inTimeline: boolean; + }>(); + useEffect(() => { + if ( + !navigation || + !requestedMessage || + !currentId || + navigation.signal.aborted + ) + return; + let selected = false; + const choose = () => { + if (selected || navigation.signal.aborted) return; + const window = queries.channels.window(currentId); + if (window.status === "idle" || window.status === "loading") return; + selected = true; + // Freeze the presentation for this attempt. An isolated lookup or later + // live event must not move an already-opened thread into the timeline. + setExactOpening({ + request: navigation, + inTimeline: + window.status === "ready" && + window.freshness !== "cached" && + window.rows.some( + (row) => row.id === requestedMessage && !row.threadRootId, + ), + }); + }; + const stop = queries.channels.subscribeWindow(currentId, choose); + choose(); + return stop; + }, [navigation, requestedMessage, currentId, queries]); + const exact = exactOpening?.request === navigation ? exactOpening : undefined; + const showingThread = requestedMessage + ? exact && !exact.inTimeline && current + ? { channelId: current.id, messageId: requestedMessage, navigation } + : undefined + : thread && thread.channelId === current?.id + ? { ...thread, navigation: undefined } + : undefined; useEffect(() => { if (thread && !showingThread) setThread(undefined); }, [thread, showingThread]); @@ -252,15 +295,17 @@ function ChannelWorkspace({ document.activeElement instanceof HTMLElement ? document.activeElement : null; + if (requestedMessage) select(current.id); setThread({ channelId: current.id, messageId }); open(undefined); }, - [current, open], + [current, open, requestedMessage, select], ); - const closeThread = useCallback(() => { + const closeThread = () => { + if (showingThread?.navigation && current) select(current.id); setThread(undefined); if (threadTrigger.current?.isConnected) threadTrigger.current.focus(); - }, []); + }; const panelTrigger = useRef(null); const close = useCallback(() => { open(undefined); @@ -518,22 +563,7 @@ function ChannelWorkspace({ channelId={current?.id} partialRoster={list.coverage === "partial"} /> - {current && requestedMessage && navigation ? ( - select(current.id)} - retry={() => { - void navigator?.retry(); - }} - /> - ) : current ? ( + {current ? ( Select a channel to read it. )} - {current && !requestedMessage && ( + {current && ( boolean) | undefined; revealMessageId?: string | undefined; @@ -669,6 +703,7 @@ function ChannelBody({ canOpenLink={canOpenLink} onOpenThread={onOpenThread} revealMessageId={revealMessageId} + navigation={exactInTimeline ? navigation : undefined} /> ); } diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 1312d2f9..d2bc5150 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -12,6 +12,8 @@ import { geometryFor, geometrySignature } from "./geometry"; import { readView, writeView } from "../../shared/view-state"; import styles from "./Messages.module.css"; import { useReading } from "./use-reading"; +import { useMessageReveal } from "./use-message-reveal"; +import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; const EDGE_HEIGHT = 56; @@ -72,6 +74,7 @@ export type ChannelTimelineProps = { onOpenLink(url: string): boolean; canOpenLink?: ((target: string) => boolean) | undefined; revealMessageId?: string | undefined; + navigation?: PageNavigation | undefined; onOpenThread?(messageId: string): void; }; @@ -94,6 +97,7 @@ function Timeline({ onOpenLink, canOpenLink, revealMessageId, + navigation, onOpenThread, }: ChannelTimelineProps) { const [initialPosition] = useState(() => @@ -167,6 +171,31 @@ function Timeline({ }, [rows], ); + const targetId = + navigation?.target.kind === "conversation" + ? navigation.target.messageId + : undefined; + const targetIndex = rows.findIndex((row) => row.id === targetId); + const prepareTarget = useCallback(() => { + if (!handle.current) return; + intent.current++; + follow.current = false; + restoredAnchor.current = undefined; + settled.current = false; + handle.current.scrollToIndex(targetIndex, { align: "center" }); + }, [targetIndex]); + const completeTarget = useCallback(() => { + navigation?.complete({ status: "opened" }); + }, [navigation]); + const exactRevealed = useMessageReveal({ + scroller, + settled, + messageId: targetId, + signal: navigation?.signal, + ready: !!size.width && !!size.height && targetIndex >= 0, + prepare: prepareTarget, + complete: completeTarget, + }); useReading({ session: queries, channelId, scroller, settled }); const prepend = !!edges.current.first && @@ -216,6 +245,7 @@ function Timeline({ // Above-bottom reading and prepend anchoring remain Virtua's responsibility. edges.current = { first: rows[0]?.id, last: rows.at(-1)?.id }; if ( + (targetId && navigation && exactRevealed.current !== navigation.signal) || !size.width || !size.height || !rows.length || @@ -290,7 +320,15 @@ function Timeline({ cancelAnimationFrame(frame); observer?.disconnect(); }; - }, [rows, size, prepend, recordPosition]); + }, [ + rows, + size, + prepend, + recordPosition, + targetId, + navigation, + exactRevealed, + ]); const revealed = useRef(undefined); useLayoutEffect(() => { if (!width || !revealMessageId || revealed.current === revealMessageId) diff --git a/src/features/messages/MessageDetailPanel.tsx b/src/features/messages/MessageDetailPanel.tsx deleted file mode 100644 index 7f79248e..00000000 --- a/src/features/messages/MessageDetailPanel.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import type { ConversationExtensions } from "../conversation/contracts"; -import type { PageNavigation } from "../navigation/service"; -import type { RelaySession } from "../relay/session"; -import type { MessageDetailView } from "../relay/message-detail"; -import { useRowProfiles } from "../relay/react"; -import { MessageRow } from "./MessageRow"; -import { messageViewKey } from "./view-key"; -import { useMessageReveal } from "./use-message-reveal"; -import { useReading } from "./use-reading"; -import styles from "./Messages.module.css"; - -type Props = { - session: RelaySession; - scope: string; - channelId: string; - messageId: string; - navigation: PageNavigation; - extensions?: ConversationExtensions | undefined; - onOpenLink(url: string): boolean; - canOpenLink?: ((target: string) => boolean) | undefined; - openChannel(): void; - retry(): void; -}; - -/** A bounded exact-target presentation. Normal channel/thread scrolling is independent. */ -export function MessageDetailPanel(props: Props) { - return ( - - ); -} -function OwnedDetail(props: Props) { - const { session, channelId, messageId, navigation } = props; - const [ownedView, setView] = useState<{ - request: PageNavigation; - view: MessageDetailView; - }>(); - const view = ownedView?.request === navigation ? ownedView.view : undefined; - const [error, setError] = useState(); - useEffect(() => { - if (navigation.signal.aborted) return; - setError(undefined); - try { - const owned = session.messageDetail(channelId, messageId); - const cancel = () => { - owned.dispose(); - setView(undefined); - }; - setView({ request: navigation, view: owned }); - navigation.signal.addEventListener("abort", cancel, { once: true }); - void owned.refresh(); - return () => { - navigation.signal.removeEventListener("abort", cancel); - owned.dispose(); - }; - } catch (error) { - setError(String(error)); - navigation.complete({ status: "failed", reason: "unavailable" }); - } - }, [session, channelId, messageId, navigation]); - const failed = useCallback( - (message: string, missing: boolean) => { - setError(message); - navigation.complete({ - status: "failed", - reason: missing ? "not-found" : "unavailable", - }); - }, - [navigation], - ); - return ( - <> -
-

- Message detail · Selected message only. -

- -
- {view ? ( - - ) : ( -
-

- {error ?? - (navigation.signal.aborted - ? "Message opening interrupted." - : "Locating message…")} -

- {(error || navigation.signal.aborted) && ( - - )} -
- )} - - ); -} -function DetailMessages({ - session, - channelId, - messageId, - navigation, - extensions, - onOpenLink, - canOpenLink, - view, - failed, - retry, -}: Props & { - view: MessageDetailView; - failed(message: string, missing: boolean): void; -}) { - const snapshot = useSyncExternalStore( - view.subscribe, - view.snapshot, - view.snapshot, - ); - const rows = useMemo( - () => (snapshot.target ? [snapshot.target] : []), - [snapshot.target], - ); - const authors = [ - ...new Set(rows.flatMap((row) => [row.authorId, ...row.mentions])), - ] - .sort() - .join(":"); - useEffect(() => { - if (authors) - void session.profiles - .ensure(authors.split(":"), "background") - .catch(() => {}); - }, [session.profiles, authors]); - const profiles = useRowProfiles(session.profiles, rows); - const scroller = useRef(null); - const settled = useRef(false); - const complete = useCallback(() => { - navigation.complete({ status: "opened" }); - }, [navigation]); - useMessageReveal({ - scroller, - settled, - messageId, - signal: navigation.signal, - ready: snapshot.status === "ready" && snapshot.target?.id === messageId, - complete, - }); - // Opening itself is not mark-read. Reuse focus, visibility and dwell policy. - useReading({ session, channelId, scroller, settled }); - useEffect(() => { - if (snapshot.status === "error" || snapshot.status === "unavailable") - failed( - snapshot.error ?? "The selected message is missing or deleted.", - snapshot.status === "unavailable", - ); - }, [snapshot.status, snapshot.error, failed]); - return ( -
- {snapshot.target && ( - - )} - {snapshot.status === "loading" &&

Locating message…

} - {snapshot.status === "unavailable" && ( -

The selected message is missing or deleted.

- )} - {snapshot.error &&

{snapshot.error}

} - {(snapshot.status === "error" || snapshot.status === "unavailable") && ( - - )} -
- ); -} diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 80a01d32..85de18ef 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -1,5 +1,6 @@ // biome-ignore-all lint/a11y/noNoninteractiveTabindex: The thread region supports keyboard scrolling and Escape. import { + useCallback, useEffect, useLayoutEffect, useMemo, @@ -16,6 +17,8 @@ import { MessageRow } from "./MessageRow"; import { MessageComposer } from "./MessageComposer"; import styles from "./Messages.module.css"; import { useReading } from "./use-reading"; +import { useMessageReveal } from "./use-message-reveal"; +import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; export type ThreadPanelProps = { @@ -25,6 +28,7 @@ export type ThreadPanelProps = { channelName: string; channelId: string; messageId: string; + navigation?: PageNavigation | undefined; close(): void; onOpenLink(url: string): boolean; canOpenLink?: ((target: string) => boolean) | undefined; @@ -51,6 +55,7 @@ function OwnedThreadPanel({ channelName, channelId, messageId, + navigation, close, onOpenLink, canOpenLink, @@ -66,15 +71,27 @@ function OwnedThreadPanel({ // biome-ignore lint/correctness/useExhaustiveDependencies: attempt is explicit recovery after view allocation fails. useEffect(() => { try { - const owned = session.thread(channelId, messageId); + if (navigation?.signal.aborted) return; + const owned = navigation + ? session.thread(channelId, messageId, { exact: true }) + : session.thread(channelId, messageId); + const cancel = () => { + owned.dispose(); + setView(undefined); + }; setError(undefined); setView(owned); + navigation?.signal.addEventListener("abort", cancel, { once: true }); void owned.refresh(); - return () => owned.dispose(); + return () => { + navigation?.signal.removeEventListener("abort", cancel); + owned.dispose(); + }; } catch (error) { setError(String(error)); + navigation?.complete({ status: "failed", reason: "unavailable" }); } - }, [session, channelId, messageId, attempt]); + }, [session, channelId, messageId, attempt, navigation]); return (