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" && (
+ void notifications.requestPermission()}
+ >
+ Allow notifications
+
+ )}
+ void notifications.refreshPermission()}
+ >
+ Check permission
+
+
+
+ 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}
+
notifications.updatePreferences({})}
+ >
+ Retry saving choices
+ {" "}
+
notifications.reloadPreferences()}
+ >
+ Reload saved choices
+
+
+ )}
+ {state.error && (
+
+ {state.error}
+
+ )}
+
+
+ );
+}
+function Toggle({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange(checked: boolean): void;
+}) {
+ return (
+
+ {label}
+ onChange(event.target.checked)}
+ />
+
+ );
+}
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.
+
+
+ Open channel
+
+
+ {view ? (
+
+ ) : (
+
+
+ {error ??
+ (navigation.signal.aborted
+ ? "Message opening interrupted."
+ : "Locating message…")}
+
+ {(error || navigation.signal.aborted) && (
+
+ Retry message
+
+ )}
+
+ )}
+ >
+ );
+}
+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") && (
+
+ Retry message
+
+ )}
+
+ );
+}
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` \n\nhidden
",
+ ),
+ ).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.
-
-
- Open channel
-
-
- {view ? (
-
- ) : (
-
-
- {error ??
- (navigation.signal.aborted
- ? "Message opening interrupted."
- : "Locating message…")}
-
- {(error || navigation.signal.aborted) && (
-
- Retry message
-
- )}
-
- )}
- >
- );
-}
-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") && (
-
- Retry message
-
- )}
-
- );
-}
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 (
@@ -133,6 +152,8 @@ function ThreadMessages({
channelId,
channelName,
view,
+ navigation,
+ messageId,
onOpenLink,
canOpenLink,
}: {
@@ -142,6 +163,8 @@ function ThreadMessages({
channelId: string;
channelName: string;
view: ThreadView;
+ messageId: string;
+ navigation?: PageNavigation | undefined;
onOpenLink(url: string): boolean;
canOpenLink?: ((target: string) => boolean) | undefined;
}) {
@@ -170,6 +193,42 @@ function ThreadMessages({
const scroller = useRef(null);
const positioned = useRef(false);
const follow = useRef(true);
+ const targetAnchor = useRef(undefined);
+ const selectedRow = useCallback(
+ () =>
+ [
+ ...(scroller.current?.querySelectorAll(
+ "[data-message-id]",
+ ) ?? []),
+ ].find((row) => row.dataset.messageId === messageId),
+ [messageId],
+ );
+ const completeTarget = useCallback(() => {
+ // Exact lookup can finish before context. Preserve this row's reading
+ // position through prepended history without refocusing it after opening.
+ targetAnchor.current = selectedRow()?.offsetTop;
+ navigation?.complete({ status: "opened" });
+ }, [navigation, selectedRow]);
+ const prepareTarget = useCallback(() => {
+ follow.current = false;
+ }, []);
+ const revealed = useMessageReveal({
+ scroller,
+ settled: positioned,
+ messageId,
+ signal: navigation?.signal,
+ ready:
+ snapshot.targetStatus === "ready" && snapshot.target?.id === messageId,
+ complete: completeTarget,
+ prepare: prepareTarget,
+ });
+ useEffect(() => {
+ if (!navigation || navigation.signal.aborted) return;
+ if (snapshot.targetStatus === "unavailable")
+ navigation.complete({ status: "failed", reason: "not-found" });
+ else if (snapshot.targetStatus === "error")
+ navigation.complete({ status: "failed", reason: "unavailable" });
+ }, [navigation, snapshot.targetStatus]);
useReading({ session, channelId, scroller, settled: positioned });
const [sent, setSent] = useState();
// The bridge walks oldest-first. Finish its bounded range automatically, rather
@@ -183,16 +242,37 @@ function ThreadMessages({
const element = scroller.current;
if (
!element ||
+ (navigation && revealed.current !== navigation.signal) ||
(!positioned.current &&
(snapshot.status !== "ready" || snapshot.canLoadMore))
)
return;
+ if (targetAnchor.current !== undefined) {
+ const row = selectedRow();
+ if (row) {
+ element.scrollTop += row.offsetTop - targetAnchor.current;
+ targetAnchor.current = row.offsetTop;
+ follow.current = false;
+ }
+ if (snapshot.status !== "loading" && !snapshot.canLoadMore)
+ targetAnchor.current = undefined;
+ }
// Initial positioning waits for automatic history loading. User intent wins;
// subsequent live changes follow only while the reader is at the bottom.
if (follow.current) element.scrollTop = element.scrollHeight;
positioned.current = true;
- }, [snapshot.status, snapshot.canLoadMore, rows, profiles, sent]);
+ }, [
+ snapshot.status,
+ snapshot.canLoadMore,
+ rows,
+ profiles,
+ sent,
+ navigation,
+ revealed,
+ selectedRow,
+ ]);
const keepReadingPosition = () => {
+ targetAnchor.current = undefined;
if (positioned.current) return;
positioned.current = true;
follow.current = false;
@@ -293,6 +373,7 @@ function ThreadMessages({
channelName={channelName}
threadRootId={snapshot.root.id}
onSend={(id) => {
+ targetAnchor.current = undefined;
positioned.current = true;
follow.current = true;
setSent(id);
diff --git a/src/features/messages/use-message-reveal.ts b/src/features/messages/use-message-reveal.ts
index b7a2037f..f29460c6 100644
--- a/src/features/messages/use-message-reveal.ts
+++ b/src/features/messages/use-message-reveal.ts
@@ -8,39 +8,54 @@ export function useMessageReveal({
signal,
ready,
complete,
+ prepare,
}: {
scroller: RefObject;
settled: RefObject;
- messageId: string;
- signal: AbortSignal;
+ messageId: string | undefined;
+ signal: AbortSignal | undefined;
ready: boolean;
complete(): void;
+ prepare?(): void;
}) {
const revealed = useRef(undefined);
useLayoutEffect(() => {
- if (!ready || signal.aborted || revealed.current === signal) return;
+ if (
+ !ready ||
+ !messageId ||
+ !signal ||
+ 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 cancel = () => {
+ cancelAnimationFrame(frame);
+ observer.disconnect();
+ };
+ const schedule = () => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(reveal);
+ };
+ const observer = new MutationObserver(schedule);
+ function reveal() {
+ if (signal?.aborted || !container?.isConnected) return;
const row = [
...container.querySelectorAll("[data-message-id]"),
].find((element) => element.dataset.messageId === messageId);
- if (!row) return;
+ if (!row) return; // Virtual rows mount asynchronously; observe the real mount.
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))
+ if (signal?.aborted || !row.isConnected || !container.contains(row))
return;
const box = row.getBoundingClientRect();
const viewport = container.getBoundingClientRect();
@@ -53,12 +68,26 @@ export function useMessageReveal({
box.left < Math.min(viewport.right, window.innerWidth);
if (document.activeElement !== row || !visible) return;
revealed.current = signal;
+ cancel();
complete();
});
+ }
+ signal.addEventListener("abort", cancel, { once: true });
+ // Virtua attaches its scroller in an effect, after our layout effect.
+ frame = requestAnimationFrame(() => {
+ observer.observe(container, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ["style"],
+ });
+ prepare?.();
+ schedule();
});
return () => {
cancel();
signal.removeEventListener("abort", cancel);
};
- }, [scroller, settled, messageId, signal, ready, complete]);
+ }, [scroller, settled, messageId, signal, ready, complete, prepare]);
+ return revealed;
}
diff --git a/src/features/relay/message-detail.test.ts b/src/features/relay/message-detail.test.ts
deleted file mode 100644
index d586b704..00000000
--- a/src/features/relay/message-detail.test.ts
+++ /dev/null
@@ -1,308 +0,0 @@
-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
deleted file mode 100644
index 7c695a93..00000000
--- a/src/features/relay/message-detail.ts
+++ /dev/null
@@ -1,254 +0,0 @@
-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 05c3ac40..14429afa 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -43,11 +43,6 @@ 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 {
@@ -100,7 +95,6 @@ export function createRelaySession(
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
@@ -130,7 +124,7 @@ export function createRelaySession(
: undefined;
const rawLocal = () => writes?.local.snapshot() ?? [];
function retainedThreadEvent(id: string) {
- for (const thread of [...threads, ...details]) {
+ for (const thread of threads) {
if (!canAccess(thread.channelId)) continue;
const event = thread.event(id);
if (event) return event;
@@ -608,63 +602,12 @@ 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) {
+ thread(
+ channelId: string,
+ messageId: string,
+ options?: { exact?: boolean },
+ ) {
if (closed || views.size >= 64)
throw new Error("Relay view capacity unavailable");
if (!/^[0-9a-f]{64}$/.test(messageId))
@@ -673,7 +616,39 @@ export function createRelaySession(
channelId,
messageId,
relayAuthor: transport?.relayAuthor ?? "",
- reader: verified,
+ reader: options?.exact
+ ? {
+ async read(filters, settings) {
+ const epoch = accessEpoch;
+ let events: readonly RelayEvent[];
+ try {
+ events = await requests.reader.read(filters, settings);
+ } catch (error) {
+ if (
+ !closed &&
+ epoch === accessEpoch &&
+ readErrorKind(error) === "denied"
+ )
+ channels.denyChannel(channelId, error);
+ throw error;
+ }
+ if (closed || epoch !== accessEpoch)
+ throw new DOMException("Stale thread target", "AbortError");
+ settings?.signal?.throwIfAborted();
+ // A capped raw target/overlay read cannot establish a safe fold.
+ if (
+ !filters.some((filter) => filter.depth_limit) &&
+ events.length >= 500
+ )
+ throw new Error(
+ "Selected message exceeded its evidence limit",
+ );
+ // Isolated lookup must not masquerade as contiguous channel history.
+ return accept(events, false);
+ },
+ }
+ : verified,
+ exact: options?.exact ?? false,
seed: recent.peek(messageId)?.event,
local: localViews,
canAccess: () => !closed && canAccess(channelId),
@@ -681,6 +656,8 @@ export function createRelaySession(
notify,
});
threads.add(thread);
+ if (options?.exact)
+ thread.receive(recent.entries().map(([, item]) => item.event));
observations.add(thread.receive);
const unsubscribe = localViews?.subscribe(thread.changed);
const dispose = () => {
@@ -1053,7 +1030,7 @@ export function createRelaySession(
return;
}
if (!channels.canAccess(channelId)) return;
- for (const thread of [...threads, ...details])
+ for (const thread of threads)
if (thread.channelId === channelId) void thread.view.refresh();
const job = {
generation: liveGeneration,
diff --git a/src/features/relay/thread-target.test.ts b/src/features/relay/thread-target.test.ts
new file mode 100644
index 00000000..bca0a2b1
--- /dev/null
+++ b/src/features/relay/thread-target.test.ts
@@ -0,0 +1,288 @@
+import { afterEach, expect, it } from "vitest";
+import { createRelaySession } from "./session";
+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 root", 1);
+const reply = message(alice, "a", "Selected reply", 10000, [
+ ["e", root.id, "", "reply"],
+]);
+const aux = (kind: number, target: RelayEvent, content = "", time = 10001) =>
+ signed(alice, { kind, content, created_at: time, tags: [["e", target.id]] });
+function setup(id = reply.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.thread("a", id, { exact: true }),
+ };
+}
+async function targetRead(
+ h: ReturnType,
+ target = reply,
+ overlays: RelayEvent[] = [],
+) {
+ const loading = h.view.refresh();
+ h.next().respond([target]);
+ await flush();
+ h.next().respond(overlays);
+ await flush();
+ if (overlays.length) {
+ h.next().respond([]);
+ await flush();
+ }
+ return { loading };
+}
+
+it("retains the selected reply beyond traversal limits without using it as a cursor or channel history", async () => {
+ const h = setup();
+ 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();
+ for (const pending of h.pending.splice(0)) pending.respond([]);
+ const { loading } = await targetRead(h, reply, [
+ aux(40003, reply, "Edited target"),
+ ]);
+ h.next().respond([
+ root,
+ message(alice, "a", "First reply", 2, [["e", root.id, "", "reply"]]),
+ ]);
+ await loading;
+ expect(h.view.snapshot().target).toMatchObject({
+ id: reply.id,
+ content: "Edited target",
+ });
+ expect(h.view.snapshot().replies.map((row) => row.content)).toEqual([
+ "First reply",
+ "Edited target",
+ ]); // Unrelated top-level seed events must not become replies before root resolution.
+ for (let page = 2; page <= 10; page++) {
+ const more = h.view.loadMore();
+ const request = h.next();
+ expect(request.filters[1]?.thread_cursor).toBe(page);
+ request.respond([
+ root,
+ message(alice, "a", `Reply ${page}`, page + 1, [
+ ["e", root.id, "", "reply"],
+ ]),
+ ]);
+ await more;
+ }
+ expect(h.view.snapshot()).toMatchObject({
+ limited: true,
+ canLoadMore: false,
+ targetStatus: "ready",
+ target: { id: reply.id },
+ });
+ expect(h.session.channels.window("a").rows.map((row) => row.id)).toEqual([
+ head.id,
+ ]);
+});
+
+it("folds reference-only edits and deletes of overlays before exposing the target", async () => {
+ const h = setup();
+ const loading = h.view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ const edit = aux(40003, reply, "Edited"),
+ reaction = aux(7, reply, "+");
+ const overlays = h.next();
+ expect(overlays.filters[0]?.["#h"]).toBeUndefined();
+ overlays.respond([edit, reaction]);
+ await flush();
+ expect(h.view.snapshot().target).toBeUndefined();
+ h.next().respond([aux(5, reaction)]);
+ await flush();
+ h.next().respond([root]);
+ await loading;
+ expect(h.view.snapshot().target).toMatchObject({
+ content: "Edited",
+ reactions: [],
+ });
+ h.traffic.receive([aux(5, edit)]);
+ expect(h.view.snapshot().target?.content).toBe(reply.content);
+ h.traffic.receive([aux(5, reply)]);
+ expect(h.view.snapshot()).toMatchObject({
+ targetStatus: "unavailable",
+ target: undefined,
+ });
+});
+
+it("keeps an accessible reply when the original is unavailable, without a composer root", async () => {
+ const h = setup();
+ const { loading } = await targetRead(h);
+ h.next().respond([]);
+ await loading;
+ expect(h.view.snapshot()).toMatchObject({
+ root: undefined,
+ targetStatus: "ready",
+ target: { id: reply.id },
+ });
+ expect(h.view.snapshot().replies.map((row) => row.id)).toEqual([reply.id]);
+});
+
+it("keeps a fetched root in the existing thread surface", async () => {
+ const h = setup(root.id);
+ const { loading } = await targetRead(h, root);
+ h.next().respond([root]);
+ await loading;
+ expect(h.view.snapshot()).toMatchObject({
+ root: { id: root.id },
+ target: { id: root.id },
+ targetStatus: "ready",
+ });
+});
+
+it("distinguishes absent targets and failed lookup, then permits explicit retry", async () => {
+ const h = setup();
+ const missing = h.view.refresh();
+ h.next().respond([]);
+ await missing;
+ expect(h.view.snapshot().targetStatus).toBe("unavailable");
+ const failed = h.view.refresh();
+ h.next().fail(new Error("offline"));
+ await failed;
+ expect(h.view.snapshot().targetStatus).toBe("error");
+ await flush();
+ const { loading } = await targetRead(h);
+ h.next().respond([root]);
+ await loading;
+ expect(h.view.snapshot().target?.id).toBe(reply.id);
+});
+
+it("rejects a capped raw overlay response before visibility filtering hides its size", async () => {
+ const h = setup();
+ const reading = h.view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ const other = message(alice, "private", "Hidden", 2);
+ h.next().respond(
+ Array.from({ length: 500 }, (_, i) => aux(7, other, `${i}`)),
+ );
+ await reading;
+ expect(h.view.snapshot()).toMatchObject({
+ targetStatus: "error",
+ target: undefined,
+ });
+});
+
+it("retains target tombstones across sparse refresh and shared-cache eviction", async () => {
+ const h = setup();
+ const edit = aux(40003, reply, "Edited");
+ const { loading } = await targetRead(h, reply, [edit]);
+ h.next().respond([root]);
+ await loading;
+ h.traffic.receive(
+ Array.from({ length: 140 }, (_, i) =>
+ message(alice, "b", `${i}${"x".repeat(65536)}`, i + 20),
+ ),
+ );
+ h.traffic.receive([aux(5, edit)]);
+ expect(h.view.snapshot().target?.content).toBe(reply.content);
+ h.traffic.receive([aux(5, reply)]);
+ const again = h.view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ h.next().respond([]);
+ await flush();
+ h.next().respond([]);
+ await again;
+ expect(h.view.snapshot()).toMatchObject({
+ targetStatus: "unavailable",
+ target: undefined,
+ });
+});
+
+it("seeds already observed tombstones rather than resurrecting a selected row", async () => {
+ const h = setup();
+ h.view.dispose();
+ h.traffic.receive([reply, aux(5, reply)]);
+ const view = h.session.thread("a", reply.id, { exact: true });
+ const reading = view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ h.next().respond([]);
+ await flush();
+ h.next().respond([]);
+ await reading;
+ expect(view.snapshot()).toMatchObject({
+ targetStatus: "unavailable",
+ target: undefined,
+ });
+});
+
+it("revokes the channel after a denied reference-only read and fences late access-lost results", async () => {
+ const h = setup();
+ h.traffic.receive([roster(relay, "a", [viewer.pubkey], 10)]);
+ const reading = h.view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ h.next().fail(new ReadError("denied", "denied"));
+ await reading;
+ expect(h.session.channels.list().channels).toEqual([]);
+ expect(h.view.snapshot().target).toBeUndefined();
+ h.traffic.receive([roster(relay, "a", [viewer.pubkey], 11)]);
+ expect(
+ h.session.channels.list().channels.map((channel) => channel.id),
+ ).toEqual(["a"]);
+ const retry = h.view.refresh();
+ // The shared scheduler yields to the host after a failed transport read.
+ await flush();
+ h.next().respond([reply]);
+ await flush();
+ const held = h.next();
+ h.traffic.receive([roster(relay, "a", [], 12)]);
+ expect(held.signal?.aborted).toBe(true);
+ held.respond([]);
+ await retry;
+ expect(h.view.snapshot().target).toBeUndefined();
+});
+
+it("disposes held exact reads and purges loaded targets on cache clear", async () => {
+ const h = setup();
+ const reading = h.view.refresh();
+ const held = h.next();
+ h.view.dispose();
+ expect(held.signal?.aborted).toBe(true);
+ held.respond([reply]);
+ await reading;
+ expect(h.view.snapshot().target).toBeUndefined();
+ const next = setup();
+ const { loading } = await targetRead(next);
+ next.next().respond([root]);
+ await loading;
+ await next.clearCache();
+ expect(next.view.snapshot().target).toBeUndefined();
+});
diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts
index 5ab2897f..07c41b24 100644
--- a/src/features/relay/threads.ts
+++ b/src/features/relay/threads.ts
@@ -26,6 +26,9 @@ export type ThreadSnapshot = Readonly<{
/** Continuation is possible, not a claim about total thread size or exhaustion. */
canLoadMore: boolean;
limited: boolean;
+ /** Exact navigation target, folded independently of bounded thread traversal. */
+ target?: ChannelMessage | undefined;
+ targetStatus?: "loading" | "ready" | "unavailable" | "error" | undefined;
}>;
export type ThreadView = {
snapshot(): ThreadSnapshot;
@@ -46,6 +49,7 @@ export function createThreadView({
canAccess,
visible,
notify,
+ exact = false,
}: {
channelId: string;
messageId: string;
@@ -56,10 +60,14 @@ export function createThreadView({
canAccess(): boolean;
visible(events: readonly RelayEvent[]): readonly RelayEvent[];
notify(listener: () => void): void;
+ exact?: boolean;
}) {
let disposed = false;
let rootId: string | undefined;
let rootUnavailable = false;
+ let targetStatus: ThreadSnapshot["targetStatus"] = exact
+ ? "loading"
+ : undefined;
let remote: readonly RelayEvent[] = [];
let cursor: RelayEvent | undefined;
let pages = 0;
@@ -75,12 +83,14 @@ export function createThreadView({
});
const listeners = new Set<() => void>();
function related(events: readonly T[]): T[] {
- if (!rootId) return [];
+ if (!rootId && !exact) return [];
const rows = events.filter(
(event) =>
!AUX.has(event.kind) &&
inChannel(event, channelId) &&
- (event.id === rootId || threadReference(event)?.rootId === rootId),
+ (event.id === rootId ||
+ (exact && event.id === messageId) ||
+ (!!rootId && threadReference(event)?.rootId === rootId)),
);
const ids = new Set([...remote, ...rows].map((event) => event.id));
const result = new Map(rows.map((event) => [event.id, event]));
@@ -123,7 +133,7 @@ export function createThreadView({
const rows = foldMessages(
channelId,
relayAuthor,
- rootUnavailable ? [] : [...inputs.values()],
+ rootUnavailable && !exact ? [] : [...inputs.values()],
{
includeReplies: true,
},
@@ -138,14 +148,29 @@ export function createThreadView({
: row;
});
// Thread forward order differs from channel-history's descending-ID tiebreak.
+ const target =
+ targetStatus === "ready"
+ ? rows.find((row) => row.id === messageId)
+ : undefined;
+ if (targetStatus === "ready" && !target) targetStatus = "unavailable";
+ const readable = !exact || targetStatus === "ready";
const replies = rows
- .filter((row) => row.id !== rootId)
+ .filter(
+ (row) =>
+ readable &&
+ row.id !== rootId &&
+ (!rootUnavailable || row.id === messageId),
+ )
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
snapshot = Object.freeze({
...snapshot,
...patch,
- root: rows.find((row) => row.id === rootId),
+ root:
+ readable && !rootUnavailable
+ ? rows.find((row) => row.id === rootId)
+ : undefined,
replies: Object.freeze(replies),
+ ...(exact ? { target, targetStatus } : {}),
});
for (const listener of listeners) notify(listener);
}
@@ -158,6 +183,7 @@ export function createThreadView({
cursor = undefined;
pages = 0;
again = false;
+ if (exact) targetStatus = "error";
publish({
status: "error",
limited: true,
@@ -171,7 +197,7 @@ export function createThreadView({
return true;
}
function receive(events: readonly RelayEvent[]) {
- if (disposed || !canAccess() || !rootId) return;
+ if (disposed || !canAccess() || (!rootId && !exact)) return;
const incoming = related(events);
if (!incoming.length) return;
if (retain(union(remote, incoming))) {
@@ -187,6 +213,7 @@ export function createThreadView({
controller?.abort();
controller = undefined;
again = false;
+ if (exact) targetStatus = "loading";
if (clear || !canAccess()) {
remote = [];
rootId = undefined;
@@ -221,8 +248,64 @@ export function createThreadView({
let nextCursor = replace ? undefined : cursor;
let nextPages = replace ? 0 : pages;
let fetched: readonly RelayEvent[] = [];
+ if (exact && replace) targetStatus = "loading";
publish({ status: "loading", error: undefined });
try {
+ if (exact && replace) {
+ const selected = await reader.read(
+ [{ ids: [messageId], "#h": [channelId], limit: 1 }],
+ { signal: owned.signal },
+ );
+ if (!active()) return;
+ const event = selected.find(
+ (event) =>
+ event.id === messageId &&
+ contentKind(event) &&
+ inChannel(event, channelId),
+ );
+ if (!event) {
+ targetStatus = "unavailable";
+ publish({ status: "ready", canLoadMore: false });
+ return;
+ }
+ rootId = threadReference(event)?.rootId ?? event.id;
+ if (!retain(union(remote, [event]))) return;
+ // ID reads do not expand overlays. Fold the selected row even when it
+ // lies beyond the thread's traversal cap; its ID never supplies a cursor.
+ const overlays = await reader.read(
+ [
+ {
+ kinds: [5, 7, 9005, 40003, 39005],
+ "#e": [messageId],
+ limit: 500,
+ },
+ ],
+ { signal: owned.signal },
+ );
+ if (!active() || !retain(union(remote, related(overlays)))) return;
+ const ids = remote
+ .filter(
+ (event) =>
+ AUX.has(event.kind) &&
+ event.tags.some(
+ ([name, value]) => name === "e" && value === messageId,
+ ),
+ )
+ .map((event) => event.id);
+ if (ids.length) {
+ const tombstones = await reader.read(
+ [{ kinds: [5, 9005], "#e": ids, limit: 500 }],
+ { signal: owned.signal },
+ );
+ if (!active() || !retain(union(remote, related(tombstones)))) return;
+ }
+ targetStatus = "ready";
+ publish();
+ if (snapshot.targetStatus !== "ready") {
+ publish({ status: "ready", canLoadMore: false });
+ return;
+ }
+ }
if (!rootId) {
const selected = await reader.read(
[{ ids: [messageId], "#h": [channelId], limit: 1 }],
@@ -320,7 +403,10 @@ export function createThreadView({
limited: more && pages >= MAX_PAGES,
});
} catch (error) {
- if (active()) publish({ status: "error", error: String(error) });
+ if (active()) {
+ if (targetStatus === "loading") targetStatus = "error";
+ publish({ status: "error", error: String(error) });
+ }
} finally {
if (controller === owned) {
controller = undefined;
diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs
index 8ca44f3f..d91ea08e 100644
--- a/tests/browser/fixture.mjs
+++ b/tests/browser/fixture.mjs
@@ -174,7 +174,7 @@ export const test = base.extend({
);
for (const community of ["primary", "secondary"])
for (const id of dmIds) histories.set(`${community}/${id}`, []);
- const detailEvents = [];
+ const targetEvents = [];
let exact;
if (exactMessages) {
const root = histories.get("primary/alpha")[2];
@@ -213,7 +213,7 @@ export const test = base.extend({
userKey,
target.created_at + 3,
);
- detailEvents.push(...replies, edit, reaction, deletion);
+ targetEvents.push(...replies, edit, reaction, deletion);
exact = { root, target, replies, edit, reaction, deletion };
}
if (membershipActivity) {
@@ -231,7 +231,9 @@ export const test = base.extend({
}
// Opt-in upstream thread evidence: no client cache/read-state injection.
// Uppercase signed references exercise canonical thread/unread parity.
- const threadReplies = new Map();
+ const threadReplies = new Map(
+ exact ? [[exact.root.id, exact.replies]] : [],
+ );
const threadSummaries = [];
if (threadUnread) {
const history = histories.get("primary/alpha");
@@ -441,7 +443,7 @@ export const test = base.extend({
return [...histories.entries()]
.filter(([key]) => key.startsWith(`${community}/`))
.flatMap(([, events]) => events)
- .concat(community === "primary" ? detailEvents : [])
+ .concat(community === "primary" ? targetEvents : [])
.filter(
(event) =>
filter.ids.includes(event.id) &&
@@ -455,7 +457,7 @@ export const test = base.extend({
filter["#e"] &&
filter.kinds?.every((kind) => [5, 7, 9005, 39005, 40003].includes(kind))
)
- return (community === "primary" ? detailEvents : [])
+ return (community === "primary" ? targetEvents : [])
.filter(
(event) =>
filter.kinds.includes(event.kind) &&
@@ -467,8 +469,21 @@ export const test = base.extend({
(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]) ?? [])
+ if (filter.depth_limit) {
+ const rootId = filter["#e"]?.[0];
+ const candidates = [
+ ...(community === "primary" ? (threadReplies.get(rootId) ?? []) : []),
+ ...(histories.get(`${community}/${filter["#h"]?.[0]}`) ?? []),
+ ].filter((event) => {
+ const refs = event.tags.filter(([key]) => key === "e");
+ const root =
+ refs.find((tag) => tag[3] === "root") ??
+ refs.find((tag) => tag[3] === "reply");
+ return root?.[1]?.toLowerCase() === rootId;
+ });
+ const rows = [
+ ...new Map(candidates.map((event) => [event.id, event])).values(),
+ ]
.filter(
(event) =>
filter.thread_cursor === undefined ||
@@ -476,7 +491,29 @@ export const test = base.extend({
(event.created_at === filter.thread_cursor &&
event.id > filter.thread_cursor_id),
)
+ .toSorted(
+ (a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id),
+ )
.slice(0, filter.limit);
+ const ids = new Set(rows.map((event) => event.id));
+ const aux = [];
+ if (filter.include_aux && community === "primary")
+ for (let hop = 0; hop < 2; hop++)
+ for (const event of targetEvents) {
+ if (
+ ids.has(event.id) ||
+ ![5, 7, 9005, 39005, 40003].includes(event.kind)
+ )
+ continue;
+ if (
+ event.tags.some(([key, value]) => key === "e" && ids.has(value))
+ ) {
+ aux.push(event);
+ ids.add(event.id);
+ }
+ }
+ return [...rows, ...aux];
+ }
// Unread evidence is not a top-level window, even for a one-ID final batch.
if (
filter.kinds?.includes(9) &&
@@ -652,10 +689,19 @@ export const test = base.extend({
throw new Error(
`Unexpected fixture request: ${request.method} ${request.url}`,
);
- expect(body).toHaveLength(1);
+ expect(body.length).toBeGreaterThan(0);
+ expect(body.length).toBeLessThanOrEqual(2);
const filter = body[0];
- report.queries.push({ community, filter });
- const result = answer(community, filter);
+ const result = [
+ ...new Map(
+ body
+ .flatMap((filter) => {
+ report.queries.push({ community, filter });
+ return answer(community, filter);
+ })
+ .map((event) => [event.id, event]),
+ ).values(),
+ ];
if (filter.until !== undefined) {
pending.push({
community,
diff --git a/tests/browser/message-detail.spec.mjs b/tests/browser/message-navigation.spec.mjs
similarity index 67%
rename from tests/browser/message-detail.spec.mjs
rename to tests/browser/message-navigation.spec.mjs
index b280fa28..cc96aa4b 100644
--- a/tests/browser/message-detail.spec.mjs
+++ b/tests/browser/message-navigation.spec.mjs
@@ -2,8 +2,8 @@ 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 thread = (page) =>
+ page.getByRole("region", { name: "Thread messages", exact: true });
const target = (app, id = app.exact.target.id) => ({
version: 1,
kind: "conversation",
@@ -50,7 +50,7 @@ test("old root and reply beyond the first thread page open exactly; reclick and
mode,
clickToOpenedMs: performance.now() - start,
});
- const row = detail(page).locator(`[data-message-id="${id}"]`);
+ const row = thread(page).locator(`[data-message-id="${id}"]`);
await expect(row).toBeVisible();
await expect(row).toBeFocused();
expect(await status(page)).toBe("opened");
@@ -65,27 +65,27 @@ test("old root and reply beyond the first thread page open exactly; reclick and
exact: true,
}),
).toHaveCount(0); // Edited-body names cannot inherit original signed recipients.
+ await expect(thread(page).locator("[data-message-id]")).toHaveCount(81);
await expect(
- page.getByText("Message detail · Selected message only."),
+ page.getByRole("textbox", { name: "Reply to thread", exact: true }),
).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.some((q) => q.filter.depth_limit)).toBe(true);
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,
- });
+ const mention = thread(page)
+ .locator(`[data-message-id="${app.exact.replies.at(-2).id}"]`)
+ .getByRole("button", {
+ name: "View Alice Fixture profile",
+ exact: true,
+ });
await mention.click();
await expect(
page.getByRole("region", { name: "Profile details" }),
@@ -95,7 +95,7 @@ test("old root and reply beyond the first thread page open exactly; reclick and
.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 page.getByRole("button", { name: "Close thread", exact: true }).click();
await expect(
page.getByRole("textbox", { name: "Message #Alpha", exact: true }),
).toBeVisible();
@@ -104,11 +104,123 @@ test("old root and reply beyond the first thread page open exactly; reclick and
).toHaveCount(0);
await page.getByRole("button", { name: "Go back", exact: true }).click();
await expect(
- detail(page).locator(`[data-message-id="${app.exact.target.id}"]`),
+ thread(page).locator(`[data-message-id="${app.exact.target.id}"]`),
).toBeFocused();
await expect.poll(() => status(page)).toBe("opened");
});
+test("loaded virtual rows reveal per attempt without thread reads or live-update focus theft", async ({
+ page,
+ app,
+}) => {
+ await open(page, app);
+ const history = page.getByRole("region", {
+ name: "Channel message history",
+ exact: true,
+ });
+ const id = app.histories.get("primary/alpha").at(-18).id;
+ const composer = page.getByRole("textbox", {
+ name: "Message #Alpha",
+ exact: true,
+ });
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const start = performance.now();
+ expect(await openTarget(page, target(app, id))).toEqual({
+ status: "opened",
+ });
+ const row = history.locator(`[data-message-id="${id}"]`);
+ await expect(row).toBeFocused();
+ await expect(row).toBeInViewport();
+ app.report.measurements.push({
+ mode: `loaded timeline attempt ${attempt}`,
+ clickToOpenedMs: performance.now() - start,
+ });
+ await composer.focus();
+ await history.evaluate((element) => {
+ element.scrollTop = element.scrollHeight;
+ });
+ }
+ expect(app.report.queries.filter((q) => q.filter.depth_limit)).toHaveLength(
+ 0,
+ );
+ await expect(thread(page)).toHaveCount(0);
+ app.append("primary", "alpha", "Live after exact timeline reveal");
+ await expect(history).toContainText("Live after exact timeline reveal");
+ await expect(composer).toBeFocused();
+});
+
+test("an accessible exact reply stays visible without its root or a thread composer", async ({
+ page,
+ app,
+}) => {
+ app.histories.set(
+ "primary/alpha",
+ app.histories
+ .get("primary/alpha")
+ .filter((event) => event.id !== app.exact.root.id),
+ );
+ await open(page, app);
+ expect(await openTarget(page, target(app))).toEqual({ status: "opened" });
+ await expect(
+ thread(page).locator(`[data-message-id="${app.exact.target.id}"]`),
+ ).toBeFocused();
+ await expect(
+ thread(page).getByText("Original message unavailable."),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("textbox", { name: "Reply to thread", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("textbox", { name: "Message #Alpha", exact: true }),
+ ).toBeVisible();
+});
+
+test("exact reply reveals before slow surrounding traversal and keeps its position afterwards", async ({
+ page,
+ app,
+}) => {
+ await open(page, app);
+ let release;
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ let intercepted;
+ const seen = new Promise((resolve) => {
+ intercepted = resolve;
+ });
+ let first = true;
+ await page.route("**/api/relay/**/query", async (route) => {
+ if (
+ !first ||
+ !route
+ .request()
+ .postDataJSON()
+ .some((filter) => filter.depth_limit)
+ )
+ return route.continue();
+ first = false;
+ intercepted();
+ await held;
+ await route.continue().catch(() => {});
+ });
+ await page.evaluate((value) => {
+ window.exactResult = window.fixtureNavigation.open(value);
+ }, target(app));
+ await seen;
+ await expect.poll(() => status(page)).toBe("opened");
+ const row = thread(page).locator(
+ `[data-message-id="${app.exact.target.id}"]`,
+ );
+ await expect(row).toBeFocused();
+ await expect(row).toBeInViewport();
+ const close = page.getByRole("button", { name: "Close thread", exact: true });
+ await close.focus();
+ release();
+ await expect(thread(page).locator("[data-message-id]")).toHaveCount(81);
+ await expect(row).toBeInViewport();
+ await expect(close).toBeFocused();
+});
+
test("unknown target fails without channel-head success and retries in the same visit", async ({
page,
app,
@@ -131,7 +243,7 @@ test("unknown target fails without channel-head success and retries in the same
expect(
await page.evaluate(() => window.fixtureNavigation.snapshot().entry.id),
).toBe(visit);
- await expect(page.locator("[data-message-id]")).toHaveCount(0);
+ await expect(thread(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 ({
@@ -172,7 +284,7 @@ test("superseding a held exact read cancels it; a late response cannot steal foc
await composer.focus();
release();
await expect(composer).toBeFocused();
- await expect(detail(page)).toHaveCount(0);
+ await expect(thread(page)).toHaveCount(0);
await expect.poll(() => status(page)).toBe("opened");
});
@@ -211,11 +323,11 @@ test("same-scope replacement withdraws a held old session and reopens the exact
window.fixtureRelay.disconnect();
return generation;
});
- await expect(detail(page)).toHaveCount(0);
+ await expect(thread(page)).toHaveCount(0);
await page.evaluate(() => window.fixtureRelay.retry());
release();
await expect(
- detail(page).locator(`[data-message-id="${app.exact.target.id}"]`),
+ thread(page).locator(`[data-message-id="${app.exact.target.id}"]`),
).toBeFocused();
expect(
await page.evaluate(() => window.fixtureRelay.snapshot().generation),
@@ -225,15 +337,15 @@ test("same-scope replacement withdraws a held old session and reopens the exact
});
});
-test("post-success membership loss removes detail and live updates do not snap it back to the target", async ({
+test("post-success membership loss removes the thread 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 region = thread(page);
const channelButton = page.getByRole("button", {
- name: "Open channel",
+ name: "Close thread",
exact: true,
});
await channelButton.focus();
@@ -261,7 +373,7 @@ test("post-success membership loss removes detail and live updates do not snap i
const readingTest = test.extend({ tallMessages: true });
readingTest(
- "exact detail leaves the ordinary channel reading anchor unchanged",
+ "exact thread navigation leaves the ordinary channel reading anchor unchanged",
async ({ page, app }) => {
const { settle, anchor, expectAnchor } = await import("./timeline.mjs");
await open(page, app);
@@ -274,7 +386,7 @@ readingTest(
const reading = await anchor(page);
expect(await openTarget(page, target(app))).toEqual({ status: "opened" });
await page
- .getByRole("button", { name: "Open channel", exact: true })
+ .getByRole("button", { name: "Close thread", exact: true })
.click();
await expect(history).toBeVisible();
await settle(page);
diff --git a/tests/browser/notifications.spec.mjs b/tests/browser/notifications.spec.mjs
index 4199cc84..be402427 100644
--- a/tests/browser/notifications.spec.mjs
+++ b/tests/browser/notifications.spec.mjs
@@ -234,7 +234,7 @@ test("a fully visible incoming row stays quiet without publishing read intent",
});
for (const kind of ["mention", "thread reply"]) {
- test(`live ${kind} notification opens only its exact focused row, then ordinary dwell reads it`, async ({
+ test(`live ${kind} notification focuses its exact row in the normal conversation, then ordinary dwell reads it`, async ({
page,
app,
}) => {
@@ -278,11 +278,11 @@ for (const kind of ["mention", "thread reply"]) {
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",
+ const surface = page.getByRole("region", {
+ name: root ? "Thread messages" : "Channel message history",
exact: true,
});
- const row = detail.locator(`[data-message-id="${incoming.id}"]`);
+ const row = surface.locator(`[data-message-id="${incoming.id}"]`);
await expect(row).toBeFocused();
await expect(row).toBeVisible();
await expect(row.locator("strong").filter({ hasText: kind })).toHaveText(
@@ -294,17 +294,31 @@ for (const kind of ["mention", "thread reply"]) {
)
.toBe("opened");
app.report.measurements.push({
- mode: `live ${kind} click to exact detail`,
+ mode: `live ${kind} click to exact conversation row`,
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,
- );
+ ).toBeVisible();
+ if (root) {
+ await expect(
+ surface.locator(`[data-message-id="${root.id}"]`),
+ ).toBeAttached();
+ await expect(
+ page.getByRole("textbox", { name: "Reply to thread", exact: true }),
+ ).toBeVisible();
+ await expect
+ .poll(() => app.report.queries.some((q) => q.filter.depth_limit))
+ .toBe(true);
+ } else {
+ await expect(
+ page.getByRole("region", { name: "Thread messages", 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);
From 65cc4c1fd948826c4b0f33653d2f64846b9f6e55 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 09:11:07 -0600
Subject: [PATCH 07/17] Preserve exact thread evidence and reading state
through repairs
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src/features/messages/ThreadPanel.tsx | 5 +-
src/features/relay/thread-target.test.ts | 61 +++++++++++++
src/features/relay/threads.ts | 26 +++---
tests/browser/fixture.mjs | 14 +++
tests/browser/message-navigation.spec.mjs | 101 +++++++++++++++++++++-
5 files changed, 191 insertions(+), 16 deletions(-)
diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx
index 85de18ef..4bd07991 100644
--- a/src/features/messages/ThreadPanel.tsx
+++ b/src/features/messages/ThreadPanel.tsx
@@ -351,11 +351,14 @@ function ThreadMessages({
(snapshot.status === "ready" && snapshot.canLoadMore)) && (
Loading thread…
)}
+ {snapshot.targetStatus === "unavailable" && (
+ Selected message unavailable.
+ )}
{snapshot.error && {snapshot.error}
}
{snapshot.limited && !snapshot.error && (
Thread history limit reached.
)}
- {snapshot.error && (
+ {(snapshot.error || snapshot.targetStatus === "unavailable") && (
void view.refresh()}>
Retry thread
diff --git a/src/features/relay/thread-target.test.ts b/src/features/relay/thread-target.test.ts
index bca0a2b1..ef5cffee 100644
--- a/src/features/relay/thread-target.test.ts
+++ b/src/features/relay/thread-target.test.ts
@@ -286,3 +286,64 @@ it("disposes held exact reads and purges loaded targets on cache clear", async (
await next.clearCache();
expect(next.view.snapshot().target).toBeUndefined();
});
+
+it("seeds a retained deletion after its selected target was evicted from the shared cache", async () => {
+ const h = setup();
+ h.view.dispose();
+ h.traffic.receive([reply]);
+ const noise = Array.from({ length: 140 }, (_, i) =>
+ message(alice, "b", `${i}${"x".repeat(65536)}`, i + 20),
+ );
+ h.traffic.receive(noise.slice(0, 100));
+ // Still-resolvable deletion is newer in the LRU than its original content.
+ h.traffic.receive([aux(5, reply)]);
+ h.traffic.receive(noise.slice(100)); // >8 MiB evicts the target, not this tombstone.
+ const view = h.session.thread("a", reply.id, { exact: true });
+ const reading = view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ h.next().respond([]); // Sparse relay read omits the already-observed deletion.
+ await flush();
+ const tombstones = h.next();
+ expect(tombstones.filters[0]?.kinds).toEqual([5, 9005]);
+ tombstones.respond([]);
+ await reading;
+ expect(view.snapshot()).toMatchObject({
+ targetStatus: "unavailable",
+ target: undefined,
+ });
+});
+
+it("retains verified rows throughout repair and keeps valid context when the selected reply is deleted", async () => {
+ const h = setup();
+ const sibling = message(alice, "a", "Sibling", 2, [
+ ["e", root.id, "", "reply"],
+ ]);
+ const { loading } = await targetRead(h);
+ h.next().respond([root, sibling]);
+ await loading;
+ const repairing = h.view.refresh();
+ expect(h.view.snapshot()).toMatchObject({
+ status: "loading",
+ root: { id: root.id },
+ target: { id: reply.id },
+ targetStatus: "ready",
+ });
+ expect(h.view.snapshot().replies.map((row) => row.id)).toEqual([
+ sibling.id,
+ reply.id,
+ ]);
+ h.next().respond([reply]);
+ await flush();
+ h.next().respond([]);
+ await flush();
+ h.next().respond([root, sibling]);
+ await repairing;
+ h.traffic.receive([aux(5, reply)]);
+ expect(h.view.snapshot()).toMatchObject({
+ root: { id: root.id },
+ target: undefined,
+ targetStatus: "unavailable",
+ });
+ expect(h.view.snapshot().replies.map((row) => row.id)).toEqual([sibling.id]);
+});
diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts
index 07c41b24..6771b629 100644
--- a/src/features/relay/threads.ts
+++ b/src/features/relay/threads.ts
@@ -92,7 +92,10 @@ export function createThreadView({
(exact && event.id === messageId) ||
(!!rootId && threadReference(event)?.rootId === rootId)),
);
- const ids = new Set([...remote, ...rows].map((event) => event.id));
+ const ids = new Set([
+ ...(exact ? [messageId] : []),
+ ...[...remote, ...rows].map((event) => event.id),
+ ]);
const result = new Map(rows.map((event) => [event.id, event]));
// Aux closure includes deletion of an auxiliary, not just direct row overlays.
for (let hop = 0; hop < 2; hop++) {
@@ -153,22 +156,21 @@ export function createThreadView({
? rows.find((row) => row.id === messageId)
: undefined;
if (targetStatus === "ready" && !target) targetStatus = "unavailable";
- const readable = !exact || targetStatus === "ready";
- const replies = rows
+ const readable = rows.filter(
+ (row) => !exact || row.id !== messageId || targetStatus === "ready",
+ );
+ const replies = readable
.filter(
(row) =>
- readable &&
- row.id !== rootId &&
- (!rootUnavailable || row.id === messageId),
+ row.id !== rootId && (!rootUnavailable || row.id === messageId),
)
.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
snapshot = Object.freeze({
...snapshot,
...patch,
- root:
- readable && !rootUnavailable
- ? rows.find((row) => row.id === rootId)
- : undefined,
+ root: !rootUnavailable
+ ? readable.find((row) => row.id === rootId)
+ : undefined,
replies: Object.freeze(replies),
...(exact ? { target, targetStatus } : {}),
});
@@ -248,7 +250,9 @@ export function createThreadView({
let nextCursor = replace ? undefined : cursor;
let nextPages = replace ? 0 : pages;
let fetched: readonly RelayEvent[] = [];
- if (exact && replace) targetStatus = "loading";
+ // Repair retains already-verified presentation; only a new/unavailable
+ // selection waits for its initial fold. Never unmount a reader on reconnect.
+ if (exact && replace && targetStatus !== "ready") targetStatus = "loading";
publish({ status: "loading", error: undefined });
try {
if (exact && replace) {
diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs
index d91ea08e..b4ecb916 100644
--- a/tests/browser/fixture.mjs
+++ b/tests/browser/fixture.mjs
@@ -900,6 +900,20 @@ export const test = base.extend({
}
return event;
},
+ deleteTarget() {
+ const event = sign(
+ 5,
+ [
+ ["h", "alpha"],
+ ["e", exact.target.id],
+ ],
+ "",
+ userKey,
+ exact.target.created_at + 100,
+ );
+ targetEvents.push(event);
+ relay.publish("primary", event);
+ },
reply(rootId, own = false) {
const replies = threadReplies.get(rootId);
if (!replies) throw new Error("Unknown fixture thread");
diff --git a/tests/browser/message-navigation.spec.mjs b/tests/browser/message-navigation.spec.mjs
index cc96aa4b..ef95c12c 100644
--- a/tests/browser/message-navigation.spec.mjs
+++ b/tests/browser/message-navigation.spec.mjs
@@ -1,5 +1,5 @@
import { test, expect } from "./fixture.mjs";
-import { open } from "./timeline.mjs";
+import { open, end, settle } from "./timeline.mjs";
test.use({ pluginFixtures: true, exactMessages: true });
const thread = (page) =>
@@ -136,9 +136,7 @@ test("loaded virtual rows reveal per attempt without thread reads or live-update
clickToOpenedMs: performance.now() - start,
});
await composer.focus();
- await history.evaluate((element) => {
- element.scrollTop = element.scrollHeight;
- });
+ await end(page);
}
expect(app.report.queries.filter((q) => q.filter.depth_limit)).toHaveLength(
0,
@@ -410,3 +408,98 @@ readTest(
.toBeGreaterThan(before);
},
);
+
+const liveTest = test.extend({ productionBroker: true });
+liveTest(
+ "stream repair retains thread rows, reading position and composer focus after exact opening",
+ async ({ page, app }) => {
+ await open(page, app);
+ await expect.poll(() => app.relay.hasRoute("primary", "alpha")).toBe(true);
+ expect(await openTarget(page, target(app))).toEqual({ status: "opened" });
+ const region = thread(page);
+ await expect(region.locator("[data-message-id]")).toHaveCount(81);
+ await expect(region.getByText("Loading thread…")).toHaveCount(0);
+ const readingRow = region.locator(
+ `[data-message-id="${app.exact.replies[40].id}"]`,
+ );
+ await readingRow.scrollIntoViewIfNeeded();
+ await region.dispatchEvent("wheel", { deltaY: -1 });
+ const composer = page.getByRole("textbox", {
+ name: "Reply to thread",
+ exact: true,
+ });
+ await composer.fill("Preserve my thread draft");
+ await settle(page);
+ const before = await region.evaluate((element) => element.scrollTop);
+ let release;
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ let intercepted;
+ const seen = new Promise((resolve) => {
+ intercepted = resolve;
+ });
+ let first = true;
+ await page.route("**/api/relay/**/query", async (route) => {
+ if (
+ !first ||
+ !route
+ .request()
+ .postDataJSON()
+ .some((filter) => filter.ids?.includes(app.exact.target.id))
+ )
+ return route.continue();
+ first = false;
+ intercepted();
+ await held;
+ await route.continue().catch(() => {});
+ });
+ app.relay.disconnect("primary");
+ await seen;
+ await expect(region.locator("[data-message-id]")).toHaveCount(81);
+ await expect(composer).toBeFocused();
+ expect(await region.evaluate((element) => element.scrollTop)).toBeCloseTo(
+ before,
+ 0,
+ );
+ release();
+ await expect(region.getByText("Loading thread…")).toHaveCount(0);
+ await expect(composer).toBeFocused();
+ await expect(composer).toHaveValue("Preserve my thread draft");
+ expect(await region.evaluate((element) => element.scrollTop)).toBeCloseTo(
+ before,
+ 0,
+ );
+ await expect(readingRow).toBeInViewport();
+ },
+);
+
+liveTest(
+ "deleting the selected reply keeps valid thread context and exposes a local unavailable state",
+ async ({ page, app }) => {
+ await open(page, app);
+ expect(await openTarget(page, target(app))).toEqual({ status: "opened" });
+ const region = thread(page);
+ await expect(region.locator("[data-message-id]")).toHaveCount(81);
+ app.deleteTarget();
+ await expect(
+ region.locator(`[data-message-id="${app.exact.target.id}"]`),
+ ).toHaveCount(0);
+ await expect(
+ region.locator(`[data-message-id="${app.exact.root.id}"]`),
+ ).toBeAttached();
+ await expect(region.locator("[data-message-id]")).toHaveCount(80);
+ await expect(
+ region.getByText("Selected message unavailable."),
+ ).toBeVisible();
+ await expect(region.getByText("Original message unavailable.")).toHaveCount(
+ 0,
+ );
+ await expect(
+ region.getByRole("button", { name: "Retry thread", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("textbox", { name: "Reply to thread", exact: true }),
+ ).toBeVisible();
+ },
+);
From 256142af75c27b22cb6029209654e739808ddd07 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 09:15:24 -0600
Subject: [PATCH 08/17] Admit exact target repairs only after overlay closure
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src/features/relay/session.ts | 5 +--
src/features/relay/thread-target.test.ts | 37 ++++++++++++++++++++++
src/features/relay/threads.ts | 40 +++++++++++++++++++-----
3 files changed, 72 insertions(+), 10 deletions(-)
diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts
index 14429afa..3b5831f4 100644
--- a/src/features/relay/session.ts
+++ b/src/features/relay/session.ts
@@ -643,12 +643,13 @@ export function createRelaySession(
throw new Error(
"Selected message exceeded its evidence limit",
);
- // Isolated lookup must not masquerade as contiguous channel history.
- return accept(events, false);
+ // The thread owner admits the complete target fold atomically.
+ return events;
},
}
: verified,
exact: options?.exact ?? false,
+ admit: options?.exact ? (events) => accept(events, false) : undefined,
seed: recent.peek(messageId)?.event,
local: localViews,
canAccess: () => !closed && canAccess(channelId),
diff --git a/src/features/relay/thread-target.test.ts b/src/features/relay/thread-target.test.ts
index ef5cffee..f1c1dbb3 100644
--- a/src/features/relay/thread-target.test.ts
+++ b/src/features/relay/thread-target.test.ts
@@ -347,3 +347,40 @@ it("retains verified rows throughout repair and keeps valid context when the sel
});
expect(h.view.snapshot().replies.map((row) => row.id)).toEqual([sibling.id]);
});
+
+for (const outcome of ["deleted", "failed", "live withdrawal"] as const) {
+ it(`keeps the last safe selected fold through held repair overlays: ${outcome}`, async () => {
+ const h = setup();
+ const { loading } = await targetRead(h);
+ h.next().respond([root]);
+ await loading;
+ const repair = h.view.refresh();
+ h.next().respond([reply]);
+ await flush();
+ const edit = aux(40003, reply, "Unsafe intermediate edit");
+ h.next().respond([edit]);
+ await flush();
+ const held = h.next();
+ expect(held.filters[0]?.kinds).toEqual([5, 9005]);
+ expect(h.view.snapshot().target?.content).toBe(reply.content);
+ expect(h.view.snapshot().replies[0]?.content).toBe(reply.content);
+ if (outcome === "failed") held.fail(new Error("offline"));
+ else {
+ if (outcome === "live withdrawal") {
+ h.traffic.receive([aux(5, edit)]); // Resolve against staged evidence without exposing it.
+ h.traffic.receive([aux(5, reply)]);
+ expect(h.view.snapshot().target).toBeUndefined();
+ }
+ held.respond([aux(5, edit)]);
+ await flush();
+ if (outcome !== "live withdrawal") h.next().respond([root]);
+ }
+ await repair;
+ if (outcome === "live withdrawal") {
+ expect(h.view.snapshot().targetStatus).toBe("unavailable");
+ } else {
+ expect(h.view.snapshot().target?.content).toBe(reply.content);
+ expect(h.view.snapshot().replies[0]?.content).toBe(reply.content);
+ }
+ });
+}
diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts
index 6771b629..c398ae62 100644
--- a/src/features/relay/threads.ts
+++ b/src/features/relay/threads.ts
@@ -50,6 +50,7 @@ export function createThreadView({
visible,
notify,
exact = false,
+ admit = (events) => events,
}: {
channelId: string;
messageId: string;
@@ -61,6 +62,10 @@ export function createThreadView({
visible(events: readonly RelayEvent[]): readonly RelayEvent[];
notify(listener: () => void): void;
exact?: boolean;
+ /** Exact finite reads enter session reconciliation only after a complete fold. */
+ admit?:
+ | ((events: readonly RelayEvent[]) => readonly RelayEvent[])
+ | undefined;
}) {
let disposed = false;
let rootId: string | undefined;
@@ -69,6 +74,7 @@ export function createThreadView({
? "loading"
: undefined;
let remote: readonly RelayEvent[] = [];
+ let staged: readonly RelayEvent[] = [];
let cursor: RelayEvent | undefined;
let pages = 0;
let controller: AbortController | undefined;
@@ -94,7 +100,7 @@ export function createThreadView({
);
const ids = new Set([
...(exact ? [messageId] : []),
- ...[...remote, ...rows].map((event) => event.id),
+ ...[...remote, ...staged, ...rows].map((event) => event.id),
]);
const result = new Map(rows.map((event) => [event.id, event]));
// Aux closure includes deletion of an auxiliary, not just direct row overlays.
@@ -176,11 +182,12 @@ export function createThreadView({
});
for (const listener of listeners) notify(listener);
}
- function retain(events: readonly RelayEvent[]) {
+ function retain(events: readonly RelayEvent[], commit = true) {
if (events.length > MAX_EVENTS || byteSize(events) > MAX_BYTES) {
// Never silently evict a deletion/ancestor then display resurrected content.
controller?.abort();
remote = [];
+ staged = [];
rootId = undefined;
cursor = undefined;
pages = 0;
@@ -195,7 +202,7 @@ export function createThreadView({
});
return false;
}
- remote = events;
+ if (commit) remote = events;
return true;
}
function receive(events: readonly RelayEvent[]) {
@@ -215,9 +222,11 @@ export function createThreadView({
controller?.abort();
controller = undefined;
again = false;
+ staged = [];
if (exact) targetStatus = "loading";
if (clear || !canAccess()) {
remote = [];
+ staged = [];
rootId = undefined;
cursor = undefined;
pages = 0;
@@ -286,8 +295,10 @@ export function createThreadView({
],
{ signal: owned.signal },
);
- if (!active() || !retain(union(remote, related(overlays)))) return;
- const ids = remote
+ if (!active()) return;
+ staged = related(overlays);
+ if (!retain(union(remote, staged), false)) return;
+ const ids = union(remote, staged)
.filter(
(event) =>
AUX.has(event.kind) &&
@@ -301,8 +312,15 @@ export function createThreadView({
[{ kinds: [5, 9005], "#e": ids, limit: 500 }],
{ signal: owned.signal },
);
- if (!active() || !retain(union(remote, related(tombstones)))) return;
+ if (!active()) return;
+ staged = union(staged, related(tombstones));
+ if (!retain(union(remote, staged), false)) return;
}
+ // Keep incomplete finite overlays out of both our displayed fold and
+ // the shared observation path. Live evidence still reconciles immediately.
+ const accepted = admit([event, ...staged]);
+ if (!active() || !retain(union(remote, related(accepted)))) return;
+ staged = [];
targetStatus = "ready";
publish();
if (snapshot.targetStatus !== "ready") {
@@ -327,7 +345,7 @@ export function createThreadView({
}
let more = false;
for (let page = 0; page < targetPages; page++) {
- const events = await reader.read(
+ const response = await reader.read(
[
{ ids: [rootId], "#h": [channelId], limit: 1 },
{
@@ -348,6 +366,8 @@ export function createThreadView({
{ signal: owned.signal },
);
if (!active()) return;
+ const events = admit(response);
+ if (!active()) return;
if (
!events.some(
(event) =>
@@ -413,6 +433,7 @@ export function createThreadView({
}
} finally {
if (controller === owned) {
+ staged = [];
controller = undefined;
if (again && !disposed) {
again = false;
@@ -423,7 +444,10 @@ export function createThreadView({
}
return {
channelId,
- event: (id: string) => remote.find((event) => event.id === id),
+ // Staged verified IDs allow immediate live delete-of-overlay access checks,
+ // without publishing the incomplete finite overlay into any shared view.
+ event: (id: string) =>
+ [...remote, ...staged].find((event) => event.id === id),
receive,
purge,
changed: () => publish(),
From bcb5aae03b7edb1d70d099afeddf180157f4109b Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 09:25:35 -0600
Subject: [PATCH 09/17] Admit selected content before retaining exact evidence
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src/features/relay/thread-target.test.ts | 24 ++++++++++++++++++++++++
src/features/relay/threads.ts | 6 +++++-
2 files changed, 29 insertions(+), 1 deletion(-)
diff --git a/src/features/relay/thread-target.test.ts b/src/features/relay/thread-target.test.ts
index f1c1dbb3..ca00e035 100644
--- a/src/features/relay/thread-target.test.ts
+++ b/src/features/relay/thread-target.test.ts
@@ -384,3 +384,27 @@ for (const outcome of ["deleted", "failed", "live withdrawal"] as const) {
}
});
}
+
+it("never retains a selected reply rejected by the shared dual-channel access gate", async () => {
+ const denied = message(alice, "a", "Forbidden dual-tag reply", 3, [
+ ["h", "b"],
+ ["e", root.id, "", "reply"],
+ ]);
+ const h = setup(denied.id);
+ h.traffic.receive([
+ roster(relay, "a", [viewer.pubkey]),
+ roster(relay, "b", []),
+ ]);
+ const reading = h.view.refresh();
+ h.next().respond([denied]);
+ await reading;
+ expect(h.view.snapshot()).toMatchObject({
+ targetStatus: "unavailable",
+ target: undefined,
+ replies: [],
+ });
+ expect(h.pending).toHaveLength(0); // No overlay/traversal can authorize rejected content.
+ h.traffic.receive([root]);
+ expect(h.view.snapshot().target).toBeUndefined();
+ expect(h.view.snapshot().replies).toEqual([]);
+});
diff --git a/src/features/relay/threads.ts b/src/features/relay/threads.ts
index c398ae62..1fb4c0b8 100644
--- a/src/features/relay/threads.ts
+++ b/src/features/relay/threads.ts
@@ -265,11 +265,15 @@ export function createThreadView({
publish({ status: "loading", error: undefined });
try {
if (exact && replace) {
- const selected = await reader.read(
+ const response = await reader.read(
[{ ids: [messageId], "#h": [channelId], limit: 1 }],
{ signal: owned.signal },
);
if (!active()) return;
+ // Admit the immutable selected content before retention, just as normal
+ // reads do. Only its separately fetched overlays wait for closure.
+ const selected = admit(response);
+ if (!active()) return;
const event = selected.find(
(event) =>
event.id === messageId &&
From 8cabf9ef39b6c8127d64b9fd4489b0b310191405 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 10:10:31 -0600
Subject: [PATCH 10/17] test(notifications): make freshness boundaries
deterministic
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src/features/notifications/messages.test.ts | 35 +++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts
index ae9f82bf..55dce04f 100644
--- a/src/features/notifications/messages.test.ts
+++ b/src/features/notifications/messages.test.ts
@@ -233,6 +233,8 @@ async function setup(
it.each([9, 40002])(
"only production live kind-%s traffic can notify, never history/replay/local observation",
async (kind) => {
+ // Keep second-rounded fixtures outside the cutoff while signing/admitting.
+ vi.spyOn(Date, "now").mockReturnValue(1_780_000_000_000);
const h = await setup();
const original = h.make;
h.make = (text, age = 0, author = h.peer) =>
@@ -262,6 +264,39 @@ it.each([9, 40002])(
);
},
);
+it.each(
+ [9, 40002].flatMap((kind) =>
+ [
+ { age: -30001, allowed: false },
+ { age: -30000, allowed: true },
+ { age: 120000, allowed: true },
+ { age: 120001, allowed: false },
+ ].map((boundary) => ({ kind, ...boundary })),
+ ),
+)(
+ "live kind-$kind at age $age ms: notification allowed=$allowed",
+ async ({ kind, age, allowed }) => {
+ const createdAt = 1_780_000_000;
+ vi.spyOn(Date, "now").mockReturnValue(createdAt * 1000 + age);
+ const h = await setup();
+ h.emit(
+ [
+ signed(h.peer, {
+ kind,
+ created_at: createdAt,
+ content: "boundary",
+ tags: [
+ ["h", "room"],
+ ["p", h.viewer.pubkey],
+ ],
+ }),
+ ],
+ "live",
+ );
+ await flush();
+ expect(h.show).toHaveBeenCalledTimes(allowed ? 1 : 0);
+ },
+);
it("live membership activity and observer telemetry never become message notifications", async () => {
const h = await setup();
h.emit(
From ddf468c67c0f782661a4848862629a203dbf2a19 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 10:54:22 -0600
Subject: [PATCH 11/17] Open exact content from running desktop notification
clicks
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
.github/workflows/ci.yml | 33 +-
Cargo.lock | 4 +
docs/contributing.md | 5 +-
docs/notifications.md | 56 ++-
src-tauri/Cargo.toml | 10 +
src-tauri/capabilities/default.json | 1 -
src-tauri/src/lib.rs | 4 +
src-tauri/src/notifications.rs | 326 ++++++++++++++++++
src/app/NotificationSettings.tsx | 3 +-
.../notifications/desktop-channel.test.ts | 136 ++++++++
src/features/notifications/desktop.test.ts | 176 +++++++++-
src/features/notifications/platform.ts | 51 ++-
12 files changed, 761 insertions(+), 44 deletions(-)
create mode 100644 src-tauri/src/notifications.rs
create mode 100644 src/features/notifications/desktop-channel.test.ts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b54c9f69..50bcb38a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,34 @@ jobs:
- name: All Node integration tests
run: node --test tests/integration/*.test.mjs
+ windows-native:
+ name: Windows native notifications
+ runs-on: windows-2025
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+ # Hermit does not run on Windows. Use the same repository Rust pin with
+ # the hosted runner's standard rustup, not a second floating toolchain.
+ - name: Select pinned Rust
+ shell: pwsh
+ run: |
+ $pins = @(Get-ChildItem bin/.rust-*.pkg)
+ if ($pins.Count -ne 1) { throw "Expected exactly one repository Rust pin" }
+ $version = $pins[0].Name -replace '^\.rust-(.*)\.pkg$', '$1'
+ rustup toolchain install $version --profile minimal --component clippy
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ "RUSTUP_TOOLCHAIN=$version" >> $env:GITHUB_ENV
+ - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
+ with:
+ key: windows-native
+ save-if: ${{ github.event_name == 'push' }}
+ - name: Native lint including Windows backend
+ run: cargo clippy -p buzz-foundation --locked --all-targets -- -D warnings
+ - name: All native package tests
+ run: cargo test -p buzz-foundation --locked
+
measurements:
name: Browser measurements
runs-on: ubuntu-24.04
@@ -127,7 +155,7 @@ jobs:
required:
name: CI required
if: always()
- needs: [javascript, native, measurements, browser]
+ needs: [javascript, native, windows-native, measurements, browser]
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
@@ -135,9 +163,10 @@ jobs:
env:
JAVASCRIPT: ${{ needs.javascript.result }}
NATIVE: ${{ needs.native.result }}
+ WINDOWS_NATIVE: ${{ needs.windows-native.result }}
MEASUREMENTS: ${{ needs.measurements.result }}
BROWSER: ${{ needs.browser.result }}
run: |
- for result in "$JAVASCRIPT" "$NATIVE" "$MEASUREMENTS" "$BROWSER"; do
+ for result in "$JAVASCRIPT" "$NATIVE" "$WINDOWS_NATIVE" "$MEASUREMENTS" "$BROWSER"; do
test "$result" = success || exit 1
done
diff --git a/Cargo.lock b/Cargo.lock
index 3931db68..e69031b4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -339,7 +339,10 @@ name = "buzz-foundation"
version = "0.0.0"
dependencies = [
"buzzodz-plugins",
+ "gtk",
"libc",
+ "mac-notification-sys",
+ "notify-rust",
"portable-pty",
"serde",
"tauri",
@@ -347,6 +350,7 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
+ "tauri-winrt-notification",
"url",
"uuid",
]
diff --git a/docs/contributing.md b/docs/contributing.md
index 729a7f65..9cc3939f 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -189,6 +189,9 @@ the complete suite still runs with `pnpm test` / `just scan`:
doctests (including Tauri), and every Node integration test. The CLI integration
tests build Rust and install scaffold dependencies; they are intentionally CI-only
rather than part of pre-push.
+- **Windows native notifications:** Clippy and all Tauri-package tests on Windows,
+ using the repository Rust pin through rustup (Hermit is not available there).
+ This compiles the Windows backend; it does not exercise OS banner interaction.
- **Browser measurements:** Chromium then WebKit, serially on an isolated runner.
- **Browser journeys:** four runners (Chromium and WebKit, two file-level shards
per engine), each with two workers. They start alongside measurements on separate
@@ -204,7 +207,7 @@ the complete suite still runs with `pnpm test` / `just scan`:
Actions and tool versions are pinned, installs use the frozen lockfile, and
Hermit/pnpm/Cargo/browser caches avoid repeat downloads and cold compilation.
-Superseded PR runs are cancelled. CI uses disposable Ubuntu runners and no live
+Superseded PR runs are cancelled. CI uses disposable Ubuntu/Windows runners and no live
Buzz identity or signing credentials. It is not native GUI acceptance, a signed
package, or a cross-platform release gate. `just scan` remains available locally;
CI does not add full scans to commit/push or ordinary interactive feedback rounds.
diff --git a/docs/notifications.md b/docs/notifications.md
index 8148e2d5..4b8f0882 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -16,7 +16,7 @@ export function apply(ctx) {
```
Categories use existing installation ownership. Disabled/replaced plugins cannot
-submit new alerts. A browser notification click belongs to the host; opening never enables
+submit new alerts. A 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.
@@ -40,9 +40,11 @@ checks, not that an OS banner was displayed or read.
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
+ alerts, closing the oldest before retiring its callback. Desktop retains at most
+ 128 active callbacks/waits and rejects new presentations at capacity rather than
+ evicting an existing target or queuing unbounded workers. These are not durable
exactly-once or cross-window guarantees.
-- Browser clicks use the existing typed, account/community-scoped navigation path. It owns
+- Browser and desktop 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.
Loaded top-level targets use the timeline; off-window targets and replies use
@@ -66,22 +68,42 @@ 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.
+Desktop builds use one small Tauri bridge into the same maintained backends as
+the official plugin: mac-notification-sys on macOS, notify-rust on Linux, and
+tauri-winrt-notification on Windows. No dependency upgrade or new native FFI is
+needed. The plugin is retained only for its permission API; its send capability
+is no longer granted. The main-window-only bridge carries display text and an
+opaque presentation ID, never an account, credential or navigation destination.
+Its Tauri response channel is registered before native submission.
-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.
+Desktop clicks restore/foreground Buzz and then call the existing activation
+closure. macOS explicitly waits for a body click off the UI thread (the generic
+notify-rust wrapper omits that flag). Windows retains its callback when the
+banner fades, because timeout is not removal from Notification Center. Linux
+requests the standard default action and checks that the notification service
+supports actions; GTK's standard present operation shows/restores/raises the
+window without the framework's stale minimized-state focus guard. Compositor
+focus policy still applies. Dismissal never navigates. Observable send/focus
+failures reach Settings without retry; a focus error does not discard navigation.
-Real banners still require OS permission, an available notification service and
+The permission API does not expose actual OS permission state. Settings describes
+permission and sound as system-controlled, without an ineffective desktop sound
+toggle. The bridge accepts a submission before waiting for interaction: acceptance
+is **not** proof that a visible banner appeared. The macOS backend does not expose
+all delivery failures, and no uniform withdrawal/receipt guarantee is promised.
+Callbacks stop navigating after account change or frontend disposal. Native waits
+remain bounded until the OS resolves them; no artificial expiry strands an
+otherwise actionable alert. Reload/cold-start restoration remains out of scope.
+
+Real banners 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.
+Chromium/WebKit fixtures replace only OS/IPC boundaries; tests and native builds
+do not prove actual permission dialogs, appearance, sound or foregrounding.
+Report native checks and real banner/click results separately for each platform.
+
+For macOS, Windows and Linux, manual acceptance includes background and minimized
+Buzz, two distinct message/thread targets, immediate banner click, banner fade
+then Notification Center click, dismissal without navigation, and old-account or
+revoked-access rejection. A macOS pass is not Windows/Linux acceptance.
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 1fc57bf1..a475c463 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -25,3 +25,13 @@ url = "2"
[target.'cfg(unix)'.dependencies]
portable-pty = "0.9"
libc = "0.2"
+
+[target.'cfg(target_os = "macos")'.dependencies]
+mac-notification-sys = "=0.6.15"
+
+[target.'cfg(target_os = "windows")'.dependencies]
+tauri-winrt-notification = "=0.7.3"
+
+[target.'cfg(target_os = "linux")'.dependencies]
+notify-rust = "=4.18.0"
+gtk = "0.18"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 7a9d7644..4f42760f 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -8,7 +8,6 @@
"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 1346fac7..58eafab4 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,4 +1,6 @@
+mod notifications;
mod terminal;
+use notifications::{notification_show, Notifications};
use tauri::Manager as _;
use terminal::{
terminal_close, terminal_close_owner, terminal_create_owner, terminal_read, terminal_resize,
@@ -159,8 +161,10 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.manage(Imports::default())
.manage(Terminals::default())
+ .manage(Notifications::default())
.manage(PluginManager(Manager::from_env()))
.invoke_handler(tauri::generate_handler![
+ notification_show,
terminal_create_owner,
terminal_spawn,
terminal_read,
diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs
new file mode 100644
index 00000000..4f821bc2
--- /dev/null
+++ b/src-tauri/src/notifications.rs
@@ -0,0 +1,326 @@
+//! Running-session desktop clicks. Policy and navigation remain in the host service.
+//! The maintained OS backends already used by Tauri own delivery and native callbacks.
+use serde::Serialize;
+use std::sync::{Arc, Mutex};
+use tauri::{ipc::Channel, Manager};
+
+const MAX_ACTIVE: usize = 128;
+#[derive(Clone, Default)]
+pub(crate) struct Notifications(Arc>);
+
+#[derive(Debug, PartialEq)]
+enum Outcome {
+ Activated,
+ Closed,
+ Failed(String),
+}
+
+type Callback = Box;
+struct Pending {
+ count: Notifications,
+ callback: Mutex>,
+}
+impl Notifications {
+ fn reserve(&self, callback: Callback) -> Result, String> {
+ let mut count = self
+ .0
+ .lock()
+ .map_err(|_| "Notification state unavailable")?;
+ if *count >= MAX_ACTIVE {
+ return Err("Too many active desktop notifications (maximum 128)".into());
+ }
+ *count += 1;
+ Ok(Arc::new(Pending {
+ count: self.clone(),
+ callback: Mutex::new(Some(callback)),
+ }))
+ }
+}
+impl Pending {
+ fn finish(&self, outcome: Outcome) {
+ let callback = self.callback.lock().ok().and_then(|mut slot| slot.take());
+ if let Some(callback) = callback {
+ if let Ok(mut count) = self.count.0.lock() {
+ *count -= 1;
+ }
+ callback(outcome);
+ }
+ }
+}
+impl Drop for Pending {
+ fn drop(&mut self) {
+ // Covers backend failure/panic before a terminal response as well.
+ self.finish(Outcome::Closed);
+ }
+}
+
+#[derive(Clone, Serialize)]
+pub(crate) struct Response {
+ id: String,
+ kind: &'static str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
+fn respond(app: tauri::AppHandle, channel: Channel, id: String, outcome: Outcome) {
+ let mut response = Response {
+ id,
+ kind: match outcome {
+ Outcome::Activated => "activated",
+ Outcome::Closed => "closed",
+ Outcome::Failed(_) => "failed",
+ },
+ error: None,
+ };
+ if let Outcome::Failed(ref error) = outcome {
+ response.error = Some(error.clone());
+ }
+ if outcome != Outcome::Activated {
+ let _ = channel.send(response);
+ return;
+ }
+ let fallback = (channel.clone(), response.clone());
+ let main_app = app.clone();
+ // Complete foregrounding on the main thread before delivering the exact click.
+ // Even a focus failure must not silently discard the user's navigation intent.
+ if let Err(error) = app.run_on_main_thread(move || {
+ response.error = (|| {
+ let window = main_app
+ .get_webview_window("main")
+ .ok_or("Buzz window unavailable")?;
+ focus(&window)
+ })()
+ .err();
+ let _ = channel.send(response);
+ }) {
+ let (channel, mut response) = fallback;
+ response.error = Some(error.to_string());
+ let _ = channel.send(response);
+ }
+}
+
+fn focus(window: &tauri::WebviewWindow) -> Result<(), String> {
+ #[cfg(target_os = "linux")]
+ {
+ use gtk::prelude::GtkWindowExt;
+ // Tao queues show/unminimize but checks the old state before queuing
+ // focus. GTK present performs the standard show/restore/raise operation
+ // without that stale-state guard. This runs on Tauri's main thread.
+ window.gtk_window().map_err(|e| e.to_string())?.present();
+ Ok(())
+ }
+ #[cfg(not(target_os = "linux"))]
+ {
+ window.show().map_err(|e| e.to_string())?;
+ window.unminimize().map_err(|e| e.to_string())?;
+ window.set_focus().map_err(|e| e.to_string())
+ }
+}
+
+fn validate(id: &str, title: &str, body: &str) -> Result<(), String> {
+ if uuid::Uuid::parse_str(id).is_err() || title.len() > 4096 || body.len() > 4096 {
+ return Err("Invalid desktop notification".into());
+ }
+ Ok(())
+}
+
+#[tauri::command]
+pub(crate) async fn notification_show(
+ app: tauri::AppHandle,
+ window: tauri::WebviewWindow,
+ state: tauri::State<'_, Notifications>,
+ id: String,
+ title: String,
+ body: String,
+ on_event: Channel,
+) -> Result<(), String> {
+ if window.label() != "main" {
+ return Err("Desktop notifications belong to the main window".into());
+ }
+ validate(&id, &title, &body)?;
+ let responder = app.clone();
+ let pending = state.reserve(Box::new(move |outcome| {
+ respond(responder, on_event, id, outcome)
+ }))?;
+ // Admission returns promptly. Submission errors arrive on the pre-registered
+ // channel; no platform's return value is claimed as proof of a visible banner.
+ show(app, title, body, pending);
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+fn show(app: tauri::AppHandle, title: String, body: String, pending: Arc) {
+ tauri::async_runtime::spawn_blocking(move || {
+ // Preserve Tauri's development/installed identity convention. Initialize
+ // once because this backend deliberately rejects subsequent set calls.
+ static IDENTITY: std::sync::OnceLock> = std::sync::OnceLock::new();
+ let identity = IDENTITY.get_or_init(|| {
+ mac_notification_sys::set_application(if tauri::is_dev() {
+ "com.apple.Terminal"
+ } else {
+ &app.config().identifier
+ })
+ .map_err(|e| e.to_string())
+ });
+ if let Err(error) = identity {
+ pending.finish(Outcome::Failed(error.clone()));
+ return;
+ }
+ // notify-rust's buttonless wrapper omits wait_for_click. Use its existing
+ // backend directly so body clicks retain their response registration.
+ let result = mac_notification_sys::Notification::new()
+ .title(&title)
+ .message(&body)
+ .wait_for_click(true)
+ .asynchronous(false)
+ .send();
+ pending.finish(match result {
+ Ok(mac_notification_sys::NotificationResponse::Click) => Outcome::Activated,
+ Ok(_) => Outcome::Closed,
+ Err(error) => Outcome::Failed(error.to_string()),
+ });
+ });
+}
+
+#[cfg(target_os = "linux")]
+fn show(_app: tauri::AppHandle, title: String, body: String, pending: Arc) {
+ tauri::async_runtime::spawn(async move {
+ let capabilities = tauri::async_runtime::spawn_blocking(notify_rust::get_capabilities)
+ .await
+ .map_err(|e| e.to_string())
+ .and_then(|result| result.map_err(|e| e.to_string()));
+ match capabilities {
+ Ok(capabilities)
+ if capabilities
+ .iter()
+ .any(|capability| capability == "actions") => {}
+ Ok(_) => {
+ pending.finish(Outcome::Failed(
+ "The desktop notification service does not support clicks".into(),
+ ));
+ return;
+ }
+ Err(error) => {
+ pending.finish(Outcome::Failed(error));
+ return;
+ }
+ }
+ let result = notify_rust::Notification::new()
+ .summary(&title)
+ .body(&body)
+ .appname("Buzz")
+ .auto_icon()
+ .action("default", "Open")
+ .show_async()
+ .await;
+ match result {
+ Ok(handle) => {
+ handle
+ .wait_for_action_async(|response| {
+ pending.finish(
+ if matches!(response, notify_rust::NotificationResponse::Default) {
+ Outcome::Activated
+ } else {
+ Outcome::Closed
+ },
+ );
+ })
+ .await
+ }
+ Err(error) => pending.finish(Outcome::Failed(error.to_string())),
+ }
+ });
+}
+
+#[cfg(target_os = "windows")]
+fn show(app: tauri::AppHandle, title: String, body: String, pending: Arc) {
+ use tauri_winrt_notification::{Toast, ToastDismissalReason};
+ tauri::async_runtime::spawn_blocking(move || {
+ let activated = pending.clone();
+ let dismissed = pending.clone();
+ let result = (|| {
+ let exe = tauri::utils::platform::current_exe().map_err(|e| e.to_string())?;
+ let development = exe.parent().is_some_and(|dir| {
+ dir.ends_with("target/debug") || dir.ends_with("target/release")
+ });
+ Toast::new(if development {
+ Toast::POWERSHELL_APP_ID
+ } else {
+ &app.config().identifier
+ })
+ .title(&title)
+ .text1(&body)
+ .on_activated(move |_| {
+ activated.finish(Outcome::Activated);
+ Ok(())
+ })
+ .on_dismissed(move |reason| {
+ // Banner timeout is NOT notification-center dismissal. Keep
+ // the click registration until actual activation/removal.
+ if matches!(
+ reason,
+ Some(
+ ToastDismissalReason::UserCanceled
+ | ToastDismissalReason::ApplicationHidden
+ )
+ ) {
+ dismissed.finish(Outcome::Closed);
+ }
+ Ok(())
+ })
+ .show()
+ .map_err(|e| e.to_string())
+ })();
+ if let Err(error) = result {
+ pending.finish(Outcome::Failed(error));
+ }
+ });
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+fn show(_app: tauri::AppHandle, _title: String, _body: String, pending: Arc) {
+ pending.finish(Outcome::Failed(
+ "Desktop notifications unavailable on this platform".into(),
+ ));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn one_terminal_response_releases_capacity_and_callback() {
+ let state = Notifications::default();
+ let seen = Arc::new(Mutex::new(Vec::new()));
+ let output = seen.clone();
+ let pending = state
+ .reserve(Box::new(move |event| output.lock().unwrap().push(event)))
+ .unwrap();
+ pending.finish(Outcome::Activated);
+ pending.finish(Outcome::Closed);
+ drop(pending);
+ assert_eq!(*seen.lock().unwrap(), vec![Outcome::Activated]);
+ assert_eq!(*state.0.lock().unwrap(), 0);
+ }
+
+ #[test]
+ fn capacity_rejects_before_display_without_evicting_existing_callbacks() {
+ let state = Notifications::default();
+ let mut pending = Vec::new();
+ for _ in 0..MAX_ACTIVE {
+ pending.push(state.reserve(Box::new(|_| {})).unwrap());
+ }
+ assert!(state.reserve(Box::new(|_| {})).is_err());
+ pending.pop().unwrap().finish(Outcome::Closed);
+ assert!(state.reserve(Box::new(|_| {})).is_ok());
+ drop(pending);
+ assert_eq!(*state.0.lock().unwrap(), 0);
+ }
+
+ #[test]
+ fn ingress_accepts_only_bounded_text_and_an_opaque_id() {
+ assert!(validate(&uuid::Uuid::new_v4().to_string(), "Buzz", "Hello").is_ok());
+ assert!(validate("not a presentation ID", "Buzz", "Hello").is_err());
+ assert!(validate(&uuid::Uuid::new_v4().to_string(), "Buzz", &"x".repeat(4097)).is_err());
+ }
+}
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index a768597a..ce000091 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -70,7 +70,8 @@ export function NotificationSettings({
{state.systemManaged ? (
Manage sound and permission in system notification settings. Desktop
- banners do not open a specific message when clicked.
+ clicks bring Buzz forward and open the message or thread while Buzz
+ is running.
) : (
<>
diff --git a/src/features/notifications/desktop-channel.test.ts b/src/features/notifications/desktop-channel.test.ts
new file mode 100644
index 00000000..f6706a48
--- /dev/null
+++ b/src/features/notifications/desktop-channel.test.ts
@@ -0,0 +1,136 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { createNotifications } from "./platform";
+
+// Real Tauri Channel/serialization and production adapter. Only the WebView's
+// callback registry and native command boundary are replaced (no OS banners).
+type WireMessage =
+ | { message: { id: string; kind: string; error?: string }; index: number }
+ | { end: true; index: number };
+const callbacks = new Map void>();
+let sequence = 0;
+const calls: { id: string; onEvent: string; title: string; body: string }[] =
+ [];
+const invoke = vi.fn(async (_command: string, args: unknown) => {
+ calls.push(JSON.parse(JSON.stringify(args)));
+});
+beforeEach(() => {
+ vi.stubGlobal("isTauri", true);
+ vi.stubGlobal("window", {
+ __TAURI_INTERNALS__: {
+ transformCallback(callback: (message: WireMessage) => void) {
+ const id = ++sequence;
+ callbacks.set(id, callback);
+ return id;
+ },
+ unregisterCallback(id: number) {
+ callbacks.delete(id);
+ },
+ invoke,
+ },
+ });
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetAllMocks();
+ callbacks.clear();
+ calls.length = 0;
+});
+const item = () => ({
+ id: crypto.randomUUID(),
+ title: "Buzz",
+ body: "Hello",
+ silent: true,
+});
+function callback(index = 0) {
+ const call = calls[index];
+ if (!call) throw new Error("No native presentation");
+ expect(call.onEvent).toMatch(/^__CHANNEL__:\d+$/);
+ const id = Number(call.onEvent.split(":")[1]);
+ const send = callbacks.get(id);
+ if (!send) throw new Error("Native callback was not registered before send");
+ return { call, id, send };
+}
+
+it("serializes each pre-registered channel and releases it after ordered native completion", async () => {
+ const platform = createNotifications();
+ const first = vi.fn(),
+ second = vi.fn(),
+ failed = vi.fn();
+ await platform.show(item(), first, failed);
+ await platform.show(item(), second, failed);
+ expect(invoke.mock.calls.map(([command]) => command)).toEqual([
+ "notification_show",
+ "notification_show",
+ ]);
+ const a = callback(0),
+ b = callback(1);
+ expect(a.id).not.toBe(b.id);
+ expect(Object.keys(a.call).sort()).toEqual([
+ "body",
+ "id",
+ "onEvent",
+ "title",
+ ]);
+ // Tauri may deliver end ahead of a payload: Channel must hold it until index 0.
+ b.send({ end: true, index: 1 });
+ expect(callbacks.has(b.id)).toBe(true);
+ b.send({ message: { id: b.call.id, kind: "activated" }, index: 0 });
+ expect(second).toHaveBeenCalledOnce();
+ expect(first).not.toHaveBeenCalled();
+ expect(callbacks.has(b.id)).toBe(false);
+ a.send({ message: { id: a.call.id, kind: "closed" }, index: 0 });
+ a.send({ end: true, index: 1 });
+ expect(first).not.toHaveBeenCalled();
+ expect(failed).not.toHaveBeenCalled();
+ expect(callbacks.size).toBe(0);
+ platform.dispose();
+});
+
+it("accepts a native activation before the command promise resolves", async () => {
+ const platform = createNotifications();
+ const activate = vi.fn();
+ invoke.mockImplementationOnce(async (_command, args) => {
+ calls.push(JSON.parse(JSON.stringify(args)));
+ const { call, send } = callback();
+ send({ message: { id: call.id, kind: "activated" }, index: 0 });
+ send({ end: true, index: 1 });
+ });
+ await platform.show(item(), activate, vi.fn());
+ expect(activate).toHaveBeenCalledOnce();
+ expect(callbacks.size).toBe(0);
+ platform.dispose();
+});
+
+it("disposal fences the real transport while a late native end still releases registration", async () => {
+ const platform = createNotifications();
+ const activate = vi.fn(),
+ failed = vi.fn();
+ await platform.show(item(), activate, failed);
+ const { call, send } = callback();
+ platform.dispose();
+ send({
+ message: { id: call.id, kind: "activated", error: "late" },
+ index: 0,
+ });
+ send({ end: true, index: 1 });
+ expect(activate).not.toHaveBeenCalled();
+ expect(failed).not.toHaveBeenCalled();
+ expect(callbacks.size).toBe(0);
+});
+
+it("native admission rejection ends its channel without navigation or a send retry", async () => {
+ const platform = createNotifications();
+ const activate = vi.fn();
+ invoke.mockImplementationOnce(async (_command, args) => {
+ calls.push(JSON.parse(JSON.stringify(args)));
+ callback().send({ end: true, index: 0 });
+ throw new Error("Too many active desktop notifications (maximum 128)");
+ });
+ await expect(platform.show(item(), activate, vi.fn())).rejects.toThrow(
+ "maximum 128",
+ );
+ expect(callbacks.size).toBe(0);
+ expect(activate).not.toHaveBeenCalled();
+ expect(invoke).toHaveBeenCalledOnce();
+ platform.dispose();
+});
diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts
index d1817215..4ea005b0 100644
--- a/src/features/notifications/desktop.test.ts
+++ b/src/features/notifications/desktop.test.ts
@@ -13,9 +13,16 @@ const sdk = vi.hoisted(() => ({
isPermissionGranted: vi.fn(async () => true),
requestPermission: vi.fn(async () => "granted"),
sendNotification: vi.fn(),
+ invoke: vi.fn(async () => {}),
}));
const native = vi.hoisted(() => ({ value: true }));
-vi.mock("@tauri-apps/api/core", () => ({ isTauri: () => native.value }));
+vi.mock("@tauri-apps/api/core", () => ({
+ isTauri: () => native.value,
+ invoke: sdk.invoke,
+ Channel: class {
+ constructor(public onmessage: (response: unknown) => void) {}
+ },
+}));
vi.mock("@tauri-apps/plugin-notification", () => sdk);
vi.mock("react", async (original) => ({
...(await original()),
@@ -45,13 +52,13 @@ function setup() {
() => true,
() => true,
);
- return { service, submit };
+ return { service, submit, navigation, ctx };
}
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 () => {
+it("the default service sends desktop banners via the native bridge and shared policy", async () => {
const { service, submit } = setup();
await flush();
expect(service.snapshot()).toMatchObject({
@@ -63,16 +70,18 @@ it("the default service sends desktop banners via the official SDK and shared po
await submit("first");
await submit("first");
await flush();
- expect(sdk.sendNotification).toHaveBeenCalledExactlyOnceWith({
+ expect(sdk.invoke).toHaveBeenCalledExactlyOnceWith("notification_show", {
title: "Buzz",
body: "New mentions",
+ id: expect.any(String),
+ onEvent: expect.objectContaining({ onmessage: expect.any(Function) }),
});
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);
+ expect(sdk.invoke).toHaveBeenCalledTimes(1);
});
it("an explicit permission request uses the SDK without claiming OS permission is known", async () => {
@@ -82,18 +91,18 @@ it("an explicit permission request uses the SDK without claiming OS permission i
expect(service.snapshot().permission).toBe("default");
await submit("pending");
await flush();
- expect(sdk.sendNotification).not.toHaveBeenCalled();
+ expect(sdk.invoke).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();
+ expect(sdk.invoke).toHaveBeenCalledOnce();
});
it("observable SDK failures surface once without retry or a browser fallback", async () => {
const { service, submit } = setup();
- sdk.sendNotification.mockImplementationOnce(() => {
+ sdk.invoke.mockImplementationOnce(() => {
throw new Error("SDK unavailable");
});
await submit("failed");
@@ -101,13 +110,13 @@ it("observable SDK failures surface once without retry or a browser fallback", a
expect(service.snapshot().error).toBe("SDK unavailable");
await submit("failed");
await flush();
- expect(sdk.sendNotification).toHaveBeenCalledOnce();
+ expect(sdk.invoke).toHaveBeenCalledOnce();
await submit("next");
await flush();
- expect(sdk.sendNotification).toHaveBeenCalledTimes(2);
+ expect(sdk.invoke).toHaveBeenCalledTimes(2);
});
-it("desktop settings keep master/categories but explain OS sound and click limits", async () => {
+it("desktop settings explain OS sound and running-app exact clicks", async () => {
const { service } = setup();
await flush();
const html = renderToStaticMarkup(
@@ -119,7 +128,7 @@ it("desktop settings keep master/categories but explain OS sound and click limit
"Manage sound and permission in system notification settings",
);
expect(html).toContain(
- "Desktop banners do not open a specific message when clicked",
+ "Desktop clicks bring Buzz forward and open the message or thread while Buzz is",
);
expect(html).not.toContain("Sound ");
expect(html).not.toContain("Permission granted");
@@ -131,7 +140,7 @@ it("non-Tauri runs select the unchanged browser adapter, never the native SDK",
expect(platform.label).toBe("Browser notifications");
expect(await platform.permission()).toBe("unsupported");
expect(sdk.isPermissionGranted).not.toHaveBeenCalled();
- expect(sdk.sendNotification).not.toHaveBeenCalled();
+ expect(sdk.invoke).not.toHaveBeenCalled();
});
it("the production desktop adapter forwards the message title and preview unchanged", async () => {
@@ -157,8 +166,147 @@ it("the production desktop adapter forwards the message title and preview unchan
),
);
await flush();
- expect(sdk.sendNotification).toHaveBeenCalledExactlyOnceWith({
+ expect(sdk.invoke).toHaveBeenCalledExactlyOnceWith("notification_show", {
title: "Pinky mentioned you in #Room",
body: "Hello Wes",
+ id: expect.any(String),
+ onEvent: expect.objectContaining({ onmessage: expect.any(Function) }),
+ });
+});
+
+function presentation(index = 0) {
+ const call = sdk.invoke.mock.calls[index] as unknown as [
+ string,
+ {
+ id: string;
+ onEvent: {
+ onmessage(response: { id: string; kind: string; error?: string }): void;
+ };
+ },
+ ];
+ return call[1];
+}
+
+it("the production default returns each native click to its exact navigation callback once", async () => {
+ const { service, navigation } = setup();
+ const open = vi.fn();
+ navigation.subscribe(() => open(navigation.snapshot().entry.target));
+ for (const section of ["notifications", "appearance"] as const) {
+ await service.admit(
+ "mention",
+ "Mentions",
+ {
+ sourceKey: section,
+ target: { version: 1, kind: "settings", section },
+ },
+ () => true,
+ () => true,
+ );
+ }
+ await flush();
+ const first = presentation(0),
+ second = presentation(1);
+ expect(first.id).not.toBe(second.id);
+ first.onEvent.onmessage({ id: second.id, kind: "activated" });
+ expect(open).not.toHaveBeenCalled();
+ second.onEvent.onmessage({ id: second.id, kind: "activated" });
+ first.onEvent.onmessage({ id: first.id, kind: "activated" });
+ first.onEvent.onmessage({ id: first.id, kind: "activated" });
+ expect(open.mock.calls.map(([target]) => target)).toEqual([
+ { version: 1, kind: "settings", section: "appearance" },
+ { version: 1, kind: "settings", section: "notifications" },
+ ]);
+});
+
+it("native close/error never opens or retries; focus failure still preserves exact navigation", async () => {
+ const { service, submit, navigation } = setup();
+ const open = vi.fn();
+ navigation.subscribe(() => open(navigation.snapshot().entry.target));
+ for (const source of ["closed", "failed", "focus"]) await submit(source);
+ await flush();
+ const first = presentation(0),
+ second = presentation(1),
+ third = presentation(2);
+ first.onEvent.onmessage({ id: first.id, kind: "closed" });
+ second.onEvent.onmessage({
+ id: second.id,
+ kind: "failed",
+ error: "OS submission failed",
+ });
+ expect(service.snapshot().error).toBe("OS submission failed");
+ expect(open).not.toHaveBeenCalled();
+ third.onEvent.onmessage({
+ id: third.id,
+ kind: "activated",
+ error: "Window focus failed",
});
+ expect(open).toHaveBeenCalledOnce();
+ expect(service.snapshot().error).toBe("Window focus failed");
+ await service.refreshPermission();
+ await flush();
+ expect(sdk.invoke).toHaveBeenCalledTimes(3);
+});
+
+it("account replacement and service disposal fence previously displayed native clicks", async () => {
+ const { service, submit, navigation, ctx } = setup();
+ const open = vi.fn();
+ navigation.subscribe(() => open(navigation.snapshot().entry.target));
+ await submit("old-account");
+ await flush();
+ const first = presentation(0);
+ service.selectViewer("b".repeat(64));
+ first.onEvent.onmessage({ id: first.id, kind: "activated" });
+ expect(open).not.toHaveBeenCalled();
+ await submit("disposed");
+ await flush();
+ const second = presentation(1);
+ await ctx.fiber.dispose();
+ second.onEvent.onmessage({ id: second.id, kind: "activated" });
+ expect(open).not.toHaveBeenCalled();
+});
+
+it("the click channel exists before native submission, including immediate activation", async () => {
+ const { navigation, submit } = setup();
+ const open = vi.fn();
+ navigation.subscribe(() => open(navigation.snapshot().entry.target));
+ sdk.invoke.mockImplementationOnce(async (...args: unknown[]) => {
+ const { id, onEvent } = args[1] as ReturnType;
+ onEvent.onmessage({ id, kind: "activated" });
+ });
+ await submit("immediate");
+ await flush();
+ expect(open).toHaveBeenCalledExactlyOnceWith({
+ version: 1,
+ kind: "settings",
+ });
+});
+
+it("native presentation rejects at capacity before sending instead of evicting live targets", async () => {
+ const platform = createNotifications();
+ const activate = vi.fn(),
+ failed = vi.fn();
+ for (let i = 0; i < 128; i++)
+ await platform.show(
+ { id: String(i), title: "Buzz", body: "Hi", silent: true },
+ activate,
+ failed,
+ );
+ await expect(
+ platform.show(
+ { id: "overflow", title: "Buzz", body: "Hi", silent: true },
+ activate,
+ failed,
+ ),
+ ).rejects.toThrow("maximum 128");
+ expect(sdk.invoke).toHaveBeenCalledTimes(128);
+ const first = presentation(0);
+ first.onEvent.onmessage({ id: first.id, kind: "activated" });
+ expect(activate).toHaveBeenCalledOnce();
+ await platform.show(
+ { id: "next", title: "Buzz", body: "Hi", silent: true },
+ activate,
+ failed,
+ );
+ expect(sdk.invoke).toHaveBeenCalledTimes(129);
+ platform.dispose();
});
diff --git a/src/features/notifications/platform.ts b/src/features/notifications/platform.ts
index 5cc84680..cf1ad6f5 100644
--- a/src/features/notifications/platform.ts
+++ b/src/features/notifications/platform.ts
@@ -1,8 +1,7 @@
-import { isTauri } from "@tauri-apps/api/core";
+import { Channel, invoke, isTauri } from "@tauri-apps/api/core";
import {
isPermissionGranted,
requestPermission,
- sendNotification,
} from "@tauri-apps/plugin-notification";
export type NotificationPermissionState =
@@ -28,9 +27,21 @@ export interface NotificationPlatform {
dispose(): void;
}
-/** Official desktop plugin; browser callbacks are not supported by its shim. */
+type DesktopResponse = Readonly<{
+ id: string;
+ kind: "activated" | "closed" | "failed";
+ error?: string;
+}>;
+
+/** One native presentation owner, with its callback registered before sending. */
export function createNotifications(): NotificationPlatform {
if (!isTauri()) return createBrowserNotifications();
+ let disposed = false;
+ const active = new Map>();
+ const release = (id: string, channel: Channel) => {
+ channel.onmessage = () => {};
+ active.delete(id);
+ };
return {
label: "Desktop notifications",
systemManaged: true,
@@ -41,12 +52,36 @@ export function createNotifications(): NotificationPlatform {
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 });
+ async show(item, activate, failed) {
+ if (disposed) throw new Error("Desktop notifications have stopped");
+ // Reject before sending instead of stranding an older alert's target.
+ if (active.size >= 128)
+ throw new Error("Too many active desktop notifications (maximum 128)");
+ const channel = new Channel((response) => {
+ if (disposed || response.id !== item.id || !active.has(item.id)) return;
+ release(item.id, channel);
+ if (response.error) failed(new Error(response.error));
+ if (response.kind === "activated") activate();
+ });
+ active.set(item.id, channel);
+ try {
+ // Native code restores/focuses the main window before returning the click.
+ // No destination or account data crosses this boundary.
+ await invoke("notification_show", {
+ id: item.id,
+ title: item.title,
+ body: item.body,
+ onEvent: channel,
+ });
+ } catch (error) {
+ release(item.id, channel);
+ throw error;
+ }
+ },
+ dispose() {
+ disposed = true;
+ for (const [id, channel] of active) release(id, channel);
},
- dispose() {},
};
}
From 7e585aa1459ba6810f32329be1bc11d0c93e3566 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 11:00:33 -0600
Subject: [PATCH 12/17] Compile Unix-only terminal helpers only on Unix
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src-tauri/src/terminal/context.rs | 1 +
src-tauri/src/terminal/mod.rs | 2 ++
2 files changed, 3 insertions(+)
diff --git a/src-tauri/src/terminal/context.rs b/src-tauri/src/terminal/context.rs
index 303ee145..1053d0e2 100644
--- a/src-tauri/src/terminal/context.rs
+++ b/src-tauri/src/terminal/context.rs
@@ -51,6 +51,7 @@ impl TerminalContext {
Ok(())
}
+ #[cfg(unix)]
pub(super) fn display(&self) -> &str {
if !self.channel_name.is_empty()
&& self.channel_name.chars().count() <= 64
diff --git a/src-tauri/src/terminal/mod.rs b/src-tauri/src/terminal/mod.rs
index 20db63ee..52ca3fe3 100644
--- a/src-tauri/src/terminal/mod.rs
+++ b/src-tauri/src/terminal/mod.rs
@@ -13,7 +13,9 @@ use std::sync::{Arc, Mutex};
use uuid::Uuid;
const MAX_OWNERS: usize = 32;
+#[cfg(unix)]
const MAX_SESSIONS: usize = 20;
+#[cfg(unix)]
pub(super) const MAX_BYTES: usize = 64 * 1024;
pub(super) const MAX_PENDING_INPUT: usize = 1024 * 1024;
From 7298d95ba35c72b1bf576c5dbc8a8783baff8d55 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 11:03:25 -0600
Subject: [PATCH 13/17] Include Windows in required CI gate regression coverage
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
tests/integration/browser-ci.test.mjs | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/tests/integration/browser-ci.test.mjs b/tests/integration/browser-ci.test.mjs
index fe221c37..7469116c 100644
--- a/tests/integration/browser-ci.test.mjs
+++ b/tests/integration/browser-ci.test.mjs
@@ -141,13 +141,21 @@ test("required gate executes its real shell and rejects every unsuccessful lane"
assert.doesNotMatch(required, /^ {8}if:/m);
assert.match(
required,
- /^ {4}needs: \[javascript, native, measurements, browser\]$/m,
+ /^ {4}needs: \[javascript, native, windows-native, measurements, browser\]$/m,
);
assert.doesNotMatch(required, /continue-on-error/);
- const lanes = ["JAVASCRIPT", "NATIVE", "MEASUREMENTS", "BROWSER"];
+ const lanes = [
+ "JAVASCRIPT",
+ "NATIVE",
+ "WINDOWS_NATIVE",
+ "MEASUREMENTS",
+ "BROWSER",
+ ];
for (const lane of lanes)
assert.ok(
- required.includes(`${lane}: \${{ needs.${lane.toLowerCase()}.result }}`),
+ required.includes(
+ `${lane}: \${{ needs.${lane.toLowerCase().replaceAll("_", "-")}.result }}`,
+ ),
);
const script = required.match(/^ {8}run: \|\n((?: {10}.+\n?)+)/m)?.[1];
assert.ok(script, "required shell must exist");
From 27289d996580ff401ba634608bfa66a6663aa533 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 11:43:04 -0600
Subject: [PATCH 14/17] Remove obsolete desktop notification permission plugin
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
Cargo.lock | 78 -------------------
docs/notifications.md | 20 ++---
package.json | 1 -
pnpm-lock.yaml | 10 ---
src-tauri/Cargo.toml | 1 -
src-tauri/capabilities/default.json | 4 +-
src-tauri/src/lib.rs | 1 -
src/app/NotificationSettings.tsx | 28 +++----
.../notifications/desktop-channel.test.ts | 10 +++
src/features/notifications/desktop.test.ts | 26 +++----
src/features/notifications/platform.ts | 14 +---
11 files changed, 51 insertions(+), 142 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index e69031b4..400733f6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -348,7 +348,6 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
- "tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-winrt-notification",
"url",
@@ -2813,15 +2812,6 @@ 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"
@@ -2920,35 +2910,6 @@ 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"
@@ -3856,25 +3817,6 @@ 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"
@@ -5379,26 +5321,6 @@ 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/docs/notifications.md b/docs/notifications.md
index 4b8f0882..d457dcda 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -71,10 +71,11 @@ The browser adapter works only in a running tab with the Notification API.
Desktop builds use one small Tauri bridge into the same maintained backends as
the official plugin: mac-notification-sys on macOS, notify-rust on Linux, and
tauri-winrt-notification on Windows. No dependency upgrade or new native FFI is
-needed. The plugin is retained only for its permission API; its send capability
-is no longer granted. The main-window-only bridge carries display text and an
-opaque presentation ID, never an account, credential or navigation destination.
-Its Tauri response channel is registered before native submission.
+needed. Permission and sound remain system-controlled; no permission-only plugin
+or synthetic desktop permission prompt is installed. The main-window-only bridge
+carries display text and an opaque presentation ID, never an account, credential
+or navigation destination. Its Tauri response channel is registered before native
+submission.
Desktop clicks restore/foreground Buzz and then call the existing activation
closure. macOS explicitly waits for a body click off the UI thread (the generic
@@ -86,11 +87,12 @@ window without the framework's stale minimized-state focus guard. Compositor
focus policy still applies. Dismissal never navigates. Observable send/focus
failures reach Settings without retry; a focus error does not discard navigation.
-The permission API does not expose actual OS permission state. Settings describes
-permission and sound as system-controlled, without an ineffective desktop sound
-toggle. The bridge accepts a submission before waiting for interaction: acceptance
-is **not** proof that a visible banner appeared. The macOS backend does not expose
-all delivery failures, and no uniform withdrawal/receipt guarantee is promised.
+Desktop permission state is not observable through these backends. Settings
+describes permission and sound as system-controlled, without ineffective desktop
+permission or sound controls. The bridge accepts a submission before waiting for
+interaction: acceptance is **not** proof that a visible banner appeared. The macOS
+backend does not expose all delivery failures, and no uniform withdrawal/receipt
+guarantee is promised.
Callbacks stop navigating after account change or frontend disposal. Native waits
remain bounded until the OS resolves them; no artificial expiry strands an
otherwise actionable alert. Reload/cold-start restoration remains out of scope.
diff --git a/package.json b/package.json
index 3b9ed1e2..2532f3c1 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,6 @@
"@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",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
"blurhash": "2.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8c0d3177..5ca4af2f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -32,9 +32,6 @@ importers:
'@tauri-apps/api':
specifier: ^2.11.1
version: 2.11.1
- '@tauri-apps/plugin-notification':
- specifier: 2.4.0
- version: 2.4.0
'@xterm/addon-fit':
specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -606,9 +603,6 @@ 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==}
@@ -1945,10 +1939,6 @@ 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 a475c463..5de0fdda 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -17,7 +17,6 @@ tauri = { version = "2", features = [] }
buzzodz-plugins = { path = "../crates/plugin-manager" }
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
-tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 4f42760f..868e796c 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -1,13 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main-window",
- "description": "Allow the main window title bar controls, external HTTP(S) links, and notifications.",
+ "description": "Allow the main window title bar controls and external HTTP(S) links.",
"windows": ["main"],
"permissions": [
"core:window:allow-start-dragging",
"core:window:allow-internal-toggle-maximize",
- "notification:allow-is-permission-granted",
- "notification:allow-request-permission",
{
"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 58eafab4..a2a84f1c 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -158,7 +158,6 @@ 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(Terminals::default())
.manage(Notifications::default())
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index ce000091..6ff66cea 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -42,24 +42,26 @@ export function NotificationSettings({
? "Permission is controlled by system notification settings."
: "Allow notifications to receive alerts."}
-
- {permission === "default" && (
+ {!state.systemManaged && (
+
+ {permission === "default" && (
+ void notifications.requestPermission()}
+ >
+ Allow notifications
+
+ )}
void notifications.requestPermission()}
+ onClick={() => void notifications.refreshPermission()}
>
- Allow notifications
+ Check permission
- )}
- void notifications.refreshPermission()}
- >
- Check permission
-
-
+
+ )}
{
+ const platform = createNotifications();
+ expect(platform.systemManaged).toBe(true);
+ expect(await platform.permission()).toBe("unknown");
+ expect(await platform.requestPermission()).toBe("unknown");
+ expect(invoke).not.toHaveBeenCalled();
+ expect(callbacks.size).toBe(0);
+ platform.dispose();
+});
+
it("serializes each pre-registered channel and releases it after ordered native completion", async () => {
const platform = createNotifications();
const first = vi.fn(),
diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts
index 4ea005b0..8955287f 100644
--- a/src/features/notifications/desktop.test.ts
+++ b/src/features/notifications/desktop.test.ts
@@ -10,9 +10,6 @@ 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(),
invoke: vi.fn(async () => {}),
}));
const native = vi.hoisted(() => ({ value: true }));
@@ -23,7 +20,6 @@ vi.mock("@tauri-apps/api/core", () => ({
constructor(public onmessage: (response: unknown) => void) {}
},
}));
-vi.mock("@tauri-apps/plugin-notification", () => sdk);
vi.mock("react", async (original) => ({
...(await original()),
useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) =>
@@ -66,7 +62,6 @@ it("the default service sends desktop banners via the native bridge and shared p
systemManaged: true,
preferences: { enabled: true },
});
- expect(sdk.requestPermission).not.toHaveBeenCalled();
await submit("first");
await submit("first");
await flush();
@@ -84,20 +79,20 @@ it("the default service sends desktop banners via the native bridge and shared p
expect(sdk.invoke).toHaveBeenCalledTimes(1);
});
-it("an explicit permission request uses the SDK without claiming OS permission is known", async () => {
+it("desktop permission remains system-managed without a permission RPC or shim", 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.invoke).not.toHaveBeenCalled();
- sdk.isPermissionGranted.mockResolvedValue(true);
+ await service.refreshPermission();
await service.requestPermission();
- await flush();
- expect(sdk.requestPermission).toHaveBeenCalledOnce();
expect(service.snapshot().permission).toBe("unknown");
+ expect(sdk.invoke).not.toHaveBeenCalled();
+ await submit("first");
+ await flush();
expect(sdk.invoke).toHaveBeenCalledOnce();
+ expect(sdk.invoke).toHaveBeenCalledWith(
+ "notification_show",
+ expect.anything(),
+ );
});
it("observable SDK failures surface once without retry or a browser fallback", async () => {
@@ -132,6 +127,8 @@ it("desktop settings explain OS sound and running-app exact clicks", async () =>
);
expect(html).not.toContain("Sound ");
expect(html).not.toContain("Permission granted");
+ expect(html).not.toContain("Check permission");
+ expect(html).not.toContain("Allow notifications");
});
it("non-Tauri runs select the unchanged browser adapter, never the native SDK", async () => {
@@ -139,7 +136,6 @@ it("non-Tauri runs select the unchanged browser adapter, never the native SDK",
const platform = createNotifications();
expect(platform.label).toBe("Browser notifications");
expect(await platform.permission()).toBe("unsupported");
- expect(sdk.isPermissionGranted).not.toHaveBeenCalled();
expect(sdk.invoke).not.toHaveBeenCalled();
});
diff --git a/src/features/notifications/platform.ts b/src/features/notifications/platform.ts
index cf1ad6f5..dc98a598 100644
--- a/src/features/notifications/platform.ts
+++ b/src/features/notifications/platform.ts
@@ -1,8 +1,4 @@
import { Channel, invoke, isTauri } from "@tauri-apps/api/core";
-import {
- isPermissionGranted,
- requestPermission,
-} from "@tauri-apps/plugin-notification";
export type NotificationPermissionState =
| NotificationPermission
@@ -45,13 +41,9 @@ export function createNotifications(): NotificationPlatform {
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;
- },
+ // The native backends do not expose an OS permission check or prompt.
+ permission: async () => "unknown",
+ requestPermission: async () => "unknown",
async show(item, activate, failed) {
if (disposed) throw new Error("Desktop notifications have stopped");
// Reject before sending instead of stranding an older alert's target.
From d78a7d9494dbcf43f0af45435a7db8d2aca6f218 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 12:21:37 -0600
Subject: [PATCH 15/17] Arm Linux notification responses before display
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
.github/workflows/ci.yml | 2 +-
Cargo.lock | 17 +-
docs/contributing.md | 4 +-
docs/notifications.md | 19 +-
src-tauri/Cargo.toml | 3 +-
src-tauri/src/notifications.rs | 50 +---
src-tauri/src/notifications/linux.rs | 128 ++++++++++
src-tauri/src/notifications/linux/tests.rs | 270 +++++++++++++++++++++
8 files changed, 424 insertions(+), 69 deletions(-)
create mode 100644 src-tauri/src/notifications/linux.rs
create mode 100644 src-tauri/src/notifications/linux/tests.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 50bcb38a..5918b2a9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -46,7 +46,7 @@ jobs:
- name: Tauri system libraries
run: |
sudo apt-get update
- sudo apt-get install --no-install-recommends -y libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev patchelf
+ sudo apt-get install --no-install-recommends -y dbus-daemon libwebkit2gtk-4.1-dev build-essential libssl-dev librsvg2-dev libayatana-appindicator3-dev patchelf
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: native
diff --git a/Cargo.lock b/Cargo.lock
index 400733f6..5cdae71d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -339,10 +339,10 @@ name = "buzz-foundation"
version = "0.0.0"
dependencies = [
"buzzodz-plugins",
+ "futures-lite",
"gtk",
"libc",
"mac-notification-sys",
- "notify-rust",
"portable-pty",
"serde",
"tauri",
@@ -352,6 +352,7 @@ dependencies = [
"tauri-winrt-notification",
"url",
"uuid",
+ "zbus",
]
[[package]]
@@ -2292,20 +2293,6 @@ dependencies = [
"libc",
]
-[[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"
diff --git a/docs/contributing.md b/docs/contributing.md
index 9cc3939f..2117824f 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -57,7 +57,9 @@ need their own validation.
This is broader validation, not a signed package or a cross-platform test.
Before the first `scan`, install the pinned browser engines with
-`bin/pnpm test:browser:install`; missing engines fail rather than skip. See
+`bin/pnpm test:browser:install`; missing engines fail rather than skip. Linux native
+notification tests also require `dbus-daemon` (installed in CI). They start and stop
+isolated test buses, never use the desktop session bus or display real banners. See
[browser regression coverage and measurement limits](browser-testing.md).
Installs run on every invocation to account for branch and lockfile changes.
diff --git a/docs/notifications.md b/docs/notifications.md
index d457dcda..3b1e4971 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -68,10 +68,12 @@ generic category text.
## Current acceptance limits
The browser adapter works only in a running tab with the Notification API.
-Desktop builds use one small Tauri bridge into the same maintained backends as
-the official plugin: mac-notification-sys on macOS, notify-rust on Linux, and
-tauri-winrt-notification on Windows. No dependency upgrade or new native FFI is
-needed. Permission and sound remain system-controlled; no permission-only plugin
+Desktop builds use one small Tauri bridge into maintained native backends:
+mac-notification-sys on macOS, the freedesktop notification interface through
+zbus on Linux, and tauri-winrt-notification on Windows. Linux uses the already
+locked zbus dependency directly because notify-rust's send-then-listen wrapper
+can lose early actions. No dependency upgrade or new native FFI is needed.
+Permission and sound remain system-controlled; no permission-only plugin
or synthetic desktop permission prompt is installed. The main-window-only bridge
carries display text and an opaque presentation ID, never an account, credential
or navigation destination. Its Tauri response channel is registered before native
@@ -82,8 +84,13 @@ closure. macOS explicitly waits for a body click off the UI thread (the generic
notify-rust wrapper omits that flag). Windows retains its callback when the
banner fades, because timeout is not removal from Notification Center. Linux
requests the standard default action and checks that the notification service
-supports actions; GTK's standard present operation shows/restores/raises the
-window without the framework's stale minimized-state focus guard. Compositor
+supports actions. A single, sender-filtered receiver is armed on the same D-Bus
+connection before Notify. It is drained while the reply is pending; first terminal
+responses are retained by ID (maximum 128 distinct IDs, including other apps'
+broadcasts), then correlated with the returned ID. Overflow reports failure and
+releases capacity; this is not proof that Notify was never displayed. GTK's
+standard present operation shows/restores/raises the window without the
+framework's stale minimized-state focus guard. Compositor
focus policy still applies. Dismissal never navigates. Observable send/focus
failures reach Settings without retry; a focus error does not discard navigation.
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 5de0fdda..95ce2f33 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -32,5 +32,6 @@ mac-notification-sys = "=0.6.15"
tauri-winrt-notification = "=0.7.3"
[target.'cfg(target_os = "linux")'.dependencies]
-notify-rust = "=4.18.0"
+zbus = { version = "=5.19.0", default-features = false, features = ["async-io"] }
+futures-lite = "=2.6.1"
gtk = "0.18"
diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs
index 4f821bc2..67890248 100644
--- a/src-tauri/src/notifications.rs
+++ b/src-tauri/src/notifications.rs
@@ -1,5 +1,5 @@
//! Running-session desktop clicks. Policy and navigation remain in the host service.
-//! The maintained OS backends already used by Tauri own delivery and native callbacks.
+//! Maintained OS backends own delivery; Linux uses their standard D-Bus interface.
use serde::Serialize;
use std::sync::{Arc, Mutex};
use tauri::{ipc::Channel, Manager};
@@ -182,53 +182,13 @@ fn show(app: tauri::AppHandle, title: String, body: String, pending: Arc) {
tauri::async_runtime::spawn(async move {
- let capabilities = tauri::async_runtime::spawn_blocking(notify_rust::get_capabilities)
- .await
- .map_err(|e| e.to_string())
- .and_then(|result| result.map_err(|e| e.to_string()));
- match capabilities {
- Ok(capabilities)
- if capabilities
- .iter()
- .any(|capability| capability == "actions") => {}
- Ok(_) => {
- pending.finish(Outcome::Failed(
- "The desktop notification service does not support clicks".into(),
- ));
- return;
- }
- Err(error) => {
- pending.finish(Outcome::Failed(error));
- return;
- }
- }
- let result = notify_rust::Notification::new()
- .summary(&title)
- .body(&body)
- .appname("Buzz")
- .auto_icon()
- .action("default", "Open")
- .show_async()
- .await;
- match result {
- Ok(handle) => {
- handle
- .wait_for_action_async(|response| {
- pending.finish(
- if matches!(response, notify_rust::NotificationResponse::Default) {
- Outcome::Activated
- } else {
- Outcome::Closed
- },
- );
- })
- .await
- }
- Err(error) => pending.finish(Outcome::Failed(error.to_string())),
- }
+ linux::show(zbus::Connection::session().await, &title, &body, pending).await;
});
}
diff --git a/src-tauri/src/notifications/linux.rs b/src-tauri/src/notifications/linux.rs
new file mode 100644
index 00000000..ddb5dc69
--- /dev/null
+++ b/src-tauri/src/notifications/linux.rs
@@ -0,0 +1,128 @@
+//! Standard freedesktop notifications, with the receiver armed before Notify.
+use super::{Outcome, Pending, MAX_ACTIVE};
+use futures_lite::{future, StreamExt};
+use std::{collections::HashMap, sync::Arc};
+use zbus::{zvariant::Value, Connection, Message, Proxy};
+
+const SERVICE: &str = "org.freedesktop.Notifications";
+const PATH: &str = "/org/freedesktop/Notifications";
+
+pub(super) async fn show(
+ connection: zbus::Result,
+ title: &str,
+ body: &str,
+ pending: Arc,
+) {
+ let result = match connection {
+ Ok(connection) => notify(&connection, title, body).await,
+ Err(error) => Err(error.to_string()),
+ };
+ pending.finish(result.unwrap_or_else(Outcome::Failed));
+}
+
+async fn notify(connection: &Connection, title: &str, body: &str) -> Result {
+ let proxy = Proxy::new(connection, SERVICE, PATH, SERVICE)
+ .await
+ .map_err(|e| e.to_string())?;
+ let capabilities: Vec = proxy
+ .call("GetCapabilities", &())
+ .await
+ .map_err(|e| e.to_string())?;
+ if !capabilities
+ .iter()
+ .any(|capability| capability == "actions")
+ {
+ return Err("The desktop notification service does not support clicks".into());
+ }
+ // This installs the match rule AND retains an active receiver on the same
+ // connection used for Notify, including signals addressed only to its caller.
+ let mut signals = proxy
+ .receive_all_signals()
+ .await
+ .map_err(|e| e.to_string())?;
+ let icon = std::env::current_exe()
+ .ok()
+ .and_then(|path| {
+ path.file_name()
+ .map(|name| name.to_string_lossy().into_owned())
+ })
+ .unwrap_or_default();
+ let args = (
+ "Buzz",
+ 0u32,
+ icon,
+ title,
+ body,
+ ["default", "Open"],
+ HashMap::<&str, Value<'_>>::new(),
+ -1i32,
+ );
+ let reply = proxy.call::<_, _, u32>("Notify", &args);
+ futures_lite::pin!(reply);
+ let mut early = HashMap::new();
+ let id = loop {
+ // Drain while the reply is pending: filling zbus's bounded signal queue
+ // would otherwise prevent its socket reader from reaching the reply.
+ enum Next {
+ Reply(zbus::Result),
+ Signal(Option),
+ }
+ match future::or(async { Next::Reply(reply.as_mut().await) }, async {
+ Next::Signal(signals.next().await)
+ })
+ .await
+ {
+ Next::Reply(result) => break result.map_err(|e| e.to_string())?,
+ Next::Signal(Some(message)) => {
+ if let Some((id, outcome)) = response(&message)? {
+ // The daemon assigns IDs. Until its reply arrives, retain
+ // first terminal responses without an unbounded event log.
+ if !early.contains_key(&id) && early.len() == MAX_ACTIVE {
+ return Err(
+ "Too many notification responses before Notify completed".into()
+ );
+ }
+ early.entry(id).or_insert(outcome);
+ }
+ }
+ Next::Signal(None) => return Err("Desktop notification service disconnected".into()),
+ }
+ };
+ if let Some(outcome) = early.remove(&id) {
+ return Ok(outcome);
+ }
+ while let Some(message) = signals.next().await {
+ if let Some((response_id, outcome)) = response(&message)? {
+ if response_id == id {
+ return Ok(outcome);
+ }
+ }
+ }
+ Err("Desktop notification service disconnected".into())
+}
+
+fn response(message: &Message) -> Result, String> {
+ match message.header().member().map(|name| name.as_str()) {
+ Some("ActionInvoked") => {
+ let (id, action): (u32, String) =
+ message.body().deserialize().map_err(|e| e.to_string())?;
+ Ok(Some((
+ id,
+ if action == "default" {
+ Outcome::Activated
+ } else {
+ Outcome::Closed
+ },
+ )))
+ }
+ Some("NotificationClosed") => {
+ let (id, _reason): (u32, u32) =
+ message.body().deserialize().map_err(|e| e.to_string())?;
+ Ok(Some((id, Outcome::Closed)))
+ }
+ _ => Ok(None),
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src-tauri/src/notifications/linux/tests.rs b/src-tauri/src/notifications/linux/tests.rs
new file mode 100644
index 00000000..0b4c5a2a
--- /dev/null
+++ b/src-tauri/src/notifications/linux/tests.rs
@@ -0,0 +1,270 @@
+use super::*;
+use crate::notifications::Notifications;
+use std::{
+ io::{BufRead, BufReader},
+ process::{Child, Command, Stdio},
+ sync::{
+ atomic::{AtomicUsize, Ordering},
+ mpsc, Mutex,
+ },
+ time::Duration,
+};
+use zbus::{connection::Builder, message::Header, zvariant::OwnedValue};
+
+// Each test owns an isolated real bus. Never register a fake notification service
+// on the user's session bus or change process-global DBUS_SESSION_BUS_ADDRESS.
+struct Bus(Child, String);
+impl Bus {
+ fn start() -> Self {
+ let mut child = Command::new("dbus-daemon")
+ .args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
+ .stdout(Stdio::piped())
+ .spawn()
+ .expect("Linux notification tests require dbus-daemon");
+ let mut address = String::new();
+ BufReader::new(child.stdout.take().unwrap())
+ .read_line(&mut address)
+ .unwrap();
+ assert!(
+ !address.trim().is_empty(),
+ "dbus-daemon did not return an address"
+ );
+ Self(child, address.trim().to_owned())
+ }
+}
+impl Drop for Bus {
+ fn drop(&mut self) {
+ let _ = self.0.kill();
+ let _ = self.0.wait();
+ }
+}
+
+#[derive(Clone, Copy)]
+enum Scenario {
+ Click,
+ Close,
+ Burst,
+ Overflow,
+ Unsupported,
+ Rejected,
+}
+struct Daemon {
+ scenario: Scenario,
+ unicast: bool,
+ calls: Arc,
+}
+#[zbus::interface(name = "org.freedesktop.Notifications")]
+impl Daemon {
+ fn get_capabilities(&self) -> Vec<&str> {
+ if matches!(self.scenario, Scenario::Unsupported) {
+ vec![]
+ } else {
+ vec!["actions"]
+ }
+ }
+
+ #[allow(clippy::too_many_arguments)] // The standard freedesktop Notify signature.
+ async fn notify(
+ &self,
+ app_name: &str,
+ replaces_id: u32,
+ _app_icon: &str,
+ summary: &str,
+ body: &str,
+ actions: Vec,
+ hints: HashMap,
+ expire_timeout: i32,
+ #[zbus(connection)] connection: &Connection,
+ #[zbus(header)] header: Header<'_>,
+ ) -> zbus::fdo::Result {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ assert_eq!(
+ (app_name, replaces_id, summary, body),
+ ("Buzz", 0, "Title", "Preview")
+ );
+ assert_eq!(actions, ["default", "Open"]);
+ assert!(hints.is_empty());
+ assert_eq!(expire_timeout, -1);
+ if matches!(self.scenario, Scenario::Rejected) {
+ return Err(zbus::fdo::Error::Failed("Notify rejected".into()));
+ }
+ let destination = if self.unicast {
+ header.sender().map(|s| s.as_str())
+ } else {
+ None
+ };
+ let count = match self.scenario {
+ Scenario::Burst => 96,
+ Scenario::Overflow => MAX_ACTIVE + 1,
+ _ => 1,
+ };
+ for index in 0..count {
+ let id = if matches!(self.scenario, Scenario::Overflow) {
+ 1000 + index as u32
+ } else {
+ 7
+ };
+ connection
+ .emit_signal(
+ destination,
+ PATH,
+ SERVICE,
+ "NotificationClosed",
+ &(id, 2u32),
+ )
+ .await?;
+ }
+ if matches!(self.scenario, Scenario::Overflow) {
+ // Keep Notify pending until the client reports its resource limit.
+ // Ping fences socket receipt, not application consumption: replying
+ // here could let reply-first polling bypass the early-buffer limit.
+ // The test tears down this isolated bus after checking completion.
+ return future::pending().await;
+ }
+ if matches!(self.scenario, Scenario::Close) {
+ connection
+ .emit_signal(
+ destination,
+ PATH,
+ SERVICE,
+ "NotificationClosed",
+ &(42u32, 2u32),
+ )
+ .await?;
+ }
+ // First terminal response wins, even if the daemon immediately follows
+ // action with close (or sends duplicate activation).
+ for _ in 0..2 {
+ connection
+ .emit_signal(
+ destination,
+ PATH,
+ SERVICE,
+ "ActionInvoked",
+ &(42u32, "default"),
+ )
+ .await?;
+ }
+ connection
+ .emit_signal(
+ destination,
+ PATH,
+ SERVICE,
+ "NotificationClosed",
+ &(42u32, 2u32),
+ )
+ .await?;
+ // Round trip to the caller forces its socket reader past the signals
+ // before Notify replies. This is an ordering barrier, not a sleep or a
+ // manually pre-armed notification listener in the fixture.
+ connection
+ .call_method(
+ header.sender().map(|name| name.as_str()),
+ PATH,
+ Some("org.freedesktop.DBus.Peer"),
+ "Ping",
+ &(),
+ )
+ .await?;
+ Ok(42)
+ }
+}
+
+fn exercise(scenario: Scenario, unicast: bool) -> (Outcome, usize) {
+ let bus = Bus::start();
+ let address = bus.1.clone();
+ let (tx, rx) = mpsc::channel();
+ let worker = std::thread::spawn(move || {
+ zbus::block_on(async move {
+ let calls = Arc::new(AtomicUsize::new(0));
+ let _server = Builder::address(address.as_str())
+ .unwrap()
+ .name(SERVICE)
+ .unwrap()
+ .serve_at(
+ PATH,
+ Daemon {
+ scenario,
+ unicast,
+ calls: calls.clone(),
+ },
+ )
+ .unwrap()
+ .build()
+ .await
+ .unwrap();
+ let connection = Builder::address(address.as_str())
+ .unwrap()
+ .build()
+ .await
+ .unwrap();
+ // Enable the standard Peer.Ping endpoint for the ordering barrier.
+ // No notification signal subscription is installed by the test.
+ let _ = connection.object_server();
+ let state = Notifications::default();
+ let seen = Arc::new(Mutex::new(Vec::new()));
+ let output = seen.clone();
+ let pending = state
+ .reserve(Box::new(move |outcome| {
+ output.lock().unwrap().push(outcome)
+ }))
+ .unwrap();
+ // The real production operation owns receiver setup, Notify,
+ // correlation, terminal callback and capacity release.
+ show(Ok(connection), "Title", "Preview", pending).await;
+ assert_eq!(*state.0.lock().unwrap(), 0);
+ let mut seen = seen.lock().unwrap();
+ assert_eq!(seen.len(), 1);
+ tx.send((seen.remove(0), calls.load(Ordering::SeqCst)))
+ .unwrap();
+ });
+ });
+ // Deadline is test failure detection only, never notification expiry.
+ let result = rx
+ .recv_timeout(Duration::from_secs(10))
+ .expect("native notification operation did not finish");
+ worker.join().unwrap();
+ result
+}
+
+#[test]
+fn click_before_notify_reply_survives_unicast_and_broadcast_and_closes_once() {
+ for unicast in [true, false] {
+ assert_eq!(exercise(Scenario::Click, unicast), (Outcome::Activated, 1));
+ }
+}
+
+#[test]
+fn dismissal_before_notify_reply_never_becomes_activation() {
+ for unicast in [true, false] {
+ assert_eq!(exercise(Scenario::Close, unicast), (Outcome::Closed, 1));
+ }
+}
+
+#[test]
+fn pre_reply_burst_drains_beyond_zbus_queue_and_deduplicates_other_ids() {
+ assert_eq!(exercise(Scenario::Burst, true), (Outcome::Activated, 1));
+}
+
+#[test]
+fn distinct_id_overflow_fails_explicitly_and_releases_capacity() {
+ let (outcome, calls) = exercise(Scenario::Overflow, false);
+ assert_eq!(calls, 1);
+ assert!(
+ matches!(outcome, Outcome::Failed(error) if error.contains("Too many notification responses"))
+ );
+}
+
+#[test]
+fn unsupported_actions_fail_without_display_and_release_capacity() {
+ let (outcome, calls) = exercise(Scenario::Unsupported, true);
+ assert_eq!(calls, 0);
+ assert!(matches!(outcome, Outcome::Failed(error) if error.contains("does not support clicks")));
+}
+
+#[test]
+fn notify_error_releases_capacity() {
+ let (outcome, calls) = exercise(Scenario::Rejected, true);
+ assert_eq!(calls, 1);
+ assert!(matches!(outcome, Outcome::Failed(error) if error.contains("Notify rejected")));
+}
From 0548649db0a887313264e8ab460e9dca9fefe7cd Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 12:28:12 -0600
Subject: [PATCH 16/17] Serialize Linux notification actions as a D-Bus array
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
src-tauri/src/notifications/linux.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src-tauri/src/notifications/linux.rs b/src-tauri/src/notifications/linux.rs
index ddb5dc69..9ac26ac9 100644
--- a/src-tauri/src/notifications/linux.rs
+++ b/src-tauri/src/notifications/linux.rs
@@ -53,7 +53,7 @@ async fn notify(connection: &Connection, title: &str, body: &str) -> Result>::new(),
-1i32,
);
From 622cc50dcdc635153c8f805c3c858894286d4018 Mon Sep 17 00:00:00 2001
From: Pinky
<5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Date: Sun, 13 Sep 2026 13:08:52 -0600
Subject: [PATCH 17/17] Keep focused timeline controls mounted through panel
reflow
Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
---
.../messages/ChannelTimeline.test.tsx | 47 +++++++++++++++++++
src/features/messages/ChannelTimeline.tsx | 14 ++++++
tests/browser/layout.spec.mjs | 39 ++++++++++++++-
3 files changed, 99 insertions(+), 1 deletion(-)
diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx
index 49529627..db09202f 100644
--- a/src/features/messages/ChannelTimeline.test.tsx
+++ b/src/features/messages/ChannelTimeline.test.tsx
@@ -245,6 +245,8 @@ function setup({
children: unknown[];
onScroll: (event: unknown) => void;
onWheel: () => void;
+ onFocus: (event: unknown) => void;
+ onBlur: (event: unknown) => void;
}>;
let section: Section;
let channelId = "channel";
@@ -306,6 +308,29 @@ function setup({
return {
element,
handle,
+ focus(id?: string) {
+ section.props.onFocus({
+ target: { closest: () => (id ? { dataset: { messageId: id } } : null) },
+ });
+ render();
+ },
+ blur(inside: boolean) {
+ section.props.onBlur({
+ currentTarget: { contains: () => inside },
+ relatedTarget: null,
+ });
+ render();
+ },
+ pinned() {
+ const virtualizer = section.props.children.find(
+ (child) =>
+ !!child &&
+ typeof child === "object" &&
+ "type" in child &&
+ child.type === Virtualizer,
+ ) as ReactElement<{ keepMounted: number[] }>;
+ return virtualizer.props.keepMounted;
+ },
loadOlder,
olderReads,
flush,
@@ -399,6 +424,28 @@ function setup({
};
}
+it("pins only the focused message by identity across prepend and releases on focus exit", () => {
+ const h = setup();
+ expect(h.pinned()).toEqual([]);
+ h.focus("last");
+ expect(h.pinned()).toEqual([1]);
+ h.prepend();
+ expect(h.pinned()).toEqual([2]);
+ h.blur(true);
+ expect(h.pinned()).toEqual([2]);
+ h.focus("first");
+ expect(h.pinned()).toEqual([1]);
+ h.blur(false);
+ expect(h.pinned()).toEqual([]);
+ h.focus("last");
+ h.focus(); // The history region itself is not a message row.
+ expect(h.pinned()).toEqual([]);
+ h.focus("last");
+ h.setRows([]);
+ expect(h.pinned()).toEqual([]);
+ h.unmount();
+});
+
it("persists the event target's reading position at real component cleanup, not the previous virtualizer offset", () => {
const h = setup();
h.scroll();
diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx
index d2bc5150..9db2483c 100644
--- a/src/features/messages/ChannelTimeline.tsx
+++ b/src/features/messages/ChannelTimeline.tsx
@@ -112,6 +112,8 @@ function Timeline({
() => geometrySignature(window.rows, profiles),
[window.rows, profiles],
);
+ const [focusedMessageId, setFocusedMessageId] = useState();
+ const focusedIndex = rows.findIndex((row) => row.id === focusedMessageId);
const scroller = useRef(null);
const handle = useRef(null);
const [size, setSize] = useState({ width: 0, height: 0 });
@@ -406,6 +408,16 @@ function Timeline({
onTouchMove={gesture}
onKeyDown={gesture}
onPointerDown={gesture}
+ onFocus={(event) => {
+ setFocusedMessageId(
+ event.target.closest("[data-message-id]")?.dataset
+ .messageId,
+ );
+ }}
+ onBlur={(event) => {
+ if (!event.currentTarget.contains(event.relatedTarget))
+ setFocusedMessageId(undefined);
+ }}
tabIndex={0}
aria-label="Channel message history"
onScroll={(event) => {
@@ -448,6 +460,8 @@ function Timeline({
scrollRef={scroller}
shift={prepend}
bufferSize={1600}
+ // Reflow must not evict the focused control and drop keyboard focus.
+ keepMounted={focusedIndex < 0 ? [] : [focusedIndex]}
as="ol"
item="li"
startMargin={EDGE_HEIGHT}
diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs
index 9ddd1ecb..43fe3c58 100644
--- a/tests/browser/layout.spec.mjs
+++ b/tests/browser/layout.spec.mjs
@@ -295,9 +295,9 @@ readingTest(
await button(page, "Close channel panel").focus();
await button(page, "Close channel panel").click();
const trigger = page.getByRole("link", { name: target, exact: true });
+ await settle(page);
await expect(trigger).toBeFocused();
expect(await page.evaluate(() => window.panelFocusScrollDelta)).toBe(0);
- await settle(page);
await expectAnchor(page, saved);
await page.setViewportSize({ width: 1200, height: 700 });
await settle(page);
@@ -306,6 +306,43 @@ readingTest(
},
);
+readingTest(
+ "a focused message stays mounted until focus leaves the timeline",
+ async ({ page, app }) => {
+ await open(page, app);
+ await settle(page);
+ const target = "https://example.com/focused-message";
+ const previous = await page
+ .locator("[data-message-id]")
+ .last()
+ .getAttribute("data-message-id");
+ const message = app.append("primary", "alpha", `Keep focus on ${target}`);
+ const trigger = page.getByRole("link", { name: target, exact: true });
+ await expect(trigger).toBeInViewport();
+ await settle(page);
+ await trigger.evaluate((el) => el.focus({ preventScroll: true }));
+ await expect(trigger).toBeFocused();
+ const history = page.getByRole("region", {
+ name: "Channel message history",
+ });
+ await history.hover();
+ await page.mouse.wheel(0, -3500);
+ // The adjacent unpinned row proves that real virtualization has evicted this
+ // range; a timeout or a mocked virtualizer would not establish that boundary.
+ await expect(page.locator(`[data-message-id="${previous}"]`)).toHaveCount(
+ 0,
+ );
+ await settle(page);
+ await expect(trigger).toBeFocused();
+ await expect(trigger).not.toBeInViewport();
+ await history.evaluate((el) => el.focus({ preventScroll: true }));
+ await expect(history).toBeFocused();
+ await expect(page.locator(`[data-message-id="${message.id}"]`)).toHaveCount(
+ 0,
+ );
+ },
+);
+
test("Bestie owns the launcher and the reusable companion card across pages and disable", async ({
page,
app,