diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b54c9f69..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 @@ -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 bcaaba91..5cdae71d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,15 +339,20 @@ name = "buzz-foundation" version = "0.0.0" dependencies = [ "buzzodz-plugins", + "futures-lite", + "gtk", "libc", + "mac-notification-sys", "portable-pty", "serde", "tauri", "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-winrt-notification", "url", "uuid", + "zbus", ] [[package]] @@ -2142,6 +2147,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" @@ -3907,6 +3926,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" 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 ce22a2e9..2027d4a1 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -5,6 +5,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, @@ -612,8 +613,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 }); }, telemetry: (event, generation) => { if (res.destroyed) return; @@ -801,7 +803,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/channels.md b/docs/channels.md index 2158e5cd..75f421de 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -177,7 +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. There is no jump-to-specific-reply navigation yet. +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 @@ -295,3 +296,20 @@ Without IntersectionObserver, only the ordinary placeholder/original is used. This favors bounded visible work over instant offscreen previews on scrolling. `tests/browser/image-scroll.spec.mjs` covers delayed/failed loads, actual remounts, bottom following, reading anchors and narrow layout in Chromium and WebKit. + +## Opening an exact message + +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. + +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 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/contributing.md b/docs/contributing.md index 729a7f65..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. @@ -189,6 +191,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 +209,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 new file mode 100644 index 00000000..3b1e4971 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,118 @@ +# 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 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 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. 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. +- 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. 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 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 + 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 +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 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 +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 +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. 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. + +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. + +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 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/docs/plugin-architecture.md b/docs/plugin-architecture.md index 70c2b641..530760c4 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -261,7 +261,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. @@ -282,11 +282,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..62fac93a 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -140,6 +140,25 @@ 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 navigation + +`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. + +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. There is no separate +reader owner, subscription or persistence. + ## Ownership and reconciliation | Internal owner | Responsibility | 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55a44e61..5ca4af2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,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==} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5f31ec48..95ce2f33 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,3 +24,14 @@ 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] +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/lib.rs b/src-tauri/src/lib.rs index cac21731..a2a84f1c 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, @@ -158,8 +160,10 @@ pub fn run() { .plugin(tauri_plugin_opener::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..67890248 --- /dev/null +++ b/src-tauri/src/notifications.rs @@ -0,0 +1,286 @@ +//! Running-session desktop clicks. Policy and navigation remain in the host service. +//! 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}; + +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")] +mod linux; + +#[cfg(target_os = "linux")] +fn show(_app: tauri::AppHandle, title: String, body: String, pending: Arc) { + tauri::async_runtime::spawn(async move { + linux::show(zbus::Connection::session().await, &title, &body, pending).await; + }); +} + +#[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-tauri/src/notifications/linux.rs b/src-tauri/src/notifications/linux.rs new file mode 100644 index 00000000..9ac26ac9 --- /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, + vec!["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"))); +} 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; 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..6ff66cea --- /dev/null +++ b/src/app/NotificationSettings.tsx @@ -0,0 +1,157 @@ +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."} +

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

+ Manage sound and permission in system notification settings. Desktop + clicks bring Buzz forward and open the message or thread while Buzz + is running. +

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

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

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

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

+ {state.preferencesError && ( +
+

{state.preferencesError}

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

+ {state.error} +

+ )} +
+
+ ); +} +function Toggle({ + label, + checked, + onChange, +}: { + label: string; + checked: boolean; + onChange(checked: boolean): void; +}) { + return ( + + ); +} diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 5fdba59f..04a899d5 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import { RecoveryScreen } from "./RecoveryScreen"; -import { Blocks, Settings2, UserRound, Palette } from "lucide-react"; +import { Blocks, Settings2, UserRound, Palette, Bell } from "lucide-react"; import type { PluginManager } from "../plugins/manager"; import type { Communities } from "../features/communities/service"; import { PluginImport } from "./PluginImport"; @@ -8,23 +8,28 @@ import { ProfileSettings } from "./ProfileSettings"; import type { Appearance } from "../shared/theme/service"; import { AppearanceSettings } from "./AppearanceSettings"; +import { NotificationSettings } from "./NotificationSettings"; +import type { NotificationsService } from "../features/notifications/service"; const sections = [ { id: "profile", label: "Profile", icon: UserRound }, { id: "plugins", label: "Plugins", icon: Blocks }, { id: "appearance", label: "Appearance", icon: Palette }, + { id: "notifications", label: "Notifications", icon: Bell }, ] as const; export function Settings({ plugins, communities, appearance, + notifications, navigation, onSection, }: { plugins: PluginManager; communities: Communities; appearance: Appearance; + notifications: NotificationsService; navigation?: | import("../features/navigation/service").PageNavigation | undefined; @@ -90,6 +95,9 @@ export function Settings({ ))}
+ diff --git a/src/app/navigation.ts b/src/app/navigation.ts index 2a885c01..79c49dc3 100644 --- a/src/app/navigation.ts +++ b/src/app/navigation.ts @@ -77,7 +77,9 @@ export function useAppNavigation(services: AppServices) { if ( target.kind === "settings" && target.section && - !["profile", "plugins", "appearance"].includes(target.section) + !["profile", "plugins", "appearance", "notifications"].includes( + target.section, + ) ) failure = "unavailable"; const owner = useMemo( diff --git a/src/app/services.ts b/src/app/services.ts index 8b762229..ed644fe5 100644 --- a/src/app/services.ts +++ b/src/app/services.ts @@ -1,5 +1,10 @@ // FOUNDATION: Compose the bundled distribution, plugin runtime, and services here. import { provideNavigation } from "../features/navigation/service"; +import { NotificationsService } from "../features/notifications/service"; +import { + bindMessageNotifications, + notificationAuthorized, +} from "../features/notifications/messages"; import { ShortcutsService } from "../features/shortcuts/service"; import { ConversationService } from "../features/conversation/service"; import { createAppearance } from "../shared/theme/service"; @@ -28,8 +33,17 @@ export function createServices() { import.meta.env.VITE_BUZZ_LIVE === "1", ); const relay = communities.relay; + const notifications = new NotificationsService( + ctx, + navigation, + undefined, + undefined, + (target) => notificationAuthorized(communities, target), + ); + ctx.effect(() => bindMessageNotifications(notifications, communities)); let disposal: Promise | undefined; return { + notifications, navigation, navigationHost, shortcuts, diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index fe38e0f0..19e67daf 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -64,15 +64,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" }); @@ -159,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; @@ -215,7 +210,53 @@ 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 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]); @@ -254,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); @@ -529,6 +572,7 @@ function ChannelWorkspace({ scope={scope} channelId={current.id} navigation={navigation} + exactInTimeline={exact?.inTimeline ?? false} onOpenLink={openLink} canOpenLink={canOpenLink} onOpenThread={openThread} @@ -563,6 +607,7 @@ function ChannelWorkspace({ channelName={current?.name ?? ""} channelId={showingThread.channelId} messageId={showingThread.messageId} + navigation={showingThread.navigation} close={closeThread} onOpenLink={openLink} canOpenLink={canOpenLink} @@ -601,6 +646,7 @@ function ChannelBody({ revealMessageId, onOpenThread, navigation, + exactInTimeline, }: { extensions?: ConversationExtensions | undefined; scope: string; @@ -608,6 +654,7 @@ function ChannelBody({ viewer?: string | undefined; channelId: string; navigation?: PageNavigation | undefined; + exactInTimeline: boolean; onOpenLink(url: string): boolean; canOpenLink?: ((target: string) => boolean) | undefined; revealMessageId?: string | undefined; @@ -656,6 +703,7 @@ function ChannelBody({ canOpenLink={canOpenLink} onOpenThread={onOpenThread} revealMessageId={revealMessageId} + navigation={exactInTimeline ? navigation : undefined} /> ); } diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index 27b235f7..db09202f 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -1,3 +1,4 @@ +import { useReading } from "./use-reading"; import { afterEach, expect, it, vi } from "vitest"; import type { ReactElement } from "react"; import { Virtualizer } from "virtua"; @@ -19,6 +20,9 @@ import type { ChannelMessage, ChannelWindow } from "../relay/contracts"; // Boundary test, not a browser renderer. Capture this production component's // effects/refs and invoke its returned DOM handlers. Deliberately stale Virtua // metrics reproduce the event ordering measured separately in Chromium/WebKit. +// 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 }[], states: [] as unknown[], @@ -241,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"; @@ -302,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, @@ -395,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(); @@ -1031,6 +1082,17 @@ it("an accepted button read retires earlier blocked gesture before verification" 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) }), + }); +}); + const membershipRow = (id: string, time: number): ChannelMessage => ({ id, channelId: "channel", diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 1312d2f9..9db2483c 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(() => @@ -108,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 }); @@ -167,6 +173,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 +247,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 +322,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) @@ -368,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) => { @@ -410,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/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/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 80a01d32..4bd07991 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 (