From 2d280376ad36134cec1f23bead6d866d30bed147 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:11:07 -0400 Subject: [PATCH 001/101] perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Lands the build-now items from the desktop latency plan (#ui-performance-deep-dive) as one change. Every perceived-latency hot path a user hits on launch, channel open, thread open, and reply send drops one or more round trips. **A1 — persisted channel heads (the big one).** Native WAL SQLite cache (`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey, relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap, schema-version reset, corrupt-row tolerance, checkpointed on shutdown. Three blocking-pool commands: `channel_head_cache_load` / `_store` / `_clear`. On the renderer side, `CommunityQueryProvider` kicks off hydration of up to 12 heads when it constructs the query client — the app, splash and relay preconnect mount immediately; only `useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then consumes a one-shot hydrated gate so a hydrated channel pays **zero** `get_channel_window` calls on mount and exactly **one** on the post-subscription refresh, whose response replaces page zero wholesale. That refresh fires whether live-subscription setup succeeds or fails, and is sequenced behind hydration so it is always a distinct authoritative fetch (see Review follow-ups). Bounds-only persisted heads (zero rows) are not hydrated and take the cold loading path. The timeline loading latch recognizes native-hydrated rows as restart-safe so they paint immediately instead of holding a skeleton. The cache is a paint accelerator only — the relay response is always authoritative. Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401 lines). Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or `localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is cleared on community removal and scoped per identity, so a replaced signer never sees the previous identity's rows. **B1 — thread aux in one response.** Relay thread filters accept `include_aux`; the bridge appends the same authorized two-hop reactions/edits/deletions closure a channel window gets (`build_aux_query` shared with the window path). Renderer `useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is computed from reply-kind rows only since aux rows are unpaged. Documented in `docs/bridge-channel-window.md`. Thread queries keep `staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s, which CI's `thread-unread.spec.ts` caught — once the user leaves a channel, the live subscription stops feeding that thread's cache, so a reopen must always take the (now single) authoritative read. **B2 — cached root on reply send.** `send_channel_message` gains `root_event_id`; when the renderer already holds the parent (channel or thread cache) it passes the NIP-10 root, and native signs without the relay round trip that `resolve_thread_ref` used to make. Strict hex parse; `root_event_id` requires `parent_event_id`; absent root falls back to the existing relay resolution. The renderer never sends a guessed root. **B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5** relay preconnect fires as soon as identity is ready instead of waiting for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts` "service restart close resets accumulated backoff") had been relying on the idle-callback batching to skip past its own seeded dial failures before the channel list painted; `8133d70bb` makes it wait for the connected state instead (test-only, still fails with the 1012 backoff reset disabled). **B6** profile freshness 60s→10 min (both the in-memory entry check and the query `staleTime`). Tradeoff: another user's display-name/avatar edit can take up to 10 min to propagate to a client that already holds their profile (relay reconnect refetches `users-batch` but resolves from the still-fresh per-pubkey entry); your own edits still evict the entry immediately (`evictUsersBatchEntries` in `useUpdateProfileMutation`). ### Related issue Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the measurement instrument and is intentionally not folded in. No duplicate PR found. ### Review follow-ups Addressing Carl's reviews [5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109) and [5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542), each pushed as new commits (no rebase): - `4f06b7770` fix(desktop): mount app while channel heads hydrate; always revalidate — provider no longer gates children on the cache load; `refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads skipped at seed; seed merges into an existing window store. +3 tests. - `35834cb31` fix(relay): drain aux closure hops across the page clamp — `query_all_pages` walks the `(created_at, id)` keyset via `until`/`before_id` until a short page (`AUX_PAGE_LIMIT` = `DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so one-shot `limit: 1000` newest-first no longer drops the oldest edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated. - `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no overlap). - `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind channel head hydration — `refreshChannelWindowMessages` awaits `channelHeadHydration()` and, for a hydration-seeded query (`data !== undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before invalidating. Without this, a subscription that settles before the SQLite load invalidated a data-less in-flight query; TanStack dedupes that onto the existing fetch (`query-core` `fetch()` only cancels when `state.data` exists), which returned the seeded snapshot — 0 authoritative fetches. Regression test reproduces Carl's exact ordering (fails at `35834cb31` with 0 calls), plus a cold-channel guard that the fix does not double-fetch. - `b129231c8` fix(desktop): let concurrent post-hydration refreshes share one window fetch — found independently by Max and Wren reviewing `5a5566c0f`: subscribe settlement + reconnect both wake on the same snapshot promise and both invalidate; the second (default `cancelRefetch: true`) cancelled and replaced the first authoritative fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits the relay). The seeded branch now invalidates with `cancelRefetch: false` so a second waker joins the in-flight fetch; cold/warm keep the default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window` relies on it). Concurrent regression test fails at `5a5566c0f` with 3. ### Testing At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD` = `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0, Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` + `relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh `build:e2e`, pre-push hooks green. At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0, Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` + `relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh `build:e2e`, pre-push hooks green. At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib` 910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same specs minus affordance); GitHub CI green on every job except Smoke (3) (unrelated project-review row-count + messaging timing flake, per Carl) and Unit Tests (sherpa cache skeleton, below). Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a comments-only commit correcting two `profile/hooks.ts` freshness comments from 60s to 10 min; pre-push desktop check/typecheck/test 5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7` at PR open, since moved to `4baccd539` (#6558, mobile only — zero file overlap, `git merge-tree` clean): - `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount then 1 on invalidate with wholesale replacement) - Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` + `channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at `7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec persists a head, reloads into a fresh mock relay with the head fetch held 5s, asserts the persisted row paints within 2s, exactly one `get_channel_window` after open, and the stale row is removed when the authoritative page lands. - `pnpm typecheck`, `pnpm check` — clean At `7acbf951b` (everything except the two-line `useThreadReplies.ts` staleTime revert and the test-only `relay-reconnect.spec.ts` change), also green in one shell: - `just desktop-tauri-test` — 2,859 passed / 0 failed across the workspace (channel_head_cache: wire shape, LRU+caps, schema reset, corrupt-row skip) - `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p buzz-relay --lib` — 908 passed / 0 failed - `just check` components: fmt-check, clippy, desktop-check, desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy, web-check, mobile-check, file-size-check — all green - `just desktop-build`, `web-build`, `desktop-tauri-check`, `mobile-test` (1,661 passed) — all green CI note: the "Unit Tests" job goes red on this PR and on `main` whenever it hits a poisoned `rust-cache` entry (an empty-directory skeleton of `target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts), surfacing as `could not find native static library sherpa-onnx-c-api` in `buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry and rerunning turned the job green at `0c492366d` (28/28); it re-poisons on the next `main` push until the workflow clears that directory after cache restore. Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and line-by-line by me before opening; the staleTime fix re-verified by Wren and me independently; the relay-reconnect test fix bisected and verified by me. --------- Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Max Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: Max Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/api/bridge.rs | 238 +++++++++- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/app_state.rs | 4 +- desktop/src-tauri/src/channel_head_cache.rs | 432 ++++++++++++++++++ desktop/src-tauri/src/commands/messages.rs | 68 ++- .../src/commands/messages/thread_ref.rs | 34 +- .../src-tauri/src/commands/messages_tests.rs | 12 + desktop/src-tauri/src/lib.rs | 5 + desktop/src-tauri/src/shutdown.rs | 1 + desktop/src/app/App.tsx | 33 +- .../src/app/useAppShellLifecycleEffects.ts | 28 +- .../features/channels/ui/ChannelScreen.tsx | 10 +- .../features/communities/useCommunities.tsx | 14 +- .../features/communities/useCommunityInit.ts | 8 +- desktop/src/features/messages/hooks.ts | 103 ++++- .../messages/lib/channelHeadCache.test.mjs | 382 ++++++++++++++++ .../features/messages/lib/channelHeadCache.ts | 110 +++++ .../messages/lib/messageSnapshot.test.mjs | 199 -------- .../features/messages/lib/messageSnapshot.ts | 202 -------- .../lib/projectChannelWindow.test.mjs | 53 +++ .../messages/lib/projectChannelWindow.ts | 28 +- .../messages/lib/sendChannelBinding.test.mjs | 21 + .../messages/useThreadReplies.test.mjs | 35 +- .../src/features/messages/useThreadReplies.ts | 63 +-- desktop/src/features/profile/hooks.ts | 11 +- .../src/shared/api/tauriChannelHeadCache.ts | 25 + desktop/src/shared/api/tauriMessages.ts | 2 + desktop/src/testing/e2eBridge.ts | 84 +++- .../tests/e2e/channel-head-restart.spec.ts | 88 ++++ desktop/tests/e2e/relay-reconnect.spec.ts | 13 +- desktop/tests/helpers/bridge.ts | 2 + docs/bridge-channel-window.md | 8 +- 32 files changed, 1701 insertions(+), 616 deletions(-) create mode 100644 desktop/src-tauri/src/channel_head_cache.rs create mode 100644 desktop/src/features/messages/lib/channelHeadCache.test.mjs create mode 100644 desktop/src/features/messages/lib/channelHeadCache.ts delete mode 100644 desktop/src/features/messages/lib/messageSnapshot.test.mjs delete mode 100644 desktop/src/features/messages/lib/messageSnapshot.ts create mode 100644 desktop/src/shared/api/tauriChannelHeadCache.ts create mode 100644 desktop/tests/e2e/channel-head-restart.spec.ts diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8fdea4b3c02..bbfcd8ecfe8 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -393,6 +393,83 @@ const WINDOW_AUX_DELETE_KINDS: [u32; 2] = [ buzz_core::kind::KIND_NIP29_DELETE_EVENT, ]; +/// Page size for one aux-closure hop. Matches the DB clamp +/// (`buzz_db::DEFAULT_MAX_PAGE_LIMIT`) so each page is one full query. +const AUX_PAGE_LIMIT: i64 = buzz_db::DEFAULT_MAX_PAGE_LIMIT; +/// Upper bound on pages drained per hop: 64k aux events referencing one page +/// of rows is far past any real thread; past it we log and stop rather than +/// loop forever against a pathological write pattern. +const AUX_MAX_PAGES: usize = 64; + +fn build_aux_query( + community: buzz_core::CommunityId, + target_ids: Vec, + kinds: &[u32], +) -> buzz_db::EventQuery { + let mut query = buzz_db::EventQuery::for_community(community); + query.kinds = Some(kinds.iter().map(|kind| *kind as i32).collect()); + query.e_tags = Some(target_ids); + query +} + +/// Where an aux hop reads from: the window path pins the request's proved +/// read session; the thread path takes the routed display-read fast path. +enum AuxReader<'a> { + Session(&'a mut buzz_db::ReadSession), + Routed(&'a buzz_db::Db, &'static str), + #[cfg(test)] + Fake(&'a mut (dyn FnMut(&buzz_db::EventQuery) -> Vec + Send)), +} + +impl AuxReader<'_> { + async fn fetch( + &mut self, + query: &buzz_db::EventQuery, + ) -> buzz_db::Result> { + match self { + AuxReader::Session(session) => session.query_events(query).await, + AuxReader::Routed(db, path) => db.query_events_routed(path, query).await, + #[cfg(test)] + AuxReader::Fake(fetch) => Ok(fetch(query)), + } + } +} + +/// Drain every event matching `query`, walking the `(created_at, id)` keyset +/// cursor `query_events` already orders by until a short page. An aux hop +/// over a reaction-heavy page can exceed a single page clamp, and because +/// results are newest-first a one-shot query silently drops the *oldest* +/// edits and deletions — rendering original or deleted content, not merely +/// losing decoration. +async fn query_all_pages( + mut query: buzz_db::EventQuery, + page_limit: i64, + reader: &mut AuxReader<'_>, +) -> buzz_db::Result> { + query.limit = Some(page_limit); + let mut events = Vec::new(); + for _ in 0..AUX_MAX_PAGES { + let page = reader.fetch(&query).await?; + let next = if page.len() as i64 >= page_limit { + page.last().map(|se| (se.event.created_at, se.event.id)) + } else { + None + }; + events.extend(page); + let Some((created_at, id)) = next else { + return Ok(events); + }; + query.until = chrono::DateTime::from_timestamp(created_at.as_secs() as i64, 0); + query.before_id = Some(id.to_bytes().to_vec()); + } + tracing::warn!( + pages = AUX_MAX_PAGES, + events = events.len(), + "aux closure hop exceeded page cap; returning truncated closure" + ); + Ok(events) +} + /// Serve one `top_level: true` channel-window filter on the bridge `/query` /// path (docs/bridge-channel-window.md). Appends, in order: row events, the /// aux closure (`include_aux`), `39005` thread-summary overlays @@ -496,14 +573,15 @@ async fn handle_channel_window_filter( std::collections::HashSet::new(); let mut hop_ids = row_ids_hex.clone(); for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] { - let mut aux_query = buzz_db::EventQuery::for_community(tenant.community()); - aux_query.kinds = Some(hop_kinds.iter().map(|k| *k as i32).collect()); - aux_query.e_tags = Some(std::mem::take(&mut hop_ids)); - aux_query.limit = Some(1000); - let aux_events = session - .query_events(&aux_query) - .await - .map_err(|e| internal_error(&format!("window aux error: {e}")))?; + let aux_query = + build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds); + let aux_events = query_all_pages( + aux_query, + AUX_PAGE_LIMIT, + &mut AuxReader::Session(&mut session), + ) + .await + .map_err(|e| internal_error(&format!("window aux error: {e}")))?; for se in aux_events { if !seen_aux.insert(se.event.id) { continue; @@ -1203,6 +1281,8 @@ async fn query_events_authed( .await .map_err(|e| internal_error(&format!("thread query error: {e}")))?; + let mut thread_row_ids = Vec::with_capacity(thread_replies.len() + 1); + thread_row_ids.push(root_hex.to_string()); for reply in thread_replies { let se = reply.stored_event; if !event_in_accessible_channel(&se, &accessible_channels) { @@ -1214,10 +1294,45 @@ async fn query_events_authed( if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) { continue; } + thread_row_ids.push(se.event.id.to_hex()); if let Ok(v) = serde_json::to_value(&se.event) { events.push(v); } } + + if extension_flag(raw, "include_aux") && !thread_row_ids.is_empty() { + let mut seen_aux = std::collections::HashSet::new(); + let mut hop_ids = thread_row_ids; + for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] { + let aux_query = + build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds); + let aux_events = query_all_pages( + aux_query, + AUX_PAGE_LIMIT, + &mut AuxReader::Routed(&state.db, "bridge_thread_aux"), + ) + .await + .map_err(|e| internal_error(&format!("thread aux query error: {e}")))?; + for se in aux_events { + if !seen_aux.insert(se.event.id) + || !event_in_accessible_channel(&se, &accessible_channels) + || !buzz_core::filter::reader_authorized_for_event( + &se.event, + &authed_pubkey_hex, + ) + { + continue; + } + hop_ids.push(se.event.id.to_hex()); + if let Ok(value) = serde_json::to_value(&se.event) { + events.push(value); + } + } + if hop_ids.is_empty() { + break; + } + } + } handled.insert(idx); } @@ -2373,6 +2488,113 @@ mod tests { assert!(!has_mixed_search_filters(&filters)); } + #[test] + fn thread_aux_query_targets_root_and_replies() { + let tenant = fresh_tenant("relay.example"); + let targets = vec!["root".to_string(), "reply".to_string()]; + let query = build_aux_query(tenant.community(), targets.clone(), &WINDOW_AUX_KINDS); + + assert_eq!(query.e_tags, Some(targets)); + assert_eq!( + query.kinds, + Some(WINDOW_AUX_KINDS.iter().map(|kind| *kind as i32).collect()) + ); + assert_eq!(query.limit, None); + assert_eq!(query.until, None); + assert_eq!(query.before_id, None); + } + + fn aux_event(keys: &Keys, created_at: u64, content: &str) -> buzz_core::StoredEvent { + let ev = EventBuilder::new(Kind::Custom(7), content) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap(); + buzz_core::StoredEvent::new(ev, None) + } + + /// Carl/#6572: a one-shot `limit=1000` aux query is newest-first, so the + /// oldest reactions/edits/deletions past the clamp vanished. The paged + /// drain must walk the keyset cursor until a short page and return every + /// event exactly once. + #[tokio::test] + async fn query_all_pages_drains_past_the_page_clamp() { + let keys = Keys::generate(); + // Newest-first store: 5 events, two sharing a second so the id + // tiebreak is exercised. + let mut store = [ + aux_event(&keys, 50, "e"), + aux_event(&keys, 40, "d1"), + aux_event(&keys, 40, "d2"), + aux_event(&keys, 30, "c"), + aux_event(&keys, 10, "a"), + ]; + store.sort_by(|l, r| { + r.event + .created_at + .cmp(&l.event.created_at) + .then(l.event.id.cmp(&r.event.id)) + }); + let expected: Vec<_> = store.iter().map(|se| se.event.id).collect(); + let mut calls = Vec::new(); + + let tenant = fresh_tenant("relay.example"); + let query = build_aux_query(tenant.community(), vec!["root".into()], &WINDOW_AUX_KINDS); + let mut fetch = |q: &buzz_db::EventQuery| { + calls.push((q.limit, q.until, q.before_id.clone())); + // Emulate `query_events_on`: `created_at < until OR + // (created_at = until AND id > before_id)`, newest-first, limit. + let page: Vec<_> = store + .iter() + .filter(|se| match (q.until, q.before_id.as_deref()) { + (Some(until), Some(before)) => { + let ts = se.event.created_at.as_secs() as i64; + ts < until.timestamp() + || (ts == until.timestamp() + && se.event.id.as_bytes().as_slice() > before) + } + _ => true, + }) + .take(q.limit.unwrap() as usize) + .cloned() + .collect(); + page + }; + let events = query_all_pages(query, 2, &mut AuxReader::Fake(&mut fetch)) + .await + .unwrap(); + + assert_eq!( + events.iter().map(|se| se.event.id).collect::>(), + expected + ); + assert_eq!(calls.len(), 3, "2 full pages + 1 short page"); + assert!(calls.iter().all(|(limit, _, _)| *limit == Some(2))); + assert_eq!(calls[0].1, None); + // Second page resumes from the last row of the first (ts 40, larger id). + assert_eq!(calls[1].1.unwrap().timestamp(), 40); + assert_eq!( + calls[1].2.as_deref(), + Some(store[1].event.id.as_bytes().as_slice()) + ); + assert_eq!(calls[2].1.unwrap().timestamp(), 30); + } + + #[tokio::test] + async fn query_all_pages_stops_at_one_short_page() { + let tenant = fresh_tenant("relay.example"); + let query = build_aux_query(tenant.community(), vec!["root".into()], &WINDOW_AUX_KINDS); + let mut calls = 0; + let mut fetch = |_q: &buzz_db::EventQuery| { + calls += 1; + Vec::new() + }; + let events = query_all_pages(query, 1000, &mut AuxReader::Fake(&mut fetch)) + .await + .unwrap(); + assert!(events.is_empty()); + assert_eq!(calls, 1); + } + #[test] fn bridge_search_mode_extension_defaults_to_full_text() { assert_eq!( diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..9099beff69e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -99,6 +99,7 @@ export default defineConfig({ "**/scroll-history.spec.ts", "**/channel-dense-second-reach.spec.ts", "**/channel-window-mock-paging.spec.ts", + "**/channel-head-restart.spec.ts", "**/live-broadcast-reply-timeline.spec.ts", "**/markdown-parse-cache.spec.ts", "**/overscroll-boundary.spec.ts", diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 7c41f6bfe26..9cbb4444ab3 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -194,8 +194,8 @@ pub fn build_app_state() -> AppState { identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) - .pool_idle_timeout(std::time::Duration::from_secs(10)) - .pool_max_idle_per_host(1) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(2) .build() .unwrap_or_else(|_| reqwest::Client::new()), media_fetch_client: build_media_fetch_client().expect( diff --git a/desktop/src-tauri/src/channel_head_cache.rs b/desktop/src-tauri/src/channel_head_cache.rs new file mode 100644 index 00000000000..f84c534d30c --- /dev/null +++ b/desktop/src-tauri/src/channel_head_cache.rs @@ -0,0 +1,432 @@ +//! Persistent native cache for recently visited channel head pages. +//! +//! The cache is a paint accelerator only: the renderer always replaces a +//! hydrated page with an authoritative relay response after subscribing. + +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tauri::{AppHandle, Manager, State}; + +const SCHEMA_VERSION: i64 = 1; +const CHANNELS_PER_SCOPE_CAP: i64 = 32; +const ROW_BYTES_CAP: usize = 1024 * 1024; + +/// Serializes cache mutations on the blocking pool. +#[derive(Default)] +pub(crate) struct ChannelHeadCacheStore { + write_lock: Arc>, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChannelHeadScope { + pub(crate) pubkey: String, + pub(crate) relay_url: String, +} + +impl ChannelHeadScope { + fn key(&self) -> String { + format!( + "{}:{}", + self.pubkey.trim().to_ascii_lowercase(), + self.relay_url.trim().trim_end_matches('/') + ) + } +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChannelHeadEntry { + channel_id: String, + events: Vec, + saved_at: i64, + last_visited_at: i64, +} + +fn db_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|error| format!("resolve channel-head cache data dir: {error}"))?; + std::fs::create_dir_all(&dir) + .map_err(|error| format!("create channel-head cache data dir: {error}"))?; + Ok(dir.join("channel-head-cache.db")) +} + +fn create_schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + "CREATE TABLE schema_meta(version INTEGER NOT NULL); + INSERT INTO schema_meta(version) VALUES(1); + CREATE TABLE channel_head( + scope TEXT NOT NULL, + channel_id TEXT NOT NULL, + events_json TEXT NOT NULL, + row_count INTEGER NOT NULL, + saved_at INTEGER NOT NULL, + last_visited_at INTEGER NOT NULL, + PRIMARY KEY(scope, channel_id) + );", + ) + .map_err(|error| format!("initialize channel-head cache db: {error}")) +} + +fn open_db(path: &Path) -> Result { + let conn = + Connection::open(path).map_err(|error| format!("open channel-head cache db: {error}"))?; + conn.pragma_update(None, "busy_timeout", 5_000) + .map_err(|error| format!("configure channel-head cache db: {error}"))?; + conn.pragma_update(None, "journal_mode", "WAL") + .map_err(|error| format!("configure channel-head cache WAL: {error}"))?; + + let has_schema_meta: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta')", + [], + |row| row.get(0), + ) + .map_err(|error| format!("inspect channel-head cache schema: {error}"))?; + if !has_schema_meta { + create_schema(&conn)?; + return Ok(conn); + } + + let version = conn + .query_row("SELECT version FROM schema_meta LIMIT 1", [], |row| { + row.get::<_, i64>(0) + }) + .optional() + .map_err(|error| format!("read channel-head cache schema: {error}"))?; + let has_channel_head: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='channel_head')", + [], + |row| row.get(0), + ) + .map_err(|error| format!("inspect channel-head cache table: {error}"))?; + if version != Some(SCHEMA_VERSION) || !has_channel_head { + conn.execute_batch("DROP TABLE IF EXISTS channel_head; DROP TABLE IF EXISTS schema_meta;") + .map_err(|error| format!("reset channel-head cache schema: {error}"))?; + create_schema(&conn)?; + } + Ok(conn) +} + +async fn run_blocking(task: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + tauri::async_runtime::spawn_blocking(task) + .await + .map_err(|error| format!("channel-head cache db task failed: {error}"))? +} + +fn load_from_path( + path: &Path, + scope: &ChannelHeadScope, + limit: u32, +) -> Result, String> { + let conn = open_db(path)?; + let mut statement = conn + .prepare( + "SELECT channel_id, events_json, saved_at, last_visited_at + FROM channel_head WHERE scope=?1 + ORDER BY last_visited_at DESC, saved_at DESC, channel_id ASC LIMIT ?2", + ) + .map_err(|error| format!("prepare channel-head cache load: {error}"))?; + let rows = statement + .query_map(params![scope.key(), i64::from(limit)], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }) + .map_err(|error| format!("query channel-head cache: {error}"))?; + let mut entries = Vec::new(); + for row in rows { + let (channel_id, events_json, saved_at, last_visited_at) = + row.map_err(|error| format!("read channel-head cache row: {error}"))?; + let events = match serde_json::from_str(&events_json) { + Ok(events) => events, + Err(error) => { + eprintln!("skipping corrupt channel-head cache row {channel_id}: {error}"); + continue; + } + }; + entries.push(ChannelHeadEntry { + channel_id, + events, + saved_at, + last_visited_at, + }); + } + Ok(entries) +} + +fn store_in_transaction( + transaction: &Transaction<'_>, + scope: &str, + channel_id: &str, + events_json: &str, + row_count: usize, + now: i64, +) -> Result<(), String> { + let last_visited_at: i64 = transaction + .query_row( + "SELECT COALESCE(MAX(last_visited_at), ?2 - 1) + 1 FROM channel_head WHERE scope=?1", + params![scope, now], + |row| row.get(0), + ) + .map_err(|error| format!("advance channel-head cache visit clock: {error}"))?; + transaction + .execute( + "INSERT INTO channel_head(scope, channel_id, events_json, row_count, saved_at, last_visited_at) + VALUES(?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(scope, channel_id) DO UPDATE SET + events_json=excluded.events_json, + row_count=excluded.row_count, + saved_at=excluded.saved_at, + last_visited_at=excluded.last_visited_at", + params![scope, channel_id, events_json, row_count as i64, now, last_visited_at], + ) + .map_err(|error| format!("store channel-head cache row: {error}"))?; + transaction + .execute( + "DELETE FROM channel_head WHERE rowid IN ( + SELECT rowid FROM channel_head WHERE scope=?1 + ORDER BY last_visited_at DESC, saved_at DESC, channel_id ASC + LIMIT -1 OFFSET ?2 + )", + params![scope, CHANNELS_PER_SCOPE_CAP], + ) + .map_err(|error| format!("prune channel-head cache: {error}"))?; + Ok(()) +} + +fn store_at( + path: &Path, + scope: &ChannelHeadScope, + channel_id: &str, + events: &[Value], + now: i64, +) -> Result<(), String> { + let events_json = serde_json::to_string(events) + .map_err(|error| format!("encode channel-head cache row: {error}"))?; + let mut conn = open_db(path)?; + let transaction = conn + .transaction() + .map_err(|error| format!("begin channel-head cache store: {error}"))?; + if events_json.len() > ROW_BYTES_CAP { + transaction + .execute( + "DELETE FROM channel_head WHERE scope=?1 AND channel_id=?2", + params![scope.key(), channel_id], + ) + .map_err(|error| format!("drop oversized channel-head cache row: {error}"))?; + } else { + store_in_transaction( + &transaction, + &scope.key(), + channel_id, + &events_json, + events.len(), + now, + )?; + } + transaction + .commit() + .map_err(|error| format!("commit channel-head cache store: {error}")) +} + +/// Loads the most recently visited channel heads for one identity and relay. +#[tauri::command] +pub(crate) async fn channel_head_cache_load( + scope: ChannelHeadScope, + limit: u32, + app: AppHandle, +) -> Result, String> { + let path = db_path(&app)?; + run_blocking(move || load_from_path(&path, &scope, limit)).await +} + +/// Stores one raw channel-window response, dropping payloads above one MiB. +#[tauri::command] +pub(crate) async fn channel_head_cache_store( + scope: ChannelHeadScope, + channel_id: String, + events: Vec, + app: AppHandle, + store: State<'_, ChannelHeadCacheStore>, +) -> Result<(), String> { + let path = db_path(&app)?; + let write_lock = Arc::clone(&store.write_lock); + run_blocking(move || { + let _guard = write_lock.lock().map_err(|error| error.to_string())?; + store_at( + &path, + &scope, + &channel_id, + &events, + chrono::Utc::now().timestamp(), + ) + }) + .await +} + +/// Clears all persisted channel heads for one identity and relay. +#[tauri::command] +pub(crate) async fn channel_head_cache_clear( + scope: ChannelHeadScope, + app: AppHandle, + store: State<'_, ChannelHeadCacheStore>, +) -> Result<(), String> { + let path = db_path(&app)?; + let write_lock = Arc::clone(&store.write_lock); + run_blocking(move || { + let _guard = write_lock.lock().map_err(|error| error.to_string())?; + let conn = open_db(&path)?; + conn.execute("DELETE FROM channel_head WHERE scope=?1", [scope.key()]) + .map_err(|error| format!("clear channel-head cache scope: {error}"))?; + Ok(()) + }) + .await +} + +pub(crate) fn flush(app: &AppHandle) { + if let Ok(path) = db_path(app) { + if let Ok(conn) = open_db(&path) { + let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope() -> ChannelHeadScope { + ChannelHeadScope { + pubkey: "PK".into(), + relay_url: "wss://relay/".into(), + } + } + + #[test] + fn serialized_entry_matches_typescript_contract() { + let actual = serde_json::to_value(ChannelHeadEntry { + channel_id: "general".into(), + events: vec![serde_json::json!({"id":"event"})], + saved_at: 42, + last_visited_at: 43, + }) + .unwrap(); + let expected = serde_json::json!({ + "channelId":"general", + "events":[{"id":"event"}], + "savedAt":42, + "lastVisitedAt":43 + }); + assert_eq!(actual, expected); + + let decoded: ChannelHeadScope = serde_json::from_value(serde_json::json!({ + "pubkey":"PK", "relayUrl":"wss://relay/" + })) + .unwrap(); + assert_eq!(decoded, scope()); + assert_eq!(decoded.key(), "pk:wss://relay"); + } + + #[test] + fn enforces_lru_and_payload_caps() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("channel-head-cache.db"); + for index in 0..=CHANNELS_PER_SCOPE_CAP { + store_at( + &path, + &scope(), + &format!("channel-{index:02}"), + &[serde_json::json!({"index":index})], + 1_000 + index, + ) + .unwrap(); + } + let entries = load_from_path(&path, &scope(), 100).unwrap(); + assert_eq!(entries.len(), CHANNELS_PER_SCOPE_CAP as usize); + assert_eq!(entries.first().unwrap().channel_id, "channel-32"); + assert!(!entries.iter().any(|entry| entry.channel_id == "channel-00")); + + let oversized = vec![Value::String("x".repeat(ROW_BYTES_CAP))]; + store_at(&path, &scope(), "channel-32", &oversized, 2_000).unwrap(); + let count: i64 = open_db(&path) + .unwrap() + .query_row( + "SELECT COUNT(*) FROM channel_head WHERE channel_id='channel-32'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn skips_corrupt_rows_without_blanketing_good_entries() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("channel-head-cache.db"); + store_at( + &path, + &scope(), + "good-channel", + &[serde_json::json!({"id":"good-event"})], + 1_000, + ) + .unwrap(); + let conn = open_db(&path).unwrap(); + conn.execute( + "INSERT INTO channel_head VALUES(?1, 'bad-channel', 'not-json', 1, 1001, 1001)", + [scope().key()], + ) + .unwrap(); + drop(conn); + + let entries = load_from_path(&path, &scope(), 12).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].channel_id, "good-channel"); + assert_eq!( + entries[0].events, + vec![serde_json::json!({"id":"good-event"})] + ); + } + + #[test] + fn schema_mismatch_recreates_cache() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("channel-head-cache.db"); + let conn = open_db(&path).unwrap(); + conn.execute("UPDATE schema_meta SET version=99", []) + .unwrap(); + conn.execute( + "INSERT INTO channel_head VALUES('scope','channel','[]',0,1,1)", + [], + ) + .unwrap(); + drop(conn); + + let reset = open_db(&path).unwrap(); + let version: i64 = reset + .query_row("SELECT version FROM schema_meta", [], |row| row.get(0)) + .unwrap(); + let rows: i64 = reset + .query_row("SELECT COUNT(*) FROM channel_head", [], |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + assert_eq!(rows, 0); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 31559777d2b..461f29e7fa6 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -236,22 +236,11 @@ fn search_messages_limit(limit: Option) -> u32 { limit.unwrap_or(20).min(500) } -/// Fetch the full reply subtree under a thread root, server-side. -/// -/// Unlike the channel timeline (which the desktop assembles from its local -/// cache by grouping on `e`-root tags), this walks `thread_metadata` on the -/// relay via `get_thread_replies`, so a thread renders complete even when its -/// replies fell outside the channel cold-load window. Results are chronological -/// (oldest first) and are the *replies* under the root (depth >= 1); the root -/// event itself is NOT returned (the relay query keys on `root_event_id`, and a -/// root row has no `root_event_id`). Callers already hold the root — it is the -/// open thread head — so this closes the descendant gap without re-fetching it. +/// Fetch the reply subtree and its auxiliary events under a thread root. /// /// Paging is forward keyset on `(created_at, event_id)`: pass the `next_cursor` /// from a previous page back as `cursor` to fetch the next batch. The event-id -/// tiebreak is required because replies routinely share a `created_at` second; -/// a timestamp-only cursor would skip every tied reply past the page limit. -/// `next_cursor` is `Some` only when a full page was returned. +/// tiebreak prevents same-second replies from being skipped. #[tauri::command] pub async fn get_thread_replies( root_event_id: String, @@ -275,8 +264,12 @@ pub async fn get_thread_replies( // A full page implies there may be more; hand back the last event's // composite key as the next cursor (the DB returns replies strictly after // it, tiebroken by event_id so same-second replies are not skipped). - let next_cursor = if events.len() as u32 >= cap { - events.last().map(|ev| crate::models::ThreadCursor { + let reply_events: Vec<_> = events + .iter() + .filter(|event| TIMELINE_KINDS.contains(&(event.kind.as_u16() as u32))) + .collect(); + let next_cursor = if reply_events.len() as u32 >= cap { + reply_events.last().map(|ev| crate::models::ThreadCursor { created_at: ev.created_at.as_secs() as i64, event_id: ev.id.to_hex(), }) @@ -295,21 +288,9 @@ pub async fn get_thread_replies( }) } -/// Build the relay `/query` filter for the server-side thread-subtree read. -/// -/// The relay routes a filter to `get_thread_replies` purely off a single `#e` -/// (root) tag plus `depth_limit` — kind is NOT part of that routing or the -/// underlying DB query (it keys on `root_event_id`). Yet `kinds` is still -/// required here: the bridge runs the p-gate (`p_gated_filters_authorized`) on -/// every filter *before* routing, and a kindless filter "could match" a p-gated -/// kind, so the gate demands a `#p` tag we don't send -> HTTP 403 -/// `restricted: p-gated kinds require #p tag`, before the thread query ever -/// runs. Carrying non-p-gated [`TIMELINE_KINDS`] makes the filter provably -/// un-p-gated so it clears the gate. `build_channel_messages_before_filter` is -/// the sibling that already does this, which is why the dense-second channel -/// pager was never gated and this reader was. Extracted so a unit test can pin -/// that `kinds` is present (the e2e mock does not model p-gating, so only a -/// unit test guards this contract). +/// Build the relay `/query` filter for a thread-subtree read. +/// `kinds` is required to prove the filter cannot match p-gated events; without +/// it, relay authorization rejects this otherwise kindless query. fn build_thread_replies_filter( root_event_id: &str, channel_id: Option<&str>, @@ -324,6 +305,7 @@ fn build_thread_replies_filter( // defaults it to a deep-but-bounded value so nested replies aren't dropped. filter.insert("depth_limit".to_string(), serde_json::json!(depth_limit)); filter.insert("limit".to_string(), serde_json::json!(cap)); + filter.insert("include_aux".to_string(), serde_json::json!(true)); if let Some(cid) = channel_id { filter.insert("#h".to_string(), serde_json::json!([cid])); } @@ -435,7 +417,7 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result, + root_event_id: Option, media_tags: Option>>, emoji_tags: Option>>, mention_tags: Option>>, @@ -483,6 +466,9 @@ pub async fn send_channel_message( if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { return Err("sent-from-thread provenance requires a stream message".into()); } + if root_event_id.is_some() && parent_event_id.is_none() { + return Err("root_event_id requires parent_event_id".into()); + } let mut resolved_root: Option = None; @@ -498,8 +484,14 @@ pub async fn send_channel_message( let parent_id = parent_event_id .as_deref() .ok_or("forum comment requires parent_event_id")?; - let thread_ref = - resolve_thread_ref(parent_id, &state, &relay_base, Some(&signing_keys)).await?; + let thread_ref = thread_ref( + parent_id, + root_event_id.as_deref(), + &state, + &relay_base, + Some(&signing_keys), + ) + .await?; resolved_root = Some(thread_ref.root_event_id.to_hex()); events::build_forum_comment( channel_uuid, @@ -513,8 +505,14 @@ pub async fn send_channel_message( _ => { let thread_ref = match parent_event_id.as_deref() { Some(pid) => { - let tr = - resolve_thread_ref(pid, &state, &relay_base, Some(&signing_keys)).await?; + let tr = thread_ref( + pid, + root_event_id.as_deref(), + &state, + &relay_base, + Some(&signing_keys), + ) + .await?; resolved_root = Some(tr.root_event_id.to_hex()); Some(tr) } diff --git a/desktop/src-tauri/src/commands/messages/thread_ref.rs b/desktop/src-tauri/src/commands/messages/thread_ref.rs index 97a03fdad5b..8ec82beebb7 100644 --- a/desktop/src-tauri/src/commands/messages/thread_ref.rs +++ b/desktop/src-tauri/src/commands/messages/thread_ref.rs @@ -1,4 +1,4 @@ -use nostr::EventId; +use nostr::{EventId, Keys}; use crate::{ app_state::AppState, @@ -6,6 +6,38 @@ use crate::{ relay::{query_relay_at, query_relay_at_with_keys}, }; +/// Build a thread reference from a renderer-supplied root and parent. +/// +/// Both IDs are parsed before signing. This path intentionally performs no +/// relay query: the renderer supplies a root only when the parent is already +/// present in its cache and the root can be read from that event's NIP-10 tags. +pub(super) fn provided_thread_ref( + root_event_id: &str, + parent_event_id: &str, +) -> Result { + let root_event_id = + EventId::from_hex(root_event_id).map_err(|e| format!("invalid root event ID: {e}"))?; + let parent_event_id = + EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; + Ok(events::ThreadRef { + root_event_id, + parent_event_id, + }) +} + +pub(super) async fn thread_ref( + parent_event_id: &str, + root_event_id: Option<&str>, + state: &AppState, + api_base_url: &str, + signing_keys: Option<&Keys>, +) -> Result { + match root_event_id { + Some(root_event_id) => provided_thread_ref(root_event_id, parent_event_id), + None => resolve_thread_ref(parent_event_id, state, api_base_url, signing_keys).await, + } +} + /// Fetch a parent event and extract the thread root from its NIP-10 e-tags. /// /// Reads through the explicit `api_base_url` the calling command resolved — diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index c0ad03d936b..dc7c0f4b5a2 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -171,6 +171,7 @@ fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() { assert_eq!(filter["#e"], serde_json::json!(["root-hex"])); assert_eq!(filter["depth_limit"], serde_json::json!(64)); assert_eq!(filter["#h"], serde_json::json!(["channel-1"])); + assert_eq!(filter["include_aux"], serde_json::json!(true)); } #[test] @@ -224,3 +225,14 @@ fn legacy_managed_agent_auth_tag_skips_self_attestation() { assert_eq!(tag, None); } + +#[test] +fn provided_thread_ref_validates_and_preserves_root_and_parent() { + let root = "11".repeat(32); + let parent = "22".repeat(32); + let thread_ref = thread_ref::provided_thread_ref(&root, &parent) + .expect("valid 64-hex event ids should be accepted"); + assert_eq!(thread_ref.root_event_id.to_hex(), root); + assert_eq!(thread_ref.parent_event_id.to_hex(), parent); + assert!(thread_ref::provided_thread_ref("not-hex", &parent).is_err()); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 71a5eb3806e..428aa4d2a78 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod app_menu; mod app_state; mod archive; mod builderlab; +mod channel_head_cache; mod commands; mod deep_link; mod egress_guard; @@ -234,6 +235,7 @@ pub fn run() { .manage(archive::sync::ArchiveSyncState::default()) .manage(native_relay_client::NativeRelayClient::default()) .manage(observed_unread::ObservedUnreadStore::default()) + .manage(channel_head_cache::ChannelHeadCacheStore::default()) .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] @@ -724,6 +726,9 @@ pub fn run() { unread_catch_up::unread_catch_up, observed_unread::observed_unread_open_scope, observed_unread::observed_unread_ingest, + channel_head_cache::channel_head_cache_load, + channel_head_cache::channel_head_cache_store, + channel_head_cache::channel_head_cache_clear, list_personas, create_persona, update_persona, diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index 17ca7a7bb37..b1548c69370 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -20,6 +20,7 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a if !shutdown_done.swap(true, Ordering::SeqCst) { prevent_sleep::release(&app.state::().prevent_sleep); crate::observed_unread::flush(app); + crate::channel_head_cache::flush(app); app.state::() .shutdown_all(); if let Err(error) = shutdown_managed_agents(app) { diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index bfaf2ba2008..bdb26c1930d 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -63,6 +63,7 @@ import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChang import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; +import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache"; import { useIdentityQuery } from "@/shared/api/hooks"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -213,8 +214,30 @@ function CommunitySwitchGate() { ); } -function CommunityQueryProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState(createBuzzQueryClient); +function CommunityQueryProvider({ + children, + pubkey, + relayUrl, +}: { + children: ReactNode; + pubkey: string | null; + relayUrl: string | null; +}) { + // Seeding persisted channel heads is part of constructing the client, not a + // gate in front of the app: the splash, AppReady, and relay preconnect mount + // immediately, and only the channel query waits on the cache load (see + // channelHeadHydration). It must start here rather than in an effect — + // React Query fires a child's queryFn when it subscribes, before any parent + // effect runs — and StrictMode's dev-only double initializer just issues one + // redundant read on a discarded client. The provider is keyed on the + // community, so one client maps to one {pubkey, relayUrl} scope. + const [queryClient] = useState(() => { + const client = createBuzzQueryClient(); + if (pubkey && relayUrl) { + void hydrateChannelHeads(client, { pubkey, relayUrl }); + } + return client; + }); useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]); @@ -601,7 +624,11 @@ function CommunityApp({ }, [communityApplied]); if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( - + diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 969bf67ca67..71db0523695 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -42,33 +42,13 @@ export function useAppShellLifecycleEffects({ React.useEffect(() => { let isCancelled = false; - - const startPreconnect = () => { - if (isCancelled) { - return; + void relayClient.preconnect().catch((error) => { + if (!isCancelled) { + console.error("Failed to preconnect to relay", error); } - - void relayClient.preconnect().catch((error) => { - if (!isCancelled) { - console.error("Failed to preconnect to relay", error); - } - }); - }; - - if ("requestIdleCallback" in window) { - const idleId = window.requestIdleCallback(startPreconnect, { - timeout: 1_500, - }); - return () => { - isCancelled = true; - window.cancelIdleCallback(idleId); - }; - } - - const timeoutId = globalThis.setTimeout(startPreconnect, 250); + }); return () => { isCancelled = true; - globalThis.clearTimeout(timeoutId); }; }, []); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c71..68df9bc05c6 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; @@ -45,6 +46,7 @@ import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMen import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; import { getThreadReference } from "@/features/messages/lib/threading"; +import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, selectTimelineLoadingState, @@ -95,6 +97,7 @@ export function ChannelScreen({ targetMessageEvents, targetMessageId, }: ChannelScreenProps) { + const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); const { @@ -607,7 +610,12 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - hasSettledThisChannel, + // A persisted head only counts as hydrated when it has rows to paint + // (channelHeadCache.ts), so this bypass never settles onto an empty + // placeholder while the authoritative refresh is still in flight. + hasSettledThisChannel || + (activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId)), ); const { settledChannelId, isLoading: isTimelineLoading } = resolveTimelineLoadingLatch( diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index e0a10017883..f30353af72d 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -19,7 +19,8 @@ import { import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; import { removeUserLabelCacheForRelay } from "@/features/profile/lib/userLabelStorage"; import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; -import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; +import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache"; +import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore"; import { clearCommunityDestinations, @@ -234,7 +235,16 @@ function useCommunitiesInternal(): UseCommunitiesReturn { removeSelfProfileCachesForRelay(removed.relayUrl); removeUserLabelCacheForRelay(removed.relayUrl); removeChannelSnapshotForRelay(removed.relayUrl); - removeMessageSnapshotsForRelay(removed.relayUrl); + void getIdentity() + .then((identity) => + clearChannelHeadCache({ + pubkey: identity.pubkey, + relayUrl: removed.relayUrl, + }), + ) + .catch((error) => { + console.warn("Failed to clear persisted channel heads", error); + }); clearSavedCommunitySnapshot(id); removeCommunityDestination(id); diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 5493a47b1e3..c565ee0f7b4 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -84,7 +84,12 @@ async function resetCommunityState({ } type CommunityInitResult = - | { isReady: true; needsSetup: false; appliedKey: string } + | { + isReady: true; + needsSetup: false; + appliedKey: string; + identityPubkey: string | null; + } | { isReady: false; needsSetup: true; @@ -342,6 +347,7 @@ export function useCommunityInit( isReady: true, needsSetup: false, appliedKey: communityKey, + identityPubkey, }); } } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf8..a3c1e7f172b 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -24,6 +24,12 @@ import { refreshChannelWindowMessages, } from "@/features/messages/lib/projectChannelWindow"; import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation"; +import { + channelHeadCacheScope, + channelHeadHydration, + consumeHydratedChannel, +} from "@/features/messages/lib/channelHeadCache"; +import { storeChannelHeadCache } from "@/shared/api/tauriChannelHeadCache"; import { mergeMessages, mergeTimelineCacheMessages, @@ -85,6 +91,18 @@ type MessageQueryContext = { const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); const CHANNEL_AUX_KINDS = new Set(CHANNEL_AUX_EVENT_KINDS); +export function resolveCachedReplyRootId( + parentEventId: string, + messageCaches: readonly RelayEvent[][], +): string | null { + for (const messages of messageCaches) { + if (messages.some((event) => event.id === parentEventId)) { + return resolveReplyRootId(parentEventId, messages); + } + } + return null; +} + export function createOptimisticMessage( channelId: string, content: string, @@ -257,18 +275,30 @@ export function reconcileFetchedChannelWindow( emptyChannelWindowStore(); const next = replaceNewestChannelWindow(current, page); queryClient.setQueryData(windowKey, next); + const scope = channelHeadCacheScope(queryClient); + if (scope) { + void storeChannelHeadCache(scope, channelId, events).catch((error) => { + console.warn("Failed to persist channel head", channelId, error); + }); + } return reconcileChannelWindowMessages(next, previousMessages); } export function useChannelMessagesQuery(channel: Channel | null) { const queryClient = useQueryClient(); const queryKey = channelMessagesKey(channel?.id ?? "none"); - return useQuery({ enabled: channel !== null && channel.channelType !== "forum", queryKey, queryFn: async ({ signal }) => { if (!channel) throw new Error("No channel selected."); + // Persisted heads seed asynchronously; wait for that seed so a channel + // opened during boot takes the hydrated path instead of racing it with + // a cold relay fetch. + await channelHeadHydration(queryClient); + if (consumeHydratedChannel(queryClient, channel.id)) { + return queryClient.getQueryData(queryKey) ?? []; + } const previousMessages = queryClient.getQueryData(queryKey) ?? []; const events = await getChannelWindowEvents(channel.id); @@ -393,36 +423,45 @@ export function useChannelSubscription(channel: Channel | null) { }); }); + // The live subscription starts at "now", so it cannot close the gap + // between the last page snapshot and subscription establishment. Always + // refresh once subscription setup settles — on success because freshness + // alone is not proof that no relay events landed in that interval, and on + // failure because a hydrated channel has no other authoritative fetch: + // the relay window endpoint may be healthy even when the live socket is + // not, and the reconnect listener above re-syncs when it recovers. + const refreshAfterSubscribe = (outcome: string) => { + if (isDisposed) return; + void refreshNewestWindow().catch((error) => { + if (!isDisposed) { + console.error( + `Failed to refresh channel window after ${outcome}`, + channelId, + error, + ); + } + }); + }; relayClient .subscribeToChannelLive(channelId, (event) => { if (!isDisposed) { appendMessage(event); } }) - .then((dispose) => { - if (isDisposed) { - void dispose(); - return; - } - - cleanup = dispose; - // The live subscription starts at "now", so it cannot close the gap - // between the last page snapshot and subscription establishment. Always - // refresh after the subscription is active; freshness alone is not a - // proof that no relay events landed in that interval. - void refreshNewestWindow().catch((error) => { - if (!isDisposed) { - console.error( - "Failed to refresh channel window after subscribing", - channelId, - error, - ); + .then( + (dispose) => { + if (isDisposed) { + void dispose(); + return; } - }); - }) - .catch((error) => { - console.error("Failed to subscribe to channel", channelId, error); - }); + cleanup = dispose; + refreshAfterSubscribe("subscribing"); + }, + (error) => { + console.error("Failed to subscribe to channel", channelId, error); + refreshAfterSubscribe("subscription failure"); + }, + ); return () => { isDisposed = true; @@ -538,6 +577,17 @@ export function useSendMessageMutation( queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), ) ?? []; + const threadCaches = queryClient + .getQueriesData({ + queryKey: ["thread-replies", effectiveChannel.id], + }) + .flatMap(([, events]) => (events ? [events] : [])); + const suppliedRootEventId = parentEventId + ? resolveCachedReplyRootId(parentEventId, [ + cachedMessages, + ...threadCaches, + ]) + : null; const result = await sendChannelMessage( effectiveChannel.id, content, @@ -549,6 +599,9 @@ export function useSendMessageMutation( mentionTags, linkPreviewTags, sentFromThreadTag, + undefined, + undefined, + suppliedRootEventId, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -559,7 +612,7 @@ export function useSendMessageMutation( effectiveChannel.id, identity.pubkey, parentEventId, - resolveReplyRootId(parentEventId, cachedMessages), + result.rootEventId ?? parentEventId, recipientPubkeys, ) : []; diff --git a/desktop/src/features/messages/lib/channelHeadCache.test.mjs b/desktop/src/features/messages/lib/channelHeadCache.test.mjs new file mode 100644 index 00000000000..9fc62331f8f --- /dev/null +++ b/desktop/src/features/messages/lib/channelHeadCache.test.mjs @@ -0,0 +1,382 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, mock, test } from "node:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { JSDOM } from "jsdom"; +import React from "react"; +import { + consumeHydratedChannel, + hasPersistedHydratedChannel, + hydrateChannelHeads, +} from "./channelHeadCache.ts"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; +import { + reconcileFetchedChannelWindow, + useChannelMessagesQuery, + useChannelSubscription, +} from "../hooks.ts"; +import { relayClient } from "../../../shared/api/relayClient.ts"; +const dom = new JSDOM("", { + url: "http://localhost", +}); +const channel = { + id: "channel-a", + name: "general", + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: 1, + memberPubkeys: [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: true, + ttlSeconds: null, + ttlDeadline: null, +}; +let channelWindowCalls = 0; +let channelWindowEvents = []; + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); +const channelId = "channel-a"; +const root = { + id: "a".repeat(64), + pubkey: "b".repeat(64), + created_at: 10, + kind: 40002, + tags: [["h", channelId]], + content: "persisted", + sig: "", +}; +const replacement = { + ...root, + id: "c".repeat(64), + created_at: 11, + content: "relay", +}; +function bounds() { + return { + id: "d".repeat(64), + pubkey: "e".repeat(64), + created_at: 12, + kind: 39006, + tags: [ + ["h", channelId], + ["d", `${channelId}:head`], + ], + content: JSON.stringify({ has_more: false, next_cursor: null }), + sig: "", + }; +} +function install(entries, { loadDelayMs = 0 } = {}) { + channelWindowCalls = 0; + channelWindowEvents = [replacement, bounds()]; + window.localStorage.clear(); + window.__TAURI_INTERNALS__ = { + invoke: async (command) => { + if (command === "channel_head_cache_load") { + if (loadDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, loadDelayMs)); + } + return entries; + } + if (command === "get_channel_window") { + channelWindowCalls += 1; + return channelWindowEvents; + } + return null; + }, + }; +} + +async function mountChannelQuery(client) { + const { renderHook, waitFor } = await import("@testing-library/react"); + const view = renderHook(() => useChannelMessagesQuery(channel), { + wrapper: ({ children }) => + React.createElement(QueryClientProvider, { client }, children), + }); + await waitFor(() => assert.equal(view.result.current.isSuccess, true)); + return view; +} +test("mount fetches cold and prefetched channels but consumes hydrated data", async () => { + install([]); + const coldClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const coldView = await mountChannelQuery(coldClient); + assert.equal(channelWindowCalls, 1); + + install([]); + const prefetchedClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + prefetchedClient.setQueryData(channelMessagesKey(channelId), [root], { + updatedAt: 0, + }); + const prefetchedView = await mountChannelQuery(prefetchedClient); + assert.equal(channelWindowCalls, 1); + + install([ + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const hydratedClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + await hydrateChannelHeads(hydratedClient, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + const hydratedView = await mountChannelQuery(hydratedClient); + assert.equal(channelWindowCalls, 0); + assert.deepEqual(hydratedView.result.current.data, [root]); + + await hydratedClient.invalidateQueries({ + queryKey: channelMessagesKey(channelId), + exact: true, + refetchType: "active", + }); + assert.equal(channelWindowCalls, 1); + assert.deepEqual(hydratedClient.getQueryData(channelMessagesKey(channelId)), [ + replacement, + ]); + hydratedView.unmount(); + coldView.unmount(); + prefetchedView.unmount(); + coldClient.clear(); + prefetchedClient.clear(); + hydratedClient.clear(); +}); + +test("hydrates stale data and consumes its mount gate once", async () => { + install([ + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const client = new QueryClient(); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + assert.deepEqual(client.getQueryData(channelMessagesKey(channelId)), [root]); + assert.equal( + client.getQueryState(channelMessagesKey(channelId)).dataUpdatedAt, + 0, + ); + assert.equal(consumeHydratedChannel(client, channelId), true); + assert.equal(consumeHydratedChannel(client, channelId), false); +}); +test("authoritative refresh deletes a vanished hydrated row", async () => { + install([ + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const client = new QueryClient(); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + const next = reconcileFetchedChannelWindow( + client, + channelId, + [replacement, bounds()], + client.getQueryData(channelMessagesKey(channelId)), + new AbortController().signal, + ); + assert.deepEqual( + next.map((e) => e.id), + [replacement.id], + ); +}); +test("drops malformed entries independently", async () => { + install([ + { channelId: "bad", events: [root], savedAt: 1, lastVisitedAt: 2 }, + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + const client = new QueryClient(); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + assert.equal(client.getQueryData(channelMessagesKey("bad")), undefined); + assert.deepEqual(client.getQueryData(channelMessagesKey(channelId)), [root]); +}); + +test("a channel mounted during a slow cache load still takes the hydrated path", async () => { + // Carl/#6572 (1): the app no longer waits for the cache before mounting, so + // the channel query must itself wait for the seed instead of racing it with + // a cold relay fetch. + install( + [{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }], + { loadDelayMs: 150 }, + ); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + void hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + const view = await mountChannelQuery(client); + assert.equal(channelWindowCalls, 0); + assert.deepEqual(view.result.current.data, [root]); + view.unmount(); + client.clear(); +}); +test("a bounds-only persisted head is not hydrated", async () => { + // Carl/#6572 (3): zero rows paint nothing, so the channel must take the + // cold path and hold its skeleton rather than flash the empty-channel intro. + install([{ channelId, events: [bounds()], savedAt: 1, lastVisitedAt: 1 }]); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + assert.equal(client.getQueryData(channelMessagesKey(channelId)), undefined); + assert.equal(hasPersistedHydratedChannel(client, channelId), false); + const view = await mountChannelQuery(client); + assert.equal(channelWindowCalls, 1); + assert.deepEqual(view.result.current.data, [replacement]); + view.unmount(); + client.clear(); +}); + +test("a hydrated channel still revalidates when live subscription setup fails", async () => { + // Carl/#6572 (2): the hydrated path skips get_channel_window on mount, so + // the post-subscribe refresh is its only authoritative fetch. A rejected + // subscribe must trigger it too, or the channel stays stale all session. + install([ + { channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }, + ]); + mock.method(relayClient, "subscribeToReconnects", () => () => {}); + mock.method(relayClient, "subscribeToChannelLive", () => + Promise.reject(new Error("socket down")), + ); + const consoleError = mock.method(console, "error", () => {}); + try { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + await hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + const { renderHook, waitFor } = await import("@testing-library/react"); + const view = renderHook( + () => { + useChannelSubscription(channel); + return useChannelMessagesQuery(channel); + }, + { + wrapper: ({ children }) => + React.createElement(QueryClientProvider, { client }, children), + }, + ); + await waitFor(() => assert.equal(channelWindowCalls, 1)); + await waitFor(() => + assert.deepEqual(view.result.current.data, [replacement]), + ); + view.unmount(); + client.clear(); + } finally { + mock.restoreAll(); + } + assert.ok( + consoleError.mock.calls.some( + (call) => call.arguments[0] === "Failed to subscribe to channel", + ), + ); +}); + +test("a live subscription that settles before a slow cache load still revalidates", async () => { + // Carl/#6572 re-review: the query is parked on the hydration gate with no + // data yet, so an invalidation issued now dedupes onto that in-flight fetch + // (cancelRefetch only cancels when data exists). The seed then lands and the + // queryFn returns the persisted snapshot — zero authoritative fetches. + install( + [{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }], + { loadDelayMs: 150 }, + ); + mock.method(relayClient, "subscribeToReconnects", () => () => {}); + mock.method(relayClient, "subscribeToChannelLive", () => + Promise.resolve(async () => {}), + ); + try { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + void hydrateChannelHeads(client, { + pubkey: "f".repeat(64), + relayUrl: "wss://relay", + }); + const { renderHook, waitFor } = await import("@testing-library/react"); + const view = renderHook( + () => { + useChannelSubscription(channel); + return useChannelMessagesQuery(channel); + }, + { + wrapper: ({ children }) => + React.createElement(QueryClientProvider, { client }, children), + }, + ); + await waitFor(() => assert.equal(channelWindowCalls, 1)); + await waitFor(() => + assert.deepEqual(view.result.current.data, [replacement]), + ); + view.unmount(); + client.clear(); + } finally { + mock.restoreAll(); + } +}); + +test("a cold channel with an immediate live subscription fetches the window once", async () => { + // The post-subscribe refresh must still dedupe onto a cold relay fetch that + // is already in flight; only the hydration-parked fetch needs sequencing. + install([]); + mock.method(relayClient, "subscribeToReconnects", () => () => {}); + mock.method(relayClient, "subscribeToChannelLive", () => + Promise.resolve(async () => {}), + ); + try { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const { renderHook, waitFor } = await import("@testing-library/react"); + const view = renderHook( + () => { + useChannelSubscription(channel); + return useChannelMessagesQuery(channel); + }, + { + wrapper: ({ children }) => + React.createElement(QueryClientProvider, { client }, children), + }, + ); + await waitFor(() => + assert.deepEqual(view.result.current.data, [replacement]), + ); + await waitFor(() => assert.equal(view.result.current.isFetching, false)); + assert.equal(channelWindowCalls, 1); + view.unmount(); + client.clear(); + } finally { + mock.restoreAll(); + } +}); diff --git a/desktop/src/features/messages/lib/channelHeadCache.ts b/desktop/src/features/messages/lib/channelHeadCache.ts new file mode 100644 index 00000000000..0927b40b2c9 --- /dev/null +++ b/desktop/src/features/messages/lib/channelHeadCache.ts @@ -0,0 +1,110 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + loadChannelHeadCache, + type ChannelHeadScope, +} from "@/shared/api/tauriChannelHeadCache"; +import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys"; +import { parseChannelWindowResponse } from "./channelWindowResponse"; +import { + type ChannelWindowStore, + emptyChannelWindowStore, + replaceNewestChannelWindow, +} from "./channelWindowStore"; +import { reconcileChannelWindowMessages } from "./channelWindowReconciliation"; +import type { RelayEvent } from "@/shared/api/types"; +const hydrations = new WeakMap>(); +const hydratedChannels = new WeakMap>(); +const persistedHydratedChannels = new WeakMap>(); +const cacheScopes = new WeakMap(); +export function isChannelHeadCacheEnabled(): boolean { + if (typeof window === "undefined") return false; + if (import.meta.env?.VITE_BUZZ_CHANNEL_HEAD_CACHE === "off") return false; + return window.localStorage.getItem("buzz-channel-head-cache") !== "off"; +} +export function channelHeadCacheScope( + queryClient: QueryClient, +): ChannelHeadScope | null { + return cacheScopes.get(queryClient) ?? null; +} +/** + * Resolves once persisted heads have been seeded into this client (or + * immediately when no hydration was started). The channel query awaits this so + * it never races the cache load with a cold relay fetch, while the rest of the + * app mounts without waiting on the optional paint cache. + */ +export function channelHeadHydration(queryClient: QueryClient): Promise { + return hydrations.get(queryClient) ?? Promise.resolve(); +} +export function consumeHydratedChannel( + queryClient: QueryClient, + channelId: string, +): boolean { + const channels = hydratedChannels.get(queryClient); + if (!channels?.delete(channelId)) return false; + if (channels.size === 0) hydratedChannels.delete(queryClient); + return true; +} +export function hasPersistedHydratedChannel( + queryClient: QueryClient, + channelId: string, +): boolean { + return persistedHydratedChannels.get(queryClient)?.has(channelId) ?? false; +} +export function hydrateChannelHeads( + queryClient: QueryClient, + scope: ChannelHeadScope, +): Promise { + const hydration = seedChannelHeads(queryClient, scope).catch((error) => { + console.warn("Failed to hydrate persisted channel heads", error); + }); + hydrations.set(queryClient, hydration); + return hydration; +} +async function seedChannelHeads( + queryClient: QueryClient, + scope: ChannelHeadScope, +): Promise { + if (!isChannelHeadCacheEnabled()) return; + cacheScopes.set(queryClient, scope); + const entries = await loadChannelHeadCache(scope, 12); + const hydrated = new Set(); + for (const entry of entries) { + try { + const page = parseChannelWindowResponse( + entry.events, + entry.channelId, + null, + ); + // A bounds-only head has nothing to paint; let it take the cold path so + // the skeleton holds until the relay answers instead of flashing the + // empty-channel intro over rows that are still revalidating. + if (page.rows.length === 0) continue; + const windowKey = channelWindowKey(entry.channelId); + const messagesKey = channelMessagesKey(entry.channelId); + // Merge, don't replace: the app mounts while this load is in flight, so + // the live subscription may already have overlaid events on this store. + const window = replaceNewestChannelWindow( + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(), + page, + ); + const messages = reconcileChannelWindowMessages( + window, + queryClient.getQueryData(messagesKey) ?? [], + ); + queryClient.setQueryData(windowKey, window, { updatedAt: 0 }); + queryClient.setQueryData(messagesKey, messages, { updatedAt: 0 }); + hydrated.add(entry.channelId); + } catch (error) { + console.warn( + "Ignoring invalid persisted channel head", + entry.channelId, + error, + ); + } + } + if (hydrated.size > 0) { + hydratedChannels.set(queryClient, hydrated); + persistedHydratedChannels.set(queryClient, new Set(hydrated)); + } +} diff --git a/desktop/src/features/messages/lib/messageSnapshot.test.mjs b/desktop/src/features/messages/lib/messageSnapshot.test.mjs deleted file mode 100644 index a188f51da08..00000000000 --- a/desktop/src/features/messages/lib/messageSnapshot.test.mjs +++ /dev/null @@ -1,199 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - mergeHistoryOverSnapshot, - messageSnapshotKey, - readMessageSnapshot, - removeMessageSnapshotsForRelay, - writeMessageSnapshot, -} from "./messageSnapshot.ts"; - -if (typeof globalThis.window === "undefined") { - const storage = new Map(); - globalThis.window = { - localStorage: { - getItem: (key) => storage.get(key) ?? null, - setItem: (key, value) => storage.set(key, value), - removeItem: (key) => storage.delete(key), - key: (index) => [...storage.keys()][index] ?? null, - get length() { - return storage.size; - }, - }, - }; -} - -function makeEvent(overrides = {}) { - return { - id: `event-${Math.random().toString(36).slice(2)}`, - pubkey: "pubkey-1", - created_at: 1_700_000_000, - kind: 9, - tags: [["h", "chan-1"]], - content: "hello", - sig: "sig", - ...overrides, - }; -} - -const RELAY = "wss://relay.example.com"; - -function clearRelay(relayUrl = RELAY) { - removeMessageSnapshotsForRelay(relayUrl); -} - -test("messageSnapshotKey: normalizes trailing slash and case", () => { - assert.equal( - messageSnapshotKey("WSS://Relay.Example.com/", "chan-1"), - messageSnapshotKey("wss://relay.example.com", "chan-1"), - ); -}); - -test("read after write returns the persisted events", () => { - clearRelay(); - const events = [makeEvent({ id: "a" }), makeEvent({ id: "b" })]; - writeMessageSnapshot(RELAY, "chan-1", events); - assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), events); -}); - -test("read for an unknown channel returns null", () => { - clearRelay(); - assert.equal(readMessageSnapshot(RELAY, "chan-never"), null); -}); - -test("read returns null for malformed JSON", () => { - window.localStorage.setItem( - messageSnapshotKey(RELAY, "chan-bad"), - "not-json{{{", - ); - assert.equal(readMessageSnapshot(RELAY, "chan-bad"), null); -}); - -test("read returns null for a wrong-version payload", () => { - window.localStorage.setItem( - messageSnapshotKey(RELAY, "chan-v2"), - JSON.stringify({ version: 2, updatedAt: 1, events: [makeEvent()] }), - ); - assert.equal(readMessageSnapshot(RELAY, "chan-v2"), null); -}); - -test("pending optimistic events are not persisted", () => { - clearRelay(); - const settled = makeEvent({ id: "settled" }); - writeMessageSnapshot(RELAY, "chan-1", [ - settled, - makeEvent({ id: "optimistic", pending: true }), - ]); - assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), [settled]); -}); - -test("write with only pending events persists nothing", () => { - clearRelay(); - writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ pending: true })]); - assert.equal(readMessageSnapshot(RELAY, "chan-1"), null); -}); - -test("snapshot keeps only the newest slice of a long timeline", () => { - clearRelay(); - const events = Array.from({ length: 200 }, (_, i) => - makeEvent({ id: `event-${i}`, created_at: 1_700_000_000 + i }), - ); - writeMessageSnapshot(RELAY, "chan-1", events); - const persisted = readMessageSnapshot(RELAY, "chan-1"); - assert.equal(persisted.length, 80); - assert.equal(persisted[persisted.length - 1].id, "event-199"); - assert.equal(persisted[0].id, "event-120"); -}); - -test("per-relay channel cap evicts the least recently written snapshot", () => { - clearRelay(); - for (let i = 0; i < 21; i++) { - writeMessageSnapshot(RELAY, `chan-${i}`, [makeEvent({ id: `e-${i}` })]); - } - // chan-0 was written first (oldest updatedAt tie broken by insertion) — - // with 21 channels, at least one of the earliest must be evicted and the - // newest retained. - assert.notEqual(readMessageSnapshot(RELAY, "chan-20"), null); - const retained = Array.from({ length: 21 }, (_, i) => - readMessageSnapshot(RELAY, `chan-${i}`), - ).filter((snapshot) => snapshot !== null); - assert.equal(retained.length, 20); -}); - -test("remove clears every snapshot for that relay only", () => { - clearRelay(); - clearRelay("wss://other.example.com"); - writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ id: "keep-other" })]); - writeMessageSnapshot("wss://other.example.com", "chan-1", [ - makeEvent({ id: "other" }), - ]); - removeMessageSnapshotsForRelay(RELAY); - assert.equal(readMessageSnapshot(RELAY, "chan-1"), null); - assert.notEqual( - readMessageSnapshot("wss://other.example.com", "chan-1"), - null, - ); -}); - -test("write is tolerant of storage failures", () => { - const original = window.localStorage.setItem; - window.localStorage.setItem = () => { - throw new Error("quota exceeded"); - }; - try { - assert.doesNotThrow(() => - writeMessageSnapshot(RELAY, "chan-1", [makeEvent()]), - ); - } finally { - window.localStorage.setItem = original; - } -}); - -test("cold snapshot load: merge keeps snapshot-only rows and widens aux backfill to them", () => { - const snapshotOnly = makeEvent({ id: "ghost", created_at: 1_700_000_000 }); - const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 }); - const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ - cached: undefined, - snapshot: [snapshotOnly], - history: [fresh], - }); - assert.deepEqual( - merged.map((event) => event.id), - ["ghost", "fresh"], - ); - assert.ok(auxBackfillWindow.some((event) => event.id === "ghost")); - assert.ok(auxBackfillWindow.some((event) => event.id === "fresh")); -}); - -test("warm load: aux backfill stays scoped to the fresh window", () => { - const cached = makeEvent({ id: "cached", created_at: 1_700_000_000 }); - const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 }); - const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ - cached: [cached], - snapshot: [makeEvent({ id: "stale-snapshot" })], - history: [fresh], - }); - assert.ok(merged.some((event) => event.id === "cached")); - assert.deepEqual( - auxBackfillWindow.map((event) => event.id), - ["fresh"], - ); -}); - -test("cold load without a snapshot backfills the fresh window only", () => { - const fresh = makeEvent({ id: "fresh" }); - const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({ - cached: undefined, - snapshot: null, - history: [fresh], - }); - assert.deepEqual( - merged.map((event) => event.id), - ["fresh"], - ); - assert.deepEqual( - auxBackfillWindow.map((event) => event.id), - ["fresh"], - ); -}); diff --git a/desktop/src/features/messages/lib/messageSnapshot.ts b/desktop/src/features/messages/lib/messageSnapshot.ts deleted file mode 100644 index d4a183c2ff6..00000000000 --- a/desktop/src/features/messages/lib/messageSnapshot.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Per-channel persisted message snapshots. - * - * A channel revisited after its React-Query cache entry is gone (app restart, - * gcTime expiry, community remount) goes fully cold and holds a skeleton for a - * relay round trip. This module persists the newest slice of each channel's - * timeline so a revisit can paint instantly from the snapshot while the - * history fetch revalidates behind it — the same stale-then-revalidate pattern - * the sidebar's channelSnapshot uses for the channel list. - * - * Keyed per relay URL + channel id so one relay's messages never bleed into - * another. Bounded two ways: only the newest MAX_EVENTS_PER_SNAPSHOT events - * per channel, and only the MAX_CHANNELS_PER_RELAY most recently written - * channels per relay (older ones are evicted LRU on write). - */ - -import { mergeTimelineHistoryMessages } from "@/features/messages/lib/messageQueryKeys"; -import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; -import type { RelayEvent } from "@/shared/api/types"; -import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota"; - -const STORAGE_KEY_PREFIX = "buzz-channel-messages.v1"; - -// Newest events kept per channel. The trailing slice of the sorted timeline -// cache, so recent auxiliary events (reactions/edits) ride along with the -// content rows they decorate. -const MAX_EVENTS_PER_SNAPSHOT = 80; - -const MAX_CHANNELS_PER_RELAY = 20; - -export function messageSnapshotKey(relayUrl: string, channelId: string) { - return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:${channelId}`; -} - -type SnapshotPayload = { - version: 1; - updatedAt: number; - events: RelayEvent[]; -}; - -function parseSnapshotPayload(json: unknown): SnapshotPayload | null { - if (typeof json !== "object" || json === null) return null; - const obj = json as Record; - if (obj.version !== 1 || !Array.isArray(obj.events)) return null; - const updatedAt = - typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt) - ? obj.updatedAt - : 0; - return { version: 1, updatedAt, events: obj.events as RelayEvent[] }; -} - -/** - * Reads the persisted message snapshot for a channel, or null when absent or - * malformed. - */ -export function readMessageSnapshot( - relayUrl: string, - channelId: string, -): RelayEvent[] | null { - try { - const raw = window.localStorage.getItem( - messageSnapshotKey(relayUrl, channelId), - ); - if (!raw) return null; - const parsed = parseSnapshotPayload(JSON.parse(raw)); - if (!parsed || parsed.events.length === 0) return null; - return parsed.events; - } catch { - return null; - } -} - -function relayPrefix(relayUrl: string) { - return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`; -} - -function collectKeysWithPrefix(prefix: string): string[] { - const keys: string[] = []; - for (let i = 0; i < window.localStorage.length; i++) { - const key = window.localStorage.key(i); - if (key?.startsWith(prefix)) { - keys.push(key); - } - } - return keys; -} - -function evictOldestSnapshots(prefix: string, keepingKey: string) { - const others = collectKeysWithPrefix(prefix).filter( - (key) => key !== keepingKey, - ); - if (others.length < MAX_CHANNELS_PER_RELAY) { - return; - } - - const byAge = others - .map((key) => { - let updatedAt = 0; - try { - const parsed = parseSnapshotPayload( - JSON.parse(window.localStorage.getItem(key) ?? ""), - ); - updatedAt = parsed?.updatedAt ?? 0; - } catch { - // Malformed entries sort oldest and get evicted first. - } - return { key, updatedAt }; - }) - .sort((a, b) => a.updatedAt - b.updatedAt); - - for (const { key } of byAge.slice( - 0, - others.length - (MAX_CHANNELS_PER_RELAY - 1), - )) { - window.localStorage.removeItem(key); - } -} - -/** - * Persists the newest slice of a channel's timeline. Pending optimistic events - * are dropped (they have no relay identity to revalidate against). Skips the - * write when unchanged so live-append churn does not re-serialize an identical - * snapshot. Non-fatal on storage failure (e.g. quota exceeded). - */ -export function writeMessageSnapshot( - relayUrl: string, - channelId: string, - events: RelayEvent[], -): void { - try { - const persistable = events - .filter((event) => !event.pending) - .slice(-MAX_EVENTS_PER_SNAPSHOT); - if (persistable.length === 0) { - return; - } - - const key = messageSnapshotKey(relayUrl, channelId); - const previous = window.localStorage.getItem(key); - if (previous) { - const parsed = parseSnapshotPayload(JSON.parse(previous)); - if ( - parsed && - JSON.stringify(parsed.events) === JSON.stringify(persistable) - ) { - return; - } - } - - evictOldestSnapshots(relayPrefix(relayUrl), key); - setLocalStorageItemWithRecovery( - key, - JSON.stringify({ - version: 1, - updatedAt: Date.now(), - events: persistable, - } satisfies SnapshotPayload), - ); - } catch { - // Storage access failures are non-fatal. - } -} - -/** - * Merge a fresh history window over the in-memory cache — or, when cold, over - * the persisted snapshot — and pick the window aux backfill must cover. - * - * The snapshot can hold events older than the fetch window; dropping them on - * settle would visibly shrink an already-painted timeline, so the merge keeps - * them. But a kept snapshot row deleted/edited while the app was closed never - * reappears in any history fetch (the relay soft-deletes), so its tombstone or - * edit is only reachable by `#e` over that row's id — cold snapshot loads must - * therefore backfill over the merged timeline, not just the fresh window. - * Otherwise the ghost paints, and the post-settle snapshot rewrite persists it - * forever. - */ -export function mergeHistoryOverSnapshot(input: { - cached: RelayEvent[] | undefined; - snapshot: RelayEvent[] | null; - history: RelayEvent[]; -}): { merged: RelayEvent[]; auxBackfillWindow: RelayEvent[] } { - const usedSnapshot = !input.cached && input.snapshot !== null; - const merged = mergeTimelineHistoryMessages( - input.cached ?? input.snapshot ?? [], - input.history, - ); - return { merged, auxBackfillWindow: usedSnapshot ? merged : input.history }; -} - -/** - * Removes every channel message snapshot for a relay. Called when a community - * is removed. - */ -export function removeMessageSnapshotsForRelay(relayUrl: string): void { - try { - for (const key of collectKeysWithPrefix(relayPrefix(relayUrl))) { - window.localStorage.removeItem(key); - } - } catch { - // Storage access failures are non-fatal. - } -} diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 14ec110addf..618f3fc9912 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -363,3 +363,56 @@ test("test_pageless_live_projection_preserves_cached_timeline", () => { assert.deepEqual(contents(harness), ["initial", "live"]); assert.equal(harness.client.getQueryData(harness.messagesKey)[0], cached[0]); }); + +test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fetch", async () => { + // Subscribe settlement and a reconnect can both call the helper while the + // channel query is still parked on its hydration-seeded snapshot. Both wake + // on the same promise; the second invalidation must join the first + // authoritative fetch, not cancel and replace it (Max/Wren, #6572 review). + const harness = createHarness(); + const seeded = event("seeded", 100); + harness.client.setQueryData(harness.messagesKey, [seeded], { updatedAt: 0 }); + const requests = []; + const observer = new QueryObserver(harness.client, { + queryKey: harness.messagesKey, + queryFn: async ({ signal }) => { + const previousMessages = harness.client.getQueryData(harness.messagesKey); + let resolveFetch; + const fetch = new Promise((resolve) => { + resolveFetch = resolve; + }); + requests.push({ resolveFetch, signal }); + const events = await fetch; + return reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + events, + previousMessages, + signal, + ); + }, + }); + const unsubscribe = observer.subscribe(() => {}); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(requests.length, 1); + const first = refreshChannelWindowMessages( + harness.client, + harness.channelId, + ); + const second = refreshChannelWindowMessages( + harness.client, + harness.channelId, + ); + requests[0].resolveFetch(wirePage([seeded])); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(requests.length, 2); + requests[1].resolveFetch(wirePage([event("gap", 110), seeded])); + await Promise.all([first, second]); + assert.equal(requests.length, 2); + assert.equal(requests[1].signal.aborted, false); + assert.deepEqual(contents(harness), ["seeded", "gap"]); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index 81ef3de42d0..b16187ce18c 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -7,6 +7,7 @@ import { type ChannelWindowStore, } from "./channelWindowStore"; import { reconcileChannelWindowMessages } from "./channelWindowReconciliation"; +import { channelHeadHydration } from "./channelHeadCache"; /** Keep the rendered timeline cache aligned with its authoritative window. */ export function projectChannelWindowMessages( @@ -26,10 +27,27 @@ export async function refreshChannelWindowMessages( queryClient: QueryClient, channelId: string, ) { - await queryClient.invalidateQueries({ - queryKey: channelMessagesKey(channelId), - exact: true, - refetchType: "active", - }); + const queryKey = channelMessagesKey(channelId); + // Sequence behind persisted-head hydration. While the channel query is parked + // on that gate it has no data, so TanStack would dedupe this invalidation + // onto it — and that fetch returns the seeded snapshot, never asking the + // relay. A seeded query is recognisable by data at `dataUpdatedAt` 0; let its + // snapshot fetch settle (consuming the mount gate) before invalidating, so + // the refetch is a distinct authoritative window fetch. Concurrent callers + // (subscribe settlement + reconnect) wake on the same promise, so the seeded + // branch must join an authoritative fetch already in flight rather than + // cancel and replace it. Cold and warm channels carry no such marker and + // dedupe/cancel exactly as before. + await channelHeadHydration(queryClient); + const query = queryClient.getQueryCache().find({ queryKey, exact: true }); + const seeded = + query?.state.data !== undefined && query.state.dataUpdatedAt === 0; + if (seeded) { + await query.promise?.catch(() => {}); + } + await queryClient.invalidateQueries( + { queryKey, exact: true, refetchType: "active" }, + { cancelRefetch: !seeded }, + ); projectChannelWindowMessages(queryClient, channelId); } diff --git a/desktop/src/features/messages/lib/sendChannelBinding.test.mjs b/desktop/src/features/messages/lib/sendChannelBinding.test.mjs index 421799d3734..bfe66afcff1 100644 --- a/desktop/src/features/messages/lib/sendChannelBinding.test.mjs +++ b/desktop/src/features/messages/lib/sendChannelBinding.test.mjs @@ -24,6 +24,7 @@ import test from "node:test"; import { createOptimisticMessage, + resolveCachedReplyRootId, resolveEffectiveChannel, resolveSendChannel, resolveThreadReplyTarget, @@ -322,3 +323,23 @@ test("resolveThreadReplyTarget_nullContext_noLiveRefs_returnsNull", () => { assert.strictEqual(result, null); }); + +test("resolveCachedReplyRootId sends only roots proven by a cached parent", () => { + const rootId = "1".repeat(64); + const parentId = "2".repeat(64); + const parent = { + id: parentId, + pubkey: IDENTITY.pubkey, + kind: 9, + created_at: 1, + content: "parent", + tags: [ + ["e", rootId, "", "root"], + ["e", rootId, "", "reply"], + ], + sig: "", + }; + + assert.equal(resolveCachedReplyRootId(parentId, [[], [parent]]), rootId); + assert.equal(resolveCachedReplyRootId(parentId, [[], []]), null); +}); diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 48896c5c517..53399cd2f39 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -1,30 +1,15 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; -import { collectThreadAuxMessageIds } from "./useThreadReplies.ts"; - -const ROOT_ID = "1".repeat(64); -const REPLY_ID = "2".repeat(64); - -function reply(id = REPLY_ID) { - return { - id, - pubkey: "a".repeat(64), - kind: 9, - created_at: 1_700_000_000, - content: "reply", - tags: [["e", ROOT_ID]], - sig: "sig", - }; -} - -test("thread aux hydration includes the root when there are no replies", () => { - assert.deepEqual(collectThreadAuxMessageIds(ROOT_ID, []), [ROOT_ID]); -}); - -test("thread aux hydration includes and deduplicates root and reply ids", () => { - assert.deepEqual( - collectThreadAuxMessageIds(ROOT_ID, [reply(), reply(ROOT_ID)]), - [ROOT_ID, REPLY_ID], +test("thread replies trust the relay-provided aux closure", async () => { + const source = await readFile( + new URL("./useThreadReplies.ts", import.meta.url), + "utf8", + ); + assert.doesNotMatch( + source, + /withThreadAux|fetchStructuralAuxForMessages|fetchAuxEventsByReference/, ); + assert.match(source, /replies\.push\(\.\.\.response\.events\)/); }); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index bb1c2909b68..25a6b68986b 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -5,76 +5,16 @@ import { useQueryClient, } from "@tanstack/react-query"; -import { - collectMessageIdsForAuxBackfill, - fetchStructuralAuxForMessages, -} from "@/features/messages/lib/auxBackfill"; import { threadRepliesKey, sortMessages, } from "@/features/messages/lib/messageQueryKeys"; -import { relayClient } from "@/shared/api/relayClient"; -import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; import { getThreadReplies } from "@/shared/api/tauri"; import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types"; const THREAD_PAGE_LIMIT = 200; const MAX_THREAD_PAGES = 500; -/** - * Append the structural aux closure (edits/deletions) for the fetched replies. - * The server thread-subtree query resolves deletions itself but omits - * kind:40003 edits, so a bare refetch would render every edited reply with its - * original text. Best-effort: an aux failure logs and returns the replies - * unadorned rather than failing the whole thread load. - */ -async function fetchThreadAuxBestEffort( - label: string, - channelId: string, - fetchAux: () => Promise, -): Promise { - try { - return await fetchAux(); - } catch (error) { - console.error( - `Failed to backfill thread reply ${label} for channel`, - channelId, - error, - ); - return []; - } -} - -export function collectThreadAuxMessageIds( - threadRootId: string, - replies: RelayEvent[], -): string[] { - return [ - ...new Set([threadRootId, ...collectMessageIdsForAuxBackfill(replies)]), - ]; -} - -async function withThreadAux( - channelId: string, - threadRootId: string, - replies: RelayEvent[], -): Promise { - const messageIds = collectThreadAuxMessageIds(threadRootId, replies); - const [structuralAux, reactions] = await Promise.all([ - fetchThreadAuxBestEffort("structural aux", channelId, () => - fetchStructuralAuxForMessages(channelId, messageIds), - ), - fetchThreadAuxBestEffort("reactions", channelId, () => - relayClient.fetchAuxEventsByReference( - channelId, - messageIds, - buildChannelReactionAuxFilter, - ), - ), - ]); - return sortMessages([...replies, ...structuralAux, ...reactions]); -} - async function loadThreadReplies( queryClient: QueryClient, channelId: string, @@ -92,12 +32,11 @@ async function loadThreadReplies( }); replies.push(...response.events); if (!response.nextCursor) { - const fetched = await withThreadAux(channelId, rootId, replies); const current = queryClient.getQueryData(queryKey) ?? []; const receivedInFlight = current.filter( (event) => !idsAtStart.has(event.id), ); - return sortMessages([...fetched, ...receivedInFlight]); + return sortMessages([...replies, ...receivedInFlight]); } cursor = response.nextCursor; } diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index f174d504080..d46d27df9ee 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -284,8 +284,8 @@ export function useUserProfileQuery(pubkey?: string) { // Per-pubkey resolution cache backing `useUsersBatchQuery`'s delta fetch. // `summary: null` records a relay-confirmed miss so unknown pubkeys aren't -// re-requested every page. Entries older than the hook's 60s staleTime are -// treated as unresolved and refetched. +// re-requested every page. Entries older than the hook's 10-minute staleTime +// are treated as unresolved and refetched. export type UsersBatchEntry = { summary: UserProfileSummary | null; fetchedAt: number; @@ -301,7 +301,8 @@ export const usersBatchEntryKey = (pubkey: string) => [ * run re-fetches these profiles from the relay. Must be called anywhere a * specific profile (or a containing `users-batch` query) is invalidated — * otherwise the re-run resolves from the still-fresh-looking entry and - * renders the stale name/avatar for up to the entry's 60s freshness window. + * renders the stale name/avatar for up to the entry's 10-minute freshness + * window. * Synchronous, so callers can evict before awaiting aggregate invalidations. */ export function evictUsersBatchEntries( @@ -351,7 +352,7 @@ export function useUsersBatchQuery( const entry = queryClient.getQueryData( usersBatchEntryKey(pubkey), ); - if (entry && now - entry.fetchedAt < 60_000) { + if (entry && now - entry.fetchedAt < 10 * 60_000) { if (entry.summary) profiles[pubkey] = entry.summary; else missing.push(pubkey); } else { @@ -384,7 +385,7 @@ export function useUsersBatchQuery( relayUrl, normalizedPubkeys, ), - staleTime: 60_000, + staleTime: 10 * 60_000, gcTime: 5 * 60 * 1_000, }); diff --git a/desktop/src/shared/api/tauriChannelHeadCache.ts b/desktop/src/shared/api/tauriChannelHeadCache.ts new file mode 100644 index 00000000000..9431fced046 --- /dev/null +++ b/desktop/src/shared/api/tauriChannelHeadCache.ts @@ -0,0 +1,25 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +export type ChannelHeadScope = { pubkey: string; relayUrl: string }; +export type ChannelHeadEntry = { + channelId: string; + events: RelayEvent[]; + savedAt: number; + lastVisitedAt: number; +}; +export function loadChannelHeadCache( + scope: ChannelHeadScope, + limit = 12, +): Promise { + return invokeTauri("channel_head_cache_load", { scope, limit }); +} +export function storeChannelHeadCache( + scope: ChannelHeadScope, + channelId: string, + events: RelayEvent[], +): Promise { + return invokeTauri("channel_head_cache_store", { scope, channelId, events }); +} +export function clearChannelHeadCache(scope: ChannelHeadScope): Promise { + return invokeTauri("channel_head_cache_clear", { scope }); +} diff --git a/desktop/src/shared/api/tauriMessages.ts b/desktop/src/shared/api/tauriMessages.ts index 4abe03ee09b..965e498b411 100644 --- a/desktop/src/shared/api/tauriMessages.ts +++ b/desktop/src/shared/api/tauriMessages.ts @@ -15,6 +15,7 @@ export async function sendChannelMessage( sentFromThreadTag?: string[], expectedRelayUrl?: string, expectedSignerPubkey?: string, + rootEventId?: string | null, ): Promise { const response = await invokeTauri( "send_channel_message", @@ -22,6 +23,7 @@ export async function sendChannelMessage( channelId, content, parentEventId, + rootEventId: rootEventId ?? null, mediaTags: mediaTags ?? null, emojiTags: emojiTags ?? null, mentionTags: mentionTags ?? null, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e4028f01716..3252a025c0f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -370,6 +370,8 @@ type E2eConfig = { /** Delay (ms) applied to continuation channel-window requests so e2e * tests can observe the in-flight prepend window. 0/undefined = instant. */ channelWindowDelayMs?: number; + /** Delay (ms) applied to newest-page channel-window requests. */ + channelHeadDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; /** Override whether get_profile reports a real kind:0 event. */ @@ -5546,19 +5548,20 @@ async function handleGetChannelWindow( return relayQuery(config, [filter]); }; - if (!args.cursor) { - return execute(); - } - const probe = window as unknown as { __CHANNEL_WINDOW_FETCH_COUNT__?: number; __CHANNEL_WINDOW_INFLIGHT__?: number; __CHANNEL_WINDOW_INFLIGHT_PEAK__?: number; }; - probe.__CHANNEL_WINDOW_FETCH_COUNT__ = - (probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1; + if (args.cursor !== null) { + probe.__CHANNEL_WINDOW_FETCH_COUNT__ = + (probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1; + } - const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0; + const delayMs = + args.cursor === null + ? (getConfig()?.mock?.channelHeadDelayMs ?? 0) + : (getConfig()?.mock?.channelWindowDelayMs ?? 0); if (delayMs <= 0) { return execute(); } @@ -9577,6 +9580,7 @@ async function handleSendChannelMessage( channelId: string; content: string; parentEventId?: string | null; + rootEventId?: string | null; kind?: number | null; mentionPubkeys?: string[]; mediaTags?: string[][] | null; @@ -9699,7 +9703,8 @@ async function handleSendChannelMessage( parentEventId: null, rootEventId: null, }; - const rootEventId = parentThread.rootEventId ?? args.parentEventId; + const rootEventId = + args.rootEventId ?? parentThread.rootEventId ?? args.parentEventId; const depth = parentEvent ? (() => { let currentEvent: RelayEvent | undefined = parentEvent; @@ -9758,7 +9763,7 @@ async function handleSendChannelMessage( args.channelId, relayIdentity.pubkey, args.parentEventId, - args.parentEventId, + args.rootEventId ?? args.parentEventId, args.mentionPubkeys, ) : buildTopLevelMessageTags( @@ -9776,8 +9781,12 @@ async function handleSendChannelMessage( return { event_id: result.event_id, parent_event_id: args.parentEventId ?? null, - root_event_id: args.parentEventId ?? null, - depth: args.parentEventId ? 1 : 0, + root_event_id: args.rootEventId ?? args.parentEventId ?? null, + depth: args.parentEventId + ? args.rootEventId && args.rootEventId !== args.parentEventId + ? 2 + : 1 + : 0, created_at: Math.floor(Date.now() / 1000), }; } @@ -13889,6 +13898,59 @@ export function maybeInstallE2eTauriMocks() { return null; case "fetch_persona_catalog": return mockPersonaCatalogPublications(); + case "channel_head_cache_load": { + const args = payload as { + scope: { pubkey: string; relayUrl: string }; + limit: number; + }; + const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`; + const entries = JSON.parse( + window.localStorage.getItem(key) ?? "[]", + ) as Array<{ + channelId: string; + events: RelayEvent[]; + savedAt: number; + lastVisitedAt: number; + }>; + return entries + .sort((left, right) => right.lastVisitedAt - left.lastVisitedAt) + .slice(0, args.limit); + } + case "channel_head_cache_store": { + const args = payload as { + scope: { pubkey: string; relayUrl: string }; + channelId: string; + events: RelayEvent[]; + }; + const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`; + const entries = JSON.parse( + window.localStorage.getItem(key) ?? "[]", + ) as Array<{ + channelId: string; + events: RelayEvent[]; + savedAt: number; + lastVisitedAt: number; + }>; + const now = Math.floor(Date.now() / 1000); + const next = entries + .filter((entry) => entry.channelId !== args.channelId) + .concat({ + channelId: args.channelId, + events: args.events, + savedAt: now, + lastVisitedAt: now, + }) + .sort((left, right) => right.lastVisitedAt - left.lastVisitedAt) + .slice(0, 32); + window.localStorage.setItem(key, JSON.stringify(next)); + return null; + } + case "channel_head_cache_clear": { + const args = payload as { scope: { pubkey: string; relayUrl: string } }; + const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`; + window.localStorage.removeItem(key); + return null; + } case "observed_unread_open_scope": { const request = payload as { request: { diff --git a/desktop/tests/e2e/channel-head-restart.spec.ts b/desktop/tests/e2e/channel-head-restart.spec.ts new file mode 100644 index 00000000000..4bf082786bb --- /dev/null +++ b/desktop/tests/e2e/channel-head-restart.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const PERSISTED_ONLY = "persisted restart head"; + +test("restart paints a persisted head before the single authoritative refresh", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + await page.evaluate((content) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + }); + }, PERSISTED_ONLY); + + await page.getByTestId("channel-general").click(); + await expect(page.getByText(PERSISTED_ONLY)).toBeVisible(); + await expect + .poll(() => + page.evaluate( + () => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "channel_head_cache_store", + ).length, + ), + ) + .toBeGreaterThan(0); + + const persistedCache = await page.evaluate(() => + Object.fromEntries( + Object.entries(window.localStorage).filter(([key]) => + key.startsWith("buzz-e2e-channel-head:"), + ), + ), + ); + expect(Object.keys(persistedCache)).toHaveLength(1); + await page.addInitScript((cache) => { + for (const [key, value] of Object.entries(cache)) { + window.localStorage.setItem(key, value); + } + const testWindow = window as Window & { + __BUZZ_E2E__?: { mock?: Record }; + }; + testWindow.__BUZZ_E2E__ = { + ...testWindow.__BUZZ_E2E__, + mock: { + ...testWindow.__BUZZ_E2E__?.mock, + channelHeadDelayMs: 5_000, + }, + }; + }, persistedCache); + await page.reload(); + await expect(page.getByTestId("channel-general")).toBeVisible(); + const restartedCallsBeforeOpen = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "get_channel_window", + ).length, + ); + await page.getByTestId("channel-general").click(); + + // The mock relay fetch is held for 5s. Seeing this row inside 2s proves + // restart hydration painted persisted data rather than waiting for the relay. + await expect(page.getByText(PERSISTED_ONLY)).toBeVisible({ timeout: 2_000 }); + await expect + .poll(() => + page.evaluate( + (callsBeforeOpen) => + (window.__BUZZ_E2E_COMMANDS__ ?? []).filter( + (command) => command === "get_channel_window", + ).length - callsBeforeOpen, + restartedCallsBeforeOpen, + ), + ) + .toBe(1); + + // Reload resets the mock relay store, so the authoritative refresh omits the + // persisted-only row and must replace it wholesale. + await expect(page.getByText(PERSISTED_ONLY)).toHaveCount(0, { + timeout: 8_000, + }); +}); diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index af573373d36..7868e577dab 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -368,9 +368,16 @@ test("service restart close resets accumulated backoff", async ({ page }) => { websocketConnectErrors: ["down 1", "down 2", "down 3"], }); await page.goto("/"); - await expect(page.getByTestId("channel-general")).toBeVisible({ - timeout: 15_000, - }); + // Three rejected dials put the session deep in its backoff loop before it + // connects; wait for that connect rather than for the Tauri-backed channel + // list, which paints long before the websocket is up. + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 15_000 }, + ) + .toBe("connected"); const startedAt = Date.now(); await restartMockWebsockets(page); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ed94e6b1767..2637b94a808 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -304,6 +304,8 @@ type MockBridgeOptions = { usersBatchDelayMs?: number; /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ channelWindowDelayMs?: number; + /** Delay (ms) for newest-page fetches; see e2eBridge mock config. */ + channelHeadDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; /** Override whether get_profile reports a real kind:0 event. */ diff --git a/docs/bridge-channel-window.md b/docs/bridge-channel-window.md index 42f1d82835e..aec9d19542a 100644 --- a/docs/bridge-channel-window.md +++ b/docs/bridge-channel-window.md @@ -87,7 +87,9 @@ Clients **partition by kind before any cursor math**: 2. **Aux closure** (`include_aux`) — reactions (7), deletions (5, 9005), and edits (40003) targeting the retained rows by `#e`, **plus** deletions targeting those aux events (the transitive second hop, e.g. - a delete-of-a-reaction). One round trip; no client `#e` fan-out. + a delete-of-a-reaction). One round trip; no client `#e` fan-out. Each + hop is drained server-side across the DB page clamp, so the closure is + complete rather than newest-1000. 3. **Thread summaries** (`include_summaries`) — one relay-signed `kind:39005` per row that has replies. 4. **Window bounds** — exactly one relay-signed `kind:39006` per window @@ -126,7 +128,9 @@ Both kinds are relay-only: client submission is rejected at ingest. - Reconnect refetches page 0 and re-arms the live subscription (`since: now`); deeper pages need no repair path. - Replies never enter the channel timeline; the thread panel uses the - existing `thread_cursor` surface (#1418). + existing `thread_cursor` surface (#1418). Thread filters may opt into + `include_aux` to append the same authorized two-hop reactions, edits, and + deletions closure as a channel-window response. ## Siblings From 17af15effac63e6bc5338448326ce52ba4426e5f Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 25 Aug 2026 01:02:14 +1000 Subject: [PATCH 002/101] fix(desktop): keep member runtime status off the UI thread (#6445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - skip managed-agent runtime discovery when the members sidebar has no local managed bots - run runtime listing disk, process, and mutex work on Tauri’s blocking pool - preserve local managed-bot status and Start/Stop behavior with positive and negative E2E coverage Opening Add people in a human-only channel could invoke synchronous native runtime discovery before the sidebar painted, leaving the macOS app beachballed. Human invites do not depend on that data. ## Why I have seen slowness opening this dialog in the UI https://github.com/user-attachments/assets/1955eb5e-ee47-4edf-8e5c-606d11ffbc25 ### Related issue Related overlap: #4851 is a broader managed-agent lifecycle change that includes a similar native offload. This draft is intentionally limited to the sidebar critical path and adds the human-only query gate. ### Testing I have verified the pause in the video goes away after this change. - `just ci` - `just desktop-check` - `just desktop-test` (5,241 passed) - `just desktop-tauri-fmt-check` - `just desktop-tauri-clippy` - `just desktop-tauri-test` (2,702 passed; 18 ignored) - focused Playwright: human-only sidebar skips runtime discovery - focused Playwright: local managed bot retains status and Stop/Start controls No visual styling changed, so screenshots are not applicable. Signed-off-by: Matt Toohey --- .../src/managed_agents/runtime_commands.rs | 153 ++++++++++-------- .../features/channels/ui/MembersSidebar.tsx | 15 +- desktop/tests/e2e/channels.spec.ts | 28 ++++ 3 files changed, 125 insertions(+), 71 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b19..135224d01db 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -137,86 +137,91 @@ pub fn put_managed_agent_runtime_lifecycle( Ok(status) } +// Keep disk, process, and mutex work off the main thread so opening members cannot stall the UI. #[tauri::command] -pub fn list_managed_agent_runtimes( +pub async fn list_managed_agent_runtimes( app: AppHandle, ) -> Result, String> { - // This command is polled whenever the members sidebar opens and refetched - // on every status event — load the per-row status inputs once, outside - // the locks, instead of hitting disk per row while holding them. - let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); - let state = app.state::(); - let _transition = state - .managed_agent_runtime_transition - .lock() - .map_err(|e| e.to_string())?; - let _store = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let exited_keys: Vec<_> = runtimes - .iter_mut() - .filter_map(|(key, runtime)| match runtime.child.try_wait() { - Ok(Some(_)) | Err(_) => Some(key.clone()), - Ok(None) => None, - }) - .collect(); - let records_changed = !exited_keys.is_empty(); - let mut statuses = Vec::new(); - for key in exited_keys { - runtimes.remove(&key); - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - if let Some(record) = records + tokio::task::spawn_blocking(move || { + // This command is polled whenever the members sidebar opens and refetched + // on every status event — load the per-row status inputs once, outside + // the locks, instead of hitting disk per row while holding them. + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let exited_keys: Vec<_> = runtimes .iter_mut() - .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) - { - record.updated_at = crate::util::now_iso(); - record.last_stopped_at = Some(record.updated_at.clone()); - let status = status_for_with( + .filter_map(|(key, runtime)| match runtime.child.try_wait() { + Ok(Some(_)) | Err(_) => Some(key.clone()), + Ok(None) => None, + }) + .collect(); + let records_changed = !exited_keys.is_empty(); + let mut statuses = Vec::new(); + for key in exited_keys { + runtimes.remove(&key); + super::remove_agent_runtime_receipt(&app, &key); + state.clear_agent_session_cache(&key); + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) + { + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + let status = status_for_with( + &app, + record, + &key, + None, + None, + StatusInputs { + personas: &personas, + global: &global, + }, + ); + emit_status(&app, &status); + statuses.push(status); + } + } + statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; + Some(status_for_with( &app, record, - &key, - None, + key, + Some(runtime), None, StatusInputs { personas: &personas, global: &global, }, - ); - emit_status(&app, &status); - statuses.push(status); + )) + })); + drop(runtimes); + // Records are only mutated above when a runtime exited — skip the store + // rewrite on the common nothing-changed poll. + if records_changed { + save_managed_agents(&app, &records)?; } - } - statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { - let record = records - .iter() - .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; - Some(status_for_with( - &app, - record, - key, - Some(runtime), - None, - StatusInputs { - personas: &personas, - global: &global, - }, - )) - })); - drop(runtimes); - // Records are only mutated above when a runtime exited — skip the store - // rewrite on the common nothing-changed poll. - if records_changed { - save_managed_agents(&app, &records)?; - } - Ok(statuses) + Ok(statuses) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } pub(crate) fn start_managed_agent_runtime_pair_lazy( @@ -572,6 +577,18 @@ pub async fn reconcile_managed_agent_runtimes( mod tests { use super::*; + #[test] + fn list_managed_agent_runtimes_returns_a_future() { + fn assert_async_command(_command: F) + where + F: Fn(AppHandle) -> Fut, + Fut: std::future::Future, String>>, + { + } + + assert_async_command(list_managed_agent_runtimes); + } + fn payload( relay_url: &str, lifecycle: ManagedAgentRuntimeLifecycle, diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 70c7bf10a8a..59d68e4057f 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -141,9 +141,6 @@ export function MembersSidebar({ relayUrl, }: MembersSidebarProps) { const channelId = channel?.id ?? null; - const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({ - enabled: open, - }); const queryClient = useQueryClient(); const searchInputRef = React.useRef(null); const [searchQuery, setSearchQuery] = React.useState(""); @@ -470,6 +467,18 @@ export function MembersSidebar({ ), [managedAgentsQuery.data], ); + const hasLocalManagedMember = React.useMemo( + () => + [...bots, ...archived].some( + (member) => + managedAgentByPubkey.get(normalizePubkey(member.pubkey))?.backend + .type === "local", + ), + [archived, bots, managedAgentByPubkey], + ); + const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({ + enabled: open && Boolean(relayUrl) && hasLocalManagedMember, + }); const controllableManagedBots = React.useMemo( () => bots.flatMap((member) => { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..6c6a53f91b0 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4017,6 +4017,25 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { ).toBeVisible(); }); +test("opening a human-only members sidebar skips managed runtime discovery", async ({ + page, +}) => { + await page.goto("/"); + const baselineCommands = await readCommandLog(page); + const baselineRuntimeListCount = commandCount( + baselineCommands, + "list_managed_agent_runtimes", + ); + + await openMembersSidebar(page, "random"); + await expect(page.getByTestId("members-sidebar-people")).toBeVisible(); + + const commands = await readCommandLog(page); + expect(commandCount(commands, "list_managed_agent_runtimes")).toBe( + baselineRuntimeListCount, + ); +}); + test("members sidebar can invite relay-authorized agents", async ({ page }) => { await installMockBridge(page, { relayAgents: [ @@ -4528,8 +4547,17 @@ test("members sidebar can stop and start a managed bot in this community", async baselineCommands, "stop_managed_agent", ); + const baselineRuntimeListCount = commandCount( + baselineCommands, + "list_managed_agent_runtimes", + ); await openMembersSidebar(page, "general"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "list_managed_agent_runtimes"), + ) + .toBe(baselineRuntimeListCount + 1); const agentStatus = page.getByTestId( `sidebar-managed-agent-status-${agentPubkey}`, From 72ba987c365abb98939153c4d43dde73257c1264 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 25 Aug 2026 01:18:29 +1000 Subject: [PATCH 003/101] fix(desktop): stabilize members dialog scrolling (#6670) ## Summary - Keep virtualized member rows measurable by removing `content-visibility: auto` from the measured row subtree. - Use the member card's 60px baseline as the virtualizer estimate while retaining deferred rendering for eager search and archived-member lists. - Cover large rosters with a regression test that checks stable scroll extent across the list and verifies the final member remains reachable. ### Related issue None found. ### Testing - `just ci` - `pnpm -C desktop build:e2e` - `pnpm -C desktop exec playwright test tests/e2e/channels.spec.ts --grep 'members sidebar virtualizes large channel rosters' --repeat-each=5` #### Before https://github.com/user-attachments/assets/a5fcc040-6872-4200-bcc3-7b4197a4dd23 #### After https://github.com/user-attachments/assets/f2c97f2f-3719-4c2a-b17a-2450c6c70a55 Signed-off-by: Matt Toohey --- .../features/channels/ui/MembersSidebar.tsx | 113 ++++++++++-------- .../src/shared/styles/globals/utilities.css | 9 ++ desktop/tests/e2e/channels.spec.ts | 33 +++++ 3 files changed, 102 insertions(+), 53 deletions(-) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 59d68e4057f..562f890c0f5 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -70,6 +70,7 @@ import { useMembersSidebarActions } from "./useMembersSidebarActions"; import { useMembersSidebarModeration } from "./useMembersSidebarModeration"; const MEMBER_ADD_RESULT_LIMIT = 50; const MEMBER_SEARCH_MIN_QUERY_LENGTH = 2; +const MEMBER_ROW_ESTIMATE_PX = 60; type AddMemberSearchCandidate = UserSearchResult & { isManagedAgent?: boolean; isMember?: boolean; @@ -645,57 +646,62 @@ export function MembersSidebar({ ? managedAgentPairAction(managedAgentRuntime) : undefined; return ( -
- { - void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); - }} - onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} - onManagedAgentAction={(agent) => { - void handleAgentLifecycleAction(agent, managedAgentRuntime); - }} - onOpenProfile={handleOpenProfile} - onRemoveMember={handleRemoveMember} - onTimeout={onTimeout} - onUnban={onUnban} - onUntimeout={onUntimeout} - onViewActivity={ - onViewActivity - ? (pubkey: string) => { - onOpenChange(false); - onViewActivity(pubkey); - } - : undefined - } - pairAction={pairAction} - presenceStatus={ - memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null - } - profileAvatarUrl={memberProfile?.avatarUrl ?? null} - showOtherSetupMarker={showOtherSetupMarker} - viewerIsOwner={viewerIsOwner} - /> + { + void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); + }} + onEditRespondTo={memberIsBot ? setEditRespondToAgent : undefined} + onManagedAgentAction={(agent) => { + void handleAgentLifecycleAction(agent, managedAgentRuntime); + }} + onOpenProfile={handleOpenProfile} + onRemoveMember={handleRemoveMember} + onTimeout={onTimeout} + onUnban={onUnban} + onUntimeout={onUntimeout} + onViewActivity={ + onViewActivity + ? (pubkey: string) => { + onOpenChange(false); + onViewActivity(pubkey); + } + : undefined + } + pairAction={pairAction} + presenceStatus={ + memberPresenceQuery.data?.[member.pubkey.toLowerCase()] ?? null + } + profileAvatarUrl={memberProfile?.avatarUrl ?? null} + showOtherSetupMarker={showOtherSetupMarker} + viewerIsOwner={viewerIsOwner} + /> + ); + } + + function renderDeferredMemberCard( + member: ChannelMember, + memberIsBot: boolean, + ) { + return ( +
+ {renderMemberCard(member, memberIsBot)}
); } @@ -780,7 +786,7 @@ export function MembersSidebar({ {normalizedSearchQuery ? (
{filteredActiveMembers.map((member) => - renderMemberCard(member, isBot(member)), + renderDeferredMemberCard(member, isBot(member)), )} {canAddMembers ? ( <> @@ -824,6 +830,7 @@ export function MembersSidebar({ ) : filteredActiveMembers.length > 0 ? ( member.pubkey} items={filteredActiveMembers} renderItem={(member) => @@ -867,7 +874,7 @@ export function MembersSidebar({ data-testid="members-sidebar-archived-list" > {filteredArchivedMembers.map((member) => - renderMemberCard(member, isBot(member)), + renderDeferredMemberCard(member, isBot(member)), )} {filteredArchivedMembers.length === 0 ? (

diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index 0d5e0f453f4..2c858474a47 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -37,6 +37,15 @@ contain-intrinsic-size: auto 2rem; } + /* + * Member cards rendered eagerly can skip offscreen layout and paint. + * Virtualized member rows must not use this utility. + */ + .content-visibility-auto-member-row { + content-visibility: auto; + contain-intrinsic-size: auto 3.75rem; + } + .buzz-huddle-tooltip { background: hsl( var(--huddle-tooltip-surface, var(--huddle-control-surface, 0 0% 20%)) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 6c6a53f91b0..0e27f771f99 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4007,7 +4007,40 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { await expect(memberRows.first()).toBeVisible(); expect(await memberRows.count()).toBeLessThan(50); + const firstGeneratedRow = memberList.getByTestId( + `sidebar-member-${pubkeys[0]}`, + ); + await expect + .poll(() => + firstGeneratedRow.evaluate( + (row) => + getComputedStyle(row.parentElement as HTMLElement).contentVisibility, + ), + ) + .toBe("visible"); + const virtualizedList = memberList.locator(".overflow-y-auto"); + await page.evaluate(() => document.fonts.ready); + + const heights: number[] = []; + for (const ratio of [0, 0.1, 0.25, 0.5, 0.75, 1]) { + heights.push( + await virtualizedList.evaluate(async (element, ratio) => { + element.scrollTop = + (element.scrollHeight - element.clientHeight) * ratio; + element.dispatchEvent(new Event("scroll")); + + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + + return element.scrollHeight; + }, ratio), + ); + } + + expect(Math.max(...heights) - Math.min(...heights)).toBeLessThan(120); + await virtualizedList.evaluate((element) => { element.scrollTop = element.scrollHeight; element.dispatchEvent(new Event("scroll")); From 2f13e30e88e84851e7ad336364dd3cfd547b8c16 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Tue, 25 Aug 2026 01:39:55 +1000 Subject: [PATCH 004/101] fix(desktop): hide selection formatting tray on composer right-click (#6683) ## Summary Right-clicking a text selection in the message composer left the selection formatting tray floating over the native context menu. This suppresses the tray for the duration of the right-click interaction. ## Changes - `SelectionFormattingTray.tsx`: a `contextmenu` listener on the editor DOM sets a suppression ref, cancels any queued rAF reposition, and hides the tray. Suppression clears on the next left-click `pointerdown` or `keydown` in the editor, which reschedules a normal position update. - `scheduleUpdate`/`updatePosition` both honor the suppression ref, so editor `selectionUpdate`/`transaction`/`focus` events fired during the right-click can't bring the tray back. - Extracted `cancelScheduledUpdate` to replace the duplicated rAF-cancel logic, and reset suppression on editor change / cleanup. - E2E coverage in `composer-selection-formatting.spec.ts`: double-click to select, assert the tray shows, right-click and assert the tray hides *and* that `contextmenu` is not `defaultPrevented` (the native menu still opens), then re-select and assert the tray returns. ## Testing `just` pre-push gate ran green: `desktop-check`, `desktop-typecheck`, `desktop-test` (5397 passing), `file-size-check`. ## Demo https://github.com/user-attachments/assets/2fdfced9-6cd6-4eb2-a6df-c03164ab1c42 Signed-off-by: Matt Toohey --- .../messages/ui/SelectionFormattingTray.tsx | 53 +++++++++-- .../e2e/composer-selection-formatting.spec.ts | 88 +++++++++++++++++++ 2 files changed, 132 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index 9eb24a970c8..84934d1826d 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -115,11 +115,24 @@ export function SelectionFormattingTray({ }: SelectionFormattingTrayProps) { const [position, setPosition] = React.useState(null); const rafRef = React.useRef(null); + const suppressRightClickUpdatesRef = React.useRef(false); const trayRef = React.useRef(null); const [trayWidth, setTrayWidth] = React.useState(0); + const cancelScheduledUpdate = React.useCallback(() => { + if (rafRef.current === null) return; + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + }, []); + const updatePosition = React.useCallback(() => { - if (!editor || disabled || !editor.isEditable || !editor.isFocused) { + if ( + suppressRightClickUpdatesRef.current || + !editor || + disabled || + !editor.isEditable || + !editor.isFocused + ) { setPosition(null); return; } @@ -127,44 +140,66 @@ export function SelectionFormattingTray({ }, [disabled, editor, trayWidth]); const scheduleUpdate = React.useCallback(() => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current); + if (suppressRightClickUpdatesRef.current) { + cancelScheduledUpdate(); + setPosition(null); + return; } + cancelScheduledUpdate(); rafRef.current = window.requestAnimationFrame(() => { rafRef.current = null; updatePosition(); }); - }, [updatePosition]); + }, [cancelScheduledUpdate, updatePosition]); React.useEffect(() => { + suppressRightClickUpdatesRef.current = false; + if (!editor) { + cancelScheduledUpdate(); setPosition(null); return; } + const editorDom = editor.view.dom; const hide = () => setPosition(null); + const handleContextMenu = () => { + suppressRightClickUpdatesRef.current = true; + cancelScheduledUpdate(); + setPosition(null); + }; + const clearSuppression = () => { + suppressRightClickUpdatesRef.current = false; + scheduleUpdate(); + }; + const handlePointerDown = (event: PointerEvent) => { + if (event.button === 0) clearSuppression(); + }; scheduleUpdate(); editor.on("selectionUpdate", scheduleUpdate); editor.on("transaction", scheduleUpdate); editor.on("focus", scheduleUpdate); editor.on("blur", hide); + editorDom.addEventListener("contextmenu", handleContextMenu); + editorDom.addEventListener("pointerdown", handlePointerDown); + editorDom.addEventListener("keydown", clearSuppression); window.addEventListener("resize", scheduleUpdate); window.addEventListener("scroll", scheduleUpdate, true); return () => { - if (rafRef.current !== null) { - window.cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } + cancelScheduledUpdate(); editor.off("selectionUpdate", scheduleUpdate); editor.off("transaction", scheduleUpdate); editor.off("focus", scheduleUpdate); editor.off("blur", hide); + editorDom.removeEventListener("contextmenu", handleContextMenu); + editorDom.removeEventListener("pointerdown", handlePointerDown); + editorDom.removeEventListener("keydown", clearSuppression); window.removeEventListener("resize", scheduleUpdate); window.removeEventListener("scroll", scheduleUpdate, true); }; - }, [editor, scheduleUpdate]); + }, [cancelScheduledUpdate, editor, scheduleUpdate]); React.useLayoutEffect(() => { if (!position || !trayRef.current) return; diff --git a/desktop/tests/e2e/composer-selection-formatting.spec.ts b/desktop/tests/e2e/composer-selection-formatting.spec.ts index 202ec968698..25d3cbaf9ae 100644 --- a/desktop/tests/e2e/composer-selection-formatting.spec.ts +++ b/desktop/tests/e2e/composer-selection-formatting.spec.ts @@ -38,6 +38,41 @@ async function selectText(input: Locator, selectedText: string) { }, selectedText); } +async function doubleClickText( + page: Page, + input: Locator, + selectedText: string, +) { + const point = await input.evaluate((element, text) => { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + + while (walker.nextNode()) { + const node = walker.currentNode; + const value = node.textContent ?? ""; + const index = value.indexOf(text); + if (index < 0) continue; + + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + text.length); + const rect = range.getBoundingClientRect(); + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; + } + + throw new Error(`Could not locate "${text}" for double-click selection`); + }, selectedText); + + await page.mouse.dblclick(point.x, point.y); + await expect + .poll(() => page.evaluate(() => window.getSelection()?.toString())) + .toBe(selectedText); + + return point; +} + async function selectTextRange( input: Locator, firstText: string, @@ -621,6 +656,59 @@ test("block formatting preserves a backward native selection", async ({ .toBe(true); }); +test("right-clicking selected composer text hides the selection formatter", async ({ + page, +}) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.fill("before selected after"); + const rightClickPoint = await doubleClickText(page, input, "selected"); + + const tray = page.getByTestId("selection-formatting-tray"); + await expect(tray).toBeVisible(); + + await input.evaluate((element) => { + ( + window as Window & { + __BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__?: boolean; + } + ).__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__ = false; + element.addEventListener( + "contextmenu", + (event) => { + ( + window as Window & { + __BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__?: boolean; + } + ).__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__ = event.defaultPrevented; + }, + { once: true }, + ); + }); + + await page.mouse.click(rightClickPoint.x, rightClickPoint.y, { + button: "right", + }); + + await expect(tray).toBeHidden(); + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { + __BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__?: boolean; + } + ).__BUZZ_E2E_CONTEXTMENU_DEFAULT_PREVENTED__, + ), + ) + .toBe(false); + + await doubleClickText(page, input, "selected"); + await expect(tray).toBeVisible(); +}); + test("Buzz theme uses the primary color for the selection formatter", async ({ page, }) => { From 0e69b3fd7c44c09da62e2c4e89fdb4a26e666869 Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 11:48:48 -0400 Subject: [PATCH 005/101] show mention counts in channel notifications (#6696) **Category:** fix **User Impact:** Stream and forum channels now show an accessible numeric badge for unread mentions while mention chips remain clear in every theme. **Problem:** Mention notifications contributed to the app and Dock badge, but inactive stream and forum rows only became bold, making it difficult to see where multiple mentions were waiting. Mention styling and generic destructive colors could also lose contrast or visual meaning in some themes. **Solution:** Use the same app-badge projection for non-DM channel mention counts, while preserving regular unread bolding, thread activity dots, DM counts, and manual unread behavior. Dedicated notification and opaque mention-highlight tokens keep the new treatments stable and readable across syntax themes.

File changes **desktop/src/features/channels/useUnreadChannels.ts** Projects app-badge-eligible mention and broadcast counts into stream and forum channel rows while retaining DM-specific counting and manual-unread semantics. **desktop/src/features/sidebar/ui/SidebarSection.tsx** Renders an accessible numeric notification pill on inactive non-DM channels and preserves the thread activity dot fallback. **desktop/src/shared/styles/globals/markdown.css** Applies the shared opaque yellow highlight to human and agent mention chips, including hover treatment. **desktop/src/shared/styles/globals/theme.css** Adds fixed notification and mention-highlight tokens with theme-independent contrast. **desktop/tailwind.config.js** Exposes the notification token pair through semantic Tailwind utilities. **desktop/tests/e2e/badge.spec.ts** Covers aggregated mention counts, broadcasts, unchanged unread tiers, exact accessible text, and badge contrast under an adversarial theme. **desktop/tests/e2e/mentions.spec.ts** Covers human and agent mention styling, hover behavior, dark mode, and WCAG text contrast.
## Reproduction steps 1. Open a stream or forum channel, then navigate to another channel. 2. Receive two messages that mention you in the inactive channel. 3. Confirm the inactive row is bold and shows a red `2` pill matching the two notifications added to the app or Dock badge. 4. Receive a regular channel message and confirm the row only becomes bold, without a numeric pill. 5. Receive a reply in an interested thread and confirm the channel retains its activity dot instead of a mention count. 6. Switch between light and dark themes and confirm human and agent mention chips remain yellow with near-black readable text, including on hover. ## Screenshots Screenshots are posted in the PR discussion using immutable repository-hosted image URLs. Signed-off-by: tulsi --- .../features/channels/useUnreadChannels.ts | 19 +++--- .../features/sidebar/ui/SidebarSection.tsx | 14 +++- .../src/shared/styles/globals/markdown.css | 15 +++++ desktop/src/shared/styles/globals/theme.css | 8 +++ desktop/tailwind.config.js | 4 ++ desktop/tests/e2e/badge.spec.ts | 65 +++++++++++++++---- desktop/tests/e2e/mentions.spec.ts | 48 ++++++++++++++ 7 files changed, 149 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index b956b672d0e..984d69ff891 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -784,13 +784,8 @@ export function useUnreadChannels( relayClient, ]); - // Unread = inactive channels, plus any channel manually marked unread this - // session. A manually marked active channel must remain visible as unread - // until the user explicitly marks it read again. - // High-priority unread = DMs or channels with a mention/broadcast newer - // than the read marker. Forced-unread channels are dot tier only (not - // high-priority). Both sets share identical deps and always invalidate - // together, so they are computed in a single memo. + // Derive unread and high-priority projections together so they invalidate + // from the same read-state snapshot. const rawUnread = // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals React.useMemo(() => { @@ -841,7 +836,7 @@ export function useUnreadChannels( if (!isForcedUnread) continue; unread.add(channel.id); topLevelUnread.add(channel.id); - counts.set(channel.id, 1); + if (channel.channelType === "dm") counts.set(channel.id, 1); unreadChannelNotificationCount += 1; continue; } @@ -863,13 +858,17 @@ export function useUnreadChannels( observedEvents, readAtForObservedEvent, ); - counts.set(channel.id, badgeCount); - unreadChannelNotificationCount += + const appBadgeCount = nativeProjection?.appBadgeCount ?? countUnreadAppBadgeObservedEvents( observedEvents, readAtForObservedEvent, ); + counts.set( + channel.id, + channel.channelType === "dm" ? badgeCount : appBadgeCount, + ); + unreadChannelNotificationCount += appBadgeCount; // DM channels: any unread DM is high-priority. if (channel.channelType === "dm") { diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24d..0c99eaf16d1 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -79,7 +79,10 @@ function UnreadCountBadge({ data-testid={`channel-unread-${channelName}`} > {formatUnreadCount(count)} - new comment{count === 1 ? "" : "s"} + + {" "} + unread notification{count === 1 ? "" : "s"} + ); } @@ -255,6 +258,7 @@ export function ChannelMenuButton({ label, isActive, hasUnread, + unreadCount = 0, activeWorking, isMuted, dmParticipants, @@ -354,7 +358,13 @@ export function ChannelMenuButton({ )} /> ) : null} - {hasThreadUnread ? ( + {!isActive && channel.channelType !== "dm" && unreadCount > 0 ? ( + + ) : hasThreadUnread ? ( ) : null} diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 9be45482492..e443357ae82 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -302,6 +302,21 @@ color: hsl(var(--primary) / 0.9); } +/* @mentions use an opaque highlighter-yellow treatment so they scan + differently from channel links while retaining AA contrast on every theme. + Shared by human and agent chips in both timeline and composer. */ +.message-markdown .mention-chip.inline-chip-icon-human, +.message-markdown .mention-chip.inline-chip-icon-agent { + background: hsl(var(--mention-highlight)); + color: hsl(var(--buzz-content-dark)); +} + +.message-markdown .mention-chip-hover.inline-chip-icon-human:hover, +.message-markdown .mention-chip-hover.inline-chip-icon-agent:hover { + background: hsl(var(--mention-highlight-hover)); + color: hsl(var(--buzz-content-dark)); +} + .message-markdown .mention-prefix-hidden { display: inline-block; width: 0; diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 5fd2593c79c..875a17aedfc 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -28,6 +28,14 @@ --accent-foreground: 234 16.02% 35.49%; --destructive: 347 86.67% 44.12%; --destructive-foreground: 220 23.08% 94.9%; + /* Notification attention colors are fixed across syntax themes. Unlike + destructive, these must stay recognizably red with AA label contrast. */ + --notification: 348 65% 48%; + --notification-foreground: 350 100% 98%; + /* Opaque highlighter colors keep near-black mention text readable on every + message surface instead of depending on the active theme underneath. */ + --mention-highlight: 48 96% 70%; + --mention-highlight-hover: 48 96% 62%; --border: 225 13.56% 76.86%; --input: 225 13.56% 76.86%; --ring: 234 16.02% 35.49%; diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 4816b3ddc35..165d8a1ac2b 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -110,6 +110,10 @@ export default { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, + notification: { + DEFAULT: "hsl(var(--notification))", + foreground: "hsl(var(--notification-foreground))", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index f1b618b0d64..e44e6e097b5 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -402,9 +402,14 @@ test("regular message bolds inactive channel without numeric badge", async ({ ); }); -test("top-level @mention bolds the channel without a row badge", async ({ +test("top-level @mention shows a red numeric badge on its channel", async ({ page, }) => { + // slack-ochin maps the generic destructive pair to white-on-white. The + // notification pair must remain independently red and readable. + await page.addInitScript(() => { + window.localStorage.setItem("buzz-theme", "slack-ochin"); + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -413,13 +418,18 @@ test("top-level @mention bolds the channel without a row badge", async ({ await page.evaluate( ({ pubkey, mentionPubkey }) => { - window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "random", - content: "Hey @tyler check this out", - kind: 40002, - pubkey, - mentionPubkeys: [mentionPubkey], - }); + for (const content of [ + "Hey @tyler check this out", + "One more for @tyler", + ]) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content, + kind: 40002, + pubkey, + mentionPubkeys: [mentionPubkey], + }); + } }, { pubkey: TEST_IDENTITIES.alice.pubkey, @@ -431,9 +441,38 @@ test("top-level @mention bolds the channel without a row badge", async ({ "font-weight", "700", ); - await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + const mentionBadge = page.getByTestId("channel-unread-random"); + await expect(mentionBadge).toHaveText("2 unread notifications"); + await expect(mentionBadge).toHaveClass(/bg-notification/); + await expect(mentionBadge).toHaveCSS("background-color", "rgb(202, 43, 75)"); + await expect(mentionBadge).toHaveCSS("color", "rgb(255, 245, 247)"); + const badgeContrast = await mentionBadge.evaluate((element) => { + const parseRgb = (value: string) => + (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + const luminance = (color: number[]) => + color + .map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }) + .reduce( + (sum, channel, index) => + sum + channel * [0.2126, 0.7152, 0.0722][index], + 0, + ); + const style = getComputedStyle(element); + const foreground = luminance(parseRgb(style.color)); + const background = luminance(parseRgb(style.backgroundColor)); + return ( + (Math.max(foreground, background) + 0.05) / + (Math.min(foreground, background) + 0.05) + ); + }); + expect(badgeContrast).toBeGreaterThanOrEqual(4.5); await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); - await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 2)); }); test("numeric badge increments for DM message", async ({ page }) => { @@ -496,7 +535,7 @@ test("interested thread reply shows the channel preview dot without incrementing await waitForBadgeState(page, baselineBadge); }); -test("broadcast reply bolds the channel without a thread dot", async ({ +test("broadcast reply shows a numeric channel badge without a thread dot", async ({ page, }) => { await page.goto("/"); @@ -525,7 +564,9 @@ test("broadcast reply bolds the channel without a thread dot", async ({ "font-weight", "700", ); - await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-random")).toHaveText( + "1 unread notification", + ); await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 25d73b28ca7..ad1acf87c34 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -37,6 +37,37 @@ const DM_THREAD_AGENT_MENTION_ERROR_TEXT = const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT = "Checking conversation members. Try again in a moment."; +async function expectTextContrast( + locator: import("@playwright/test").Locator, + minimum = 4.5, +) { + const contrastRatio = await locator.evaluate((element) => { + const parseRgb = (value: string) => + (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); + const luminance = (color: number[]) => + color + .map((channel) => { + const value = channel / 255; + return value <= 0.04045 + ? value / 12.92 + : ((value + 0.055) / 1.055) ** 2.4; + }) + .reduce( + (sum, channel, index) => + sum + channel * [0.2126, 0.7152, 0.0722][index], + 0, + ); + const style = getComputedStyle(element); + const foreground = luminance(parseRgb(style.color)); + const background = luminance(parseRgb(style.backgroundColor)); + return ( + (Math.max(foreground, background) + 0.05) / + (Math.min(foreground, background) + 0.05) + ); + }); + expect(contrastRatio).toBeGreaterThanOrEqual(minimum); +} + /** Locator scoped to the mention autocomplete dropdown inside the composer. */ function autocomplete(page: import("@playwright/test").Page) { return page @@ -2598,6 +2629,9 @@ test("sent non-member person mention uses the normal mention style", async ({ test("sent managed non-member agent mention uses the agent mention style", async ({ page, }) => { + await page.addInitScript(() => { + window.localStorage.setItem("buzz-theme", "buzz-dark"); + }); await installMockBridge(page, { managedAgents: [ { @@ -2627,6 +2661,13 @@ test("sent managed non-member agent mention uses the agent mention style", async await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("charlie"); await expect(mentionChip).toHaveClass(/agent-mention-highlight/); + await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); + await mentionChip.hover(); + await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); }); test("mention button opens autocomplete and inserts a selected member", async ({ @@ -2732,6 +2773,13 @@ test("mention text is highlighted in sent messages", async ({ page }) => { await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); + await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); + await mentionChip.hover(); + await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); }); test("clicking author name opens user profile panel", async ({ page }) => { From 26f4c3ed304db2c273f0bd4d2746aa9598f38366 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 09:48:02 -0700 Subject: [PATCH 006/101] feat(mobile): browse and join open channels (#6243) Mobile previously exposed no way to browse or join channels. Users can now browse and join eligible open channels from the Home quick-actions menu. The public directory loads on demand when Browse channels opens, while the existing kind 9021 join path refreshes membership after success. | Browse channels | Join channel | | --- | --- | | Browse channels | Join channel | ### How is it tested? Manually tested (see screenshots) and added tests: - [`channels_provider_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/channels_provider_test.dart) covers access filtering, independently paginated membership and directory queries, relay-capped pages, repeated-page termination, hard page caps, on-demand directory loading, load failures, retry, and cached-channel retention. - [`channels_page_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/channels_page_test.dart) covers browse eligibility, loading and retry states, quick-action layout, and scrolling and joining from a 500-channel directory. - [`search_page_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/search/search_page_test.dart) covers discoverable open-channel results without presenting unknown membership counts as zero. Local validation: - `just mobile-check` - `just mobile-test` (1,560 tests) - full pre-push gate --------- Signed-off-by: Tom Brow Co-authored-by: Codex Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> --- mobile/lib/features/channels/channel.dart | 1 + .../features/channels/channel_directory.dart | 487 +++++ .../lib/features/channels/channels_page.dart | 3 +- .../channels_page/browse_channels_sheet.dart | 191 ++ .../channels/channels_page/quick_actions.dart | 9 +- .../channels_page/quick_actions_launcher.dart | 9 + .../features/channels/channels_provider.dart | 441 ++-- mobile/lib/features/search/search_page.dart | 16 +- .../features/channels/channels_page_test.dart | 437 +++- .../channels/channels_provider_test.dart | 1856 ++++++++++++++++- .../features/search/search_page_test.dart | 37 + 11 files changed, 3243 insertions(+), 244 deletions(-) create mode 100644 mobile/lib/features/channels/channel_directory.dart create mode 100644 mobile/lib/features/channels/channels_page/browse_channels_sheet.dart diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 7f0eb97d36e..120acca3c95 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -82,6 +82,7 @@ class Channel { bool get isForum => channelType == 'forum'; bool get isDm => channelType == 'dm'; bool get isPrivate => visibility == 'private'; + bool get canJoin => visibility == 'open' && !isArchived && !isMember && !isDm; /// Whether [selfRole] may add *another* identity here, mirroring the relay's /// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never, diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart new file mode 100644 index 00000000000..a99822ede5e --- /dev/null +++ b/mobile/lib/features/channels/channel_directory.dart @@ -0,0 +1,487 @@ +part of 'channels_provider.dart'; + +const _channelDirectoryPageSize = 500; +const _maxChannelDirectoryPages = 100; + +/// Describes whether the open-channel directory is ready to browse. +enum ChannelDirectoryLoadStatus { + /// No directory request has completed for the active identity and relay. + idle, + + /// A directory request is currently in flight. + loading, + + /// The directory request completed, including when it returned no channels. + loaded, + + /// The most recent directory request could not complete. + error, +} + +/// Directory loading state scoped to one relay and signing identity. +class ChannelDirectoryLoadState { + /// Relay-and-identity scope that produced [status]. + final String? scope; + + /// Current loading status for [scope]. + final ChannelDirectoryLoadStatus status; + + /// Creates directory loading state. + const ChannelDirectoryLoadState({required this.scope, required this.status}); + + /// Initial state before any directory request has started. + const ChannelDirectoryLoadState.idle() + : scope = null, + status = ChannelDirectoryLoadStatus.idle; +} + +/// Returns the stable directory scope for a relay and signing identity. +String channelDirectoryScope(String relayBaseUrl, String? pubkey) => + '$relayBaseUrl:${pubkey?.toLowerCase() ?? ''}'; + +/// Owns the independently observable channel-directory loading state. +class ChannelDirectoryLoadNotifier extends Notifier { + @override + ChannelDirectoryLoadState build() => const ChannelDirectoryLoadState.idle(); + + /// Whether [scope] currently owns an in-flight directory request. + bool isLoading(String scope) => + state.scope == scope && + state.status == ChannelDirectoryLoadStatus.loading; + + /// Marks the directory as loading. + void markLoading(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.loading, + ); + + /// Marks the directory as eligible for a fresh request. + void markIdle(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.idle, + ); + + /// Marks the directory as successfully loaded. + void markLoaded(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.loaded, + ); + + /// Marks the directory request as unsuccessful. + void markError(String scope) => state = ChannelDirectoryLoadState( + scope: scope, + status: ChannelDirectoryLoadStatus.error, + ); +} + +/// Loading state for open-channel discovery, separate from membership loading. +final channelDirectoryLoadStatusProvider = + NotifierProvider( + ChannelDirectoryLoadNotifier.new, + ); + +Future> _fetchChannelMemberships( + RelaySessionNotifier session, + String pubkey, +) => _fetchPaginatedChannelEvents( + session, + kind: 39002, + tags: { + '#p': [pubkey], + }, + operation: 'Channel memberships', +); + +Future> _fetchChannelDirectoryMetas( + RelaySessionNotifier session, +) => _fetchPaginatedChannelEvents( + session, + kind: 39000, + operation: 'Channel directory', +); + +/// Thrown when a channel-list request is retired before it settles. +/// +/// Callers must treat this as "write nothing": a newer request or scope now +/// owns the installed list and its related cache, subscription, and load state. +class _StaleChannelRefresh implements Exception { + const _StaleChannelRefresh(); + + @override + String toString() => + 'Channel refresh retired by a newer request or scope change'; +} + +/// Carries one channel-list refresh's request ownership across every await. +/// +/// This token is captured once at the start of every ordinary, directory, and +/// reconnect refresh. It is re-checked after each relay await, so an older +/// request cannot regain ownership by reaching subscription setup last. +class _ChannelRefreshFence { + /// Relay-and-identity scope that started the refresh. + final String scope; + + final _ChannelRefreshCoordinator _coordinator; + final int _generation; + + _ChannelRefreshFence(this._coordinator, this.scope, this._generation); + + /// Whether the refresh still owns the active scope and generation. + bool get isCurrent => + _generation == _coordinator.generation && + scope == _coordinator.currentScope(); + + /// Throws [_StaleChannelRefresh] once this refresh has been retired. + /// + /// Call after every await and immediately before every write to metadata, + /// cache, load status, subscriptions or provider state. + void ensureCurrent() { + if (!isCurrent) throw const _StaleChannelRefresh(); + } +} + +/// Whether a detached unread catch-up has been superseded, so it writes nothing. +/// +/// The refresh fence is acquired before the first relay await, which makes this +/// request-ordered rather than subscription-completion-ordered. The subscription +/// generation remains a second lifecycle check for disconnect and disposal. +/// +/// A retired catch-up returns rather than throwing [_StaleChannelRefresh]: +/// nothing awaits it, so a throw would only surface as an unhandled error. +/// +/// An extension in this part file rather than a method on the notifier because +/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// ceiling enforced by `just file-size-check`. +extension _CatchUpFencing on ChannelsNotifier { + bool _isCatchUpRetired( + _ChannelRefreshFence fence, + int subscriptionGeneration, + ) => !fence.isCurrent || subscriptionGeneration != _subscriptionVersion; +} + +/// Awaits [future], then rejects the result if the refresh was retired. +/// +/// One helper keeps every await site on the fenced path identical, so a new +/// await cannot be added without deciding whether it needs the fence. +/// +/// The error path is fenced too. Without it a retired refresh that fails would +/// surface an ordinary exception, and `retryDirectory` would treat it as a +/// failure of the current scope: it would mark the wrong scope's status and +/// reinstall the channel list it captured before the switch. +Future _fenced(_ChannelRefreshFence fence, Future future) async { + final T value; + try { + value = await future; + } catch (_) { + if (!fence.isCurrent) throw const _StaleChannelRefresh(); + rethrow; + } + fence.ensureCurrent(); + return value; +} + +/// Resolves display labels for the other participants in every DM meta. +/// +/// The relay stores DM channels with the literal name "DM", and the pure-Nostr +/// architecture puts name resolution in the client. So collect the non-self +/// participant pubkeys across all DM metas and batch-fetch their kind:0 +/// profiles in one round-trip. Returns lowercase pubkey to label. +/// +/// Lives in this part file because `channels_provider.dart` sits against the +/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +Future> _resolveDmDisplayNames( + RelaySessionNotifier session, + _ChannelRefreshFence fence, + Iterable dedupedMetas, + String myPk, +) async { + final dmParticipants = {}; + final myPkLower = myPk.toLowerCase(); + for (final event in dedupedMetas) { + final data = ChannelData.fromEvent(event); + if (data.channelType != 'dm') continue; + for (final pk in data.participantPubkeys) { + final lower = pk.toLowerCase(); + if (lower != myPkLower) dmParticipants.add(lower); + } + } + if (dmParticipants.isEmpty) return const {}; + + final profileEvents = await _fenced( + fence, + session.fetchHistory(NostrFilters.profilesBatch(dmParticipants.toList())), + ); + final displayNames = {}; + for (final event in profileEvents) { + if (event.kind != 0) continue; + final profile = ProfileData.fromEvent(event); + final label = profile.displayName?.trim().isNotEmpty == true + ? profile.displayName!.trim() + : profile.nip05?.trim().isNotEmpty == true + ? profile.nip05!.trim() + : shortPubkey(profile.pubkey); + displayNames[profile.pubkey.toLowerCase()] = label; + } + return displayNames; +} + +Future> _fetchHiddenDmIds( + RelaySessionNotifier session, + String myPk, +) async { + try { + final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk)); + if (events.isEmpty) return const {}; + NostrEvent latest = events.first; + for (final event in events.skip(1)) { + if (event.createdAt > latest.createdAt) latest = event; + } + return { + for (final tag in latest.tags) + if (tag.length >= 2 && tag[0] == 'h') tag[1], + }; + } catch (_) { + return const {}; + } +} + +Future> _fetchHuddleStarts( + RelaySessionNotifier session, + List parentChannelIds, +) async { + if (parentChannelIds.isEmpty) return const []; + try { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return await session.fetchHistory( + NostrFilter( + kinds: const [EventKind.huddleStarted], + tags: {'#h': parentChannelIds}, + since: now - const Duration(hours: 2).inSeconds, + limit: 500, + ), + ); + } catch (error) { + debugPrint( + '[ChannelsNotifier] Huddle backing-channel query failed: $error', + ); + return const []; + } +} + +/// Counts distinct `p`-tagged members per channel from kind:39002 events. +/// +/// Lives in this part file to keep `channels_provider.dart` under the +/// repository-wide 1000-line ceiling enforced by `just file-size-check`. +Map _memberCountsByChannelId(Iterable memberEvents) { + final memberCounts = {}; + for (final event in memberEvents) { + final channelId = event.getTagValue('d'); + if (channelId == null) continue; + final pTags = {}; + for (final tag in event.tags) { + if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) { + pTags.add(tag[1].toLowerCase()); + } + } + memberCounts[channelId] = pTags.length; + } + return memberCounts; +} + +/// Loads the open-channel directory fenced to the scope that requested it. +/// +/// A community or identity switch changes the scope, and a newer request bumps +/// the generation. Either one retires an in-flight request, so a delayed +/// response can never populate the current community's state. This is the +/// tenant boundary described in VISION.md: isolation is the boundary, not a +/// filter, so a retired response is discarded rather than merged. +/// +/// Lives in this part file because `channels_provider.dart` sits against the +/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +class _ChannelRefreshCoordinator { + /// Resolves the relay-and-identity scope that is active right now. + final String Function() currentScope; + + /// Owns the externally observable directory load status. + final ChannelDirectoryLoadNotifier Function() loadStatus; + + int _generation = 0; + + /// Generation of the most recently issued or retired request. + int get generation => _generation; + + _ChannelRefreshCoordinator({ + required this.currentScope, + required this.loadStatus, + }); + + /// Binds the fence to a notifier's [Ref] so the provider needs one line. + /// + /// Both closures read rather than watch: the fence asks what the scope is + /// right now, and must not make the notifier depend on it. + factory _ChannelRefreshCoordinator.forRef(Ref ref) => + _ChannelRefreshCoordinator( + currentScope: () => channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ), + loadStatus: () => ref.read(channelDirectoryLoadStatusProvider.notifier), + ); + + /// Retires any in-flight request without starting a new one. + /// + /// Called when the relay or identity changes so a response already on the + /// wire cannot be written into the new scope. + void retireInFlight() => _generation++; + + /// Starts a fenced refresh without issuing the directory query. + /// + /// Used by callers that must carry the scope across later awaits even when + /// they do not refresh discovery, so a membership-only refresh cannot install + /// an old scope's list either. + _ChannelRefreshFence beginRefresh({required bool fetchesDirectory}) { + final scope = currentScope(); + final generation = ++_generation; + if (!fetchesDirectory) { + final status = loadStatus(); + if (status.isLoading(scope)) { + // This refresh takes ownership of the shared generation, so the + // in-flight directory response will be discarded. The mounted Browse + // sheet cannot restart an idle request on its own: it only starts a + // load when it mounts and deliberately renders idle as its initial + // spinner. Settle the displaced load to a retryable terminal state so + // the sheet never waits forever for a response that may no longer + // write results. + status.markError(scope); + } + } + return _ChannelRefreshFence(this, scope, generation); + } + + /// Fetches the directory under [fence], or throws if the fence is retired. + /// + /// Returns null when the request failed inside the current scope, which means + /// "retain the cached discovery". The fence is re-checked after the await and + /// before every write, on both the success and the failure path. + Future?> loadDirectory( + RelaySessionNotifier session, + _ChannelRefreshFence fence, + ) async { + loadStatus().markLoading(fence.scope); + final List metas; + try { + metas = await _fetchChannelDirectoryMetas(session); + } catch (error, stackTrace) { + fence.ensureCurrent(); + loadStatus().markError(fence.scope); + debugPrint( + '[ChannelsNotifier] channel directory refresh failed; retaining ' + 'cached discovery: $error\n$stackTrace', + ); + return null; + } + fence.ensureCurrent(); + loadStatus().markLoaded(fence.scope); + return metas; + } +} + +Future> _fetchPaginatedChannelEvents( + RelaySessionNotifier session, { + required int kind, + required String operation, + Map> tags = const {}, +}) async { + final events = []; + final seenEventIds = {}; + int? until; + String? beforeId; + for (var pageIndex = 0; pageIndex < _maxChannelDirectoryPages; pageIndex++) { + final page = await session.queryRelay([ + NostrFilter( + kinds: [kind], + tags: tags, + limit: _channelDirectoryPageSize, + until: until, + extensions: {'before_id': ?beforeId}, + ), + ]); + if (page.isEmpty) break; + var madeProgress = false; + for (final event in page) { + if (seenEventIds.add(event.id)) { + events.add(event); + madeProgress = true; + } + } + if (!madeProgress) break; + + final last = page.last; + until = last.createdAt; + beforeId = last.id; + if (pageIndex == _maxChannelDirectoryPages - 1) { + throw StateError('$operation exceeded $_maxChannelDirectoryPages pages'); + } + } + return events; +} + +/// Thread-interest and unread helpers shared by [ChannelsNotifier]. +/// +/// Lives in this part file because `channels_provider.dart` sits against the +/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +String? _observedUnreadRootId(NostrEvent event) => + _isBroadcastReply(event) ? null : event.threadReference.rootId; + +bool _isBroadcastReply(NostrEvent event) => event.tags.any( + (tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1', +); + +Set _readRootIdSet(String? raw) { + if (raw == null || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return {}; + return { + for (final value in decoded) + if (value is String) value, + }; + } catch (_) { + return {}; + } +} + +String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); + +/// Records one observed unread event for a channel's badge state. +/// +/// An extension in this part file rather than a method on the notifier because +/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// ceiling enforced by `just file-size-check`. Private members stay reachable: +/// a part shares its parent's library. +extension _ObservedUnreadRecording on ChannelsNotifier { + void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) { + final isThreadedReply = + event.threadReference.parentId != null && !_isBroadcastReply(event); + final isHighPriority = + channel.isDm || isHighPriorityEvent(event.tags, myPk); + recordObservedUnreadEvent( + _observedUnreadEventsByChannel, + channel.id, + makeObservedUnreadEvent( + id: event.id, + createdAt: event.createdAt, + rootId: _observedUnreadRootId(event), + highPriority: isHighPriority, + channelType: channel.channelType, + isThreadedReply: isThreadedReply, + ), + _unreadCatchUpLimit, + ); + + final current = _latestObservedByChannel[channel.id] ?? 0; + if (event.createdAt > current) { + _latestObservedByChannel[channel.id] = event.createdAt; + } + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index d50ed8c46e8..5f1881facd4 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -53,6 +53,7 @@ import '../../shared/read_state/read_state_time.dart'; import 'unread_badge/observed_unread_event.dart'; part 'channels_page/body.dart'; +part 'channels_page/browse_channels_sheet.dart'; part 'channels_page/sections.dart'; part 'channels_page/channel_tile.dart'; part 'channels_page/sheets.dart'; @@ -62,7 +63,7 @@ part 'channels_page/community.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; -enum _QuickAction { createChannel, newDm } +enum _QuickAction { createChannel, newDm, browseChannels } const double _kChannelSectionInset = Grid.gutter; const double _kChannelLeadingWidth = 22.0; diff --git a/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart new file mode 100644 index 00000000000..9d1bb633716 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/browse_channels_sheet.dart @@ -0,0 +1,191 @@ +part of '../channels_page.dart'; + +class _BrowseChannelsSheet extends HookConsumerWidget { + const _BrowseChannelsSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final channelsAsync = ref.watch(channelsProvider); + final directoryState = ref.watch(channelDirectoryLoadStatusProvider); + final activeDirectoryScope = channelDirectoryScope( + ref.watch(relayConfigProvider).baseUrl, + ref.watch(myPubkeyProvider), + ); + final directoryStatus = directoryState.scope == activeDirectoryScope + ? directoryState.status + : ChannelDirectoryLoadStatus.idle; + final channels = channelsAsync.asData?.value + .where((channel) => channel.canJoin) + .toList(); + channels?.sort( + (left, right) => + left.name.toLowerCase().compareTo(right.name.toLowerCase()), + ); + + useEffect(() { + unawaited( + Future.microtask( + ref.read(channelsProvider.notifier).ensureDirectoryLoaded, + ), + ); + return null; + }, const []); + + final directoryIsLoading = + directoryStatus == ChannelDirectoryLoadStatus.idle || + directoryStatus == ChannelDirectoryLoadStatus.loading; + final directoryHasError = + directoryStatus == ChannelDirectoryLoadStatus.error || + channelsAsync.hasError; + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Join an open channel to add it to your conversations.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + ], + ), + ), + if (directoryIsLoading && (channels == null || channels.isEmpty)) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(Grid.sm), + child: Center(child: BuzzLoadingIndicator()), + ), + ) + else if (directoryHasError && + (channels == null || channels.isEmpty)) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Column( + children: [ + Text( + 'Couldn’t load open channels.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xxs), + TextButton( + key: const Key('browse-channels-retry'), + onPressed: () => unawaited( + ref.read(channelsProvider.notifier).retryDirectory(), + ), + child: const Text('Try again'), + ), + ], + ), + ), + ) + else if (channels == null || channels.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: Text( + 'No open channels available to join.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ) + else + SliverList.builder( + itemCount: channels.length, + itemBuilder: (context, index) => _JoinableChannelTile( + channel: channels[index], + closeAfterJoin: true, + ), + ), + ], + ), + ), + ); + } +} + +class _JoinableChannelTile extends HookConsumerWidget { + final Channel channel; + final bool closeAfterJoin; + + const _JoinableChannelTile({ + required this.channel, + required this.closeAfterJoin, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isJoining = useState(false); + final actionError = useState(null); + + Future join() async { + if (isJoining.value) return; + isJoining.value = true; + actionError.value = null; + try { + await ref.read(channelActionsProvider).joinChannel(channel.id); + if (closeAfterJoin && context.mounted) Navigator.of(context).pop(); + } catch (error) { + actionError.value = error.toString(); + } finally { + isJoining.value = false; + } + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: Key('browse-channel-${channel.id}'), + contentPadding: EdgeInsets.zero, + leading: Icon(channelIcon(channel)), + title: Text(channel.name), + subtitle: channel.description.trim().isEmpty + ? null + : Text( + channel.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: FilledButton.tonal( + key: Key('browse-channel-join-${channel.id}'), + onPressed: isJoining.value ? null : () => unawaited(join()), + child: Text(isJoining.value ? 'Joining…' : 'Join'), + ), + ), + if (actionError.value case final error?) + Align( + alignment: Alignment.centerLeft, + child: Text( + error, + key: Key('browse-channel-error-${channel.id}'), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/channels/channels_page/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart index 87548427024..3e0f6982b40 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions.dart @@ -7,7 +7,7 @@ const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1); const double _kMorphOpenBounce = 0.14; const double _kMorphCloseBounce = 0.06; const double _kMorphClosedSize = 56; -const double _kMorphOpenHeight = 160; +const double _kMorphOpenHeight = 216; const double _kMorphOpenRadius = 20; const double _kMorphSlide = 40; const double _kMorphScale = 0.97; @@ -274,6 +274,13 @@ class _QuickActionsMenu extends StatelessWidget { key: const Key('quick-action-new-dm-card'), onTap: () => onSelected(_QuickAction.newDm), ), + const SizedBox(height: Grid.xxs), + _QuickActionItem( + icon: LucideIcons.compass, + title: 'Browse channels', + key: const Key('quick-action-browse-channels-card'), + onTap: () => onSelected(_QuickAction.browseChannels), + ), ], ), ); diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index 8517fa70b9f..b298c192d0a 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -109,6 +109,15 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { if (opened != null && context.mounted) { await openChannel(opened); } + case _QuickAction.browseChannels: + await showBuzzModalBottomSheet( + context: context, + title: 'Browse channels', + constraints: _quickActionSheetConstraints(context), + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _BrowseChannelsSheet(), + ); } } diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index deb6869cb2e..2258a493c99 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -19,6 +19,8 @@ import 'unread_badge/is_high_priority_event.dart'; import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; +part 'channel_directory.dart'; + const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; const _unreadCatchUpLimit = 1000; const _participatedRootIdsPrefix = 'buzz-thread-participation.v1'; @@ -26,11 +28,11 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// Loads the user's channel list from the relay over WebSocket. /// -/// Two-step query: -/// 1. Fetch kind:39002 membership events tagged `#p:` to find -/// the channel ids I'm a member of. -/// 2. Fetch the corresponding kind:39000 channel metadata events. +/// Membership loading resolves kind:39002 events tagged `#p:`, +/// then fetches kind:39000 metadata for those channel ids. /// +/// The paginated kind:39000 directory is fetched separately when Browse +/// channels opens, so discovery never delays the main Conversations screen. /// Live updates are layered on top via per-channel subscriptions on the /// `#h` tag for any of the visible channel event kinds — incoming events /// bump `lastMessageAt` for that channel. @@ -39,7 +41,6 @@ class ChannelsNotifier extends AsyncNotifier> { final Map _unsubscribersByChannel = {}; Future _liveSubscriptionQueue = Future.value(); - List _desiredLiveChannels = const []; Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; String? _subscriptionRelayBaseUrl; @@ -54,6 +55,11 @@ class ChannelsNotifier extends AsyncNotifier> { String? _memberSnapshotRelayBaseUrl; String? _memberSnapshotPubkey; Map> _memberSnapshotsByChannelId = const {}; + List _directoryMetas = const []; + + /// Fences directory responses to the relay and identity that requested them. + late final _ChannelRefreshCoordinator _refreshCoordinator = + _ChannelRefreshCoordinator.forRef(ref); /// The member snapshot already returned while loading the channel list. /// @@ -81,6 +87,10 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotRelayBaseUrl = relayBaseUrl; _memberSnapshotPubkey = pubkey; _memberSnapshotsByChannelId = const {}; + _directoryMetas = const []; + // Retire any in-flight directory request: its response describes the + // previous relay or identity and must not reach this scope's state. + _refreshCoordinator.retireInFlight(); } final connected = Completer(); final sessionState = ref.read(relaySessionProvider); @@ -125,10 +135,12 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetch({ bool subscribeLive = false, bool fetchLastMessage = true, + bool fetchDirectory = false, }) async { final channels = await _fetchChannels( subscribeLive: subscribeLive, fetchLastMessage: fetchLastMessage, + fetchDirectory: fetchDirectory, ); _hasLoaded = true; return channels; @@ -137,6 +149,7 @@ class ChannelsNotifier extends AsyncNotifier> { Future> _fetchChannels({ bool subscribeLive = false, bool fetchLastMessage = true, + bool fetchDirectory = false, }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); @@ -144,49 +157,48 @@ class ChannelsNotifier extends AsyncNotifier> { final session = ref.read(relaySessionProvider.notifier); + // Acquire request ownership before the first relay await. Every channel-list + // path uses this fence so completion order cannot let an older ordinary, + // directory, or reconnect refresh replace a newer membership list. + final fence = _refreshCoordinator.beginRefresh( + fetchesDirectory: fetchDirectory, + ); + // Step 1: find the channels I'm a member of via kind:39002. - final memberships = []; - { - int? until; - const pageSize = 500; - while (true) { - final page = await session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: { - '#p': [myPk], - }, - limit: pageSize, - until: until, - ), - ); - memberships.addAll(page); - if (page.length < pageSize) break; - until = page.map((e) => e.createdAt).reduce(min) - 1; - } - } - final channelIds = memberships + final memberships = await _fenced( + fence, + _fetchChannelMemberships(session, myPk), + ); + final memberChannelIds = memberships .map((e) => e.getTagValue('d')) .whereType() - .toSet() - .toList(); + .toSet(); _cacheMemberSnapshots(memberships, replaceAll: true); - if (channelIds.isEmpty) { - if (subscribeLive) await _subscribeLive(const []); - return const []; - } - // Step 2: pull channel metadata in one batched filter. - final metas = await session.fetchHistory( - NostrFilters.channelMetadata(channelIds), - ); + // Step 2: pull metadata for joined channels. A user with no memberships + // must still continue to directory discovery below. + final memberMetas = memberChannelIds.isEmpty + ? const [] + : await _fenced( + fence, + session.fetchHistory( + NostrFilters.channelMetadata(memberChannelIds.toList()), + ), + ); + + // Step 3: fetch the open-channel directory. The relay filters this global + // kind:39000 query by the caller's access, but the client still rejects + // private channels and DMs below so discovery fails closed if that contract + // ever regresses. The composite cursor preserves tied-timestamp rows. + if (fetchDirectory) { + final metas = await _refreshCoordinator.loadDirectory(session, fence); + if (metas != null) _directoryMetas = metas; + } - // Dedupe by `d` tag (channel id) — kind:39000 is parameterized-replaceable, - // so logically there's exactly one current event per id, but stale revisions - // from before the relay's d_tag backfill can linger. Keep the highest - // `created_at` per id so the latest channel_type / name wins. + // Merge and dedupe by `d` tag. Kind:39000 is parameterized-replaceable, + // but stale revisions from before the relay's d_tag backfill can linger. final latestMetaPerId = {}; - for (final event in metas) { + for (final event in [...memberMetas, ..._directoryMetas]) { if (event.kind != 39000) continue; final id = event.getTagValue('d'); if (id == null) continue; @@ -197,62 +209,56 @@ class ChannelsNotifier extends AsyncNotifier> { } final dedupedMetas = latestMetaPerId.values; - // Resolve DM participant display names. Relay stores DM channels with - // literal name="DM"; pure-Nostr architecture pushes name resolution to - // the client, so collect non-self participant pubkeys across all DM - // metas and batch-fetch their kind:0 profiles in one round-trip. - final dmParticipants = {}; - final myPkLower = myPk.toLowerCase(); - for (final event in dedupedMetas) { - final data = ChannelData.fromEvent(event); - if (data.channelType != 'dm') continue; - for (final pk in data.participantPubkeys) { - final lower = pk.toLowerCase(); - if (lower != myPkLower) dmParticipants.add(lower); - } - } - - final displayNames = {}; - if (dmParticipants.isNotEmpty) { - final profileEvents = await session.fetchHistory( - NostrFilters.profilesBatch(dmParticipants.toList()), - ); - for (final event in profileEvents) { - if (event.kind != 0) continue; - final profile = ProfileData.fromEvent(event); - final label = profile.displayName?.trim().isNotEmpty == true - ? profile.displayName!.trim() - : profile.nip05?.trim().isNotEmpty == true - ? profile.nip05!.trim() - : shortPubkey(profile.pubkey); - displayNames[profile.pubkey.toLowerCase()] = label; - } - } + // Resolve DM participant display names. Extracted into the part file so + // `channels_provider.dart` stays under the 1000-line ceiling enforced by + // `just file-size-check`. + final displayNames = await _resolveDmDisplayNames( + session, + fence, + dedupedMetas, + myPk, + ); - final hiddenDmIds = await _fetchHiddenDmIds(session, myPk); + final hiddenDmIds = await _fenced(fence, _fetchHiddenDmIds(session, myPk)); // Fetch the authoritative membership snapshots before filtering Huddle // backing channels. The relay-signed kind:39000 metadata identifies the // relay, not the channel creator; the owner role in kind:39002 is the // canonical creator identity used to reject forged Huddle links. - final memberEvents = await session.fetchHistory( - NostrFilter( - kinds: const [39002], - tags: {'#d': channelIds}, - limit: channelIds.length, - ), - ); + final memberCountChannelIds = memberChannelIds.toList(); + final memberEvents = memberCountChannelIds.isEmpty + ? const [] + : await _fenced( + fence, + session.fetchHistory( + NostrFilter( + kinds: const [39002], + tags: {'#d': memberCountChannelIds}, + limit: memberCountChannelIds.length, + ), + ), + ); + final huddleStarts = memberCountChannelIds.isEmpty + ? const [] + : await _fenced( + fence, + _fetchHuddleStarts(session, memberCountChannelIds), + ); final huddleBackingIds = huddleBackingChannelIds( - await _fetchHuddleStarts(session, channelIds), + huddleStarts, memberEvents, ); final channels = []; for (final event in dedupedMetas) { + final id = event.getTagValue('d'); + if (id == null) continue; + final isMember = memberChannelIds.contains(id); final channel = _channelFromMeta( event, - isMember: true, + isMember: isMember, displayNames: displayNames, ); + if (!isMember && (channel.isPrivate || channel.isDm)) continue; if (channel.isDm && hiddenDmIds.contains(channel.id)) continue; if (huddleBackingIds.contains(channel.id) && channel.isStream && @@ -269,18 +275,7 @@ class ChannelsNotifier extends AsyncNotifier> { // Use the membership snapshots already fetched above for both Huddle // linkage validation and member-count hydration. if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); - final memberCounts = {}; - for (final event in memberEvents) { - final chId = event.getTagValue('d'); - if (chId == null) continue; - final pTags = {}; - for (final tag in event.tags) { - if (tag.isNotEmpty && tag[0] == 'p' && tag.length > 1) { - pTags.add(tag[1].toLowerCase()); - } - } - memberCounts[chId] = pTags.length; - } + final memberCounts = _memberCountsByChannelId(memberEvents); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; if (count != null) { @@ -301,7 +296,10 @@ class ChannelsNotifier extends AsyncNotifier> { final channelById = { for (final channel in activeChannels) channel.id: channel, }; - final events = await _fetchLastMessageEvents(session, activeChannels); + final events = await _fenced( + fence, + _fetchLastMessageEvents(session, activeChannels), + ); final lastMessageMap = {}; final mutedChannelIds = _mutedChannelIds(); for (final event in events) { @@ -356,6 +354,12 @@ class ChannelsNotifier extends AsyncNotifier> { // Scoped narrowly to the archived flip — broader metadata staleness // (renames, topic changes, etc.) is a separate, pre-existing concern that // already affects this provider for other reasons. + // Re-check before the first write that other providers can observe. Every + // await above is fenced, but the switch can also land in the synchronous + // gap, so the guard sits immediately before the write rather than only + // after the await. + fence.ensureCurrent(); + final prevById = { for (final c in state.value ?? const []) c.id: c, }; @@ -367,8 +371,14 @@ class ChannelsNotifier extends AsyncNotifier> { } if (subscribeLive) { - await _subscribeLive(channels); - } + // Subscriptions are shared relay state, so a retired refresh must not + // install them even though its channel list is already built. + fence.ensureCurrent(); + await _fenced(fence, _subscribeLive(channels, fence)); + } + // Guard the provider-state write in `retryDirectory` and `build`: the + // caller assigns whatever this returns, so the last check belongs here. + fence.ensureCurrent(); return channels; } @@ -470,51 +480,6 @@ class ChannelsNotifier extends AsyncNotifier> { return events; } - Future> _fetchHiddenDmIds( - RelaySessionNotifier session, - String myPk, - ) async { - try { - final events = await session.fetchHistory(NostrFilters.hiddenDms(myPk)); - if (events.isEmpty) return const {}; - NostrEvent latest = events.first; - for (final event in events.skip(1)) { - if (event.createdAt > latest.createdAt) { - latest = event; - } - } - return { - for (final tag in latest.tags) - if (tag.length >= 2 && tag[0] == 'h') tag[1], - }; - } catch (_) { - return const {}; - } - } - - Future> _fetchHuddleStarts( - RelaySessionNotifier session, - List parentChannelIds, - ) async { - if (parentChannelIds.isEmpty) return const []; - try { - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - return await session.fetchHistory( - NostrFilter( - kinds: const [EventKind.huddleStarted], - tags: {'#h': parentChannelIds}, - since: now - const Duration(hours: 2).inSeconds, - limit: 500, - ), - ); - } catch (error) { - debugPrint( - '[ChannelsNotifier] Huddle backing-channel query failed: $error', - ); - return const []; - } - } - /// Build a [Channel] from a kind:39000 metadata event. /// /// [displayNames] maps lowercase participant pubkey → resolved label and is @@ -565,26 +530,34 @@ class ChannelsNotifier extends AsyncNotifier> { } /// Subscribe per-channel to live events (requires `#h` tag for relay - /// channel-scoped fan-out). Also starts a 60s WS backstop poll to detect - /// newly created channels we don't yet have subscriptions for. - Future _subscribeLive(List channels) { + /// channel-scoped fan-out). Also starts a 60s WS backstop poll to reconcile + /// membership changes without repeatedly downloading the global directory. + Future _subscribeLive( + List channels, + _ChannelRefreshFence fence, + ) { final channelIds = { for (final channel in channels) if (channel.isMember && !channel.isArchived) channel.id, }; final relayBaseUrl = ref.read(relayConfigProvider).baseUrl; - _desiredLiveChannels = channels; _desiredLiveChannelIds = channelIds; final subscriptionVersion = ++_subscriptionVersion; final sync = _liveSubscriptionQueue.then( - (_) => - _syncLiveSubscriptions(relayBaseUrl, subscriptionVersion, channels), + (_) => _syncLiveSubscriptions( + relayBaseUrl, + subscriptionVersion, + channels, + fence, + ), ); _liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) { - debugPrint( - '[ChannelsNotifier] live subscription sync failed: $error\n$stack', - ); + if (error is! _StaleChannelRefresh) { + debugPrint( + '[ChannelsNotifier] live subscription sync failed: $error\n$stack', + ); + } }); return sync; } @@ -593,17 +566,14 @@ class ChannelsNotifier extends AsyncNotifier> { String relayBaseUrl, int subscriptionVersion, List channels, + _ChannelRefreshFence fence, ) async { + fence.ensureCurrent(); if (ref.read(relaySessionProvider).status != SessionStatus.connected) { return; } if (subscriptionVersion != _subscriptionVersion) { - await _syncLiveSubscriptions( - ref.read(relayConfigProvider).baseUrl, - _subscriptionVersion, - _desiredLiveChannels, - ); return; } @@ -642,7 +612,12 @@ class ChannelsNotifier extends AsyncNotifier> { ), _handleLiveEvent, ); - if (ref.read(relaySessionProvider).status != SessionStatus.connected || + if (!fence.isCurrent) { + unsubscribe(); + throw const _StaleChannelRefresh(); + } + if (subscriptionVersion != _subscriptionVersion || + ref.read(relaySessionProvider).status != SessionStatus.connected || !_desiredLiveChannelIds.contains(channelId) || ref.read(relayConfigProvider).baseUrl != relayBaseUrl || _subscriptionRelayBaseUrl != relayBaseUrl) { @@ -655,6 +630,8 @@ class ChannelsNotifier extends AsyncNotifier> { continue; } _unsubscribersByChannel[channelId] = unsubscribe; + } on _StaleChannelRefresh { + rethrow; } catch (error) { debugPrint( '[ChannelsNotifier] live subscription failed for $channelId: $error', @@ -676,7 +653,8 @@ class ChannelsNotifier extends AsyncNotifier> { return; } - unawaited(_catchUpUnreadEvents(channels)); + fence.ensureCurrent(); + unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); _backstopTimer?.cancel(); _backstopTimer = Timer.periodic( @@ -685,7 +663,24 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - Future _catchUpUnreadEvents(List channels) async { + /// Backfills unread badges for the channels this refresh just installed. + /// + /// Runs detached from the refresh that starts it, so the lifecycle token + /// captured below is what keeps a response that outlived its refresh from + /// writing unread state into whatever the user is looking at now. Every + /// refresh path starts a catch-up, including the initial load, the ordinary + /// membership refresh a join performs and the reconnect backstop, so the + /// token is unconditional rather than tied to discovery. A retired refresh + /// returns instead of throwing: nothing awaits this future, so a thrown + /// [_StaleChannelRefresh] would only surface as an unhandled error. + /// + /// The request fence and subscription generation are passed from the refresh + /// that installed [channels], preserving request ownership after detachment. + Future _catchUpUnreadEvents( + List channels, + _ChannelRefreshFence fence, + int subscriptionGeneration, + ) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) return; @@ -727,6 +722,10 @@ class ChannelsNotifier extends AsyncNotifier> { filters, operation: 'unread catch-up', ); + // The relay round-trip above is the window Jed's probes park in: a newer + // refresh, a community switch or an identity switch here means every + // write below belongs to a channel list the user has left. + if (_isCatchUpRetired(fence, subscriptionGeneration)) return; for (final event in events) { if (event.pubkey.toLowerCase() == myPk.toLowerCase()) { @@ -734,6 +733,7 @@ class ChannelsNotifier extends AsyncNotifier> { } } + var recorded = false; for (final event in events) { final channelId = event.channelId; if (channelId == null) continue; @@ -754,12 +754,19 @@ class ChannelsNotifier extends AsyncNotifier> { continue; } _recordUnreadEvent(channel, event, myPk); + recorded = true; + } + // Republish only when this catch-up actually changed unread state. A + // batch that recorded nothing has nothing to show, and a failed or + // superseded batch must not repaint another refresh's list: the retired + // check above already returned in that case, and no await separates it + // from here, so a second check would be dead code. + if (recorded) { + state = state.whenData((channels) => List.of(channels)); } } catch (error) { debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); } - - state = state.whenData((channels) => List.of(channels)); } void _handleLiveEvent(NostrEvent event) { @@ -858,31 +865,6 @@ class ChannelsNotifier extends AsyncNotifier> { } } - void _recordUnreadEvent(Channel channel, NostrEvent event, String myPk) { - final isThreadedReply = - event.threadReference.parentId != null && !_isBroadcastReply(event); - final isHighPriority = - channel.isDm || isHighPriorityEvent(event.tags, myPk); - recordObservedUnreadEvent( - _observedUnreadEventsByChannel, - channel.id, - makeObservedUnreadEvent( - id: event.id, - createdAt: event.createdAt, - rootId: _observedUnreadRootId(event), - highPriority: isHighPriority, - channelType: channel.channelType, - isThreadedReply: isThreadedReply, - ), - _unreadCatchUpLimit, - ); - - final current = _latestObservedByChannel[channel.id] ?? 0; - if (event.createdAt > current) { - _latestObservedByChannel[channel.id] = event.createdAt; - } - } - void clearObservedUnreadForChannel(String channelId) { _latestObservedByChannel.remove(channelId); _observedUnreadEventsByChannel.remove(channelId); @@ -908,6 +890,7 @@ class ChannelsNotifier extends AsyncNotifier> { final channels = await _fetch( subscribeLive: sessionState.status == SessionStatus.connected, fetchLastMessage: false, + fetchDirectory: false, ); for (var i = 0; i < channels.length; i++) { final prev = prevLastMessage[channels[i].id]; @@ -916,6 +899,8 @@ class ChannelsNotifier extends AsyncNotifier> { } } state = AsyncData(channels); + } on _StaleChannelRefresh { + return; } catch (error) { debugPrint('[ChannelsNotifier] backstop refresh failed: $error'); } @@ -929,12 +914,69 @@ class ChannelsNotifier extends AsyncNotifier> { // cached channel list with [] or an error. Wait for `build()` to re-run // when the session transitions to connected. if (sessionState.status != SessionStatus.connected) return; - state = await AsyncValue.guard(() => _fetch(subscribeLive: true)); + try { + final channels = await _fetch(subscribeLive: true); + state = AsyncData(channels); + } on _StaleChannelRefresh { + return; + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } + + /// Loads the directory when Browse channels opens after startup or an error. + Future ensureDirectoryLoaded() async { + final directoryState = ref.read(channelDirectoryLoadStatusProvider); + final scope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + if (directoryState.scope == scope && + (directoryState.status == ChannelDirectoryLoadStatus.loading || + directoryState.status == ChannelDirectoryLoadStatus.loaded)) { + return; + } + await retryDirectory(); + } + + /// Retries channel discovery while retaining the current channel list. + Future retryDirectory() async { + final previousChannels = state.value; + final directoryStatus = ref.read( + channelDirectoryLoadStatusProvider.notifier, + ); + final scope = channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), + ); + final directoryState = ref.read(channelDirectoryLoadStatusProvider); + if (directoryState.scope == scope && + directoryState.status == ChannelDirectoryLoadStatus.loading) { + return; + } + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + directoryStatus.markError(scope); + return; + } + try { + state = AsyncData( + await _fetch(subscribeLive: true, fetchDirectory: true), + ); + } on _StaleChannelRefresh { + // A community or identity switch retired this request. Its response + // describes a scope the user has left, so write neither the channel list + // nor the load status; the new scope owns both now. + return; + } catch (error, stackTrace) { + directoryStatus.markError(scope); + state = previousChannels == null + ? AsyncError(error, stackTrace) + : AsyncData(previousChannels); + } } void _clearLiveSubscriptions() { _subscriptionVersion++; - _desiredLiveChannels = const []; _desiredLiveChannelIds = const {}; for (final unsubscribe in _unsubscribersByChannel.values) { unsubscribe(); @@ -949,26 +991,3 @@ class ChannelsNotifier extends AsyncNotifier> { final channelsProvider = AsyncNotifierProvider>( ChannelsNotifier.new, ); - -String? _observedUnreadRootId(NostrEvent event) => - _isBroadcastReply(event) ? null : event.threadReference.rootId; - -bool _isBroadcastReply(NostrEvent event) => event.tags.any( - (tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1', -); - -Set _readRootIdSet(String? raw) { - if (raw == null || raw.isEmpty) return {}; - try { - final decoded = jsonDecode(raw); - if (decoded is! List) return {}; - return { - for (final value in decoded) - if (value is String) value, - }; - } catch (_) { - return {}; - } -} - -String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 563a46c9e8b..514629b1f72 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -644,12 +644,10 @@ class _RecentSearches extends StatelessWidget { class _ChannelsSection extends StatelessWidget { final List channels; final VoidCallback onResultSelected; - const _ChannelsSection({ required this.channels, required this.onResultSelected, }); - @override Widget build(BuildContext context) { return Column( @@ -670,12 +668,14 @@ class _ChannelsSection extends StatelessWidget { key: ValueKey('search-channel-title-${channel.id}'), style: contentListTitleTextStyle, ), - subtitle: Text( - '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', - style: contentListBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), + subtitle: channel.isMember + ? Text( + '${channel.memberCount} member${channel.memberCount == 1 ? '' : 's'}', + style: contentListBodyTextStyle.copyWith( + color: context.colors.onSurfaceVariant, + ), + ) + : null, trailing: !channel.isMember && !channel.isDm ? Container( padding: const EdgeInsets.symmetric( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 9cd955ce52d..68fa3152365 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -24,6 +24,7 @@ import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -1278,8 +1279,8 @@ void main() { } await tester.pumpAndSettle(); - expect(largestHeight, greaterThan(160)); - expect(tester.getSize(surface).height, closeTo(160, 0.01)); + expect(largestHeight, greaterThan(216)); + expect(tester.getSize(surface).height, closeTo(216, 0.01)); final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width; final surfaceRect = tester.getRect(surface); expect(surfaceRect.left, closeTo(20, 0.01)); @@ -1292,15 +1293,23 @@ void main() { const Key('quick-action-create-channel-card'), ); final dmCard = find.byKey(const Key('quick-action-new-dm-card')); + final browseCard = find.byKey( + const Key('quick-action-browse-channels-card'), + ); final createRect = tester.getRect(createCard); final dmRect = tester.getRect(dmCard); + final browseRect = tester.getRect(browseCard); expect(createRect.left - menuRect.left, closeTo(8, 0.01)); expect(menuRect.right - createRect.right, closeTo(8, 0.01)); expect(dmRect.left - menuRect.left, closeTo(8, 0.01)); expect(menuRect.right - dmRect.right, closeTo(8, 0.01)); + expect(browseRect.left - menuRect.left, closeTo(8, 0.01)); + expect(menuRect.right - browseRect.right, closeTo(8, 0.01)); expect(dmRect.top - createRect.bottom, closeTo(8, 0.01)); + expect(browseRect.top - dmRect.bottom, closeTo(8, 0.01)); expect(dmRect.width, createRect.width); + expect(browseRect.width, createRect.width); expect(dmRect.width, closeTo(menuRect.width - 16, 0.01)); final cardScheme = Theme.of(tester.element(createCard)).colorScheme; @@ -1314,8 +1323,12 @@ void main() { final dmMaterial = tester.widget( find.descendant(of: dmCard, matching: find.byType(Material)).first, ); + final browseMaterial = tester.widget( + find.descendant(of: browseCard, matching: find.byType(Material)).first, + ); expect(createMaterial.color, expectedCardColor); expect(dmMaterial.color, expectedCardColor); + expect(browseMaterial.color, expectedCardColor); expect( (createMaterial.borderRadius as BorderRadius).topLeft.x, closeTo(12, 0.01), @@ -1334,9 +1347,297 @@ void main() { tester.widget(find.text('New direct message')).style?.fontSize, 16, ); + expect( + tester.widget(find.text('Browse channels')).style?.fontSize, + 16, + ); expect(find.text('Message one or more people'), findsNothing); }); + testWidgets('browse action lists only channels eligible to join', ( + tester, + ) async { + final channels = [ + ...testChannels, + Channel( + id: 'open-to-join', + name: 'announcements', + channelType: 'stream', + visibility: 'open', + description: 'Community announcements', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 8, + ), + Channel( + id: 'private-channel', + name: 'private-planning', + channelType: 'stream', + visibility: 'private', + description: 'Private planning', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 4, + ), + Channel( + id: 'archived-channel', + name: 'old-announcements', + channelType: 'stream', + visibility: 'open', + description: 'Archived announcements', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 3, + archivedAt: DateTime(2025, 1, 2), + ), + Channel( + id: 'unjoined-dm', + name: 'Hidden DM', + channelType: 'dm', + visibility: 'open', + description: 'Direct message', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 2, + ), + ]; + + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-open-to-join')), + findsOneWidget, + ); + expect(find.byKey(const Key('browse-channel-1')), findsNothing); + expect( + find.byKey(const Key('browse-channel-private-channel')), + findsNothing, + ); + expect( + find.byKey(const Key('browse-channel-archived-channel')), + findsNothing, + ); + expect(find.byKey(const Key('browse-channel-unjoined-dm')), findsNothing); + }); + + testWidgets('browse action explains when no channels are discoverable', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect(find.text('No open channels available to join.'), findsOneWidget); + }); + + testWidgets('browse action retries an initial directory request problem', ( + tester, + ) async { + final joinable = Channel( + id: 'retry-discovery', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 0, + ); + late _RetryingDirectoryNotifier notifier; + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith( + () => notifier = _RetryingDirectoryNotifier( + initialChannels: testChannels, + retriedChannels: [...testChannels, joinable], + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Couldn’t load open channels.'), findsOneWidget); + expect(find.text('No open channels available to join.'), findsNothing); + expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget); + + await tester.tap(find.byKey(const Key('browse-channels-retry'))); + await tester.pumpAndSettle(); + + expect(notifier.retryCount, 1); + expect(find.text('Couldn’t load open channels.'), findsNothing); + expect( + find.byKey(const Key('browse-channel-retry-discovery')), + findsOneWidget, + ); + }); + + testWidgets('browse action exposes retry when refresh supersedes loading', ( + tester, + ) async { + final joinable = Channel( + id: 'superseded-directory', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 0, + ); + late _SupersededDirectoryNotifier notifier; + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith( + () => notifier = _SupersededDirectoryNotifier( + initialChannels: testChannels, + retriedChannels: [...testChannels, joinable], + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(BuzzLoadingIndicator), findsOneWidget); + + notifier.supersedeLoadingDirectory(); + await tester.pumpAndSettle(); + + expect(find.byType(BuzzLoadingIndicator), findsNothing); + expect(find.text('Couldn’t load open channels.'), findsOneWidget); + expect(find.byKey(const Key('browse-channels-retry')), findsOneWidget); + + await tester.tap(find.byKey(const Key('browse-channels-retry'))); + await tester.pumpAndSettle(); + + expect(notifier.retryCount, 1); + expect( + find.byKey(const Key('browse-channel-superseded-directory')), + findsOneWidget, + ); + }); + + testWidgets('browse action scrolls and joins an offscreen channel', ( + tester, + ) async { + final channels = List.generate( + 500, + (index) => Channel( + id: 'directory-$index', + name: 'channel-${index.toString().padLeft(3, '0')}', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 0, + ), + ); + late _RecordingChannelActions actions; + await tester.pumpWidget( + buildTestable( + disableAnimations: true, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(channels)), + channelActionsProvider.overrideWith( + (ref) => actions = _RecordingChannelActions(ref), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Create or start conversation')); + await tester.pump(); + await tester.tap( + find.byKey(const Key('quick-action-browse-channels-card')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-directory-0')), + findsAtLeast(1), + ); + expect(find.byKey(const Key('browse-channel-directory-499')), findsNothing); + + final sheet = find.byType(BottomSheet).last; + final scrollable = find + .descendant(of: sheet, matching: find.byType(Scrollable)) + .last; + expect( + tester.state(scrollable).position.maxScrollExtent, + greaterThan(0), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('browse-channel-directory-499')), + 500, + scrollable: scrollable, + maxScrolls: 100, + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('browse-channel-directory-499')), + findsOneWidget, + ); + expect(find.byKey(const Key('browse-channel-directory-0')), findsNothing); + + await tester.tap( + find.byKey(const Key('browse-channel-join-directory-499')), + ); + await tester.pumpAndSettle(); + + expect(actions.joinedChannelIds, ['directory-499']); + expect(find.byType(BottomSheet), findsNothing); + }); + testWidgets('create channel sheet lists type and visibility radio options', ( tester, ) async { @@ -1728,15 +2029,37 @@ void main() { expect(find.text('archived-stream'), findsNothing); }); - testWidgets('shows empty state when no channels', (tester) async { + testWidgets('empty state does not preview unjoined channels', (tester) async { + final discoveredChannel = Channel( + id: 'discovered-channel', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Get help from the community', + createdBy: 'abc', + createdAt: DateTime(2025), + memberCount: 7, + ); await tester.pumpWidget( buildTestable( - overrides: [channelsProvider.overrideWith(() => _FakeNotifier([]))], + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier([discoveredChannel]), + ), + ], ), ); await tester.pumpAndSettle(); expect(find.text('No conversations yet'), findsOneWidget); + expect( + find.text('Join an open channel to start a conversation.'), + findsNothing, + ); + expect( + find.byKey(const Key('browse-channel-discovered-channel')), + findsNothing, + ); }); testWidgets('shows error view with retry button', (tester) async { @@ -1997,6 +2320,13 @@ class _FakeNotifier extends ChannelsNotifier { @override Future> build() async => _channels; + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } + @override Map get latestObservedByChannel => { for (final entry in _observedEventsByChannel.entries) @@ -2011,6 +2341,105 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +class _RetryingDirectoryNotifier extends ChannelsNotifier { + _RetryingDirectoryNotifier({ + required this.initialChannels, + required this.retriedChannels, + }); + + final List initialChannels; + final List retriedChannels; + int retryCount = 0; + + @override + Future> build() async => initialChannels; + + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markError(_activeDirectoryScope(ref)); + } + + @override + Future retryDirectory() async { + retryCount++; + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoading(_activeDirectoryScope(ref)); + await Future.delayed(Duration.zero); + state = AsyncData(retriedChannels); + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } +} + +class _SupersededDirectoryNotifier extends ChannelsNotifier { + _SupersededDirectoryNotifier({ + required this.initialChannels, + required this.retriedChannels, + }); + + final List initialChannels; + final List retriedChannels; + final _directoryCompletion = Completer(); + int retryCount = 0; + + @override + Future> build() async => initialChannels; + + @override + Future ensureDirectoryLoaded() async { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoading(_activeDirectoryScope(ref)); + await _directoryCompletion.future; + } + + /// Mirrors an ordinary refresh invalidating the active directory request. + void supersedeLoadingDirectory() { + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markError(_activeDirectoryScope(ref)); + _directoryCompletion.complete(); + } + + @override + Future retryDirectory() async { + retryCount++; + state = AsyncData(retriedChannels); + ref + .read(channelDirectoryLoadStatusProvider.notifier) + .markLoaded(_activeDirectoryScope(ref)); + } +} + +String _activeDirectoryScope(Ref ref) => channelDirectoryScope( + ref.read(relayConfigProvider).baseUrl, + ref.read(myPubkeyProvider), +); + +class _RecordingChannelActions extends ChannelActions { + _RecordingChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'self', + ); + + final List joinedChannelIds = []; + + @override + Future joinChannel(String channelId) async { + joinedChannelIds.add(channelId); + } +} + class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { _FakeChannelSectionsNotifier(this._store); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index fa5d3a0e118..ea6c38cdfd1 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -9,10 +9,11 @@ import 'package:buzz/shared/relay/relay.dart'; /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// -/// The provider performs a two-step WS query: -/// 1. kind:39002 memberships tagged `#p:` +/// The provider loads membership-backed channels first: +/// 1. paginated kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids -/// then layers per-channel live subscriptions on the `#h` tag. +/// then layers per-channel live subscriptions on the `#h` tag. Browse channels +/// separately triggers paginated kind:39000 open-channel discovery. /// /// Tests stub out the relay session by overriding [relaySessionProvider] with /// a [_FakeRelaySession] that returns canned events from [fetchHistory] and @@ -21,6 +22,1496 @@ import 'package:buzz/shared/relay/relay.dart'; void main() { const myPk = 'me'; + test( + 'discovers open channels for a user with zero channel memberships', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'staff', visibility: 'private'), + _meta(id: _channelD, name: 'DM', channelType: 'dm'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + expect(session.directoryQueryFilters, isEmpty); + + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels, hasLength(1)); + expect(channels.single.id, _channelA); + expect(channels.single.isMember, isFalse); + expect(session.subscribeFilters, isEmpty); + expect(session.directoryQueryFilters, isNotEmpty); + }, + ); + + test('paginates channel discovery with a composite cursor', () async { + final firstPage = List.generate( + 500, + (index) => _meta( + id: '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000', + name: 'channel-$index', + createdAt: 10, + ), + ); + final finalChannel = _meta( + id: '99999999-9999-4999-8999-999999999999', + name: 'last-page', + createdAt: 9, + ); + final session = _FakeRelaySession( + memberships: const [], + metadataPages: [ + firstPage, + [finalChannel], + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels, hasLength(501)); + final directoryFilters = session.directoryQueryFilters; + expect(directoryFilters, hasLength(3)); + expect(directoryFilters.first.until, isNull); + expect(directoryFilters.first.extensions, isEmpty); + expect(directoryFilters[1].until, firstPage.last.createdAt); + expect(directoryFilters[1].extensions['before_id'], firstPage.last.id); + expect(directoryFilters.last.until, finalChannel.createdAt); + expect(directoryFilters.last.extensions['before_id'], finalChannel.id); + }); + + test( + 'paginates memberships when the relay caps responses below limit', + () async { + final firstPage = List.generate( + 100, + (index) => _membership( + '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000', + myPk, + ), + ); + final finalChannelId = '99999999-9999-4999-8999-999999999999'; + final finalMembership = _membership(finalChannelId, myPk); + final session = _FakeRelaySession( + memberships: const [], + membershipPages: [ + firstPage, + [finalMembership], + ], + metadata: [_meta(id: finalChannelId, name: 'last-membership')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect(channels.single.id, finalChannelId); + expect(channels.single.isMember, isTrue); + expect(session.membershipQueryFilters, hasLength(3)); + expect(session.membershipQueryFilters.first.until, isNull); + expect(session.membershipQueryFilters.first.extensions, isEmpty); + expect(session.membershipQueryFilters[1].until, firstPage.last.createdAt); + expect( + session.membershipQueryFilters[1].extensions['before_id'], + firstPage.last.id, + ); + expect( + session.membershipQueryFilters.last.extensions['before_id'], + finalMembership.id, + ); + }, + ); + + test('stops membership pagination when the relay repeats a page', () async { + final repeatedPage = List.generate( + 500, + (index) => _membership( + '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000', + myPk, + ), + ); + final session = _FakeRelaySession( + memberships: const [], + membershipPages: [repeatedPage], + repeatLastMembershipPage: true, + maxMembershipPageRequests: 2, + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + expect(session.membershipRequestCount, 2); + }); + + test('stops channel discovery when the relay repeats a full page', () async { + final repeatedPage = List.generate( + 500, + (index) => _meta( + id: 'repeated-channel-$index', + name: 'repeated-$index', + createdAt: 10, + ), + ); + final session = _FakeRelaySession( + memberships: const [], + metadataPages: [repeatedPage], + repeatLastMetadataPage: true, + maxMetadataPageRequests: 2, + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels, hasLength(500)); + expect(session.metadataPageRequestCount, 2); + }); + + test( + 'directory page-cap failure is distinct from an empty directory', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadataPageBuilder: (pageIndex) => List.generate( + 500, + (eventIndex) => _meta( + id: 'channel-$pageIndex-$eventIndex', + name: 'channel-$pageIndex-$eventIndex', + createdAt: 1000 - pageIndex, + ), + ), + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + await container.read(channelsProvider.notifier).retryDirectory(); + expect(session.metadataPageRequestCount, 100); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + }, + ); + + test( + 'directory failure retains discovery while membership refreshes', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).map((channel) => channel.id), + [_channelA], + ); + await container.read(channelsProvider.notifier).retryDirectory(); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + unorderedEquals([_channelA, _channelB]), + ); + + session.memberships = [ + _membership(_channelA, myPk), + _membership(_channelD, myPk), + ]; + session.metadata = [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + _meta(id: _channelD, name: 'newly joined'), + ]; + session.directoryFailures = 1; + + await container.read(channelsProvider.notifier).retryDirectory(); + + final refreshed = container.read(channelsProvider).requireValue; + expect( + refreshed.map((channel) => channel.id), + unorderedEquals([_channelA, _channelB, _channelD]), + ); + expect( + refreshed.firstWhere((channel) => channel.id == _channelD).isMember, + isTrue, + ); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + + await container.read(channelsProvider.notifier).retryDirectory(); + + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + }, + ); + + test('directory retry failure retains the current channel list', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final initial = await container.read(channelsProvider.future); + session.membershipFailures = 1; + + await container.read(channelsProvider.notifier).retryDirectory(); + + expect(container.read(channelsProvider).requireValue, initial); + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + }); + + test( + 'ordinary refresh settles a superseded directory load for retry', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).map((channel) => channel.id), + [_channelA], + ); + + session.pauseNextDirectoryQuery(); + final directory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + // A foreground, reconnect, pull-to-refresh, or membership update can + // start an ordinary refresh while Browse is still loading discovery. + await container.read(channelsProvider.notifier).refresh(); + + session.resumePausedDirectoryQuery(); + await directory; + await _settle(); + + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.error, + ); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelA], + ); + + await container.read(channelsProvider.notifier).retryDirectory(); + + expect( + container.read(channelDirectoryLoadStatusProvider).status, + ChannelDirectoryLoadStatus.loaded, + ); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + unorderedEquals([_channelA, _channelB]), + ); + }, + ); + + test( + 'community switch discards a stale directory success from the old relay', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + // Switch communities while community A's directory response is paused. + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'community switch discards a stale directory failure from the old relay', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + // Community A's directory request fails after the switch. + session.directoryFailures = 1; + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, contains(_channelB)); + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'identity switch discards a stale directory success from the old identity', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + // Switch signing identity while the first identity's response is paused. + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + }, + ); + + test( + 'identity switch discards a stale directory failure from the old identity', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.pauseNextDirectoryQuery(); + final staleDirectory = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextDirectoryQueryStarted; + + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + + session.directoryFailures = 1; + session.resumePausedDirectoryQuery(); + await staleDirectory; + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + contains(_channelB), + ); + }, + ); + + test( + 'community switch after directory success discards the late refresh', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // Community A now has a joined channel, so the directory-triggered + // refresh reaches the live-subscribe step and parks there. That await is + // AFTER the loader's own directory fence, which is the window this arm + // covers: directory success, then retirement, then settlement. + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextSubscribe(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextSubscribeStarted; + + // Record every list emitted from the switch onward. The live-subscription + // queue is serialized, so community B's own subscribe waits behind the + // parked one. That makes the observable defect an emission of community + // A's channel into the current scope, not just a wrong final state. + final emitted = >[]; + container.listen(channelsProvider, (previous, next) { + final value = next.value; + if (value != null) { + emitted.add(value.map((channel) => channel.id).toList()); + } + }); + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + + session.resumePausedSubscribe(); + await staleRefresh; + await _settle(); + + expect( + emitted.where((ids) => ids.contains(_channelA)), + isEmpty, + reason: 'community A channel emitted into community B scope: $emitted', + ); + // The retired refresh must not leave a subscription on the old channel. + expect(session.activeChannels, isNot(contains(_channelA))); + // Nor may it claim the new scope's directory status as its own. + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'community switch after directory success discards a late failure', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // The directory query succeeds, then the refresh parks on the member-count + // query and fails there after the switch. A retired failure must not + // overwrite the new scope's list or push it into an error state. + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextMemberCountQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextMemberCountQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + session.failClaimedMemberCountQuery = true; + session.resumePausedMemberCountQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + final ids = current.requireValue.map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + // A retired failure must not claim the new scope's directory status. + expect( + container.read(channelDirectoryLoadStatusProvider).scope, + isNot( + channelDirectoryScope( + 'https://community-b.example', + container.read(myPubkeyProvider), + ), + ), + ); + }, + ); + + test( + 'identity switch after directory success discards the late refresh', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // Directory success, then the refresh parks on the hidden-DM query while + // the signing identity changes and the new identity finishes its rebuild. + session.pauseNextHiddenDmQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextHiddenDmQueryStarted; + + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + + session.resumePausedHiddenDmQuery(); + await staleRefresh; + await _settle(); + + final ids = container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + expect(session.activeChannels, isNot(contains(_channelA))); + }, + ); + + test( + 'identity switch after directory success discards a late failure', + () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextMemberCountQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextMemberCountQueryStarted; + + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + + session.failClaimedMemberCountQuery = true; + session.resumePausedMemberCountQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + final ids = current.requireValue.map((channel) => channel.id); + expect(ids, isNot(contains(_channelA))); + expect(ids, contains(_channelB)); + }, + ); + + test('a newer ordinary refresh owns the installed membership list', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.pauseNextHiddenDmQuery(); + final olderRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextHiddenDmQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + + session.resumePausedHiddenDmQuery(); + await olderRefresh; + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelB], + reason: 'an older ordinary refresh overwrote the newer membership list', + ); + expect(session.activeChannels, {_channelB}); + }); + + test( + 'a newer refresh owns the list over an older reconnect backstop', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.pauseNextHiddenDmQuery(); + session.setStatus(SessionStatus.reconnecting); + session.setStatus(SessionStatus.connected); + await session.nextHiddenDmQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + + session.resumePausedHiddenDmQuery(); + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelB], + reason: + 'an older reconnect backstop overwrote the newer membership list', + ); + expect(session.activeChannels, {_channelB}); + }, + ); + + test('a stale request\'s Huddle leg writes no member snapshot', () async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + huddleStarts: [ + NostrEvent( + id: 'huddle-start', + pubkey: myPk, + createdAt: now, + kind: EventKind.huddleStarted, + tags: const [ + ['h', _channelA], + ], + content: '{"ephemeral_channel_id":"$_channelB"}', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final notifier = container.read(channelsProvider.notifier); + + // Park an older refresh on its Huddle-start query. Both of its membership + // fetches have already landed, so channel A is the list it is carrying. + session.pauseNextHuddleStartQuery(); + final olderRefresh = notifier.refresh(); + await session.nextHuddleStartQueryStarted; + + // A newer refresh completes on a disjoint membership set. + session.memberships = [ + _membership(_channelB, myPk, additionalPubkey: _otherPk), + ]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await notifier.refresh(); + + // Release the older request. Its member-snapshot write sits AFTER the + // Huddle leg, so if that leg is unfenced the stale snapshot lands. + session.resumePausedHuddleStartQuery(); + await olderRefresh; + await _settle(); + + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + [_channelB], + reason: 'a stale request installed its own channel list', + ); + expect( + notifier.cachedMembersForChannel(_channelA), + isEmpty, + reason: + 'a stale request wrote a member snapshot past the Huddle leg fence', + ); + expect( + notifier.cachedMembersForChannel(_channelB).map((m) => m.pubkey), + containsAll([myPk, _otherPk]), + reason: 'the newer request\'s member snapshot was clobbered', + ); + }); + + test('community switch discards a parked unread catch-up', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + recentMessages: const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + // The directory query succeeds and the refresh completes, but the unread + // catch-up it kicks off stays parked across the community switch. + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state landed in community B: ' + '${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread events landed in community B: ' + '${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('identity switch discards a parked unread catch-up', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + recentMessages: const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention for the first identity', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'first identity unread state landed on the second identity: ' + '${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'first identity unread events landed on the second identity: ' + '${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('community switch discards a parked catch-up failure', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'community-a-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + // Let community B's own refresh and its own catch-up settle first, so the + // emissions counted below can only come from the parked community A work. + await _settle(); + + // A retired catch-up that fails must publish nothing: the trailing `state` + // write belongs to whichever community is active now. + final staleEmissions = []; + final subscription = container.listen( + channelsProvider, + (_, next) => staleEmissions.add(next.requireValue.length), + fireImmediately: false, + ); + addTearDown(subscription.close); + + session.failClaimedUnreadCatchUpQuery = true; + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + expect(current.requireValue.map((channel) => channel.id), [_channelB]); + expect( + staleEmissions, + isEmpty, + reason: + 'a retired catch-up failure republished community B provider state: ' + '$staleEmissions', + ); + }); + + test('identity switch discards a parked catch-up failure', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [_meta(id: _channelA, name: 'first-identity-open')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + session.memberships = [_membership(_channelA, myPk)]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container + .read(channelsProvider.notifier) + .retryDirectory(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + await _settle(); + + final staleEmissions = []; + final subscription = container.listen( + channelsProvider, + (_, next) => staleEmissions.add(next.requireValue.length), + fireImmediately: false, + ); + addTearDown(subscription.close); + + session.failClaimedUnreadCatchUpQuery = true; + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final current = container.read(channelsProvider); + expect(current.hasError, isFalse); + expect(current.requireValue.map((channel) => channel.id), [_channelB]); + expect( + staleEmissions, + isEmpty, + reason: + 'a retired catch-up failure republished the second identity provider ' + 'state: $staleEmissions', + ); + }); + + test('an ordinary membership refresh retires an older catch-up', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + // Same relay, same identity. Only the refresh generation separates the + // parked catch-up from the membership list the user is looking at, which + // is the window the post-join membership refresh opens. + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in channel A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded ordinary refresh wrote unread state for a channel the ' + 'user has left: ${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded ordinary refresh wrote unread events for a channel the ' + 'user has left: ${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('a newer refresh retires a parked backstop catch-up', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in channel A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + // Reconnecting runs the backstop refresh, which never fetches the + // directory, so this is the path that carried no lifecycle token at all. + session.setStatus(SessionStatus.reconnecting); + session.setStatus(SessionStatus.connected); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'joined-b')]; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded backstop refresh wrote unread state for a channel the ' + 'user has left: ${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'a superseded backstop refresh wrote unread events for a channel the ' + 'user has left: ${notifier.observedUnreadEventsByChannel}', + ); + }); + + test( + 'community switch discards a parked catch-up from an ordinary refresh', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state landed in community B: ' + '${notifier.latestObservedByChannel}', + ); + }, + ); + + test( + 'identity switch discards a parked catch-up from an ordinary refresh', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'first-identity-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention for the first identity', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextUnreadCatchUpQueryStarted; + + session.memberships = [_membership(_channelB, _otherPk)]; + session.metadata = [_meta(id: _channelB, name: 'second-identity-joined')]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + await container.read(channelsProvider.future); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'first identity unread state landed on the second identity: ' + '${notifier.latestObservedByChannel}', + ); + }, + ); + + test('a disconnected community switch retires a parked catch-up', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + session.recentMessages = const [ + NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ]; + session.pauseNextUnreadCatchUpQuery(); + final started = session.nextUnreadCatchUpQueryStarted; + final staleRefresh = container.read(channelsProvider.notifier).refresh(); + await started; + + // Switch community while the session is down, so the new scope runs no + // refresh of its own. This is the arm that shows the single generation + // counter is sufficient: the rebuild's disposal bumps it even though no + // new refresh does. + session.setStatus(SessionStatus.disconnected); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await _settle(); + + session.resumePausedUnreadCatchUpQuery(); + await staleRefresh; + await _settle(); + + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state survived a disconnected switch: ' + '${notifier.latestObservedByChannel}', + ); + }); + + test('a catch-up that records nothing does not repaint the list', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'joined-a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + // No unread events to record, so the catch-up has nothing to publish. + // The catch-up is detached, so its query starts inside the refresh: hold + // the started future before awaiting the refresh or the one-shot slot is + // already claimed and reset by the time we ask for it. + session.pauseNextUnreadCatchUpQuery(); + final started = session.nextUnreadCatchUpQueryStarted; + await container.read(channelsProvider.notifier).refresh(); + await started; + await _settle(); + + final emissions = []; + final subscription = container.listen( + channelsProvider, + (_, next) => emissions.add(next.requireValue.length), + fireImmediately: false, + ); + addTearDown(subscription.close); + + session.resumePausedUnreadCatchUpQuery(); + await _settle(); + + expect( + emissions, + isEmpty, + reason: 'an empty unread catch-up republished provider state: $emissions', + ); + }); + + test('community switch drops unread state recorded before it', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'community-a-general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + + // A live mention in community A records unread state the badges read. + session.emit( + const NostrEvent( + id: 'mention-in-a', + pubkey: 'alice', + createdAt: 50, + kind: 9, + tags: [ + ['h', _channelA], + ['p', myPk], + ], + content: 'direct mention in community A', + sig: 'sig', + ), + ); + await _settle(); + final notifier = container.read(channelsProvider.notifier); + expect( + notifier.latestObservedByChannel, + contains(_channelA), + reason: 'precondition: community A unread state was never recorded', + ); + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'community-b-general')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); + await _settle(); + + expect( + notifier.latestObservedByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread state survived into community B: ' + '${notifier.latestObservedByChannel}', + ); + expect( + notifier.observedUnreadEventsByChannel, + isNot(contains(_channelA)), + reason: + 'community A unread events survived into community B: ' + '${notifier.observedUnreadEventsByChannel}', + ); + }); + + test('reconnect backstop does not refetch the channel directory', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final initialDirectoryRequests = session.metadataPageRequestCount; + final initialMembershipRequests = session.membershipRequestCount; + + session.setStatus(SessionStatus.reconnecting); + session.setStatus(SessionStatus.connected); + await _waitUntil( + () => session.membershipRequestCount > initialMembershipRequests, + ); + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } + + expect(session.metadataPageRequestCount, initialDirectoryRequests); + }); + + test('membership refresh does not refetch a loaded directory', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'discoverable'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await container.read(channelsProvider.notifier).retryDirectory(); + final directoryRequestCount = session.metadataPageRequestCount; + + await container.read(channelsProvider.notifier).refresh(); + + expect(session.metadataPageRequestCount, directoryRequestCount); + expect( + container + .read(channelsProvider) + .requireValue + .map((channel) => channel.id), + unorderedEquals([_channelA, _channelB]), + ); + }); + + test('deduplicates joined channels from directory discovery', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read( + channelsProvider.future, + )).map((channel) => channel.id), + [_channelA], + ); + await container.read(channelsProvider.notifier).retryDirectory(); + final channels = container.read(channelsProvider).requireValue; + + expect(channels.map((channel) => channel.id), [_channelA, _channelB]); + expect(channels.first.isMember, isTrue); + expect(channels.last.isMember, isFalse); + expect(session.subscribeFilters, hasLength(1)); + }); + test( 'seeds members from the channel-list snapshot during reconnect', () async { @@ -766,13 +2257,17 @@ void main() { await container.read(channelsProvider.future); - // Two history fetches for channel loading, plus one per non-DM channel - // for high-priority event backfill. - expect(session.historyFilters.length, greaterThanOrEqualTo(2)); - expect(session.historyFilters[0].kinds, [39002]); - expect(session.historyFilters[0].tags['#p'], [myPk]); - expect(session.historyFilters[1].kinds, [39000]); - expect(session.historyFilters[1].tags['#d'], [_channelA]); + expect(session.membershipQueryFilters, isNotEmpty); + expect(session.membershipQueryFilters.first.kinds, [39002]); + expect(session.membershipQueryFilters.first.tags['#p'], [myPk]); + expect( + session.historyFilters.any( + (filter) => + filter.kinds.contains(39000) && + filter.tags['#d']?.contains(_channelA) == true, + ), + isTrue, + ); // And one live subscription on the resulting channel. expect(session.subscribeFilters, hasLength(1)); @@ -782,6 +2277,7 @@ void main() { const _channelA = '11111111-1111-4111-8111-111111111111'; const _channelB = '22222222-2222-4222-8222-222222222222'; const _channelD = '44444444-4444-4444-8444-444444444444'; +const _otherPk = 'someone-else'; /// Build a kind:39002 membership event tagged with the channel id and member. NostrEvent _membership( @@ -824,9 +2320,9 @@ NostrEvent _meta({ required String id, required String name, String channelType = 'stream', + String visibility = 'open', int createdAt = 1, int? ttlSeconds, - String visibility = 'open', bool archived = false, }) => NostrEvent( id: 'meta-$id', @@ -851,11 +2347,32 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) { overrides: [ appLifecycleProvider.overrideWith(() => _FakeAppLifecycleNotifier()), relaySessionProvider.overrideWith(() => session), - myPubkeyProvider.overrideWithValue('me'), + // Route the pubkey through a mutable notifier so tests can switch the + // signing identity mid-flight the way an account change does at runtime. + myPubkeyProvider.overrideWith((ref) => ref.watch(_testPubkeyProvider)), ], ); } +/// Mutable stand-in for the signing identity derived from the active community. +class _TestPubkeyNotifier extends Notifier { + @override + String? build() => 'me'; + + void set(String? pubkey) => state = pubkey; +} + +final _testPubkeyProvider = NotifierProvider<_TestPubkeyNotifier, String?>( + _TestPubkeyNotifier.new, +); + +/// Drains pending microtasks so provider rebuilds and awaited writes land. +Future _settle() async { + for (var i = 0; i < 20; i++) { + await Future.delayed(Duration.zero); + } +} + Future _waitUntil(bool Function() predicate) async { for (var i = 0; i < 100; i++) { if (predicate()) return; @@ -864,12 +2381,19 @@ Future _waitUntil(bool Function() predicate) async { fail('Timed out waiting for asynchronous provider work'); } -/// Fake [RelaySessionNotifier] that returns canned events from [fetchHistory] -/// and records subscribe calls. +/// Fake [RelaySessionNotifier] that returns canned query results and records +/// subscriptions. class _FakeRelaySession extends RelaySessionNotifier { _FakeRelaySession({ required this.memberships, - required this.metadata, + this.membershipPages, + this.repeatLastMembershipPage = false, + this.maxMembershipPageRequests, + this.metadata = const [], + this.metadataPages, + this.metadataPageBuilder, + this.repeatLastMetadataPage = false, + this.maxMetadataPageRequests, this.hiddenDmEvents = const [], this.huddleStarts = const [], this.recentMessages = const [], @@ -877,19 +2401,47 @@ class _FakeRelaySession extends RelaySessionNotifier { }); List memberships; + final List>? membershipPages; + final bool repeatLastMembershipPage; + final int? maxMembershipPageRequests; List metadata; + final List>? metadataPages; + final List Function(int pageIndex)? metadataPageBuilder; + final bool repeatLastMetadataPage; + final int? maxMetadataPageRequests; final List hiddenDmEvents; final List huddleStarts; - final List recentMessages; + List recentMessages; int membershipFailures; + int directoryFailures = 0; + bool failClaimedMemberCountQuery = false; + bool failClaimedUnreadCatchUpQuery = false; + int membershipRequestCount = 0; + int metadataPageRequestCount = 0; final List historyFilters = []; final List> queryBatches = []; + final List directoryQueryFilters = []; + final List membershipQueryFilters = []; final List subscribeFilters = []; final Map _subscriptions = {}; int _nextSubscriptionKey = 0; Completer? _pausedSubscribe; Completer? _subscribeStarted; + Completer? _pausedDirectory; + Completer? _directoryStarted; + Completer? _pausedHiddenDm; + Completer? _hiddenDmStarted; + Completer? _claimedHiddenDm; + Completer? _pausedMemberCount; + Completer? _memberCountStarted; + Completer? _claimedMemberCount; + Completer? _pausedHuddleStarts; + Completer? _huddleStartsStarted; + Completer? _claimedHuddleStarts; + Completer? _pausedUnreadCatchUp; + Completer? _unreadCatchUpStarted; + Completer? _claimedUnreadCatchUp; int unsubscribeCount = 0; int totalSubscribeCount = 0; @@ -921,6 +2473,148 @@ class _FakeRelaySession extends RelaySessionNotifier { paused.complete(); } + /// Holds the next directory query open so a community or identity switch can + /// be interleaved between the request and its response. + void pauseNextDirectoryQuery() { + if (_pausedDirectory != null) { + throw StateError('A directory query is already paused'); + } + _pausedDirectory = Completer(); + _directoryStarted = Completer(); + } + + /// Completes once the paused directory query has been requested. + Future get nextDirectoryQueryStarted async { + final started = _directoryStarted; + if (started == null) { + throw StateError('No directory query is pending'); + } + await started.future; + } + + /// Releases the paused directory query so its response lands. + void resumePausedDirectoryQuery() { + final paused = _pausedDirectory; + if (paused == null) throw StateError('No directory query is paused'); + paused.complete(); + } + + /// Holds the next hidden-DM query open, one shot only. + /// + /// One shot matters: the refresh that follows the switch issues its own + /// hidden-DM query, and it must be able to finish while the earlier scope's + /// query is still parked. That is the window Jed's second probe describes. + void pauseNextHiddenDmQuery() { + if (_pausedHiddenDm != null) { + throw StateError('A hidden-DM query is already paused'); + } + _pausedHiddenDm = Completer(); + _hiddenDmStarted = Completer(); + } + + /// Completes once the parked hidden-DM query has been requested. + Future get nextHiddenDmQueryStarted async { + final started = _hiddenDmStarted; + if (started == null) { + throw StateError('No hidden-DM query is pending'); + } + await started.future; + } + + /// Releases the parked hidden-DM query so its response lands. + void resumePausedHiddenDmQuery() { + final paused = _claimedHiddenDm ?? _pausedHiddenDm; + if (paused == null) throw StateError('No hidden-DM query is paused'); + paused.complete(); + } + + /// Holds the next Huddle-start query open, one shot only. + /// + /// Parks the older refresh AFTER both membership fetches have landed, so the + /// only guard left between the park and the member-snapshot write is the + /// fence on this leg. + void pauseNextHuddleStartQuery() { + if (_pausedHuddleStarts != null) { + throw StateError('A Huddle-start query is already paused'); + } + _pausedHuddleStarts = Completer(); + _huddleStartsStarted = Completer(); + } + + /// Completes once the parked Huddle-start query has been requested. + Future get nextHuddleStartQueryStarted async { + final started = _huddleStartsStarted; + if (started == null) { + throw StateError('No Huddle-start query is pending'); + } + await started.future; + } + + /// Releases the parked Huddle-start query so its response lands. + void resumePausedHuddleStartQuery() { + final paused = _claimedHuddleStarts ?? _pausedHuddleStarts; + if (paused == null) throw StateError('No Huddle-start query is paused'); + _claimedHuddleStarts = null; + _pausedHuddleStarts = null; + paused.complete(); + } + + /// Holds the next member-count query open, one shot only. + void pauseNextMemberCountQuery() { + if (_pausedMemberCount != null) { + throw StateError('A member-count query is already paused'); + } + _pausedMemberCount = Completer(); + _memberCountStarted = Completer(); + } + + /// Completes once the parked member-count query has been requested. + Future get nextMemberCountQueryStarted async { + final started = _memberCountStarted; + if (started == null) { + throw StateError('No member-count query is pending'); + } + await started.future; + } + + /// Releases the parked member-count query so its response lands. + void resumePausedMemberCountQuery() { + final paused = _claimedMemberCount ?? _pausedMemberCount; + if (paused == null) throw StateError('No member-count query is paused'); + paused.complete(); + } + + /// Holds the next unread catch-up batch open, one shot only. + /// + /// The catch-up runs detached from the refresh that starts it, so this is the + /// window where a community or identity switch can land between the request + /// and the writes its response drives. + void pauseNextUnreadCatchUpQuery() { + if (_pausedUnreadCatchUp != null) { + throw StateError('An unread catch-up query is already paused'); + } + _pausedUnreadCatchUp = Completer(); + _unreadCatchUpStarted = Completer(); + } + + /// Completes once the parked unread catch-up batch has been requested. + Future get nextUnreadCatchUpQueryStarted async { + final started = _unreadCatchUpStarted; + if (started == null) { + throw StateError('No unread catch-up query is pending'); + } + await started.future; + } + + /// Releases the parked unread catch-up batch so its response lands. + void resumePausedUnreadCatchUpQuery() { + final paused = _claimedUnreadCatchUp ?? _pausedUnreadCatchUp; + if (paused == null) { + throw StateError('No unread catch-up query is paused'); + } + paused.complete(); + } + @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -931,12 +2625,24 @@ class _FakeRelaySession extends RelaySessionNotifier { }) async { historyFilters.add(filter); if (filter.kinds.contains(39002) && filter.tags['#d'] != null) { + final paused = _pausedMemberCount; + if (paused != null) { + _claimedMemberCount = paused; + _pausedMemberCount = null; + _memberCountStarted!.complete(); + _memberCountStarted = null; + await paused.future; + if (failClaimedMemberCountQuery) { + throw Exception('member-count fetch failed'); + } + } final ids = (filter.tags['#d'] ?? const []).toSet(); return memberships .where((event) => ids.contains(event.getTagValue('d'))) .toList(); } if (filter.kinds.contains(39002) && filter.tags['#p'] != null) { + membershipRequestCount++; if (membershipFailures > 0) { membershipFailures--; throw Exception('membership fetch failed'); @@ -951,14 +2657,36 @@ class _FakeRelaySession extends RelaySessionNotifier { .toList(); } if (filter.kinds.contains(EventKind.dmVisibility)) { + // Claim the parked slot so the switch's own refresh runs unblocked. + final paused = _pausedHiddenDm; + if (paused != null) { + _claimedHiddenDm = paused; + _pausedHiddenDm = null; + _hiddenDmStarted!.complete(); + _hiddenDmStarted = null; + await paused.future; + } return hiddenDmEvents; } if (filter.kinds.contains(EventKind.huddleStarted)) { + // Claim the parked slot so the newer refresh's own Huddle query runs + // unblocked: one shot, exactly like the hidden-DM and member-count hooks. + final paused = _pausedHuddleStarts; + if (paused != null) { + _claimedHuddleStarts = paused; + _pausedHuddleStarts = null; + _huddleStartsStarted!.complete(); + _huddleStartsStarted = null; + await paused.future; + } return huddleStarts; } if (filter.kinds.contains(39000)) { - // Metadata query — return all metadata events whose `d` tag matches. - final ids = (filter.tags['#d'] ?? const []).toSet(); + final ids = filter.tags['#d']?.toSet(); + if (ids == null) { + throw StateError('Directory queries must use the HTTP query bridge'); + } + // Member metadata query — return only matching `d` tags. return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList(); } return const []; @@ -969,8 +2697,98 @@ class _FakeRelaySession extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { + if (filters case [final filter] + when filter.kinds.length == 1 && + filter.kinds.single == 39002 && + filter.tags['#p'] != null) { + membershipQueryFilters.add(filter); + if (membershipFailures > 0) { + membershipFailures--; + throw Exception('membership fetch failed'); + } + final requestIndex = membershipRequestCount++; + final maxRequests = maxMembershipPageRequests; + if (maxRequests != null && requestIndex >= maxRequests) { + throw StateError('Unexpected membership page request'); + } + final pages = membershipPages; + if (pages != null) { + if (requestIndex < pages.length) return List.of(pages[requestIndex]); + if (repeatLastMembershipPage && pages.isNotEmpty) { + return List.of(pages.last); + } + return const []; + } + if (filter.until != null) return const []; + final myPk = filter.tags['#p']?.single; + return memberships + .where( + (event) => event.tags.any( + (tag) => tag.length >= 2 && tag[0] == 'p' && tag[1] == myPk, + ), + ) + .toList(); + } + if (filters case [final filter] + when filter.kinds.length == 1 && + filter.kinds.single == 39000 && + !filter.tags.containsKey('#d')) { + directoryQueryFilters.add(filter); + // Snapshot the directory contents at request time so a paused response + // reflects the community that issued it, not whichever community is + // active when the response is released. + final directorySnapshot = List.of(metadata); + final paused = _pausedDirectory; + if (paused != null) { + _directoryStarted!.complete(); + await paused.future; + _pausedDirectory = null; + _directoryStarted = null; + } + if (directoryFailures > 0) { + directoryFailures--; + throw Exception('directory fetch failed'); + } + final requestIndex = metadataPageRequestCount++; + final maxRequests = maxMetadataPageRequests; + if (maxRequests != null && requestIndex >= maxRequests) { + throw StateError('Unexpected directory page request'); + } + final pageBuilder = metadataPageBuilder; + if (pageBuilder != null) return List.of(pageBuilder(requestIndex)); + final pages = metadataPages; + if (pages != null) { + if (requestIndex < pages.length) return List.of(pages[requestIndex]); + if (repeatLastMetadataPage && pages.isNotEmpty) { + return List.of(pages.last); + } + return const []; + } + return filter.until == null ? directorySnapshot : const []; + } queryBatches.add(filters); - return recentMessages.where((event) { + // The unread catch-up is the only batch that carries `since` on every + // filter; the latest-message batch leaves it null. Snapshot the messages at + // request time so a parked response reflects the scope that asked for it. + final isUnreadCatchUp = + filters.isNotEmpty && filters.every((filter) => filter.since != null); + final messageSnapshot = List.of(recentMessages); + if (isUnreadCatchUp) { + // Claim the parked slot so the refresh that follows the switch can run + // its own catch-up unblocked while this one stays parked. + final paused = _pausedUnreadCatchUp; + if (paused != null) { + _claimedUnreadCatchUp = paused; + _pausedUnreadCatchUp = null; + _unreadCatchUpStarted!.complete(); + _unreadCatchUpStarted = null; + await paused.future; + if (failClaimedUnreadCatchUpQuery) { + throw Exception('unread catch-up fetch failed'); + } + } + } + return messageSnapshot.where((event) { return filters.any((filter) { if (!filter.kinds.contains(event.kind)) return false; for (final entry in filter.tags.entries) { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index e2951323600..8b4c903b564 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -930,6 +930,43 @@ void main() { expect(content.agentMentionPubkeys, contains(agentPubkey)); expect(find.byIcon(LucideIcons.bot), findsOneWidget); }); + + testWidgets('does not label an unjoined channel as having zero members', ( + tester, + ) async { + final state = SearchState( + query: 'community', + channelResults: [ + Channel( + id: 'community-help', + name: 'community-help', + channelType: 'stream', + visibility: 'open', + description: 'Help from the community', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 0, + ), + ], + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Open'), findsOneWidget); + expect(find.text('0 members'), findsNothing); + }); } class _FakeSearchNotifier extends SearchNotifier { From 01091c15a15d6057d80463dfd828e6e1e4b60743 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 24 Aug 2026 10:59:58 -0600 Subject: [PATCH 007/101] fix(mobile): recover stale and shuffled messages (#6691) ## Summary - refetch mounted mobile thread replies after relay reconnect, preserving the previous reply list during recovery - auto-dispose route-scoped relay reply caches so reopening a thread queries current relay state - invalidate live replies through both the channel-window and legacy websocket-history paths - preserve optimistic-reply confirmation when the route closes before its deferred cleanup - stabilize rapid same-second messages using desktop's existing split contract: channel timelines render `(created_at ASC, id DESC)` while threads render `(created_at ASC, id ASC)` - retain late live rows after a channel window is exhausted instead of dropping same-second tail messages Closes #4404. Closes #4830. Closes #6204. ## Context The broad all-channel/all-DM stale-session defect reported in #4402 is already addressed on current `main` by #4372 and #3053. Two distinct mobile gaps remained: 1. `threadRepliesProvider` was a process-lifetime one-shot query, so replies missed while the socket was stale remained absent after reconnect or after closing and reopening the thread. 2. Mobile had inconsistent timestamp-only and event-id ordering across channel producers. Rapid messages routinely share Nostr's one-second timestamp, so later hydration/live reconciliation could reshuffle them. Desktop deliberately has two render contracts: channel windows reverse the relay's composite order to `(created_at ASC, id DESC)`, while thread replies use `(created_at ASC, id ASC)`. This consolidates the current-main portions of #4831 and #3243 rather than reviving stale overlapping branches. ## Validation Exact pushed head: `be92d9542c6cd1342733bdc5e8359664b511ce02` - focused channel-provider/window/thread suites: 48/48 passed - incident regression: a mounted thread misses a reply while disconnected, reconnects, and renders the recovered reply - route regression: closing and reopening a thread performs a fresh authoritative query - websocket fallback regression: live reply invalidates the mounted thread even without the channel-window path - disposal regression: optimistic confirmation survives provider disposal between rebuild and deferred cleanup - ordering regressions: channel window/live, websocket fallback, optimistic sends, deep links, both pagination paths, and thread merges preserve their desktop-compatible same-second order - boundary regression: exhausted windows admit late same-second live rows without weakening open-page cursor boundaries - independent adversarial review: no production blocker; source contract verified across all producers and relay cursor semantics unchanged - pre-push Mobile lane passed at exact head, including analysis, file-size/branch checks, and full Flutter suite: 1,675/1,675 passed - `git diff --check` --------- Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- .../channels/channel_event_order.dart | 23 ++ .../channels/channel_messages_provider.dart | 54 ++-- .../lib/features/channels/channel_window.dart | 9 +- .../channels/thread_replies_provider.dart | 48 ++-- .../channel_messages_provider_test.dart | 223 +++++++++++++++- .../channels/channel_window_test.dart | 74 ++++-- .../thread_replies_provider_test.dart | 238 ++++++++++++++++++ 7 files changed, 597 insertions(+), 72 deletions(-) create mode 100644 mobile/lib/features/channels/channel_event_order.dart create mode 100644 mobile/test/features/channels/thread_replies_provider_test.dart diff --git a/mobile/lib/features/channels/channel_event_order.dart b/mobile/lib/features/channels/channel_event_order.dart new file mode 100644 index 00000000000..7db052bc3e9 --- /dev/null +++ b/mobile/lib/features/channels/channel_event_order.dart @@ -0,0 +1,23 @@ +import '../../shared/relay/relay.dart'; + +/// Render order for top-level channel timelines. +/// +/// The relay pages newest timestamp first and ascending id within a second. +/// Channel timelines reverse that composite order for display, matching +/// desktop: oldest timestamp first and descending id within a second. +int compareChannelTimelineEventsChronologically( + NostrEvent left, + NostrEvent right, +) { + final createdAt = left.createdAt.compareTo(right.createdAt); + return createdAt != 0 ? createdAt : right.id.compareTo(left.id); +} + +/// Render order for thread replies. +/// +/// Desktop threads use ascending id within a second, independently of the +/// channel-window display order. +int compareThreadRepliesChronologically(NostrEvent left, NostrEvent right) { + final createdAt = left.createdAt.compareTo(right.createdAt); + return createdAt != 0 ? createdAt : left.id.compareTo(right.id); +} diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index e2cab7ddc8b..e910f565b4b 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import 'channel_event_order.dart'; import 'pending_local_messages_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; @@ -158,7 +159,7 @@ class ChannelMessagesNotifier extends Notifier>> { final history = await session.fetchHistory( NostrFilters.messages(channelId), ); - history.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + history.sort(compareChannelTimelineEventsChronologically); return history; } } @@ -187,6 +188,9 @@ class ChannelMessagesNotifier extends Notifier>> { ); void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) { + // Invalidate the thread query independently of the selected channel-history + // path. The websocket fallback does not merge through the window store. + _invalidateThreadReplies(event); // A live summary can race the initial channel-window query. Buffer it in // the window store even before that query installs its first page, rather // than treating metadata as an ordinary websocket timeline event. @@ -232,28 +236,35 @@ class ChannelMessagesNotifier extends Notifier>> { state = AsyncData(flattened); } + void _invalidateThreadReplies(NostrEvent event) { + if (!EventKind.channelTimelineContentKinds.contains(event.kind)) return; + final thread = event.threadReference; + if (thread.parentId == null) return; + + final rootId = thread.rootId; + if (rootId != null) { + ref.invalidate( + threadRepliesProvider( + ThreadRepliesArgs(channelId: channelId, rootId: rootId), + ), + ); + } + final parentId = thread.parentId; + if (parentId != null && parentId != rootId) { + ref.invalidate( + threadRepliesProvider( + ThreadRepliesArgs(channelId: channelId, rootId: parentId), + ), + ); + } + } + bool _mergeWindowEventIntoStore(NostrEvent event) { final isTimelineRow = EventKind.channelTimelineContentKinds.contains( event.kind, ); final thread = isTimelineRow ? event.threadReference : null; if (thread?.parentId != null) { - final rootId = thread?.rootId; - if (rootId != null) { - ref.invalidate( - threadRepliesProvider( - ThreadRepliesArgs(channelId: channelId, rootId: rootId), - ), - ); - } - final parentId = thread?.parentId; - if (parentId != null && parentId != rootId) { - ref.invalidate( - threadRepliesProvider( - ThreadRepliesArgs(channelId: channelId, rootId: parentId), - ), - ); - } // Replies are kept in the store rather than dropped here, matching // desktop: the main timeline filters them out at render // (`buildMainTimelineEntries`), and their parent's "N replies" row needs @@ -373,10 +384,7 @@ class ChannelMessagesNotifier extends Notifier>> { ) { if (current.any((e) => e.id == incoming.id)) return current; final updated = [...current, incoming]; - updated.sort((a, b) { - final createdAt = a.createdAt.compareTo(b.createdAt); - return createdAt != 0 ? createdAt : a.id.compareTo(b.id); - }); + updated.sort(compareChannelTimelineEventsChronologically); return updated; } @@ -442,7 +450,7 @@ class ChannelMessagesNotifier extends Notifier>> { return [ ...events, ..._deepLinkEvents.values.where((event) => ids.add(event.id)), - ]..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + ]..sort(compareChannelTimelineEventsChronologically); } Future fetchOlder() async { @@ -491,7 +499,7 @@ class ChannelMessagesNotifier extends Notifier>> { } state = state.whenData((events) { final merged = [...deduped, ...events]; - merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + merged.sort(compareChannelTimelineEventsChronologically); _lastKnownMessages = merged; return merged; }); diff --git a/mobile/lib/features/channels/channel_window.dart b/mobile/lib/features/channels/channel_window.dart index 2bf8ab3a119..ca2a644c9f3 100644 --- a/mobile/lib/features/channels/channel_window.dart +++ b/mobile/lib/features/channels/channel_window.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import '../../shared/relay/relay.dart'; +import 'channel_event_order.dart'; class ChannelPageCursor { final int createdAt; @@ -330,7 +331,11 @@ ChannelWindowStore mergeLiveChannelWindowEvent( final oldest = oldestPage?.rows.isEmpty ?? true ? null : oldestPage!.rows.last.event; - if (oldest != null && _compareRelayOrder(event, oldest) >= 0) return current; + if (oldest != null && + (event.createdAt < oldest.createdAt || + (oldestPage!.hasMore && _compareRelayOrder(event, oldest) >= 0))) { + return current; + } final overlay = current.liveOverlay .where((candidate) => candidate.id != event.id) @@ -362,7 +367,7 @@ List flattenChannelWindowEvents(ChannelWindowStore store) { byId[event.id] = event; } return byId.values.toList() - ..sort((left, right) => _compareRelayOrder(right, left)); + ..sort(compareChannelTimelineEventsChronologically); } bool channelWindowHasMore(ChannelWindowStore store) => diff --git a/mobile/lib/features/channels/thread_replies_provider.dart b/mobile/lib/features/channels/thread_replies_provider.dart index 81ab5a4b39d..1fb19432f1e 100644 --- a/mobile/lib/features/channels/thread_replies_provider.dart +++ b/mobile/lib/features/channels/thread_replies_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import 'channel_event_order.dart'; import 'pending_local_messages_provider.dart'; class ThreadRepliesArgs { @@ -29,12 +30,18 @@ class _ThreadCursor { const _ThreadCursor({required this.createdAt, required this.eventId}); } -final threadRepliesProvider = - FutureProvider.family, ThreadRepliesArgs>(( - ref, - args, - ) async { - final session = ref.watch(relaySessionProvider.notifier); +final threadRepliesProvider = FutureProvider.autoDispose + .family, ThreadRepliesArgs>((ref, args) async { + // A reply missed while the socket is stale cannot invalidate this + // one-shot query. Refresh mounted threads when the session recovers; + // auto-dispose also makes reopening a thread start from relay truth. + ref.listen(relaySessionProvider, (previous, next) { + if (previous?.status != SessionStatus.connected && + next.status == SessionStatus.connected) { + ref.invalidateSelf(); + } + }); + final session = ref.read(relaySessionProvider.notifier); final replies = []; _ThreadCursor? cursor; for (var page = 0; page < 500; page++) { @@ -99,24 +106,26 @@ final threadLocalRepliesProvider = /// Relay-backed replies merged with signed local replies that are still /// waiting for acknowledgement. -final threadRepliesWithLocalProvider = - Provider.family>, ThreadRepliesArgs>(( - ref, - args, - ) { +/// +/// The relay query is route-scoped, while the optimistic local overlay stays +/// alive until confirmation so it can survive closing and reopening a thread. +final threadRepliesWithLocalProvider = Provider.autoDispose + .family>, ThreadRepliesArgs>((ref, args) { final relayReplies = ref.watch(threadRepliesProvider(args)); final localReplies = ref.watch(threadLocalRepliesProvider(args)); final authoritative = relayReplies.value; if (authoritative != null && localReplies.isNotEmpty) { final authoritativeIds = authoritative.map((event) => event.id).toSet(); if (localReplies.any((event) => authoritativeIds.contains(event.id))) { + final localRepliesNotifier = ref.read( + threadLocalRepliesProvider(args).notifier, + ); + final pendingMessagesNotifier = ref.read( + pendingLocalMessagesProvider(args.channelId).notifier, + ); Future.microtask(() { - ref - .read(threadLocalRepliesProvider(args).notifier) - .confirm(authoritativeIds); - ref - .read(pendingLocalMessagesProvider(args.channelId).notifier) - .confirm(authoritativeIds); + localRepliesNotifier.confirm(authoritativeIds); + pendingMessagesNotifier.confirm(authoritativeIds); }); } } @@ -147,8 +156,5 @@ List _mergeReplies( for (final event in [...first, ...second]) { byId[event.id] = event; } - return byId.values.toList()..sort((a, b) { - final createdAt = a.createdAt.compareTo(b.createdAt); - return createdAt != 0 ? createdAt : a.id.compareTo(b.id); - }); + return byId.values.toList()..sort(compareThreadRepliesChronologically); } diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index f23b68b1465..80eefc5b98e 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -60,6 +60,67 @@ void main() { }, ); + test( + 'initial window hydration preserves equal-second live message order', + () async { + final window = Completer>(); + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [window.future], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + + relaySession.emit(_event(id: 'z-live', createdAt: 20)); + relaySession.emit(_event(id: 'a-live', createdAt: 20)); + await _pumpEventQueue(); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['z-live', 'a-live'], + ); + + window.complete([_bounds()]); + await _pumpEventQueue(); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['z-live', 'a-live'], + ); + }, + ); + + test( + 'websocket fallback uses desktop channel order for equal-second history', + () async { + final relaySession = _RecordingRelaySessionNotifier(); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + relaySession.completeHistory([ + _event(id: 'a-history', createdAt: 10), + _event(id: 'z-history', createdAt: 10), + ]); + await _pumpEventQueue(); + + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['z-history', 'a-history'], + ); + }, + ); + test( 'buffers a live thread summary until the initial window is installed', () async { @@ -203,8 +264,14 @@ void main() { channelMessagesProvider(_channelId).notifier, ); - final targetLoad = notifier.loadEventsById(const ['target']); - relaySession.completeTargetHistory([_event(id: 'target', createdAt: 5)]); + final targetLoad = notifier.loadEventsById(const [ + 'a-target', + 'z-target', + ]); + relaySession.completeTargetHistory([ + _event(id: 'a-target', createdAt: 10), + _event(id: 'z-target', createdAt: 10), + ]); await targetLoad; expect( @@ -212,7 +279,7 @@ void main() { isTrue, ); - relaySession.completeHistory([_event(id: 'history', createdAt: 10)]); + relaySession.completeHistory([_event(id: 'm-history', createdAt: 10)]); await _pumpEventQueue(); expect( @@ -220,7 +287,7 @@ void main() { .read(channelMessagesProvider(_channelId)) .value ?.map((event) => event.id), - ['target', 'history'], + ['z-target', 'm-history', 'a-target'], ); }, ); @@ -436,7 +503,11 @@ void main() { await relaySession.subscribed; await _pumpEventQueue(); const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); - container.read(threadRepliesWithLocalProvider(args)); + final threadSubscription = container.listen( + threadRepliesWithLocalProvider(args), + (_, _) {}, + ); + addTearDown(threadSubscription.close); await _pumpEventQueue(); final notifier = container.read( channelMessagesProvider(_channelId).notifier, @@ -467,7 +538,6 @@ void main() { relaySession.emit(reply); await container.read(threadRepliesProvider(args).future); - container.read(threadRepliesWithLocalProvider(args)); await _pumpEventQueue(); expect( container @@ -515,7 +585,11 @@ void main() { await relaySession.subscribed; await _pumpEventQueue(); const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); - container.read(threadRepliesWithLocalProvider(args)); + final threadSubscription = container.listen( + threadRepliesWithLocalProvider(args), + (_, _) {}, + ); + addTearDown(threadSubscription.close); await _pumpEventQueue(); final notifier = container.read( channelMessagesProvider(_channelId).notifier, @@ -551,6 +625,61 @@ void main() { }, ); + test( + 'websocket fallback refetches an open thread when a reply arrives live', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + Exception('channel window unavailable'), + [], + [ + _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + relaySession.completeHistory([_event(id: 'history', createdAt: 10)]); + await _pumpEventQueue(); + expect(relaySession.operations, ['subscribe', 'query', 'fetch']); + + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + final subscription = container.listen( + threadRepliesProvider(args), + (_, _) {}, + ); + addTearDown(subscription.close); + expect(await container.read(threadRepliesProvider(args).future), isEmpty); + + relaySession.emit( + _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ); + await _pumpEventQueue(); + + expect( + (await container.read( + threadRepliesProvider(args).future, + )).map((event) => event.id), + ['reply'], + ); + }, + ); + test( 'successful never-echoed send releases ownership but keeps its row across reconnect', () async { @@ -724,6 +853,78 @@ void main() { expect(entries.single.summary!.lastReplyAt, 21); }); + test( + 'legacy pagination preserves desktop equal-second channel order', + () async { + final relaySession = _RecordingRelaySessionNotifier( + historyResults: [ + [_event(id: 'a-head', createdAt: 20)], + [ + _event(id: 'm-older', createdAt: 20), + _event(id: 'z-older', createdAt: 20), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + await expectLater(notifier.fetchOlder(), completion(isTrue)); + + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['z-older', 'm-older', 'a-head'], + ); + }, + ); + + test( + 'window pagination preserves desktop equal-second channel order', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [ + _event(id: 'a-head', createdAt: 20), + _bounds(hasMore: true, cursorCreatedAt: 20, cursorId: 'a-head'), + ], + [ + _event(id: 'm-older', createdAt: 20), + _event(id: 'z-older', createdAt: 20), + _bounds(dTag: '${_channelId.toLowerCase()}:20:a-head'), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + await expectLater(notifier.fetchOlder(), completion(isTrue)); + + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['z-older', 'm-older', 'a-head'], + ); + }, + ); + test('window pagination failures return false without exhausting', () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ @@ -867,6 +1068,7 @@ Future _pumpEventQueue() async { class _RecordingRelaySessionNotifier extends RelaySessionNotifier { final bool failSubscribe; final Queue _queryResults; + final Queue> _historyResults; final List operations = []; final List liveFilters = []; final List historyFilters = []; @@ -879,7 +1081,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { _RecordingRelaySessionNotifier({ this.failSubscribe = false, List queryResults = const [], - }) : _queryResults = Queue.of(queryResults); + List> historyResults = const [], + }) : _queryResults = Queue.of(queryResults), + _historyResults = Queue>.of(historyResults); Future get subscribed => _subscribed.future; @@ -918,6 +1122,9 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { _targetHistories.add(completer); return completer.future; } + if (_historyResults.isNotEmpty) { + return Future.value(_historyResults.removeFirst()); + } return _history.future; } diff --git a/mobile/test/features/channels/channel_window_test.dart b/mobile/test/features/channels/channel_window_test.dart index f6e6a25f344..e6d3e9330d5 100644 --- a/mobile/test/features/channels/channel_window_test.dart +++ b/mobile/test/features/channels/channel_window_test.dart @@ -63,6 +63,27 @@ void main() { }); group('ChannelWindowStore', () { + test('flattens equal-second rows in desktop channel order', () { + final store = replaceNewestChannelWindow( + const ChannelWindowStore.empty(), + _page( + rows: [_row('a', createdAt: 10), _row('m', createdAt: 10)], + hasMore: false, + ), + ); + final withLive = mergeLiveChannelWindowEvent( + store, + _row('z', createdAt: 10), + isTimelineRow: true, + ); + + expect(flattenChannelWindowEvents(withLive).map((event) => event.id), [ + 'z', + 'm', + 'a', + ]); + }); + test('rejects cursor interval and row order violations', () { expect( () => appendOlderChannelWindow( @@ -121,25 +142,42 @@ void main() { expect(refreshed.pages.single.rows.single.event.id, 'b'); }); - test('drops live rows at or older than oldest loaded boundary', () { - final store = replaceNewestChannelWindow( - const ChannelWindowStore.empty(), - _page(rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)]), - ); - final ignored = mergeLiveChannelWindowEvent( - store, - _row('z', createdAt: 8), - isTimelineRow: true, - ); - expect(identical(ignored, store), isTrue); + test( + 'keeps the loaded boundary closed but accepts exhausted live rows', + () { + final store = replaceNewestChannelWindow( + const ChannelWindowStore.empty(), + _page( + rows: [_row('a', createdAt: 10), _row('m', createdAt: 9)], + hasMore: true, + ), + ); + final ignored = mergeLiveChannelWindowEvent( + store, + _row('z', createdAt: 8), + isTimelineRow: true, + ); + expect(identical(ignored, store), isTrue); - final merged = mergeLiveChannelWindowEvent( - store, - _row('live', createdAt: 11), - isTimelineRow: true, - ); - expect(merged.liveOverlay.map((event) => event.id), ['live']); - }); + final merged = mergeLiveChannelWindowEvent( + store, + _row('live', createdAt: 11), + isTimelineRow: true, + ); + expect(merged.liveOverlay.map((event) => event.id), ['live']); + + final exhausted = replaceNewestChannelWindow( + const ChannelWindowStore.empty(), + _page(rows: [_row('a', createdAt: 10)], hasMore: false), + ); + final sameSecond = mergeLiveChannelWindowEvent( + exhausted, + _row('z', createdAt: 10), + isTimelineRow: true, + ); + expect(sameSecond.liveOverlay.map((event) => event.id), ['z']); + }, + ); }); group('live thread summaries', () { diff --git a/mobile/test/features/channels/thread_replies_provider_test.dart b/mobile/test/features/channels/thread_replies_provider_test.dart new file mode 100644 index 00000000000..cdfc16fe81d --- /dev/null +++ b/mobile/test/features/channels/thread_replies_provider_test.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/pending_local_messages_provider.dart'; +import 'package:buzz/features/channels/thread_replies_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _FakeRelaySession extends RelaySessionNotifier { + int queryCount = 0; + List replies = const []; + Completer>? nextQueryGate; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + void setStatus(SessionStatus status) { + state = SessionState(status: status); + } + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryCount++; + final gate = nextQueryGate; + if (gate != null) { + nextQueryGate = null; + return gate.future; + } + return replies; + } +} + +NostrEvent _reply(String id, int createdAt) => NostrEvent( + id: id, + pubkey: 'bob', + createdAt: createdAt, + kind: EventKind.streamMessage, + tags: const [ + ['h', 'chan'], + ['e', 'root', '', 'reply'], + ], + content: 'reply $id', + sig: '', +); + +void main() { + const args = ThreadRepliesArgs(channelId: 'chan', rootId: 'root'); + + (ProviderContainer, _FakeRelaySession, ProviderSubscription) + makeHarness(List initialReplies) { + final fakeSession = _FakeRelaySession()..replies = initialReplies; + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => fakeSession)], + ); + // An auto-disposed provider needs a listener to stay alive, mirroring an + // open thread page. Creating it starts the first load, so the fake's + // replies must be in place first. + final subscription = container.listen( + threadRepliesProvider(args), + (_, _) {}, + ); + return (container, fakeSession, subscription); + } + + test('thread replies keep desktop same-second id order', () { + expect( + mergeThreadEvents( + [_reply('z', 1000)], + [_reply('a', 1000), _reply('m', 1000)], + ).map((event) => event.id), + ['a', 'm', 'z'], + ); + }); + + test('does not refetch on the disconnect edge', () async { + final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]); + addTearDown(container.dispose); + + await container.read(threadRepliesProvider(args).future); + final queriesAfterFirstLoad = fakeSession.queryCount; + + fakeSession.setStatus(SessionStatus.disconnected); + await container.pump(); + + expect(fakeSession.queryCount, queriesAfterFirstLoad); + }); + + test('refetches exactly once per reconnect edge', () async { + final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]); + addTearDown(container.dispose); + + final first = await container.read(threadRepliesProvider(args).future); + expect(first.map((event) => event.id), ['r1']); + final queriesAfterFirstLoad = fakeSession.queryCount; + + // A reply lands while the connection is down. + fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)]; + fakeSession.setStatus(SessionStatus.disconnected); + await container.pump(); + fakeSession.setStatus(SessionStatus.connected); + await container.pump(); + + final second = await container.read(threadRepliesProvider(args).future); + expect(second.map((event) => event.id), ['r1', 'r2']); + expect(fakeSession.queryCount, queriesAfterFirstLoad + 1); + }); + + test( + 'does not refetch on session emissions that keep the same status', + () async { + final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]); + addTearDown(container.dispose); + + await container.read(threadRepliesProvider(args).future); + final queriesAfterFirstLoad = fakeSession.queryCount; + + // Same connected status, new state object (e.g. reconnectAttempt bump). + fakeSession.setStatus(SessionStatus.connected); + await container.pump(); + + expect(fakeSession.queryCount, queriesAfterFirstLoad); + }, + ); + + test('keeps previous replies available while a refresh is pending', () async { + final (container, fakeSession, _) = makeHarness([_reply('r1', 1000)]); + addTearDown(container.dispose); + + await container.read(threadRepliesProvider(args).future); + + // Hold the reconnect refresh open and verify the old data still reads. + final gate = Completer>(); + fakeSession.nextQueryGate = gate; + fakeSession.setStatus(SessionStatus.disconnected); + await container.pump(); + fakeSession.setStatus(SessionStatus.connected); + await container.pump(); + + final pending = container.read(threadRepliesProvider(args)); + expect(pending.isLoading, isTrue); + expect(pending.value?.map((event) => event.id), ['r1']); + + gate.complete([_reply('r1', 1000), _reply('r2', 2000)]); + final refreshed = await container.read(threadRepliesProvider(args).future); + expect(refreshed.map((event) => event.id), ['r1', 'r2']); + }); + + test( + 'confirmation survives disposing the combined provider before its microtask', + () async { + final reply = _reply('r1', 1000); + final query = Completer>(); + final fakeSession = _FakeRelaySession()..nextQueryGate = query; + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => fakeSession)], + ); + addTearDown(container.dispose); + container.read(threadLocalRepliesProvider(args).notifier).add(reply); + container + .read(pendingLocalMessagesProvider(args.channelId).notifier) + .add(reply); + + late ProviderSubscription>> subscription; + subscription = container.listen(threadRepliesWithLocalProvider(args), ( + _, + next, + ) { + if (next.value?.any((event) => event.id == reply.id) ?? false) { + subscription.close(); + } + }); + query.complete([reply]); + await container.pump(); + await Future.delayed(Duration.zero); + + expect(container.read(threadLocalRepliesProvider(args)), isEmpty); + expect( + container.read(pendingLocalMessagesProvider(args.channelId)), + isEmpty, + ); + }, + ); + + test( + 'mounted thread renders a reply missed while disconnected after reconnect', + () async { + final fakeSession = _FakeRelaySession()..replies = [_reply('r1', 1000)]; + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => fakeSession)], + ); + addTearDown(container.dispose); + final mountedThread = container.listen( + threadRepliesWithLocalProvider(args), + (_, _) {}, + ); + addTearDown(mountedThread.close); + + await container.read(threadRepliesProvider(args).future); + expect(mountedThread.read().value?.map((event) => event.id), ['r1']); + + fakeSession.setStatus(SessionStatus.disconnected); + await container.pump(); + fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)]; + fakeSession.setStatus(SessionStatus.connected); + await container.pump(); + await container.read(threadRepliesProvider(args).future); + + expect(mountedThread.read().value?.map((event) => event.id), [ + 'r1', + 'r2', + ]); + }, + ); + + test('reopening a disposed thread performs a fresh load', () async { + final (container, fakeSession, subscription) = makeHarness([ + _reply('r1', 1000), + ]); + addTearDown(container.dispose); + + await container.read(threadRepliesProvider(args).future); + final queriesAfterFirstLoad = fakeSession.queryCount; + + // Close the page: the auto-disposed query is torn down… + subscription.close(); + await container.pump(); + + // …so reopening loads fresh instead of serving a stale cache. + fakeSession.replies = [_reply('r1', 1000), _reply('r2', 2000)]; + container.listen(threadRepliesProvider(args), (_, _) {}); + final reopened = await container.read(threadRepliesProvider(args).future); + expect(reopened.map((event) => event.id), ['r1', 'r2']); + expect(fakeSession.queryCount, queriesAfterFirstLoad + 1); + }); +} From db5617dd1541aeab7bacaf039b6ca98f856776d0 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:20:53 -0700 Subject: [PATCH 008/101] fix(desktop): emit singular `mention` feed category so alerts route correctly (#6665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Mentions — and thread replies that @-mention you — played the **Needs action** sound instead of the **@Mentions** sound. Reported by @morgmart: "I set Needs action to a different sound and it's the only one I ever hear." ## Root cause Two vocabularies got conflated in #475: - **Section / filter vocabulary** (plural): `mentions`, `needs_action`, `activity`, `agent_activity` — the shape of `FeedSections`, the `--types` filter, and the agent-facing CLI docs. - **Per-item category vocabulary** (singular for mention): `mention`, `needs_action`, `activity`, `agent_activity` — the `FeedItemCategory` contract in `desktop/src/shared/api/types.ts`, unchanged since #12. The Tauri feed builder reused the filter string `"mentions"` as each mention item's `category`. Only one word differs between the vocabularies, so only mentions broke. Every frontend consumer compares against the singular, so real mentions never matched and fell through to the resolver's `needs_action` fallback. The E2E mock bridge emits the singular form, so tests never saw the drift. ### Symptoms this fixes (all from the one mislabel) - Mentions and mentioning thread replies played the Needs-action sound - Mention notifications used the Needs-action title format - Mentions in muted channels were suppressed (the mute-bypass never fired) - Inbox / Home feed labelled mentions "Channel update" - Channel activity popover's mentions list was always empty ## Fix **Fix the owner, not the symptoms.** `FeedItemInfo.category` becomes a `FeedItemCategory` enum whose serde form is exactly the TS union, so a misspelled category can't compile at the producer. A serialization test pins each variant to its wire string. **Frontend:** `slotForFeedKind` maps every known category explicitly. The `needs_action` fallback for unknown categories is **kept on purpose** — a contract drift should cost the user the wrong sound, not a missed alert — but it now `console.warn`s so the drift is visible to developers instead of masquerading as intended behavior. `e2eBridge.ts` and `tauri.ts` now derive the category type from `types.ts` instead of retyping it. Not touched: the plural `--types` filter and `FeedSections` keys. Those are the section vocabulary and are correct as-is. ## Verification - `just ci` green (file-size ratchet, Rust/Tauri/desktop/mobile tests, desktop + web builds) - New tests: 2 Rust (`feed_item_category_serializes_to_frontend_contract`, `feed_item_from_event_carries_singular_mention_category`), 3 TS in `sound.test.mjs` incl. one that feeds the old `"mentions"` string and asserts fallback + warning - **Runtime, dev build against the production relay:** controlled test from an agent identity into a test channel — - mention in channel → @Mentions sound, inbox shows "Mentioned in" ✅ (was Needs-action) - thread reply with mention → @Mentions sound, once ✅ (was Needs-action) - plain thread reply in the channel being viewed → silent, as designed ✅ ## Reviewers - @tlongwell-block — #475 introduced the plural category; please confirm it wasn't intentional - @wesbillman — owner of the original `FeedItemCategory` contract (#12) and most of the feed builder - @taylorkmho — owner of the sound-slot model and resolver (#968); the fallback-with-warning shape is the part to weigh in on - cc @klopez4212 --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Claude --- desktop/src-tauri/src/commands/messages.rs | 10 ++-- .../src-tauri/src/commands/messages_tests.rs | 40 ++++++++++++++ desktop/src-tauri/src/models.rs | 18 ++++++- .../features/notifications/lib/sound.test.mjs | 52 ++++++++++++++++++- .../src/features/notifications/lib/sound.ts | 28 ++++++++-- desktop/src/shared/api/tauri.ts | 2 +- desktop/src/testing/e2eBridge.ts | 8 ++- 7 files changed, 145 insertions(+), 13 deletions(-) diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 461f29e7fa6..5de2d32b809 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -13,7 +13,7 @@ use crate::{ events, managed_agents::{find_managed_agent_mut, load_managed_agents, ManagedAgentRecord}, models::{ - FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse, + FeedItemCategory, FeedItemInfo, FeedMeta, FeedResponse, FeedSections, SearchResponse, SendChannelMessageResponse, ThreadRepliesResponse, }, nostr_convert, @@ -138,14 +138,14 @@ pub async fn get_feed( let mentions: Vec = mention_events .iter() .map(|ev| { - let mut item = feed_item_from_event(ev, "mentions"); + let mut item = feed_item_from_event(ev, FeedItemCategory::Mention); apply_link_preview_suppression(&mut item.tags, &item.id, &suppressed_mentions); item }) .collect(); let needs_action: Vec = approval_events .iter() - .map(|ev| feed_item_from_event(ev, "needs_action")) + .map(|ev| feed_item_from_event(ev, FeedItemCategory::NeedsAction)) .collect(); let total = (mentions.len() + needs_action.len()) as u64; @@ -966,7 +966,7 @@ fn tags_to_vec(ev: &nostr::Event) -> Vec> { ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() } -fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { +fn feed_item_from_event(ev: &nostr::Event, category: FeedItemCategory) -> FeedItemInfo { let channel_id = channel_id_from_tags(ev); FeedItemInfo { id: ev.id.to_hex(), @@ -978,7 +978,7 @@ fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { channel_name: String::new(), channel_type: None, tags: tags_to_vec(ev), - category: category.to_string(), + category, } } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index dc7c0f4b5a2..627e6326432 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -236,3 +236,43 @@ fn provided_thread_ref_validates_and_preserves_root_and_parent() { assert_eq!(thread_ref.parent_event_id.to_hex(), parent); assert!(thread_ref::provided_thread_ref("not-hex", &parent).is_err()); } + +/// `FeedItem.category` is a wire contract with the desktop frontend +/// (`desktop/src/shared/api/types.ts`). The frontend routes notification +/// sounds, titles, mute-bypass, and inbox labels off these exact strings, so +/// the serialized form must stay singular `mention` — not the plural section +/// name `mentions` used by `FeedSections` and the `--types` filter. +#[test] +fn feed_item_category_serializes_to_frontend_contract() { + let cases = [ + (FeedItemCategory::Mention, "mention"), + (FeedItemCategory::NeedsAction, "needs_action"), + (FeedItemCategory::Activity, "activity"), + (FeedItemCategory::AgentActivity, "agent_activity"), + ]; + for (category, expected) in cases { + let value = serde_json::to_value(category).expect("category should serialize"); + assert_eq!(value, serde_json::Value::String(expected.to_string())); + } +} + +#[test] +fn feed_item_from_event_carries_singular_mention_category() { + let pubkey = Keys::generate().public_key().to_hex(); + let event = build_managed_agent_channel_message( + uuid::Uuid::new_v4(), + "hey @you", + None, + std::slice::from_ref(&pubkey), + &[], + ) + .expect("message should build") + .sign_with_keys(&Keys::generate()) + .expect("message should sign"); + + let item = feed_item_from_event(&event, FeedItemCategory::Mention); + let json = serde_json::to_value(&item).expect("feed item should serialize"); + + assert_eq!(json["category"], "mention"); + assert_eq!(json["id"], event.id.to_hex()); +} diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 768b2ad7db3..9693f1563ac 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -178,6 +178,22 @@ pub struct ChannelMembersResponse { pub next_cursor: Option, } +/// Per-item classification of a home feed entry. +/// +/// This is the wire contract for `FeedItem.category` in the desktop frontend +/// (`desktop/src/shared/api/types.ts`). It is distinct from the plural +/// *section* vocabulary (`mentions`, `needs_action`, …) used by +/// [`FeedSections`] and the `--types` filter: a mention item lives in the +/// `mentions` section but carries the singular `mention` category. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FeedItemCategory { + Mention, + NeedsAction, + Activity, + AgentActivity, +} + #[derive(Serialize, Deserialize)] pub struct FeedItemInfo { pub id: String, @@ -190,7 +206,7 @@ pub struct FeedItemInfo { #[serde(default)] pub channel_type: Option, pub tags: Vec>, - pub category: String, + pub category: FeedItemCategory, } #[derive(Serialize, Deserialize)] diff --git a/desktop/src/features/notifications/lib/sound.test.mjs b/desktop/src/features/notifications/lib/sound.test.mjs index dfa9c84060f..ef3cc8d4cd0 100644 --- a/desktop/src/features/notifications/lib/sound.test.mjs +++ b/desktop/src/features/notifications/lib/sound.test.mjs @@ -1,7 +1,57 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldPlayNotificationSound } from "./sound.ts"; +import { + KIND_APPROVAL_REQUEST, + KIND_STREAM_MESSAGE_V2, + KIND_JOB_ACCEPTED, +} from "../../../shared/constants/kinds.ts"; +import { shouldPlayNotificationSound, slotForFeedKind } from "./sound.ts"; + +test("routes each feed category to its own sound slot", () => { + assert.equal(slotForFeedKind(KIND_STREAM_MESSAGE_V2, "mention"), "mention"); + assert.equal( + slotForFeedKind(KIND_APPROVAL_REQUEST, "needs_action"), + "needs_action", + ); + assert.equal( + slotForFeedKind(KIND_STREAM_MESSAGE_V2, "activity"), + "needs_action", + ); + assert.equal( + slotForFeedKind(KIND_STREAM_MESSAGE_V2, "agent_activity"), + "needs_action", + ); +}); + +test("agent job kinds pick their slot for non-mention categories", () => { + assert.equal( + slotForFeedKind(KIND_JOB_ACCEPTED, "agent_activity"), + "job_accepted", + ); +}); + +test("a mention outranks the agent job kind that carried it", () => { + assert.equal(slotForFeedKind(KIND_JOB_ACCEPTED, "mention"), "mention"); +}); + +test("unknown category falls back to needs_action and warns", () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + // The backend once emitted the plural section name here; the fallback + // keeps the user alerted while the warning keeps the drift visible. + assert.equal( + slotForFeedKind(KIND_STREAM_MESSAGE_V2, "mentions"), + "needs_action", + ); + } finally { + console.warn = originalWarn; + } + assert.equal(warnings.length, 1); + assert.match(warnings[0], /unknown feed item category "mentions"/); +}); test("silences notifications from Huddle backing channels", () => { const silentChannelIds = new Set(["active-huddle"]); diff --git a/desktop/src/features/notifications/lib/sound.ts b/desktop/src/features/notifications/lib/sound.ts index 1e9ccb3839a..3371658a3bd 100644 --- a/desktop/src/features/notifications/lib/sound.ts +++ b/desktop/src/features/notifications/lib/sound.ts @@ -1,5 +1,4 @@ import { - KIND_APPROVAL_REQUEST, KIND_JOB_ACCEPTED, KIND_JOB_ERROR, KIND_JOB_PROGRESS, @@ -115,6 +114,17 @@ export function resolveSlotSound( return prefs.sounds[slot]; } +/** + * Pick the sound slot for a home-feed item. + * + * `category` is the backend's per-item classification (`FeedItemCategory` in + * `desktop/src-tauri/src/models.rs`). A mention always wins — being addressed + * directly outranks whatever kind of event carried it, including agent job + * events. Every other known category maps explicitly; anything else falls + * back to `needs_action` so a contract drift costs the user the wrong sound + * rather than a missed alert — and warns so the drift is visible to + * developers instead of silently masquerading as intended. + */ export function slotForFeedKind( kind: number, category: FeedItemCategory, @@ -124,8 +134,20 @@ export function slotForFeedKind( if (kind === KIND_JOB_PROGRESS) return "job_progress"; if (kind === KIND_JOB_RESULT) return "job_result"; if (kind === KIND_JOB_ERROR) return "job_error"; - if (kind === KIND_APPROVAL_REQUEST) return "needs_action"; - return "needs_action"; + + switch (category) { + case "needs_action": + case "activity": + case "agent_activity": + return "needs_action"; + default: { + const unexpected: never = category; + console.warn( + `[notifications] unknown feed item category ${JSON.stringify(unexpected)} for kind ${kind}; falling back to needs_action`, + ); + return "needs_action"; + } + } } export function shouldPlayNotificationSound( diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index b4f0df6fc09..9055a2a48a2 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -64,7 +64,7 @@ type RawFeedItem = { channel_name: string; channel_type: string | null; tags: string[][]; - category: "mention" | "needs_action" | "activity" | "agent_activity"; + category: HomeFeedResponse["feed"]["mentions"][number]["category"]; }; type RawHomeFeedResponse = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 3252a025c0f..9002d751c8c 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -19,7 +19,11 @@ import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism"; import type { ConnectionState } from "@/shared/api/relayClientShared"; -import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; +import type { + ChannelTemplate, + FeedItemCategory, + RelayEvent, +} from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; import { syncAgentTurnsFromEvents } from "@/features/agents/activeAgentTurnsStore"; import { recordTimeoutFromRejection } from "@/features/moderation/lib/timeoutStore"; @@ -771,7 +775,7 @@ type RawFeedItem = { // backend always emits the key, as `null` when unknown. channel_type?: string | null; tags: string[][]; - category: "mention" | "needs_action" | "activity" | "agent_activity"; + category: FeedItemCategory; }; type RawHomeFeedResponse = { From 9f55bf67456be10ff7c8238bf0d9e12e582848f6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 24 Aug 2026 11:56:27 -0700 Subject: [PATCH 009/101] fix(desktop): align jump-to-latest pill with composer height (#6606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Jump to Latest now stays above the composer as a draft grows to multiple lines. **Problem:** On WebKit, the pill's transform could retain a stale inherited composer-height value after the composer expanded, leaving the control stranded inside the composer. **Solution:** Position and animate the pill with its absolute bottom offset, which consumes the live composer height through layout rather than a promoted transform layer. A smoke test now verifies that the pill rises by the full composer growth and remains clear of the composer.
File changes **desktop/src/features/messages/ui/MessageTimeline.tsx** Anchor Jump to Latest with a live bottom offset instead of a translated compositor layer so composer resizing reliably moves it. **desktop/tests/e2e/smoke.spec.ts** Add coverage that expands a detached timeline's composer and checks the pill tracks the full height increase without overlapping it.
## Reproduction steps 1. Open a channel with enough messages to scroll. 2. Scroll away from the newest message until Jump to Latest appears. 3. Add several lines to the composer without sending. 4. Confirm Jump to Latest rises with the composer and remains directly above it. ## Validation - `pnpm --dir desktop test` — 5,397 passed - `pnpm --dir desktop check` — passed with four existing informational warnings outside this diff - `pnpm --dir desktop typecheck` — passed - `pnpm --dir desktop exec playwright test tests/e2e/smoke.spec.ts --project=smoke` — 26 passed - Push hooks — desktop check, typecheck, and tests passed ## Screenshots / Demos Both captures use the same long, mid-history timeline and the same four-line composer. | Before | After | | --- | --- | | The stale pill position overflows into the expanded composer. | The pill tracks the live composer height and stays clear above it. | | ![Before — Jump to Latest overlaps the tall composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6606/jump-pill-tall-composer-before.png) | ![After — Jump to Latest clears the tall composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6606/jump-pill-tall-composer-after.png) | Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageTimeline.tsx | 6 ++- desktop/tests/e2e/smoke.spec.ts | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index df9af09e674..fa8bb4e9f6d 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -863,8 +863,12 @@ const MessageTimelineBase = React.forwardRef<
{ + const input = page.getByTestId("message-input"); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await ensureTimelineScrollable(page, `Jump pill growth ${Date.now()}`); + await page.waitForTimeout(400); + const timeline = page.getByTestId("message-timeline"); + await timeline.evaluate((element) => { + element.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll", { bubbles: true })); + }); + + const jumpToLatest = page.getByTestId("message-scroll-to-latest"); + const composer = page.getByTestId("message-composer"); + await expect(jumpToLatest).toBeVisible(); + const initialPillBox = await jumpToLatest.boundingBox(); + const initialComposerBox = await composer.boundingBox(); + + await input.fill( + [ + "Composer growth line one", + "Composer growth line two", + "Composer growth line three", + "Composer growth line four", + ].join("\n"), + ); + + await expect + .poll(async () => (await composer.boundingBox())?.height ?? 0) + .toBeGreaterThan((initialComposerBox?.height ?? 0) + 40); + await page.waitForTimeout(250); + + const expandedPillBox = await jumpToLatest.boundingBox(); + const expandedComposerBox = await composer.boundingBox(); + expect(initialPillBox).not.toBeNull(); + expect(initialComposerBox).not.toBeNull(); + expect(expandedPillBox).not.toBeNull(); + expect(expandedComposerBox).not.toBeNull(); + + const composerGrowth = + (expandedComposerBox?.height ?? 0) - (initialComposerBox?.height ?? 0); + const pillLift = (initialPillBox?.y ?? 0) - (expandedPillBox?.y ?? 0); + expect(pillLift).toBeGreaterThanOrEqual(composerGrowth - 2); + expect( + (expandedPillBox?.y ?? 0) + (expandedPillBox?.height ?? 0), + ).toBeLessThanOrEqual(expandedComposerBox?.y ?? 0); +}); From a0298539f7043cd0f2d961030e60cc0fd82970b1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 24 Aug 2026 20:26:28 +0100 Subject: [PATCH 010/101] Add mobile profile editing (#6583) ## Summary - add mobile editing for display name, profile description, and profile photo - support image positioning, emoji backgrounds, and animated avatar capture with native iOS controls - refine settings navigation, profile motion, and the connection identity row ## Testing - `just mobile-check` - `just mobile-test` (1,685 tests) --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz> --- mobile/ios/Podfile.lock | 95 ++ mobile/ios/Runner.xcodeproj/project.pbxproj | 26 + mobile/ios/Runner/AppDelegate.swift | 27 + mobile/ios/Runner/Info.plist | 4 +- .../ios/Runner/JumpToLatestGlassButton.swift | 136 ++- .../ios/Runner/NativeProfileTextEditor.swift | 387 +++++++ mobile/ios/Runner/NativeSkinToneControl.swift | 137 +++ mobile/lib/app.dart | 6 + .../lib/features/activity/activity_page.dart | 2 +- .../channels/channel_details_page.dart | 3 +- .../lib/features/channels/channels_page.dart | 6 +- .../features/invites/invite_create_page.dart | 5 +- .../profile/animated_avatar_capture.dart | 984 ++++++++++++++++++ .../capture_controls.dart | 140 +++ .../animated_avatar_capture/error_text.dart | 22 + .../frame_processing.dart | 90 ++ .../review_controls.dart | 417 ++++++++ .../profile/animated_avatar_orientation.dart | 24 + .../profile/avatar_background_grid.dart | 85 ++ .../profile/avatar_editor_option_button.dart | 115 ++ .../features/profile/emoji_avatar_tile.dart | 58 ++ .../profile/ios_profile_text_editor.dart | 67 ++ .../profile/profile_avatar_crop_page.dart | 493 +++++++++ .../profile/profile_avatar_draft.dart | 134 +++ .../profile/profile_avatar_editor.dart | 689 ++++++++++++ .../emoji_avatar_picker.dart | 394 +++++++ .../features/profile/profile_edit_page.dart | 584 +++++++++++ .../features/profile/profile_provider.dart | 220 +++- .../profile/profile_text_edit_sheet.dart | 165 +++ .../features/profile/profile_text_editor.dart | 143 +++ mobile/lib/features/search/search_page.dart | 2 +- .../features/settings/accent_picker_page.dart | 5 +- .../lib/features/settings/settings_page.dart | 119 +++ .../settings_page/connection_section.dart | 31 +- .../features/settings/theme_picker_page.dart | 2 +- mobile/lib/shared/animated_avatar.dart | 5 + mobile/lib/shared/emoji/emoji_avatar.dart | 90 ++ .../lib/shared/emoji/native_emoji_glyph.dart | 36 +- .../shared/profile/user_cache_provider.dart | 5 + mobile/lib/shared/relay/media_upload.dart | 54 +- .../relay/media_upload/platform_bindings.dart | 34 + mobile/lib/shared/relay/nostr_models.dart | 2 + mobile/lib/shared/widgets/app_list.dart | 9 +- mobile/lib/shared/widgets/avatar_image.dart | 51 +- .../lib/shared/widgets/buzz_sheet_header.dart | 3 +- .../lib/shared/widgets/frosted_app_bar.dart | 185 +++- .../shared/widgets/immediate_page_route.dart | 10 + .../widgets/ios_glass_navigation_action.dart | 108 ++ .../widgets/ios_native_segmented_control.dart | 86 ++ .../widgets/ios_native_skin_tone_control.dart | 78 ++ .../shared/widgets/playing_avatar_image.dart | 56 + mobile/pubspec.lock | 40 + mobile/pubspec.yaml | 2 + .../features/activity/activity_page_test.dart | 1 + .../channels/channel_detail_page_test.dart | 3 +- .../features/channels/channels_page_test.dart | 9 +- .../profile/animated_avatar_capture_test.dart | 233 +++++ .../profile/profile_avatar_draft_test.dart | 174 ++++ .../profile/profile_edit_page_test.dart | 964 +++++++++++++++++ .../image_selection_tests.dart | 240 +++++ .../motion_and_accessibility_tests.dart | 528 ++++++++++ .../profile/profile_edit_retry_test.dart | 819 +++++++++++++++ .../profile/profile_provider_test.dart | 637 ++++++++++++ .../profile/settings_profile_header_test.dart | 25 + .../features/search/search_page_test.dart | 3 +- .../settings/connection_section_test.dart | 54 + .../features/settings/settings_page_test.dart | 119 ++- mobile/test/shared/animated_avatar_test.dart | 9 +- .../shared/emoji/native_emoji_glyph_test.dart | 68 +- .../shared/widgets/avatar_image_test.dart | 6 + .../shared/widgets/frosted_app_bar_test.dart | 63 ++ 71 files changed, 10440 insertions(+), 186 deletions(-) create mode 100644 mobile/ios/Runner/NativeProfileTextEditor.swift create mode 100644 mobile/ios/Runner/NativeSkinToneControl.swift create mode 100644 mobile/lib/features/profile/animated_avatar_capture.dart create mode 100644 mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart create mode 100644 mobile/lib/features/profile/animated_avatar_capture/error_text.dart create mode 100644 mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart create mode 100644 mobile/lib/features/profile/animated_avatar_capture/review_controls.dart create mode 100644 mobile/lib/features/profile/animated_avatar_orientation.dart create mode 100644 mobile/lib/features/profile/avatar_background_grid.dart create mode 100644 mobile/lib/features/profile/avatar_editor_option_button.dart create mode 100644 mobile/lib/features/profile/emoji_avatar_tile.dart create mode 100644 mobile/lib/features/profile/ios_profile_text_editor.dart create mode 100644 mobile/lib/features/profile/profile_avatar_crop_page.dart create mode 100644 mobile/lib/features/profile/profile_avatar_draft.dart create mode 100644 mobile/lib/features/profile/profile_avatar_editor.dart create mode 100644 mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart create mode 100644 mobile/lib/features/profile/profile_edit_page.dart create mode 100644 mobile/lib/features/profile/profile_text_edit_sheet.dart create mode 100644 mobile/lib/features/profile/profile_text_editor.dart create mode 100644 mobile/lib/shared/emoji/emoji_avatar.dart create mode 100644 mobile/lib/shared/relay/media_upload/platform_bindings.dart create mode 100644 mobile/lib/shared/widgets/immediate_page_route.dart create mode 100644 mobile/lib/shared/widgets/ios_glass_navigation_action.dart create mode 100644 mobile/lib/shared/widgets/ios_native_segmented_control.dart create mode 100644 mobile/lib/shared/widgets/ios_native_skin_tone_control.dart create mode 100644 mobile/lib/shared/widgets/playing_avatar_image.dart create mode 100644 mobile/test/features/profile/animated_avatar_capture_test.dart create mode 100644 mobile/test/features/profile/profile_avatar_draft_test.dart create mode 100644 mobile/test/features/profile/profile_edit_page_test.dart create mode 100644 mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart create mode 100644 mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart create mode 100644 mobile/test/features/profile/profile_edit_retry_test.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 05267ed7c13..491da241e8f 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -13,14 +13,71 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - google_mlkit_commons (0.11.1): + - Flutter + - MLKitVision (~> 10.0.0) + - google_mlkit_selfie_segmentation (0.10.1): + - Flutter + - google_mlkit_commons + - GoogleMLKit/SegmentationSelfie (~> 9.0.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/MLKitCore (9.0.0): + - MLKitCommon (~> 14.0.0) + - GoogleMLKit/SegmentationSelfie (9.0.0): + - GoogleMLKit/MLKitCore + - MLKitSegmentationSelfie (~> 1.0.0-beta14) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.0.2): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.0.2): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.0.2) + - GoogleUtilities/UserDefaults (8.0.2): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) - image_picker_ios (0.0.1): - Flutter - local_auth_darwin (0.0.1): - Flutter - FlutterMacOS + - MLImage (1.0.0-beta8) + - MLKitCommon (14.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitSegmentationCommon (1.0.0-beta14): + - MLKitCommon (~> 14.0) + - MLKitXenoCommon (= 1.0.0-beta16) + - MLKitSegmentationSelfie (1.0.0-beta14): + - MLKitSegmentationCommon (= 1.0.0-beta14) + - MLKitVision (10.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta8) + - MLKitCommon (~> 14.0) + - MLKitXenoCommon (1.0.0-beta16): + - MLKitCommon (~> 14.0) + - MLKitVision (~> 10.0) - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - open_filex (0.0.2): - Flutter - package_info_plus (0.4.5): @@ -28,6 +85,7 @@ PODS: - photo_manager (3.11.0): - Flutter - FlutterMacOS + - PromisesObjC (2.4.0) - share_plus (0.0.1): - Flutter - shared_preferences_foundation (0.0.1): @@ -47,6 +105,8 @@ DEPENDENCIES: - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`) + - google_mlkit_selfie_segmentation (from `.symlinks/plugins/google_mlkit_selfie_segmentation/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) @@ -58,6 +118,22 @@ DEPENDENCIES: - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - MLImage + - MLKitCommon + - MLKitSegmentationCommon + - MLKitSegmentationSelfie + - MLKitVision + - MLKitXenoCommon + - nanopb + - PromisesObjC + EXTERNAL SOURCES: app_badge_plus: :path: ".symlinks/plugins/app_badge_plus/ios" @@ -73,6 +149,10 @@ EXTERNAL SOURCES: :path: Flutter flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + google_mlkit_commons: + :path: ".symlinks/plugins/google_mlkit_commons/ios" + google_mlkit_selfie_segmentation: + :path: ".symlinks/plugins/google_mlkit_selfie_segmentation/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" local_auth_darwin: @@ -102,12 +182,27 @@ SPEC CHECKSUMS: file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + google_mlkit_commons: a5e4ffae5bc59ea4c7b9025dc72cb6cb79dc1166 + google_mlkit_selfie_segmentation: 0317616b7e460f242bd13a805b70f4e0ba636336 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0 + MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6 + MLKitSegmentationCommon: f634fb3c20c1d8bace22a2c026bdaa0ca17613d9 + MLKitSegmentationSelfie: 62371f35c33d49c3e7474443677b6a0c7ac9c98b + MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961 + MLKitXenoCommon: 1a4268c1222a6043047af5bb9435028206c63287 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 photo_manager: 6ab48c2ce7ec21aa06d59e6cc049f0b6d9ba7f94 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 0bbdeaa9802..43df76f6810 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */; }; 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */; }; 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */; }; + 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */; }; + 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */; }; 331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; }; 331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; }; 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; @@ -75,6 +77,8 @@ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; }; 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleMediaPlugin.swift; sourceTree = ""; }; 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuddleAudioEngine.swift; sourceTree = ""; }; + 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeProfileTextEditor.swift; sourceTree = ""; }; + 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSkinToneControl.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -206,6 +210,8 @@ 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */, 4A71C0162F40B00100A17E01 /* HuddleMediaPlugin.swift */, 4A71C0182F40C00100A17E01 /* HuddleAudioEngine.swift */, + 4A71C01A2F40D00100A17E01 /* NativeProfileTextEditor.swift */, + 4A71C01C2F40E00100A17E01 /* NativeSkinToneControl.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -255,6 +261,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, + ED5DDC1D42A9D342928222CC /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -420,6 +427,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + ED5DDC1D42A9D342928222CC /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -449,6 +473,8 @@ 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */, 4A71C0152F40B00100A17E01 /* HuddleMediaPlugin.swift in Sources */, 4A71C0172F40C00100A17E01 /* HuddleAudioEngine.swift in Sources */, + 4A71C0192F40D00100A17E01 /* NativeProfileTextEditor.swift in Sources */, + 4A71C01B2F40E00100A17E01 /* NativeSkinToneControl.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 65878e64d8e..1ef121fff4a 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -11,6 +11,7 @@ import UserNotifications private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? + private var nativeProfileTextEditorCoordinator: NativeProfileTextEditorCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? private var huddleMediaPlugin: HuddleMediaPlugin? @@ -110,6 +111,24 @@ import UserNotifications ) } + if let segmentedControlRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeSegmentedControl" + ) { + segmentedControlRegistrar.register( + NativeSegmentedControlFactory(messenger: messenger), + withId: "buzz/native_segmented_control" + ) + } + + if let skinToneRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeSkinToneControl" + ) { + skinToneRegistrar.register( + NativeSkinToneControlFactory(messenger: messenger), + withId: "buzz/native_skin_tone_control" + ) + } + if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzStickyDateGlassHeader" ) { @@ -134,6 +153,14 @@ import UserNotifications messenger: messenger, parentViewController: nativeEmojiPickerRegistrar?.viewController ) + + let nativeProfileTextEditorRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeProfileTextEditor" + ) + nativeProfileTextEditorCoordinator = NativeProfileTextEditorCoordinator( + messenger: messenger, + parentViewController: nativeProfileTextEditorRegistrar?.viewController + ) if #available(iOS 16.0, *), let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeMessageActionSurface" diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index af42ebd34b6..3f93df5b97e 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -46,11 +46,11 @@ NSFaceIDUsageDescription Buzz uses Face ID to confirm sensitive identity transfers. NSCameraUsageDescription - Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. + Buzz uses the camera to take profile photos and animated avatars, attach photos to messages, and scan QR codes for device pairing. NSMicrophoneUsageDescription Buzz needs microphone access so you can speak in Huddles. NSPhotoLibraryUsageDescription - Buzz needs photo library access so you can attach images to messages. + Buzz uses your photo library to select profile photos and images to attach to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. PHPhotoLibraryPreventAutomaticLimitedAccessAlert diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index f613dea7119..8a95831808a 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -172,6 +172,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { private let containerView: UIView private let channel: FlutterMethodChannel private let button = NavigationGlassButton(type: .system) + private var buttonLabel: String? init( frame: CGRect, @@ -195,8 +196,12 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { (arguments?["hitTargetWidth"] as? NSNumber)?.doubleValue ?? 48 let hitTargetHeight = (arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48 + let label = arguments?["label"] as? String + buttonLabel = label let icon = arguments?["icon"] as? String let symbolName = icon == "close" ? "xmark" : "chevron.backward" + let controlWidth = + (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -206,14 +211,28 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { configuration.baseBackgroundColor = UIColor.secondarySystemBackground } configuration.cornerStyle = .capsule - configuration.image = UIImage( - systemName: symbolName, - withConfiguration: UIImage.SymbolConfiguration( - pointSize: 17, - weight: .semibold + if let label { + configuration.title = label + configuration.titleLineBreakMode = .byClipping + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { + incoming in + var outgoing = incoming + let preferred = UIFont.preferredFont(forTextStyle: .subheadline) + outgoing.font = UIFont.systemFont(ofSize: preferred.pointSize, weight: .semibold) + return outgoing + } + } else { + configuration.image = UIImage( + systemName: symbolName, + withConfiguration: UIImage.SymbolConfiguration( + pointSize: 17, + weight: .semibold + ) ) - ) + } button.configuration = configuration + button.titleLabel?.numberOfLines = 1 + button.titleLabel?.lineBreakMode = .byClipping button.hitTargetInsets = UIEdgeInsets( top: max(0, (hitTargetHeight - 40) / 2), left: max(0, buttonCenterX - 20), @@ -246,7 +265,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { constant: buttonCenterX ), button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - button.widthAnchor.constraint(equalToConstant: 40), + button.widthAnchor.constraint(equalToConstant: controlWidth), button.heightAnchor.constraint(equalToConstant: 40), ]) } @@ -311,10 +330,13 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { let colorValue = (arguments?["foregroundColor"] as? NSNumber)?.uint32Value let foregroundColor = colorValue.map(Self.color(from:)) let enabled = arguments?["enabled"] as? Bool ?? true + let busy = arguments?["busy"] as? Bool ?? false containerView.overrideUserInterfaceStyle = interfaceStyle button.overrideUserInterfaceStyle = interfaceStyle button.isEnabled = enabled + button.configuration?.showsActivityIndicator = busy + button.configuration?.title = busy ? nil : buttonLabel if let foregroundColor { button.configuration?.baseForegroundColor = foregroundColor } @@ -339,3 +361,103 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { channel.setMethodCallHandler(nil) } } + +final class NativeSegmentedControlFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeSegmentedControlPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +final class NativeSegmentedControlPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private let segmentedControl: UISegmentedControl + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + let arguments = args as? [String: Any] + let items = arguments?["items"] as? [String] ?? [] + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/native_segmented_control/\(viewId)", + binaryMessenger: messenger + ) + segmentedControl = UISegmentedControl(items: items) + super.init() + + containerView.backgroundColor = .clear + containerView.isOpaque = false + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + segmentedControl.addTarget( + self, + action: #selector(selectionChanged), + for: .valueChanged + ) + applyState(from: arguments) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setState" else { + result(FlutterMethodNotImplemented) + return + } + self?.applyState(from: call.arguments) + result(nil) + } + + containerView.addSubview(segmentedControl) + NSLayoutConstraint.activate([ + segmentedControl.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + segmentedControl.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + segmentedControl.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + } + + func view() -> UIView { + containerView + } + + @objc private func selectionChanged() { + channel.invokeMethod("changed", arguments: segmentedControl.selectedSegmentIndex) + } + + private func applyState(from value: Any?) { + let arguments = value as? [String: Any] + let selectedIndex = (arguments?["selectedIndex"] as? NSNumber)?.intValue ?? 0 + let brightness = arguments?["brightness"] as? String + let enabled = arguments?["enabled"] as? Bool ?? true + let interfaceStyle: UIUserInterfaceStyle = brightness == "dark" ? .dark : .light + + containerView.overrideUserInterfaceStyle = interfaceStyle + segmentedControl.overrideUserInterfaceStyle = interfaceStyle + segmentedControl.selectedSegmentIndex = selectedIndex + segmentedControl.isEnabled = enabled + } + + deinit { + channel.setMethodCallHandler(nil) + } +} diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift new file mode 100644 index 00000000000..abccc693ad8 --- /dev/null +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -0,0 +1,387 @@ +import Flutter +import UIKit + +final class NativeProfileTextEditorCoordinator: NSObject, + UIAdaptivePresentationControllerDelegate +{ + private let channel: FlutterMethodChannel + private weak var parentViewController: UIViewController? + private weak var presentedController: UIViewController? + private var pendingResult: FlutterResult? + + init( + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + channel = FlutterMethodChannel( + name: "buzz/profile_text_editor", + binaryMessenger: messenger + ) + self.parentViewController = parentViewController + super.init() + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + private func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard call.method == "present" else { + result(FlutterMethodNotImplemented) + return + } + guard + let arguments = call.arguments as? [String: Any], + let title = arguments["title"] as? String, + let initialValue = arguments["initialValue"] as? String, + let placeholder = arguments["placeholder"] as? String, + let multiline = arguments["multiline"] as? Bool, + let brightness = arguments["brightness"] as? String + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected profile text editor configuration.", + details: nil + ) + ) + return + } + let allowUnchangedSubmission = + arguments["allowUnchangedSubmission"] as? Bool ?? false + + DispatchQueue.main.async { [weak self] in + self?.present( + title: title, + initialValue: initialValue, + placeholder: placeholder, + multiline: multiline, + brightness: brightness, + allowUnchangedSubmission: allowUnchangedSubmission, + result: result + ) + } + } + + @MainActor + private func present( + title: String, + initialValue: String, + placeholder: String, + multiline: Bool, + brightness: String, + allowUnchangedSubmission: Bool, + result: @escaping FlutterResult + ) { + guard presentedController == nil else { + result( + FlutterError( + code: "already_presented", + message: "A profile editor is already open.", + details: nil + ) + ) + return + } + guard + let presenter = topViewController( + from: parentViewController ?? activeWindowRootViewController() + ) + else { + result( + FlutterError( + code: "presentation_failed", + message: "Unable to find a view controller for the profile editor.", + details: nil + ) + ) + return + } + + let editor = NativeProfileTextEditorViewController( + title: title, + initialValue: initialValue, + placeholder: placeholder, + multiline: multiline, + allowUnchangedSubmission: allowUnchangedSubmission, + onCancel: { [weak self] in self?.finish(value: nil) }, + onSet: { [weak self] value in self?.finish(value: value) } + ) + let navigationController = UINavigationController(rootViewController: editor) + navigationController.overrideUserInterfaceStyle = brightness == "dark" + ? .dark + : .light + if UIDevice.current.userInterfaceIdiom == .pad { + navigationController.modalPresentationStyle = .formSheet + } + + pendingResult = result + presentedController = navigationController + presenter.present(navigationController, animated: true) { [weak self] in + navigationController.presentationController?.delegate = self + } + } + + @MainActor + private func finish(value: String?) { + guard let controller = presentedController else { + resolve(value: value) + return + } + controller.dismiss(animated: true) { [weak self] in + self?.resolve(value: value) + } + } + + func presentationControllerDidDismiss( + _ presentationController: UIPresentationController + ) { + resolve(value: nil) + } + + @MainActor + private func resolve(value: String?) { + let result = pendingResult + pendingResult = nil + presentedController = nil + result?(value) + } + + @MainActor + private func activeWindowRootViewController() -> UIViewController? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + } + + @MainActor + private func topViewController( + from viewController: UIViewController? + ) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + if let tabBarController = viewController as? UITabBarController { + return topViewController(from: tabBarController.selectedViewController) + } + if let presentedViewController = viewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + return viewController + } +} + +private final class NativeProfileTextEditorViewController: + UITableViewController, + UITextFieldDelegate, + UITextViewDelegate +{ + private let initialValue: String + private let placeholder: String + private let multiline: Bool + private let allowUnchangedSubmission: Bool + private let onCancel: () -> Void + private let onSet: (String) -> Void + + private lazy var textField: UITextField = { + let field = UITextField() + field.translatesAutoresizingMaskIntoConstraints = false + field.placeholder = placeholder + field.text = initialValue + field.font = .preferredFont(forTextStyle: .body) + field.adjustsFontForContentSizeCategory = true + field.clearButtonMode = .whileEditing + field.returnKeyType = .done + field.autocapitalizationType = .sentences + field.delegate = self + field.addTarget(self, action: #selector(textDidChange), for: .editingChanged) + return field + }() + + private lazy var textView: UITextView = { + let view = UITextView() + view.translatesAutoresizingMaskIntoConstraints = false + view.text = initialValue + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.backgroundColor = .clear + view.textContainerInset = .zero + view.textContainer.lineFragmentPadding = 0 + view.autocapitalizationType = .sentences + view.delegate = self + return view + }() + + private lazy var placeholderLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = placeholder + label.font = .preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.textColor = .placeholderText + label.numberOfLines = 0 + return label + }() + + init( + title: String, + initialValue: String, + placeholder: String, + multiline: Bool, + allowUnchangedSubmission: Bool, + onCancel: @escaping () -> Void, + onSet: @escaping (String) -> Void + ) { + self.initialValue = initialValue + self.placeholder = placeholder + self.multiline = multiline + self.allowUnchangedSubmission = allowUnchangedSubmission + self.onCancel = onCancel + self.onSet = onSet + super.init(style: .insetGrouped) + self.title = title + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + tableView.keyboardDismissMode = .interactive + tableView.alwaysBounceVertical = false + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancelTapped) + ) + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Set", + style: .done, + target: self, + action: #selector(setTapped) + ) + updateNavigation() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if multiline { + textView.becomeFirstResponder() + } else { + textField.becomeFirstResponder() + } + } + + private var currentValue: String { + multiline ? textView.text : (textField.text ?? "") + } + + private var hasUnsavedChanges: Bool { + allowUnchangedSubmission + || currentValue.trimmingCharacters(in: .whitespacesAndNewlines) + != initialValue.trimmingCharacters(in: .whitespacesAndNewlines) + } + + override var isModalInPresentation: Bool { + get { hasUnsavedChanges } + set {} + } + + @objc private func textDidChange() { + updateNavigation() + } + + func textViewDidChange(_ textView: UITextView) { + updateNavigation() + } + + private func updateNavigation() { + navigationItem.rightBarButtonItem?.isEnabled = hasUnsavedChanges + navigationController?.isModalInPresentation = hasUnsavedChanges + placeholderLabel.isHidden = !textView.text.isEmpty + } + + @objc private func cancelTapped() { + guard hasUnsavedChanges else { + onCancel() + return + } + let alert = UIAlertController( + title: "Discard changes?", + message: nil, + preferredStyle: .actionSheet + ) + alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel)) + alert.addAction( + UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in + self?.onCancel() + } + ) + if let popover = alert.popoverPresentationController { + popover.barButtonItem = navigationItem.leftBarButtonItem + } + present(alert, animated: true) + } + + @objc private func setTapped() { + guard hasUnsavedChanges else { return } + onSet(currentValue) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + setTapped() + return false + } + + override func numberOfSections(in tableView: UITableView) -> Int { 1 } + + override func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { 1 } + + override func tableView( + _ tableView: UITableView, + heightForRowAt indexPath: IndexPath + ) -> CGFloat { + multiline ? 132 : 52 + } + + override func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + if multiline { + cell.contentView.addSubview(textView) + textView.addSubview(placeholderLabel) + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor), + textView.topAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.topAnchor), + textView.bottomAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.bottomAnchor), + placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor), + placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor), + placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor), + ]) + placeholderLabel.isHidden = !textView.text.isEmpty + } else { + cell.contentView.addSubview(textField) + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor), + textField.topAnchor.constraint(equalTo: cell.contentView.topAnchor), + textField.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor), + ]) + } + return cell + } +} diff --git a/mobile/ios/Runner/NativeSkinToneControl.swift b/mobile/ios/Runner/NativeSkinToneControl.swift new file mode 100644 index 00000000000..d6541363d25 --- /dev/null +++ b/mobile/ios/Runner/NativeSkinToneControl.swift @@ -0,0 +1,137 @@ +import Flutter +import UIKit + +final class NativeSkinToneControlFactory: NSObject, FlutterPlatformViewFactory { + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + super.init() + } + + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeSkinToneControlPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +private final class NativeSkinToneControlPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private let button = UIButton(type: .system) + private var colors: [UIColor] = [] + private var labels: [String] = [] + private var value = 0 + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/native_skin_tone_control/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + let arguments = args as? [String: Any] + colors = (arguments?["colors"] as? [NSNumber] ?? []).map { + Self.color(fromARGB: $0.uint32Value) + } + labels = arguments?["labels"] as? [String] ?? [] + value = (arguments?["value"] as? NSNumber)?.intValue ?? 0 + let style = arguments?["brightness"] as? String == "dark" + ? UIUserInterfaceStyle.dark + : UIUserInterfaceStyle.light + containerView.overrideUserInterfaceStyle = style + + containerView.backgroundColor = .clear + containerView.isOpaque = false + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityLabel = "Skin tone" + button.showsMenuAsPrimaryAction = true + containerView.addSubview(button) + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44), + ]) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setValue", let value = call.arguments as? NSNumber else { + result(FlutterMethodNotImplemented) + return + } + self?.value = value.intValue + self?.refresh() + result(nil) + } + refresh() + } + + func view() -> UIView { containerView } + + private func refresh() { + guard !colors.isEmpty else { return } + value = min(max(0, value), colors.count - 1) + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + configuration.baseBackgroundColor = .secondarySystemBackground + } + configuration.cornerStyle = .capsule + configuration.image = Self.toneImage(color: colors[value], size: 20) + button.configuration = configuration + button.menu = UIMenu(children: colors.indices.map { index in + UIAction( + title: index < labels.count ? labels[index] : "Skin tone \(index + 1)", + image: Self.toneImage(color: colors[index], size: 18), + state: index == value ? .on : .off + ) { [weak self] _ in + self?.value = index + self?.refresh() + self?.channel.invokeMethod("changed", arguments: index) + } + }) + } + + private static func toneImage(color: UIColor, size: CGFloat) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) + return renderer.image { context in + let rect = CGRect(x: 0.5, y: 0.5, width: size - 1, height: size - 1) + color.setFill() + context.cgContext.fillEllipse(in: rect) + UIColor.label.withAlphaComponent(0.35).setStroke() + context.cgContext.setLineWidth(1) + context.cgContext.strokeEllipse(in: rect) + }.withRenderingMode(.alwaysOriginal) + } + + private static func color(fromARGB value: UInt32) -> UIColor { + UIColor( + red: CGFloat((value >> 16) & 0xff) / 255, + green: CGFloat((value >> 8) & 0xff) / 255, + blue: CGFloat(value & 0xff) / 255, + alpha: CGFloat((value >> 24) & 0xff) / 255 + ) + } + + deinit { channel.setMethodCallHandler(nil) } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index e020bd50bff..5ea55521522 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -16,6 +16,8 @@ import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; +import 'features/profile/profile_edit_page.dart'; +import 'features/profile/profile_text_editor.dart'; import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; @@ -156,6 +158,10 @@ class App extends HookConsumerWidget { Widget _buildSettingsPage(BuildContext context) => SettingsPage( profileHeader: const SettingsProfileHeader(), + profileEditPageBuilder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + onEditDisplayName: showProfileDisplayNameEditor, + onEditProfileDescription: showProfileDescriptionEditor, invitePageBuilder: (_) => const CommunityInvitePage(), identityRecoveryPageBuilder: (_) => const PairingPage(addingCommunity: true, identityRecoveryOnly: true), diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 5f7b11db29c..04cb2ff8cdd 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -377,7 +377,7 @@ class ActivityPage extends HookConsumerWidget { automaticallyImplyLeading: false, horizontalInset: Grid.gutter, showBottomDivider: true, - bottomDividerOpacity: 0.06, + bottomDividerOpacity: 0.07, title: Text('Activity', style: headerTitleStyle), titleStyle: headerTitleStyle, actions: [ diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 0fc68f7f3a8..3f2485cc1fa 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -255,6 +255,7 @@ class ChannelDetailsPage extends HookConsumerWidget { return FrostedScaffold( backgroundColor: context.colors.surface, appBar: FrostedAppBar( + centerTitle: true, leading: usesNativeIosGlassBackButton ? IosGlassNavigationButton( key: const ValueKey('channel-details-ios-glass-back'), @@ -279,7 +280,7 @@ class ChannelDetailsPage extends HookConsumerWidget { frostedBlurSigma: _channelDetailsHeaderFrostMaxBlurSigma * headerFrostProgress.value, showBottomDivider: headerFrostProgress.value > 0, - bottomDividerOpacity: 0.15 * headerFrostProgress.value, + bottomDividerOpacity: 0.07 * headerFrostProgress.value, title: AnimatedSwitcher( duration: reducedMotion ? Duration.zero diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5f1881facd4..55fec3f08eb 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -335,6 +335,7 @@ class ChannelsPage extends HookConsumerWidget { : 20, showBottomDivider: false, leading: _CommunityIndicator(onTap: openCommunitySwitcher), + centerTitle: false, titleStyle: headerTitleStyle, title: _CommunityHeaderTitle( style: headerTitleStyle, @@ -347,6 +348,7 @@ class ChannelsPage extends HookConsumerWidget { child: Center( child: ProfileAvatar( size: _kTopSectionProfileAvatarSize, + showPresence: false, onTap: () { unawaited(HapticFeedback.lightImpact()); final route = _SettingsPageRoute( @@ -392,8 +394,8 @@ class _SettingsPageRoute extends PageRouteBuilder { transitionsBuilder: _buildSettingsTransition, opaque: false, allowSnapshotting: false, - transitionDuration: const Duration(milliseconds: 220), - reverseTransitionDuration: const Duration(milliseconds: 190), + transitionDuration: const Duration(milliseconds: 150), + reverseTransitionDuration: const Duration(milliseconds: 150), ); final ValueChanged onTransitionProgress; diff --git a/mobile/lib/features/invites/invite_create_page.dart b/mobile/lib/features/invites/invite_create_page.dart index b6ce235f65e..f126f1210c4 100644 --- a/mobile/lib/features/invites/invite_create_page.dart +++ b/mobile/lib/features/invites/invite_create_page.dart @@ -32,7 +32,10 @@ class CommunityInvitePage extends ConsumerWidget { final roleAsync = ref.watch(currentCommunityRoleProvider); return FrostedScaffold( backgroundColor: context.colors.surface, - appBar: const FrostedAppBar(title: Text('Invite to community')), + appBar: const FrostedAppBar( + centerTitle: true, + title: Text('Invite to community'), + ), body: roleAsync.when( loading: () => const Center( child: BuzzLoadingIndicator( diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart new file mode 100644 index 00000000000..0878b78e6b5 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -0,0 +1,984 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:google_mlkit_selfie_segmentation/google_mlkit_selfie_segmentation.dart'; +import 'package:image/image.dart' as image; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import 'avatar_background_grid.dart'; +import 'avatar_editor_option_button.dart'; +import 'animated_avatar_orientation.dart'; +import 'profile_avatar_draft.dart'; + +part 'animated_avatar_capture/review_controls.dart'; +part 'animated_avatar_capture/capture_controls.dart'; +part 'animated_avatar_capture/frame_processing.dart'; +part 'animated_avatar_capture/error_text.dart'; + +const _captureDuration = Duration(seconds: 3); +const _captureFrameInterval = Duration(milliseconds: 125); +const _captureFrameCount = 24; +const _outputSize = 256; +const _mobileDefaultPersonScale = 1.15; +const _animatedReviewRailHeight = 88.0; + +enum _AnimatedReviewSection { person, color, poster } + +/// Records and prepares a short camera animation for a profile avatar. +class AnimatedAvatarCapture extends HookConsumerWidget { + /// Creates an animated-avatar capture and review surface. + const AnimatedAvatarCapture({ + super.key, + required this.height, + required this.onPrepareChanged, + this.initialFrames = const [], + }); + + /// The vertical space available to the capture surface. + final double height; + + /// Reports the current deferred draft-preparation callback to the parent. + final ValueChanged Function()?> onPrepareChanged; + + /// Seeds processed frames in lifecycle-focused widget tests. + @visibleForTesting + final List initialFrames; + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final controllerRef = useRef(null); + final captureEpoch = useRef(0); + final cameraGeneration = useState(0); + final isInitializing = useState(true); + final isRecording = useState(false); + final isPreparingFrames = useState(false); + final isProcessing = useState(false); + final progress = useState(0.0); + final frames = useState>(initialFrames); + final posterIndex = useState(0); + final previewFrameIndex = useState(0); + final scale = useState(_mobileDefaultPersonScale); + final offset = useState(Offset.zero); + final shapeScale = useState(1.12); + final shapeOffset = useState(const Offset(0, -7 / 60)); + final activeSection = useState(_AnimatedReviewSection.person); + final backdropColor = useState(emojiAvatarColors[8]); + final personOutline = useState(true); + final gestureStartScale = useRef(_mobileDefaultPersonScale); + final error = useState(null); + final encodedCache = useRef<_EncodedAvatarCache?>(null); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final lifecycle = ref.watch(appLifecycleProvider); + final encodeKey = frames.value.isEmpty + ? null + : _EncodeKey( + frames: frames.value, + posterIndex: posterIndex.value, + scale: scale.value, + offsetX: offset.value.dx, + offsetY: offset.value.dy, + backdropColor: backdropColor.value, + personOutline: personOutline.value, + shapeScale: shapeScale.value, + shapeOffsetX: shapeOffset.value.dx, + shapeOffsetY: shapeOffset.value.dy, + ); + useEffect(() { + encodedCache.value = null; + final key = encodeKey; + if (key == null) return null; + final timer = Timer(const Duration(milliseconds: 180), () { + if (encodedCache.value?.key == key) return; + final future = compute(_encodeAvatar, key.toRequest()); + encodedCache.value = _EncodedAvatarCache(key, future); + }); + return timer.cancel; + }, [encodeKey]); + + useEffect(() { + if (reduceMotion || frames.value.length < 2) return null; + final timer = Timer.periodic(_captureFrameInterval, (_) { + if (activeSection.value == _AnimatedReviewSection.poster) return; + previewFrameIndex.value = + (previewFrameIndex.value + 1) % frames.value.length; + }); + return timer.cancel; + }, [frames.value, reduceMotion]); + + useEffect(() { + var disposed = false; + + if (lifecycle != AppLifecycleState.resumed || frames.value.isNotEmpty) { + isInitializing.value = false; + controller.value = null; + return null; + } + + isInitializing.value = true; + + Future initialize() async { + try { + final cameras = await availableCameras(); + if (disposed || cameras.isEmpty) return; + final selected = cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.front, + orElse: () => cameras.first, + ); + final next = CameraController( + selected, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: defaultTargetPlatform == TargetPlatform.iOS + ? ImageFormatGroup.bgra8888 + : ImageFormatGroup.yuv420, + ); + await next.initialize(); + if (disposed) { + await next.dispose(); + return; + } + controllerRef.value = next; + controller.value = next; + } catch (_) { + if (!disposed) error.value = 'Could not access the camera.'; + } finally { + if (!disposed) isInitializing.value = false; + } + } + + unawaited(initialize()); + return () { + disposed = true; + captureEpoch.value++; + final active = controllerRef.value; + controllerRef.value = null; + unawaited(active?.dispose() ?? Future.value()); + }; + }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); + + Future prepare() async { + final key = encodeKey; + if (key == null) return null; + isProcessing.value = true; + error.value = null; + try { + final cached = encodedCache.value; + final encoding = cached != null && cached.key == key + ? cached.future + : compute(_encodeAvatar, key.toRequest()); + encodedCache.value = _EncodedAvatarCache(key, encoding); + final result = await encoding; + return ProfileAnimatedAvatarDraft( + animation: result.animation, + poster: result.poster, + ); + } catch (_) { + error.value = "We couldn't create that animation. Try again."; + return null; + } finally { + if (context.mounted) isProcessing.value = false; + } + } + + useEffect( + () { + final nextPrepare = frames.value.isEmpty ? null : prepare; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) onPrepareChanged(nextPrepare); + }); + return null; + }, + [ + frames.value, + posterIndex.value, + scale.value, + offset.value, + backdropColor.value, + personOutline.value, + shapeScale.value, + shapeOffset.value, + ], + ); + + Future record() async { + final active = controller.value; + if (active == null || isRecording.value) return; + final currentCapture = ++captureEpoch.value; + frames.value = const []; + posterIndex.value = 0; + previewFrameIndex.value = 0; + scale.value = _mobileDefaultPersonScale; + offset.value = Offset.zero; + shapeScale.value = 1.12; + shapeOffset.value = const Offset(0, -7 / 60); + error.value = null; + progress.value = 0; + isRecording.value = true; + final captured = []; + final startedAt = DateTime.now(); + var lastFrameAt = DateTime.fromMillisecondsSinceEpoch(0); + var converting = false; + var releasedCamera = false; + + Future releaseCamera() async { + if (releasedCamera || captureEpoch.value != currentCapture) return; + releasedCamera = true; + if (identical(controllerRef.value, active)) { + controllerRef.value = null; + } + if (context.mounted && identical(controller.value, active)) { + controller.value = null; + } + await active.dispose(); + } + + final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { + if (!context.mounted || captureEpoch.value != currentCapture) return; + final elapsed = DateTime.now().difference(startedAt); + progress.value = + (elapsed.inMilliseconds / _captureDuration.inMilliseconds).clamp( + 0, + 1, + ); + }); + + try { + await active.startImageStream((cameraImage) async { + final now = DateTime.now(); + if (captureEpoch.value != currentCapture || + converting || + now.difference(lastFrameAt) < _captureFrameInterval || + now.difference(startedAt) >= _captureDuration) { + return; + } + converting = true; + lastFrameAt = now; + try { + final request = _FrameRequest.fromCameraImage( + cameraImage, + rotationDegrees: animatedAvatarFrameRotationDegrees( + sensorOrientation: active.description.sensorOrientation, + deviceOrientation: active.value.deviceOrientation, + lensDirection: active.description.lensDirection, + ), + mirror: + active.description.lensDirection == CameraLensDirection.front, + ); + final frame = await compute(_convertCameraFrame, request); + if (captureEpoch.value == currentCapture) captured.add(frame); + } finally { + converting = false; + } + }); + await Future.delayed(_captureDuration); + if (captureEpoch.value != currentCapture || !context.mounted) return; + await active.stopImageStream(); + while (converting && captureEpoch.value == currentCapture) { + await Future.delayed(const Duration(milliseconds: 10)); + } + if (captureEpoch.value != currentCapture || !context.mounted) return; + await releaseCamera(); + if (captured.length < 2) { + throw StateError('Not enough frames were captured.'); + } + isRecording.value = false; + isPreparingFrames.value = true; + // Cut out only the frames each device captured, then resample the + // three-second window so Android and iOS use the same playback cadence. + final cutouts = await _removeBackgrounds(captured); + if (captureEpoch.value != currentCapture || !context.mounted) return; + final processed = List.unmodifiable( + _resampleCapturedFrames(cutouts, _captureFrameCount), + ); + if (!context.mounted) return; + await Future.wait([ + for (final frame in processed) + precacheImage(MemoryImage(frame), context), + ]); + if (context.mounted) frames.value = processed; + } catch (_) { + if (captureEpoch.value != currentCapture || !context.mounted) return; + try { + if (!releasedCamera && active.value.isStreamingImages) { + await active.stopImageStream(); + } + } on CameraException { + // The camera can stop independently while the capture is unwinding. + } + await releaseCamera(); + if (context.mounted) { + cameraGeneration.value++; + error.value = 'Recording failed. Try again.'; + } + } finally { + timer.cancel(); + if (context.mounted) { + progress.value = 1; + isRecording.value = false; + isPreparingFrames.value = false; + } + } + } + + if (frames.value.isNotEmpty) { + final selectedFrame = + frames.value[activeSection.value == _AnimatedReviewSection.poster + ? posterIndex.value + : previewFrameIndex.value]; + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final previewTop = activeSection.value == _AnimatedReviewSection.color + ? -avatarBackgroundPreviewShift + : 0.0; + final controlsTop = previewTop + 220; + return SizedBox( + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + AnimatedPositioned( + key: const ValueKey('animated-review-preview-position'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + top: previewTop, + height: 220, + child: Center( + child: _RepositionablePreviewSemantics( + offset: offset.value, + onMove: (delta) { + unawaited(HapticFeedback.selectionClick()); + offset.value = Offset( + (offset.value.dx + delta.dx).clamp(-1.0, 1.0), + (offset.value.dy + delta.dy).clamp(-1.0, 1.0), + ); + }, + child: GestureDetector( + key: const ValueKey('animated-avatar-review-preview'), + behavior: HitTestBehavior.opaque, + onScaleStart: (_) => gestureStartScale.value = scale.value, + onScaleUpdate: (details) { + final next = Offset( + (offset.value.dx + details.focalPointDelta.dx / 96) + .clamp(-1, 1), + (offset.value.dy + details.focalPointDelta.dy / 96) + .clamp(-1, 1), + ); + offset.value = next; + scale.value = (gestureStartScale.value * details.scale) + .clamp(0.7, 2.0) + .toDouble(); + }, + child: SizedBox.square( + dimension: 220, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: Transform.translate( + offset: + const Offset(0, 20.625) + + shapeOffset.value * 51.5625, + child: Transform.scale( + scale: shapeScale.value, + child: Container( + width: 172, + height: 172, + decoration: BoxDecoration( + color: Color(backdropColor.value), + shape: BoxShape.circle, + ), + ), + ), + ), + ), + _AnimatedPersonPreview( + bytes: selectedFrame, + offset: offset.value * 48, + scale: scale.value, + outline: personOutline.value, + outlineColor: _personOutlineColor( + backdropColor.value, + ), + ), + ], + ), + ), + IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.1, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + AnimatedPositioned( + key: const ValueKey('animated-review-controls-position'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: 0, + right: 0, + top: controlsTop, + bottom: _animatedReviewRailHeight, + child: Align( + alignment: Alignment.center, + child: SizedBox( + width: double.infinity, + child: switch (activeSection.value) { + _AnimatedReviewSection.person => _AnimatedFramingControl( + key: const ValueKey('animated-review-you'), + scale: scale.value, + outline: personOutline.value, + onScaleChanged: (value) => scale.value = value, + onOutlineChanged: (value) => personOutline.value = value, + ), + _AnimatedReviewSection.color => AvatarBackgroundGrid( + key: const ValueKey('animated-review-background'), + selectedColor: backdropColor.value, + onColorSelected: (value) => backdropColor.value = value, + colorKeyPrefix: 'animated-avatar-color', + ), + _AnimatedReviewSection.poster => _AnimatedFrameControl( + key: const ValueKey('animated-review-frame'), + frames: frames.value, + selectedIndex: posterIndex.value, + onSelected: (value) => posterIndex.value = value, + ), + }, + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _animatedReviewRailHeight, + child: _AnimatedReviewNav( + selected: activeSection.value, + onSelected: (section) => activeSection.value = section, + onRetake: isProcessing.value + ? null + : () { + frames.value = const []; + onPrepareChanged(null); + }, + ), + ), + if (error.value != null) + Positioned( + left: 0, + right: 0, + bottom: _animatedReviewRailHeight + Grid.xs, + child: _ErrorText(error.value!), + ), + ], + ), + ); + } + + final active = controller.value; + final compactCapture = height < 400; + final textScale = MediaQuery.textScalerOf(context).scale(1); + final statusStyle = context.textTheme.bodyMedium; + final errorStyle = context.textTheme.bodySmall; + final statusHeight = + ((statusStyle?.fontSize ?? 14) * + (statusStyle?.height ?? 1.2) * + textScale) + .floorToDouble(); + final errorHeight = error.value == null + ? 0.0 + : Grid.xs + + (errorStyle?.fontSize ?? 12) * + (errorStyle?.height ?? 1.2) * + textScale * + 2; + final capturePreviewSize = compactCapture ? 180.0 : 228.0; + final recordGap = max( + Grid.xs, + height - capturePreviewSize - Grid.xs - statusHeight - errorHeight - 64, + ); + Widget captureContent() => Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square( + key: const ValueKey('animated-avatar-capture-preview'), + dimension: capturePreviewSize, + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.all(4), + child: ClipOval( + child: ColoredBox( + color: Colors.black, + child: isPreparingFrames.value + ? Center( + child: BuzzLoadingIndicator( + size: 44, + color: context.colors.onSurface, + semanticLabel: 'Preparing animated avatar', + ), + ) + : active != null + ? _AspectCorrectCameraPreview(controller: active) + : Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Starting camera', + ) + : const Icon( + LucideIcons.cameraOff, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ), + if (isRecording.value) + Padding( + // A progress stroke is centred on its circular path. Inset + // it so the outer half is not clipped by the preview stack. + padding: const EdgeInsets.all(2), + child: CircularProgressIndicator( + key: const ValueKey('animated-avatar-recording-ring'), + value: progress.value, + strokeWidth: 4, + strokeCap: StrokeCap.round, + color: context.colors.onSurface, + backgroundColor: context.colors.outlineVariant, + ), + ), + ], + ), + ), + const SizedBox(height: Grid.xs), + Text( + isRecording.value + ? 'Recording…' + : isPreparingFrames.value + ? 'Cutting you out of the background…' + : 'Line up your shot.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (error.value != null) _ErrorText(error.value!), + SizedBox(height: recordGap), + _AnimatedRecordButton( + busy: isRecording.value || isPreparingFrames.value, + onPressed: active == null + ? null + : () { + unawaited(HapticFeedback.mediumImpact()); + unawaited(record()); + }, + ), + ], + ); + return SizedBox( + height: height, + child: SingleChildScrollView(child: captureContent()), + ); + } +} + +List _resampleCapturedFrames( + List frames, + int targetCount, +) { + if (frames.isEmpty || targetCount <= 0) return const []; + if (targetCount == 1) return [frames.first]; + if (frames.length == 1) return List.filled(targetCount, frames.single); + if (frames.length == targetCount) return frames; + + return List.generate(targetCount, (index) { + final progress = index / (targetCount - 1); + final sourceIndex = (progress * (frames.length - 1)).round(); + return frames[sourceIndex]; + }, growable: false); +} + +Color _personOutlineColor(int backdropColor) { + final color = Color(backdropColor); + return color.computeLuminance() > 0.74 + ? const Color(0xFF111111) + : Colors.white; +} + +@immutable +class _FramePlane { + const _FramePlane(this.bytes, this.bytesPerRow, this.bytesPerPixel); + + final Uint8List bytes; + final int bytesPerRow; + final int bytesPerPixel; +} + +@immutable +class _FrameRequest { + const _FrameRequest({ + required this.width, + required this.height, + required this.planes, + required this.isBgra, + required this.rotationDegrees, + required this.mirror, + }); + + factory _FrameRequest.fromCameraImage( + CameraImage frame, { + required int rotationDegrees, + required bool mirror, + }) => _FrameRequest( + width: frame.width, + height: frame.height, + planes: frame.planes + .map( + (plane) => _FramePlane( + Uint8List.fromList(plane.bytes), + plane.bytesPerRow, + frame.format.group == ImageFormatGroup.bgra8888 + ? 4 + : plane.bytesPerPixel ?? 1, + ), + ) + .toList(growable: false), + isBgra: frame.format.group == ImageFormatGroup.bgra8888, + rotationDegrees: rotationDegrees, + mirror: mirror, + ); + + final int width; + final int height; + final List<_FramePlane> planes; + final bool isBgra; + final int rotationDegrees; + final bool mirror; +} + +Uint8List _convertCameraFrame(_FrameRequest request) { + var result = image.Image(width: request.width, height: request.height); + if (request.isBgra) { + final plane = request.planes.first; + for (var y = 0; y < request.height; y++) { + for (var x = 0; x < request.width; x++) { + final index = y * plane.bytesPerRow + x * plane.bytesPerPixel; + result.setPixelRgba( + x, + y, + plane.bytes[index + 2], + plane.bytes[index + 1], + plane.bytes[index], + plane.bytes[index + 3], + ); + } + } + } else { + final yPlane = request.planes[0]; + final uPlane = request.planes[1]; + final vPlane = request.planes[2]; + for (var y = 0; y < request.height; y++) { + for (var x = 0; x < request.width; x++) { + final yValue = yPlane.bytes[y * yPlane.bytesPerRow + x]; + final uvX = x ~/ 2; + final uvY = y ~/ 2; + final u = + uPlane.bytes[uvY * uPlane.bytesPerRow + uvX * uPlane.bytesPerPixel]; + final v = + vPlane.bytes[uvY * vPlane.bytesPerRow + uvX * vPlane.bytesPerPixel]; + final red = (yValue + 1.402 * (v - 128)).round().clamp(0, 255); + final green = (yValue - 0.344136 * (u - 128) - 0.714136 * (v - 128)) + .round() + .clamp(0, 255); + final blue = (yValue + 1.772 * (u - 128)).round().clamp(0, 255); + result.setPixelRgb(x, y, red, green, blue); + } + } + } + // iOS pre-rotates and mirrors BGRA buffers. Android YUV buffers remain + // sensor-oriented and need both corrections here. + if (!request.isBgra && request.rotationDegrees != 0) { + result = image.copyRotate(result, angle: request.rotationDegrees); + } + if (!request.isBgra && request.mirror) { + result = image.flipHorizontal(result); + } + final side = result.width < result.height ? result.width : result.height; + result = image.copyCrop( + result, + x: (result.width - side) ~/ 2, + y: (result.height - side) ~/ 2, + width: side, + height: side, + ); + result = image.copyResize(result, width: _outputSize, height: _outputSize); + return image.encodePng(result, level: 4); +} + +@immutable +class _EncodeRequest { + const _EncodeRequest({ + required this.frames, + required this.posterIndex, + required this.scale, + required this.offsetX, + required this.offsetY, + required this.backdropColor, + required this.personOutline, + required this.shapeScale, + required this.shapeOffsetX, + required this.shapeOffsetY, + }); + + final List frames; + final int posterIndex; + final double scale; + final double offsetX; + final double offsetY; + final int backdropColor; + final bool personOutline; + final double shapeScale; + final double shapeOffsetX; + final double shapeOffsetY; +} + +@immutable +class _EncodeKey { + const _EncodeKey({ + required this.frames, + required this.posterIndex, + required this.scale, + required this.offsetX, + required this.offsetY, + required this.backdropColor, + required this.personOutline, + required this.shapeScale, + required this.shapeOffsetX, + required this.shapeOffsetY, + }); + + final List frames; + final int posterIndex; + final double scale; + final double offsetX; + final double offsetY; + final int backdropColor; + final bool personOutline; + final double shapeScale; + final double shapeOffsetX; + final double shapeOffsetY; + + _EncodeRequest toRequest() => _EncodeRequest( + frames: frames, + posterIndex: posterIndex, + scale: scale, + offsetX: offsetX, + offsetY: offsetY, + backdropColor: backdropColor, + personOutline: personOutline, + shapeScale: shapeScale, + shapeOffsetX: shapeOffsetX, + shapeOffsetY: shapeOffsetY, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _EncodeKey && + identical(frames, other.frames) && + posterIndex == other.posterIndex && + scale == other.scale && + offsetX == other.offsetX && + offsetY == other.offsetY && + backdropColor == other.backdropColor && + personOutline == other.personOutline && + shapeScale == other.shapeScale && + shapeOffsetX == other.shapeOffsetX && + shapeOffsetY == other.shapeOffsetY; + + @override + int get hashCode => Object.hash( + identityHashCode(frames), + posterIndex, + scale, + offsetX, + offsetY, + backdropColor, + personOutline, + shapeScale, + shapeOffsetX, + shapeOffsetY, + ); +} + +class _EncodedAvatarCache { + const _EncodedAvatarCache(this.key, this.future); + + final _EncodeKey key; + final Future<_EncodedAvatar> future; +} + +@immutable +class _EncodedAvatar { + const _EncodedAvatar(this.animation, this.poster); + + final Uint8List animation; + final Uint8List poster; +} + +_EncodedAvatar _encodeAvatar(_EncodeRequest request) { + final composed = request.frames + .map((bytes) { + final source = image.decodePng(bytes)!; + final scaledSize = (_outputSize * request.scale).round().clamp( + 1, + _outputSize * 2, + ); + final scaledPerson = image.copyResize( + source, + width: scaledSize, + height: scaledSize, + ); + final person = image.Image( + width: _outputSize, + height: _outputSize, + numChannels: 4, + ); + const previewSize = 220.0; + const previewTranslation = 48.0; + final translationScale = _outputSize / previewSize; + image.compositeImage( + person, + scaledPerson, + dstX: + ((_outputSize - scaledSize) / 2 + + request.offsetX * previewTranslation * translationScale) + .round(), + dstY: + ((_outputSize - scaledSize) / 2 + + request.offsetY * previewTranslation * translationScale) + .round(), + ); + final frame = image.Image( + width: _outputSize, + height: _outputSize, + numChannels: 4, + ); + final color = request.backdropColor; + image.fillCircle( + frame, + x: (_outputSize / 2 + request.shapeOffsetX * 60).round(), + y: (_outputSize / 2 + 24 + request.shapeOffsetY * 60).round(), + radius: (100 * request.shapeScale).round(), + color: image.ColorRgba8( + (color >> 16) & 0xff, + (color >> 8) & 0xff, + color & 0xff, + 255, + ), + ); + if (request.personOutline) { + final outline = image.Image.from(person); + final outlineColor = _personOutlineColor(request.backdropColor); + for (final pixel in outline) { + pixel + ..r = (outlineColor.r * 255).round() + ..g = (outlineColor.g * 255).round() + ..b = (outlineColor.b * 255).round() + ..a = (pixel.a * 0.92).round(); + } + for (final (x, y) in const [ + (-2, 0), + (2, 0), + (0, -2), + (0, 2), + (-1, -1), + (1, -1), + (-1, 1), + (1, 1), + ]) { + image.compositeImage(frame, outline, dstX: x, dstY: y); + } + } + image.compositeImage(frame, person); + frame.frameDuration = _captureFrameInterval.inMilliseconds; + return frame; + }) + .toList(growable: false); + final pingPong = [ + ...composed, + ...composed.reversed.skip(1).skipLast(1), + ]; + final encoder = image.PngEncoder()..start(pingPong.length); + for (final frame in pingPong) { + encoder.addFrame(frame); + } + final animation = encoder.finish()!; + final poster = image.encodePng( + composed[request.posterIndex.clamp(0, composed.length - 1)], + ); + return _EncodedAvatar(animation, poster); +} + +/// Encodes one poster frame for validating animated-avatar framing parity. +@visibleForTesting +Uint8List encodeAnimatedAvatarPoster({ + required Uint8List frame, + required double scale, +}) => _encodeAvatar( + _EncodeRequest( + frames: [frame], + posterIndex: 0, + scale: scale, + offsetX: 0, + offsetY: 0, + backdropColor: 0xff0000ff, + personOutline: false, + shapeScale: 1, + shapeOffsetX: 0, + shapeOffsetY: 0, + ), +).poster; + +extension on Iterable { + Iterable skipLast(int count) { + final values = toList(growable: false); + return values.take((values.length - count).clamp(0, values.length)); + } +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart new file mode 100644 index 00000000000..0c87c10b504 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -0,0 +1,140 @@ +part of '../animated_avatar_capture.dart'; + +class _AnimatedRecordButton extends StatelessWidget { + const _AnimatedRecordButton({required this.busy, required this.onPressed}); + + final bool busy; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return LayoutBuilder( + builder: (context, constraints) => Center( + child: AnimatedContainer( + key: const ValueKey('animated-avatar-record-morph'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: busy ? 64 : constraints.maxWidth, + height: 64, + child: Material( + color: context.colors.onSurface, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('animated-avatar-record'), + onTap: busy ? null : onPressed, + child: Center( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: busy + ? BuzzLoadingIndicator( + key: const ValueKey('animated-avatar-capturing'), + size: 24, + color: context.colors.surface, + semanticLabel: 'Capturing animated avatar', + ) + : Text( + 'Record', + key: const ValueKey('animated-avatar-record-label'), + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.surface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _AspectCorrectCameraPreview extends StatelessWidget { + const _AspectCorrectCameraPreview({required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + final orientation = controller.value.deviceOrientation; + final isLandscape = + orientation == DeviceOrientation.landscapeLeft || + orientation == DeviceOrientation.landscapeRight; + final aspectRatio = isLandscape + ? controller.value.aspectRatio + : 1 / controller.value.aspectRatio; + return FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: 220 * aspectRatio, + height: 220, + child: CameraPreview(controller), + ), + ); + } +} + +class _AnimatedPersonPreview extends StatelessWidget { + const _AnimatedPersonPreview({ + required this.bytes, + required this.offset, + required this.scale, + required this.outline, + required this.outlineColor, + }); + + final Uint8List bytes; + final Offset offset; + final double scale; + final bool outline; + final Color outlineColor; + + @override + Widget build(BuildContext context) { + Widget person({Color? tint, Offset outlineOffset = Offset.zero}) => + Transform.translate( + offset: offset + outlineOffset, + child: Transform.scale( + scale: scale, + child: tint == null + ? Image.memory(bytes, fit: BoxFit.cover) + : Opacity( + opacity: 0.92, + child: ColorFiltered( + colorFilter: ColorFilter.mode(tint, BlendMode.srcIn), + child: Image.memory(bytes, fit: BoxFit.cover), + ), + ), + ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + if (outline) + for (final outlineOffset in const [ + Offset(0, -2.4), + Offset(2.4, 0), + Offset(0, 2.4), + Offset(-2.4, 0), + Offset(1.7, -1.7), + Offset(1.7, 1.7), + Offset(-1.7, 1.7), + Offset(-1.7, -1.7), + ]) + person(tint: outlineColor, outlineOffset: outlineOffset), + person(), + ], + ); + } +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart new file mode 100644 index 00000000000..022a730009c --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart @@ -0,0 +1,22 @@ +part of '../animated_avatar_capture.dart'; + +class _ErrorText extends StatelessWidget { + const _ErrorText(this.message); + + final String message; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xs), + child: Semantics( + liveRegion: true, + child: Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ); +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart new file mode 100644 index 00000000000..b93175bf73a --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart @@ -0,0 +1,90 @@ +part of '../animated_avatar_capture.dart'; + +/// Creates an isolated workspace for one capture's segmentation frames. +@visibleForTesting +Future createAnimatedAvatarFrameDirectory({ + Directory? parent, +}) async => (parent ?? await getTemporaryDirectory()).createTemp( + 'buzz-avatar-capture-', +); + +Future> _removeBackgrounds(List frames) async { + final segmenter = SelfieSegmenter( + mode: SegmenterMode.stream, + enableRawSizeMask: false, + ); + Directory? captureDirectory; + final results = []; + try { + captureDirectory = await createAnimatedAvatarFrameDirectory(); + for (var index = 0; index < frames.length; index++) { + final file = File('${captureDirectory.path}/frame-$index.png'); + await file.writeAsBytes(frames[index], flush: false); + final mask = await segmenter.processImage( + InputImage.fromFilePath(file.path), + ); + if (mask == null) { + results.add(frames[index]); + continue; + } + results.add( + await compute( + _applySegmentationMask, + _MaskRequest( + frame: frames[index], + maskWidth: mask.width, + maskHeight: mask.height, + confidences: Float32List.fromList(mask.confidences), + ), + ), + ); + } + } finally { + await segmenter.close(); + final directory = captureDirectory; + if (directory != null && await directory.exists()) { + try { + await directory.delete(recursive: true); + } on FileSystemException { + // Temporary capture cleanup is best effort. + } + } + } + return results; +} + +@immutable +class _MaskRequest { + const _MaskRequest({ + required this.frame, + required this.maskWidth, + required this.maskHeight, + required this.confidences, + }); + + final Uint8List frame; + final int maskWidth; + final int maskHeight; + final Float32List confidences; +} + +Uint8List _applySegmentationMask(_MaskRequest request) { + final result = image.decodePng(request.frame)!.convert(numChannels: 4); + for (var y = 0; y < result.height; y++) { + final maskY = (y * request.maskHeight / result.height).floor().clamp( + 0, + request.maskHeight - 1, + ); + for (var x = 0; x < result.width; x++) { + final maskX = (x * request.maskWidth / result.width).floor().clamp( + 0, + request.maskWidth - 1, + ); + final confidence = request.confidences[maskY * request.maskWidth + maskX]; + final alpha = ((confidence - 0.28) / (0.72 - 0.28)).clamp(0, 1); + final pixel = result.getPixel(x, y); + pixel.a = (alpha * 255).round(); + } + } + return image.encodePng(result, level: 4); +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart new file mode 100644 index 00000000000..7c9ce4d9709 --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -0,0 +1,417 @@ +part of '../animated_avatar_capture.dart'; + +class _RepositionablePreviewSemantics extends StatelessWidget { + const _RepositionablePreviewSemantics({ + required this.offset, + required this.onMove, + required this.child, + }); + + final Offset offset; + final ValueChanged onMove; + final Widget child; + + @override + Widget build(BuildContext context) => Semantics( + label: 'Avatar position', + value: + '${(offset.dx * 100).round()} horizontal, ' + '${(offset.dy * 100).round()} vertical', + customSemanticsActions: { + if (offset.dx > -1) + const CustomSemanticsAction(label: 'Move left'): () => + onMove(const Offset(-0.1, 0)), + if (offset.dx < 1) + const CustomSemanticsAction(label: 'Move right'): () => + onMove(const Offset(0.1, 0)), + if (offset.dy > -1) + const CustomSemanticsAction(label: 'Move up'): () => + onMove(const Offset(0, -0.1)), + if (offset.dy < 1) + const CustomSemanticsAction(label: 'Move down'): () => + onMove(const Offset(0, 0.1)), + }, + child: ExcludeSemantics(child: child), + ); +} + +class _AnimatedReviewNav extends StatelessWidget { + const _AnimatedReviewNav({ + required this.selected, + required this.onSelected, + required this.onRetake, + }); + + final _AnimatedReviewSection selected; + final ValueChanged<_AnimatedReviewSection> onSelected; + final VoidCallback? onRetake; + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.userRound, + label: 'You', + selected: selected == _AnimatedReviewSection.person, + onTap: () => onSelected(_AnimatedReviewSection.person), + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.palette, + label: 'Background', + selected: selected == _AnimatedReviewSection.color, + onTap: () => onSelected(_AnimatedReviewSection.color), + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.galleryThumbnails, + label: 'Frame', + selected: selected == _AnimatedReviewSection.poster, + onTap: () => onSelected(_AnimatedReviewSection.poster), + ), + ), + const SizedBox(width: Grid.half), + Container(width: 1, height: 64, color: context.colors.outlineVariant), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + icon: LucideIcons.camera, + label: 'Retake', + selected: false, + onTap: onRetake, + ), + ), + ], + ); +} + +class _AnimatedFramingControl extends HookWidget { + const _AnimatedFramingControl({ + super.key, + required this.scale, + required this.outline, + required this.onScaleChanged, + required this.onOutlineChanged, + }); + + final double scale; + final bool outline; + final ValueChanged onScaleChanged; + final ValueChanged onOutlineChanged; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: _DesktopStyleFramingSlider( + value: scale, + onChanged: onScaleChanged, + ), + ), + const SizedBox(width: Grid.xxs), + DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.onSurface.withValues(alpha: 0.08), + ), + ), + child: IconButton( + key: const ValueKey('animated-avatar-outline'), + tooltip: outline ? 'Turn outline off' : 'Turn outline on', + onPressed: () { + unawaited(HapticFeedback.selectionClick()); + onOutlineChanged(!outline); + }, + icon: Icon( + outline ? LucideIcons.circle : LucideIcons.circleDashed, + size: 24, + ), + constraints: const BoxConstraints.tightFor( + width: 56, + height: 56, + ), + ), + ), + ], + ), + const SizedBox(height: Grid.xxs), + Text( + 'Drag the preview to reposition', + style: context.textTheme.bodySmall, + ), + ], + ); + } +} + +class _DesktopStyleFramingSlider extends HookWidget { + const _DesktopStyleFramingSlider({ + required this.value, + required this.onChanged, + }); + + static const _min = 0.7; + static const _max = 2.0; + + final double value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final lastTick = useRef((value * 20).round()); + + void update(double dx, double width) { + if (width <= 0) return; + final progress = (dx / width).clamp(0.0, 1.0).toDouble(); + final next = _min + progress * (_max - _min); + final tick = (next * 20).round(); + if (tick != lastTick.value) { + lastTick.value = tick; + unawaited(HapticFeedback.selectionClick()); + } + onChanged(next); + } + + final progress = ((value - _min) / (_max - _min)) + .clamp(0.0, 1.0) + .toDouble(); + return Semantics( + slider: true, + label: 'Avatar size', + value: '${(value * 100).round()}%', + increasedValue: value < _max + ? '${((value + 0.05).clamp(_min, _max) * 100).round()}%' + : null, + decreasedValue: value > _min + ? '${((value - 0.05).clamp(_min, _max) * 100).round()}%' + : null, + onIncrease: value < _max + ? () => onChanged((value + 0.05).clamp(_min, _max).toDouble()) + : null, + onDecrease: value > _min + ? () => onChanged((value - 0.05).clamp(_min, _max).toDouble()) + : null, + child: SizedBox( + key: const ValueKey('animated-avatar-scale'), + height: 56, + child: LayoutBuilder( + builder: (context, constraints) { + final handleX = (constraints.maxWidth * progress) + .clamp(2.0, constraints.maxWidth - 2) + .toDouble(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => + update(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragStart: (details) => + update(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragUpdate: (details) => + update(details.localPosition.dx, constraints.maxWidth), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerLeft, + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.onSurface.withValues( + alpha: 0.08, + ), + ), + ), + ), + ), + Positioned( + left: 0, + top: 0, + bottom: 0, + width: handleX, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(Radii.sm), + ), + ), + ), + for (var index = 1; index < 8; index++) + Positioned( + left: constraints.maxWidth * index / 8 - 2, + child: Container( + width: 4, + height: 4, + decoration: BoxDecoration( + color: context.colors.onSurface.withValues( + alpha: 0.16, + ), + shape: BoxShape.circle, + ), + ), + ), + Positioned( + left: handleX - 1.5, + top: 0, + bottom: 0, + child: Container( + width: 3, + decoration: BoxDecoration( + color: context.colors.onSurface, + borderRadius: BorderRadius.circular(Radii.full), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _AnimatedFrameControl extends HookWidget { + const _AnimatedFrameControl({ + super.key, + required this.frames, + required this.selectedIndex, + required this.onSelected, + }); + + final List frames; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final lastSelection = useRef(selectedIndex); + + void selectIndex(int index) { + if (frames.isEmpty) return; + final next = index.clamp(0, frames.length - 1); + if (next == lastSelection.value) return; + lastSelection.value = next; + unawaited(HapticFeedback.selectionClick()); + onSelected(next); + } + + void selectAt(double dx, double width) { + if (frames.isEmpty || width <= 0) return; + final progress = (dx / width).clamp(0.0, 1.0); + selectIndex((progress * (frames.length - 1)).round()); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + key: const ValueKey('animated-avatar-poster-scrubber'), + height: 64, + child: LayoutBuilder( + builder: (context, constraints) { + const selectorWidth = 52.0; + final progress = frames.length < 2 + ? 0.0 + : selectedIndex / (frames.length - 1); + return Semantics( + slider: true, + label: 'Choose still frame', + value: '${selectedIndex + 1} of ${frames.length}', + increasedValue: selectedIndex < frames.length - 1 + ? '${selectedIndex + 2} of ${frames.length}' + : null, + decreasedValue: selectedIndex > 0 + ? '$selectedIndex of ${frames.length}' + : null, + onIncrease: selectedIndex < frames.length - 1 + ? () => selectIndex(selectedIndex + 1) + : null, + onDecrease: selectedIndex > 0 + ? () => selectIndex(selectedIndex - 1) + : null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) => + selectAt(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragStart: (details) => + selectAt(details.localPosition.dx, constraints.maxWidth), + onHorizontalDragUpdate: (details) => + selectAt(details.localPosition.dx, constraints.maxWidth), + child: Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.sm), + child: Row( + children: [ + for (final (index, frame) in frames.indexed) + Expanded( + child: Image.memory( + frame, + key: ValueKey( + 'animated-avatar-poster-$index', + ), + height: 64, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + ), + ], + ), + ), + ), + AnimatedPositioned( + duration: const Duration(milliseconds: 75), + curve: Curves.easeOut, + left: (constraints.maxWidth - selectorWidth) * progress, + top: 0, + bottom: 0, + width: selectorWidth, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all(color: Colors.white, width: 3), + boxShadow: const [ + BoxShadow( + color: Color(0x52000000), + blurRadius: 16, + offset: Offset(0, 4), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + const SizedBox(height: Grid.xxs), + Text('Choose the still frame', style: context.textTheme.bodySmall), + ], + ); + } +} diff --git a/mobile/lib/features/profile/animated_avatar_orientation.dart b/mobile/lib/features/profile/animated_avatar_orientation.dart new file mode 100644 index 00000000000..77f2b557c4a --- /dev/null +++ b/mobile/lib/features/profile/animated_avatar_orientation.dart @@ -0,0 +1,24 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/services.dart'; + +/// Returns the clockwise correction for an Android animated-avatar frame. +/// +/// Android image-stream buffers remain sensor-oriented. The correction must +/// account for both the sensor mount and the orientation in which the device +/// is currently held; front-facing frames are mirrored separately after this +/// rotation. +int animatedAvatarFrameRotationDegrees({ + required int sensorOrientation, + required DeviceOrientation deviceOrientation, + required CameraLensDirection lensDirection, +}) { + final deviceOrientationDegrees = switch (deviceOrientation) { + DeviceOrientation.portraitUp => 0, + DeviceOrientation.landscapeRight => 90, + DeviceOrientation.portraitDown => 180, + DeviceOrientation.landscapeLeft => 270, + }; + final facingSign = lensDirection == CameraLensDirection.back ? -1 : 1; + return (sensorOrientation - deviceOrientationDegrees * facingSign + 360) % + 360; +} diff --git a/mobile/lib/features/profile/avatar_background_grid.dart b/mobile/lib/features/profile/avatar_background_grid.dart new file mode 100644 index 00000000000..220462ab53c --- /dev/null +++ b/mobile/lib/features/profile/avatar_background_grid.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/theme/theme.dart'; + +/// Vertical preview shift shared by background editors. +const avatarBackgroundPreviewShift = 136.0; + +/// The shared background-color grid used by emoji and animated avatars. +class AvatarBackgroundGrid extends StatelessWidget { + /// Creates a fixed grid of avatar background colors. + const AvatarBackgroundGrid({ + super.key, + required this.selectedColor, + required this.onColorSelected, + this.colorKeyPrefix = 'avatar-background-color', + }); + + /// The ARGB value of the currently selected background color. + final int selectedColor; + + /// Called with the selected ARGB value when the user taps a color. + final ValueChanged onColorSelected; + + /// Prefix used for each color option's test key. + final String colorKeyPrefix; + + @override + Widget build(BuildContext context) => GridView.builder( + shrinkWrap: true, + primary: false, + padding: EdgeInsets.zero, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 6, + crossAxisSpacing: Grid.xxs, + mainAxisSpacing: Grid.xs, + ), + itemCount: emojiAvatarColors.length, + itemBuilder: (context, index) { + final color = emojiAvatarColors[index]; + final isSelected = color == selectedColor; + return Center( + child: Semantics( + selected: isSelected, + label: 'Avatar color ${index + 1}', + child: InkWell( + key: ValueKey('$colorKeyPrefix-$index'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () { + unawaited(HapticFeedback.selectionClick()); + onColorSelected(color); + }, + child: Container( + width: 52, + height: 52, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: Color(color), + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + child: isSelected + ? DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: _selectionColor(Color(color)), + width: 3, + ), + ), + ) + : null, + ), + ), + ), + ); + }, + ); +} + +Color _selectionColor(Color color) => + color.computeLuminance() > 0.55 ? const Color(0xFF111111) : Colors.white; diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart new file mode 100644 index 00000000000..3ad51acb83a --- /dev/null +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -0,0 +1,115 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../shared/theme/theme.dart'; + +/// A labelled circular option used by the profile avatar editor rails. +class AvatarEditorOptionButton extends StatelessWidget { + /// Creates a selectable avatar-editor rail option. + const AvatarEditorOptionButton({ + super.key, + required this.icon, + required this.label, + required this.selected, + required this.onTap, + this.labelMaxWidth, + }); + + /// The symbol displayed inside the circular control. + final IconData icon; + + /// The text displayed beneath the control. + final String label; + + /// Whether this option represents the active editor section. + final bool selected; + + /// Called when the option is selected, or null when disabled. + final VoidCallback? onTap; + + /// Optional width constraint for the label's overflow region. + final double? labelMaxWidth; + + @override + Widget build(BuildContext context) { + final handleTap = onTap == null + ? null + : () { + unawaited(HapticFeedback.selectionClick()); + onTap!(); + }; + return Semantics( + label: label, + button: true, + selected: selected, + enabled: handleTap != null, + onTap: handleTap, + child: ExcludeSemantics( + child: InkResponse( + radius: 34, + onTap: handleTap, + child: SizedBox( + width: double.infinity, + child: Column( + children: [ + AnimatedContainer( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected + ? context.colors.onSurface + : context.colors.surfaceContainerHighest, + ), + child: Icon( + icon, + size: 26, + color: selected + ? context.colors.surface + : context.colors.onSurface, + ), + ), + const SizedBox(height: Grid.quarter), + if (labelMaxWidth case final width?) + SizedBox( + height: 20, + child: OverflowBox( + maxWidth: width, + maxHeight: 20, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ), + ) + else + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/emoji_avatar_tile.dart b/mobile/lib/features/profile/emoji_avatar_tile.dart new file mode 100644 index 00000000000..ff8e7f5dc77 --- /dev/null +++ b/mobile/lib/features/profile/emoji_avatar_tile.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/theme/theme.dart'; + +/// A selectable emoji tile with an explicit accessibility selection state. +class EmojiAvatarTile extends StatelessWidget { + /// Creates an emoji option for an avatar picker. + const EmojiAvatarTile({ + required this.emoji, + required this.label, + required this.tileId, + required this.isSelected, + required this.onTap, + super.key, + }); + + /// The Unicode emoji glyph rendered by this tile. + final String emoji; + + /// The human-readable emoji name announced to assistive technology. + final String label; + + /// The stable identifier used for the tile's widget key. + final String tileId; + + /// Whether this emoji is the current avatar selection. + final bool isSelected; + + /// Called when the tile is selected. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Semantics( + label: label, + button: true, + selected: isSelected, + onTap: onTap, + child: ExcludeSemantics( + child: InkWell( + key: ValueKey('emoji-avatar-$tileId'), + borderRadius: BorderRadius.circular(Radii.sm), + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? context.colors.primaryContainer + : Colors.transparent, + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Center( + child: NativeEmojiGlyph(emoji: emoji, size: 30, opticalBoxSize: 30), + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/profile/ios_profile_text_editor.dart b/mobile/lib/features/profile/ios_profile_text_editor.dart new file mode 100644 index 00000000000..3760b456617 --- /dev/null +++ b/mobile/lib/features/profile/ios_profile_text_editor.dart @@ -0,0 +1,67 @@ +import 'package:flutter/services.dart'; + +/// Opens the native iOS form used to edit a single profile text field. +class IosProfileTextEditor { + IosProfileTextEditor._(); + + static const _channel = MethodChannel('buzz/profile_text_editor'); + + /// Presents the native editor and returns its submitted value, or null when + /// the user cancels. + static Future present({ + required String title, + required String initialValue, + required String placeholder, + required bool multiline, + required Brightness brightness, + bool allowUnchangedSubmission = false, + }) => _channel.invokeMethod('present', { + 'title': title, + 'initialValue': initialValue, + 'placeholder': placeholder, + 'multiline': multiline, + 'brightness': brightness.name, + 'allowUnchangedSubmission': allowUnchangedSubmission, + }); + + /// Keeps the native editor's latest value available until it saves or the + /// user cancels, so a transient publish failure never discards their text. + static Future presentUntilSaved({ + required String title, + required String initialValue, + required String placeholder, + required bool multiline, + required Brightness brightness, + required Future Function(String value) onSave, + required void Function() onSaveError, + bool Function(Object error)? shouldRetryOnError, + bool Function()? canPresent, + }) async { + var draft = initialValue; + var isRetry = false; + while (true) { + if (canPresent?.call() == false) return; + final value = await present( + title: title, + initialValue: draft, + placeholder: placeholder, + multiline: multiline, + brightness: brightness, + allowUnchangedSubmission: isRetry, + ); + if (value == null) return; + try { + await onSave(value); + return; + } catch (error) { + if (shouldRetryOnError?.call(error) == false || + canPresent?.call() == false) { + return; + } + draft = value; + isRetry = true; + onSaveError(); + } + } + } +} diff --git a/mobile/lib/features/profile/profile_avatar_crop_page.dart b/mobile/lib/features/profile/profile_avatar_crop_page.dart new file mode 100644 index 00000000000..fb60dbaaf2b --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_crop_page.dart @@ -0,0 +1,493 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:image/image.dart' as image; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; + +const _cropOutputSize = 512; + +/// Lets a person pan and zoom a selected image inside the final avatar mask. +class ProfileAvatarCropPage extends HookWidget { + /// Creates a crop page that resolves the selected image asynchronously. + const ProfileAvatarCropPage({super.key, required this.imageBytes}); + + /// The selected image bytes, or null when selection was cancelled. + final Future imageBytes; + + @override + Widget build(BuildContext context) { + final controller = useTransformationController(); + final cropScale = useState(1.0); + final preparedBytes = useState(null); + final loadError = useState(false); + final dimensionsFuture = useMemoized( + () => preparedBytes.value == null + ? null + : compute(_decodeDimensions, preparedBytes.value!), + [preparedBytes.value], + ); + final dimensionsSnapshot = useFuture(dimensionsFuture); + final dimensionsData = dimensionsSnapshot.data; + final dimensions = dimensionsData == null + ? null + : _ImageDimensions(dimensionsData[0], dimensionsData[1]); + final initializedForSize = useRef(null); + final cropGeometry = useRef<_CropGeometry?>(null); + final displaySize = useRef(null); + final isClampingTransform = useRef(false); + final isCropping = useState(false); + + useEffect(() { + void clampTransform() { + if (isClampingTransform.value) return; + final geometry = cropGeometry.value; + final imageSize = displaySize.value; + if (geometry == null || imageSize == null) return; + + final matrix = controller.value.clone(); + final scale = matrix.getMaxScaleOnAxis(); + final cropLeft = (geometry.canvasWidth - geometry.cropDiameter) / 2; + final cropTop = (geometry.canvasHeight - geometry.cropDiameter) / 2; + final cropRight = cropLeft + geometry.cropDiameter; + final cropBottom = cropTop + geometry.cropDiameter; + final minX = cropRight - imageSize.width * scale; + final minY = cropBottom - imageSize.height * scale; + final nextX = matrix.storage[12].clamp(minX, cropLeft).toDouble(); + final nextY = matrix.storage[13].clamp(minY, cropTop).toDouble(); + if ((nextX - matrix.storage[12]).abs() < 0.01 && + (nextY - matrix.storage[13]).abs() < 0.01) { + return; + } + matrix.storage[12] = nextX; + matrix.storage[13] = nextY; + isClampingTransform.value = true; + controller.value = matrix; + isClampingTransform.value = false; + } + + controller.addListener(clampTransform); + return () => controller.removeListener(clampTransform); + }, [controller]); + + useEffect(() { + var disposed = false; + imageBytes.then( + (bytes) { + if (disposed) return; + if (bytes == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) Navigator.pop(context); + }); + return; + } + preparedBytes.value = bytes; + }, + onError: (_) { + if (!disposed) loadError.value = true; + }, + ); + return () => disposed = true; + }, [imageBytes]); + + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + centerTitle: true, + titleSpacing: 0, + title: Text( + 'Position Photo', + maxLines: 1, + style: context.textTheme.titleSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + leadingWidth: 120, + leading: Padding( + padding: const EdgeInsets.only(left: Grid.gutter), + child: Align( + alignment: Alignment.centerLeft, + child: _CropHeaderButton( + label: 'Cancel', + onPressed: isCropping.value ? null : () => Navigator.pop(context), + ), + ), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: Grid.gutter), + child: _CropHeaderButton( + key: const ValueKey('avatar-crop-use-photo'), + label: 'Save', + loading: isCropping.value, + onPressed: + isCropping.value || + preparedBytes.value == null || + dimensions == null + ? null + : () async { + isCropping.value = true; + unawaited(HapticFeedback.lightImpact()); + try { + if (!context.mounted) return; + final geometry = cropGeometry.value; + if (geometry == null) return; + final cropped = await compute( + _cropAvatar, + _CropRequest( + bytes: preparedBytes.value!, + sourceWidth: dimensions.width, + sourceHeight: dimensions.height, + canvasWidth: geometry.canvasWidth, + canvasHeight: geometry.canvasHeight, + cropDiameter: geometry.cropDiameter, + transform: List.from( + controller.value.storage, + ), + ), + ); + if (context.mounted) Navigator.pop(context, cropped); + } finally { + if (context.mounted) isCropping.value = false; + } + }, + ), + ), + ], + ), + body: loadError.value || dimensionsSnapshot.hasError + ? const Center( + child: Text( + "We couldn't prepare that photo.", + style: TextStyle(color: Colors.white), + ), + ) + : preparedBytes.value == null || dimensions == null + ? const Center( + child: BuzzLoadingIndicator( + color: Colors.white, + semanticLabel: 'Preparing photo', + ), + ) + : LayoutBuilder( + builder: (context, constraints) { + final canvasWidth = constraints.maxWidth; + final canvasHeight = math.max(1.0, constraints.maxHeight); + final cropDiameter = math.min(canvasWidth, canvasHeight); + cropGeometry.value = _CropGeometry( + canvasWidth: canvasWidth, + canvasHeight: canvasHeight, + cropDiameter: cropDiameter, + ); + final aspect = dimensions.width / dimensions.height; + final displayWidth = aspect >= 1 + ? cropDiameter * aspect + : cropDiameter; + final displayHeight = aspect >= 1 + ? cropDiameter + : cropDiameter / aspect; + displaySize.value = Size(displayWidth, displayHeight); + final canvasSize = Size(canvasWidth, canvasHeight); + if (initializedForSize.value != canvasSize) { + initializedForSize.value = canvasSize; + controller.value = Matrix4.identity() + ..translateByDouble( + (canvasWidth - displayWidth) / 2, + (canvasHeight - displayHeight) / 2, + 0, + 1, + ); + } + void movePhoto(Offset delta) { + unawaited(HapticFeedback.selectionClick()); + final matrix = controller.value.clone(); + matrix.storage[12] += delta.dx; + matrix.storage[13] += delta.dy; + controller.value = matrix; + } + + void zoomPhoto(double factor) { + unawaited(HapticFeedback.selectionClick()); + final matrix = controller.value.clone(); + final currentScale = matrix.getMaxScaleOnAxis(); + final nextScale = (currentScale * factor) + .clamp(1.0, 5.0) + .toDouble(); + final appliedFactor = nextScale / currentScale; + final center = Offset(canvasWidth / 2, canvasHeight / 2); + matrix.storage[12] = + center.dx - + (center.dx - matrix.storage[12]) * appliedFactor; + matrix.storage[13] = + center.dy - + (center.dy - matrix.storage[13]) * appliedFactor; + matrix.storage[0] *= appliedFactor; + matrix.storage[5] *= appliedFactor; + matrix.storage[10] *= appliedFactor; + controller.value = matrix; + cropScale.value = nextScale; + } + + final currentScale = cropScale.value; + final maskBottom = (canvasHeight + cropDiameter) / 2; + return Stack( + children: [ + SizedBox( + width: canvasWidth, + height: canvasHeight, + child: Stack( + fit: StackFit.expand, + children: [ + ClipRect( + child: Semantics( + label: 'Photo crop', + value: '${(currentScale * 100).round()}% zoom', + customSemanticsActions: { + const CustomSemanticsAction( + label: 'Move left', + ): () => + movePhoto(const Offset(-24, 0)), + const CustomSemanticsAction( + label: 'Move right', + ): () => + movePhoto(const Offset(24, 0)), + const CustomSemanticsAction( + label: 'Move up', + ): () => + movePhoto(const Offset(0, -24)), + const CustomSemanticsAction( + label: 'Move down', + ): () => + movePhoto(const Offset(0, 24)), + if (currentScale < 5) + const CustomSemanticsAction( + label: 'Zoom in', + ): () => + zoomPhoto(1.1), + if (currentScale > 1) + const CustomSemanticsAction( + label: 'Zoom out', + ): () => + zoomPhoto(1 / 1.1), + }, + child: ExcludeSemantics( + child: InteractiveViewer( + key: const ValueKey('avatar-crop-viewer'), + transformationController: controller, + constrained: false, + panEnabled: true, + scaleEnabled: true, + minScale: 1, + maxScale: 5, + onInteractionUpdate: (_) { + final nextScale = controller.value + .getMaxScaleOnAxis(); + if ((cropScale.value - nextScale).abs() > + 0.001) { + cropScale.value = nextScale; + } + }, + boundaryMargin: EdgeInsets.all( + math.max(canvasWidth, canvasHeight), + ), + child: Image.memory( + preparedBytes.value!, + width: displayWidth, + height: displayHeight, + fit: BoxFit.fill, + gaplessPlayback: true, + ), + ), + ), + ), + ), + IgnorePointer( + child: CustomPaint( + painter: _CropMaskPainter(cropDiameter), + ), + ), + ], + ), + ), + Positioned( + left: 0, + right: 0, + top: math.min( + maskBottom + Grid.twelve, + canvasHeight - 24, + ), + child: const Center( + child: Text( + 'Move and scale', + style: TextStyle(color: Colors.white70), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _CropHeaderButton extends StatelessWidget { + const _CropHeaderButton({ + super.key, + required this.label, + required this.onPressed, + this.loading = false, + }); + + final String label; + final VoidCallback? onPressed; + final bool loading; + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(minWidth: 64), + child: SizedBox( + height: 40, + child: TextButton( + style: TextButton.styleFrom( + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white54, + backgroundColor: Colors.white.withValues(alpha: 0.18), + disabledBackgroundColor: Colors.white.withValues(alpha: 0.1), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onPressed, + child: loading + ? const BuzzLoadingIndicator( + size: 18, + color: Colors.white, + semanticLabel: 'Saving photo', + ) + : Text(label, maxLines: 1, softWrap: false), + ), + ), + ); +} + +class _CropMaskPainter extends CustomPainter { + const _CropMaskPainter(this.cropDiameter); + + final double cropDiameter; + + @override + void paint(Canvas canvas, Size size) { + final circle = Rect.fromCircle( + center: size.center(Offset.zero), + radius: cropDiameter / 2 - 2, + ); + final mask = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Offset.zero & size) + ..addOval(circle); + canvas.drawPath( + mask, + Paint()..color = Colors.black.withValues(alpha: 0.58), + ); + canvas.drawOval( + circle, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Colors.white.withValues(alpha: 0.9), + ); + } + + @override + bool shouldRepaint(_CropMaskPainter oldDelegate) => + oldDelegate.cropDiameter != cropDiameter; +} + +class _CropGeometry { + const _CropGeometry({ + required this.canvasWidth, + required this.canvasHeight, + required this.cropDiameter, + }); + + final double canvasWidth; + final double canvasHeight; + final double cropDiameter; +} + +class _ImageDimensions { + const _ImageDimensions(this.width, this.height); + final int width; + final int height; +} + +List _decodeDimensions(Uint8List bytes) { + final decoded = image.decodeImage(bytes); + if (decoded == null) throw const FormatException('Unsupported image'); + return [decoded.width, decoded.height]; +} + +class _CropRequest { + const _CropRequest({ + required this.bytes, + required this.sourceWidth, + required this.sourceHeight, + required this.canvasWidth, + required this.canvasHeight, + required this.cropDiameter, + required this.transform, + }); + + final Uint8List bytes; + final int sourceWidth; + final int sourceHeight; + final double canvasWidth; + final double canvasHeight; + final double cropDiameter; + final List transform; +} + +Uint8List _cropAvatar(_CropRequest request) { + final source = image.decodeImage(request.bytes); + if (source == null) throw const FormatException('Unsupported image'); + final matrix = Matrix4.fromList(request.transform); + final scale = matrix.getMaxScaleOnAxis(); + final aspect = request.sourceWidth / request.sourceHeight; + final displayWidth = aspect >= 1 + ? request.cropDiameter * aspect + : request.cropDiameter; + final pixelsPerPoint = request.sourceWidth / displayWidth; + final cropSize = (request.cropDiameter / scale * pixelsPerPoint) + .round() + .clamp(1, source.width < source.height ? source.width : source.height); + final cropLeft = (request.canvasWidth - request.cropDiameter) / 2; + final cropTop = (request.canvasHeight - request.cropDiameter) / 2; + final x = ((cropLeft - matrix.storage[12]) / scale * pixelsPerPoint) + .round() + .clamp(0, source.width - cropSize); + final y = ((cropTop - matrix.storage[13]) / scale * pixelsPerPoint) + .round() + .clamp(0, source.height - cropSize); + final cropped = image.copyCrop( + source, + x: x, + y: y, + width: cropSize, + height: cropSize, + ); + final resized = image.copyResize( + cropped, + width: _cropOutputSize, + height: _cropOutputSize, + ); + return image.encodeJpg(resized, quality: 90); +} diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart new file mode 100644 index 00000000000..efb69004606 --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -0,0 +1,134 @@ +import 'dart:typed_data'; + +import '../../shared/animated_avatar.dart'; +import '../../shared/relay/relay.dart'; + +/// A prepared profile-avatar change that is uploaded only when the user saves. +sealed class ProfileAvatarDraft { + /// Creates a prepared profile-avatar draft. + const ProfileAvatarDraft(); + + /// Returns the avatar URL for this draft using [service] when upload is + /// required. + /// + /// Implementations cache successful uploads for the same service so a + /// profile-publish retry does not create duplicate media. Failed uploads may + /// be retried, and changing services starts a new upload for that community. + Future upload(MediaUploadService service); +} + +/// An avatar draft that already has its final URL and needs no media upload. +final class ProfileUrlAvatarDraft extends ProfileAvatarDraft { + /// Creates a draft backed by [url]. + const ProfileUrlAvatarDraft(this.url); + + /// The URL that will be written to the profile. + final String url; + + @override + Future upload(MediaUploadService service) async => url; +} + +/// A locally prepared still image awaiting upload on Save. +final class ProfileImageAvatarDraft extends ProfileAvatarDraft { + /// Creates a still-image draft from JPEG [bytes]. + ProfileImageAvatarDraft(this.bytes); + + /// The prepared JPEG payload. + final Uint8List bytes; + MediaUploadService? _uploadService; + Future? _uploadedUrl; + + @override + Future upload(MediaUploadService service) async { + if (!identical(_uploadService, service)) { + _uploadService = service; + _uploadedUrl = null; + } + final existing = _uploadedUrl; + if (existing != null) return existing; + final upload = service + .uploadBytes(bytes, mimeType: 'image/jpeg') + .then((descriptor) => descriptor.url); + _uploadedUrl = upload; + try { + return await upload; + } catch (_) { + if (identical(_uploadedUrl, upload)) _uploadedUrl = null; + rethrow; + } + } +} + +/// A locally prepared animated avatar and its still poster awaiting upload. +final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { + /// Creates an animated draft from PNG [animation] and [poster] payloads. + ProfileAnimatedAvatarDraft({required this.animation, required this.poster}); + + /// The animated PNG payload. + final Uint8List animation; + + /// The still PNG poster shown when animation is unavailable or disabled. + final Uint8List poster; + MediaUploadService? _uploadService; + Future? _uploadedUrl; + Future? _posterUpload; + Future? _animationUpload; + + Future _uploadPoster(MediaUploadService service) { + final existing = _posterUpload; + if (existing != null) return existing; + late final Future upload; + upload = service.uploadBytes(poster, mimeType: 'image/png').catchError(( + Object error, + StackTrace stackTrace, + ) { + if (identical(_posterUpload, upload)) _posterUpload = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _posterUpload = upload; + return upload; + } + + Future _uploadAnimation(MediaUploadService service) { + final existing = _animationUpload; + if (existing != null) return existing; + late final Future upload; + upload = service.uploadBytes(animation, mimeType: 'image/png').catchError(( + Object error, + StackTrace stackTrace, + ) { + if (identical(_animationUpload, upload)) _animationUpload = null; + Error.throwWithStackTrace(error, stackTrace); + }); + _animationUpload = upload; + return upload; + } + + @override + Future upload(MediaUploadService service) async { + if (!identical(_uploadService, service)) { + _uploadService = service; + _uploadedUrl = null; + _posterUpload = null; + _animationUpload = null; + } + final existing = _uploadedUrl; + if (existing != null) return existing; + // Cache each content-addressed part independently. Upload sequentially so + // relays configured to allow only one in-flight media request can accept an + // animated avatar in a single Save attempt. + final upload = _uploadPoster(service).then( + (poster) => _uploadAnimation( + service, + ).then((animation) => buildAnimatedAvatarUrl(poster.url, animation.url)), + ); + _uploadedUrl = upload; + try { + return await upload; + } catch (_) { + if (identical(_uploadedUrl, upload)) _uploadedUrl = null; + rethrow; + } + } +} diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart new file mode 100644 index 00000000000..4cc495eecb7 --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -0,0 +1,689 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/emoji/emoji_avatar.dart'; +import '../../shared/emoji/emoji_data.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; +import '../../shared/emoji/emoji_search.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/ios_native_segmented_control.dart'; +import '../../shared/widgets/ios_native_skin_tone_control.dart'; +import '../../shared/widgets/playing_avatar_image.dart'; +import 'animated_avatar_capture.dart'; +import 'avatar_background_grid.dart'; +import 'avatar_editor_option_button.dart'; +import 'emoji_avatar_tile.dart'; +import 'profile_avatar_crop_page.dart'; +import 'profile_avatar_draft.dart'; + +part 'profile_avatar_editor/emoji_avatar_picker.dart'; + +/// Avatar kinds shared with the desktop profile editor. +enum ProfileAvatarMode { + /// A still image selected from the camera or photo library. + image, + + /// A system emoji composited over a selected background color. + emoji, + + /// A short camera animation with framing and background controls. + animated, +} + +/// Builds the animated capture surface for the profile avatar editor. +typedef AnimatedAvatarCaptureBuilder = + Widget Function({ + required double height, + required ValueChanged Function()?> + onPrepareChanged, + }); + +const _previewSize = 220.0; +const _motionDuration = Duration(milliseconds: 150); +const _previewSquishDuration = Duration(milliseconds: 200); +const Curve _entranceCurve = Curves.easeOutCubic; +const Curve _exitCurve = Curves.easeInCubic; +const Curve _modeTransitionCurve = Cubic(0.22, 1, 0.36, 1); +const double _modeTransitionDistance = 24; +const double _previewBlockSize = 228; +const double _modeControlHeight = 40; +const double _editorControlsBottom = Grid.xl + Grid.xxs; +const double _editorRailHeight = 88; +const double _previewControlGap = Grid.twelve; +const double _emojiPickerPreviewShift = 140; +// Settings' avatar center sits 96dp below its standard app bar, including its +// lower 8dp app-bar rail. The expanded editor preview is centred on screen. +const double _settingsAvatarCenterBelowAppBar = 96; + +/// In-page profile avatar editor used on Android and iOS. +class ProfileAvatarEditor extends HookConsumerWidget { + /// Creates an avatar editor backed by the current profile and draft state. + const ProfileAvatarEditor({ + super.key, + required this.currentAvatarUrl, + required this.fallbackInitial, + required this.draft, + required this.mode, + required this.transition, + required this.onModeChanged, + required this.onDraftChanged, + required this.onAnimatedPrepareChanged, + this.animatedCaptureBuilder, + }); + + /// The avatar URL shown until the user selects a new draft. + final String? currentAvatarUrl; + + /// The text initial used when [currentAvatarUrl] has no displayable image. + final String fallbackInitial; + + /// The unsaved avatar selection for the active editing session. + final ProfileAvatarDraft? draft; + + /// The currently selected avatar editing mode. + final ProfileAvatarMode mode; + + /// Drives the shared preview transition into and out of editing. + final Animation transition; + + /// Called when the user selects a different avatar editing mode. + final ValueChanged onModeChanged; + + /// Called whenever the unsaved avatar selection changes. + final ValueChanged onDraftChanged; + + /// Supplies or clears the deferred animated-avatar preparation callback. + final ValueChanged Function()?> + onAnimatedPrepareChanged; + + /// Overrides the animated capture surface, primarily for tests. + final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final currentEmojiAvatar = useMemoized( + () => parseEmojiAvatarDataUrl(currentAvatarUrl), + [currentAvatarUrl], + ); + final selectedEmoji = useState(currentEmojiAvatar?.emoji ?? '😊'); + final initialColor = useMemoized( + () => + currentEmojiAvatar?.colorValue ?? + emojiAvatarColors[Random().nextInt(18)], + [currentEmojiAvatar], + ); + final selectedColor = useState(initialColor); + final emojiSection = useState(_EmojiEditorSection.emoji); + final emojiPreviewKey = useState(0); + final isPickingImage = useState(false); + final imageSelectionGeneration = useRef(0); + final currentMode = useRef(mode)..value = mode; + final error = useState(null); + final dataset = ref.watch(emojiDatasetOrEmptyProvider); + final modeTransitionDirection = useRef(1.0); + final modeTransitionFrom = useRef(mode); + final retainedPreview = useRef(null); + final retainedPreviewTop = useRef(0.0); + final modeTransitionController = useAnimationController( + duration: _motionDuration, + initialValue: 1, + ); + final modeTransitionValue = useAnimation(modeTransitionController); + final modeTransitionProgress = reduceMotion + ? 1.0 + : _modeTransitionCurve.transform(modeTransitionValue); + + void selectMode(ProfileAvatarMode nextMode) { + if (nextMode == mode) return; + modeTransitionFrom.value = mode; + modeTransitionDirection.value = nextMode.index > mode.index ? 1 : -1; + unawaited(HapticFeedback.selectionClick()); + onAnimatedPrepareChanged(null); + if (mode == ProfileAvatarMode.image) { + imageSelectionGeneration.value++; + isPickingImage.value = false; + } + if (!reduceMotion) modeTransitionController.value = 0; + onModeChanged(nextMode); + if (nextMode == ProfileAvatarMode.emoji) { + onDraftChanged( + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), + ); + } + if (reduceMotion) { + modeTransitionController.value = 1; + } else { + unawaited(modeTransitionController.forward()); + } + } + + void updateEmojiPreview() { + emojiPreviewKey.value++; + onDraftChanged( + ProfileUrlAvatarDraft( + emojiAvatarDataUrl(selectedEmoji.value, selectedColor.value), + ), + ); + } + + Future selectImage({required bool camera}) async { + if (isPickingImage.value) return; + final operation = ++imageSelectionGeneration.value; + bool isCurrentOperation() => + context.mounted && + currentMode.value == ProfileAvatarMode.image && + imageSelectionGeneration.value == operation; + isPickingImage.value = true; + error.value = null; + try { + final service = ref.read(mediaUploadServiceProvider); + unawaited(HapticFeedback.lightImpact()); + final picked = camera + ? await service.captureImage() + : await service.pickGalleryImage(); + if (picked == null || !isCurrentOperation()) return; + final preparedPhoto = await service.prepareImageBytes(picked); + if (!context.mounted || !isCurrentOperation()) return; + final cropped = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileAvatarCropPage( + imageBytes: Future.value(preparedPhoto), + ), + ), + ); + if (cropped == null || !isCurrentOperation()) return; + onDraftChanged(ProfileImageAvatarDraft(cropped)); + } catch (_) { + if (isCurrentOperation()) { + error.value = "We couldn't prepare that photo. Try again."; + } + } finally { + if (isCurrentOperation()) isPickingImage.value = false; + } + } + + final previewUrl = switch (draft) { + ProfileUrlAvatarDraft(:final url) => url, + _ => currentAvatarUrl, + }; + final fixedPreviewContent = switch (mode) { + ProfileAvatarMode.image when draft is ProfileImageAvatarDraft => + CircleAvatar( + key: const ValueKey('avatar-editor-fixed-preview'), + radius: _previewSize / 2, + backgroundImage: MemoryImage( + (draft as ProfileImageAvatarDraft).bytes, + ), + ), + ProfileAvatarMode.image => PlayingAvatarImage( + key: const ValueKey('avatar-editor-fixed-preview'), + imageUrl: previewUrl, + radius: _previewSize / 2, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + fallbackInitial, + style: context.textTheme.displayLarge?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + ProfileAvatarMode.emoji => _EmojiAvatarPreview( + key: const ValueKey('avatar-editor-fixed-preview'), + emoji: selectedEmoji.value, + color: Color(selectedColor.value), + animationKey: emojiPreviewKey.value, + reduceMotion: + reduceMotion || + (modeTransitionFrom.value == ProfileAvatarMode.animated && + modeTransitionProgress < 1), + ), + ProfileAvatarMode.animated => null, + }; + final fixedPreview = fixedPreviewContent == null + ? null + : Stack( + alignment: Alignment.center, + children: [ + fixedPreviewContent, + if (isPickingImage.value) + Container( + key: const ValueKey('avatar-image-loading-overlay'), + width: _previewSize, + height: _previewSize, + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.72), + shape: BoxShape.circle, + ), + child: const Center( + child: BuzzLoadingIndicator( + semanticLabel: 'Preparing photo', + ), + ), + ), + ], + ); + if (mode != ProfileAvatarMode.animated && fixedPreview != null) { + retainedPreview.value = mode == ProfileAvatarMode.emoji + ? _EmojiAvatarPreview( + emoji: selectedEmoji.value, + color: Color(selectedColor.value), + animationKey: emojiPreviewKey.value, + reduceMotion: true, + ) + : fixedPreview; + } + + final curvedEntrance = CurvedAnimation( + parent: transition, + curve: _entranceCurve, + // The controller runs from 1 → 0 on exit. An ease-in value curve makes + // that visible reverse movement ease out from expanded to collapsed. + reverseCurve: _exitCurve, + ); + final appBarHeight = frostedAppBarHeight(context); + final topPadding = appBarHeight + Grid.xs; + + return LayoutBuilder( + builder: (context, constraints) { + final viewportHeight = constraints.maxHeight; + final basePreviewTop = viewportHeight / 2 - _previewBlockSize / 2; + final maximumShift = max( + 0.0, + basePreviewTop - + (topPadding + _modeControlHeight + _previewControlGap), + ); + final requestedShift = mode != ProfileAvatarMode.emoji + ? 0.0 + : emojiSection.value == _EmojiEditorSection.emoji + ? _emojiPickerPreviewShift + : avatarBackgroundPreviewShift; + final previewShift = min(requestedShift, maximumShift); + final previewTop = basePreviewTop - previewShift; + final returningToEmoji = + mode == ProfileAvatarMode.emoji && + modeTransitionFrom.value == ProfileAvatarMode.animated; + final animatedModeHeight = max( + 0.0, + viewportHeight - _editorControlsBottom - basePreviewTop, + ); + final animatedPreviewSize = animatedModeHeight < 400 ? 180.0 : 228.0; + final animatedPreviewTop = + basePreviewTop + (animatedPreviewSize - _previewBlockSize) / 2; + final displayedPreviewTop = returningToEmoji + ? animatedPreviewTop + + (previewTop - animatedPreviewTop) * modeTransitionProgress + : previewTop; + if (mode != ProfileAvatarMode.animated) { + retainedPreviewTop.value = previewTop; + } + final fixedContentTop = + previewTop + _previewBlockSize + _previewControlGap; + final modeTop = mode == ProfileAvatarMode.animated + ? basePreviewTop + : fixedContentTop; + final modeHeight = max( + 0.0, + viewportHeight - _editorControlsBottom - modeTop, + ); + final modeContent = switch (mode) { + ProfileAvatarMode.image => _ImageMode( + key: const ValueKey(0), + height: modeHeight, + isPicking: isPickingImage.value, + onCamera: () => unawaited(selectImage(camera: true)), + onLibrary: () => unawaited(selectImage(camera: false)), + ), + ProfileAvatarMode.emoji => _EmojiMode( + key: const ValueKey(1), + height: modeHeight, + activeSection: emojiSection.value, + dataset: dataset, + selectedEmoji: selectedEmoji.value, + selectedColor: selectedColor.value, + transitionProgress: modeTransitionProgress, + transitionDirection: modeTransitionDirection.value, + onSectionChanged: (section) => emojiSection.value = section, + onEmojiSelected: (emoji) { + selectedEmoji.value = emoji; + updateEmojiPreview(); + }, + onColorSelected: (color) { + selectedColor.value = color; + updateEmojiPreview(); + }, + ), + ProfileAvatarMode.animated => KeyedSubtree( + key: const ValueKey(2), + child: + animatedCaptureBuilder?.call( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ) ?? + AnimatedAvatarCapture( + height: modeHeight, + onPrepareChanged: onAnimatedPrepareChanged, + ), + ), + }; + final collapsedPreviewOffset = + appBarHeight + + _settingsAvatarCenterBelowAppBar - + (previewTop + _previewBlockSize / 2); + final transitionedModeContent = + mode == ProfileAvatarMode.animated || + mode == ProfileAvatarMode.emoji + ? modeContent + : ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-transition-transform'), + offset: Offset( + modeTransitionDirection.value * + _modeTransitionDistance * + (1 - modeTransitionProgress), + 0, + ), + child: Opacity( + key: const ValueKey('avatar-mode-transition-opacity'), + opacity: modeTransitionProgress, + child: modeContent, + ), + ), + ); + + return Stack( + key: const ValueKey('avatar-editor-content'), + clipBehavior: Clip.none, + children: [ + Positioned( + left: Grid.gutter, + right: Grid.gutter, + top: topPadding, + child: FadeTransition( + opacity: curvedEntrance, + child: ScaleTransition( + scale: Tween(begin: 0.96, end: 1.0).animate(curvedEntrance), + child: Builder( + builder: (context) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + return IosNativeSegmentedControl( + key: const ValueKey('avatar-mode-control'), + items: const ['Image', 'Emoji', 'Animated'], + selectedIndex: mode.index, + onChanged: (index) { + if (index < 0 || + index >= ProfileAvatarMode.values.length) { + return; + } + selectMode(ProfileAvatarMode.values[index]); + }, + ); + } + return _AvatarModeControl( + selected: mode, + reduceMotion: reduceMotion, + onSelected: selectMode, + ); + }, + ), + ), + ), + ), + if (fixedPreview != null) + AnimatedPositioned( + key: const ValueKey('avatar-preview-position'), + curve: Curves.easeOutCubic, + left: Grid.gutter, + right: Grid.gutter, + top: displayedPreviewTop, + height: _previewBlockSize, + duration: returningToEmoji + ? Duration.zero + : reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + child: Center( + child: AnimatedBuilder( + animation: curvedEntrance, + child: fixedPreview, + builder: (context, child) { + final progress = curvedEntrance.value; + return Transform.translate( + key: const ValueKey('avatar-editor-entrance-transform'), + offset: Offset( + 0, + collapsedPreviewOffset * (1 - progress), + ), + child: Transform.scale( + scale: + 128 / _previewSize + + (1 - 128 / _previewSize) * progress, + child: child, + ), + ); + }, + ), + ), + ), + AnimatedPositioned( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + left: Grid.gutter, + right: Grid.gutter, + top: modeTop, + height: modeHeight, + child: FadeTransition( + opacity: curvedEntrance, + child: transitionedModeContent, + ), + ), + if (mode == ProfileAvatarMode.animated && + !reduceMotion && + modeTransitionProgress < 1 && + retainedPreview.value != null) + Positioned( + key: const ValueKey('avatar-mode-retained-preview'), + left: Grid.gutter, + right: Grid.gutter, + top: + retainedPreviewTop.value + + (basePreviewTop - retainedPreviewTop.value) * + modeTransitionProgress, + height: _previewBlockSize, + child: IgnorePointer( + child: Opacity( + opacity: 1 - modeTransitionProgress, + child: Center(child: retainedPreview.value), + ), + ), + ), + if (error.value != null) + Positioned( + left: Grid.gutter, + right: Grid.gutter, + bottom: _editorControlsBottom + _editorRailHeight, + child: Semantics( + liveRegion: true, + child: Text( + error.value!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _AvatarModeControl extends StatelessWidget { + const _AvatarModeControl({ + required this.selected, + required this.reduceMotion, + required this.onSelected, + }); + + final ProfileAvatarMode selected; + final bool reduceMotion; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) => Material( + key: const ValueKey('avatar-mode-control'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(Grid.quarter), + child: LayoutBuilder( + builder: (context, constraints) { + final segmentWidth = + constraints.maxWidth / ProfileAvatarMode.values.length; + return Stack( + children: [ + TweenAnimationBuilder( + tween: Tween(end: selected.index.toDouble()), + duration: reduceMotion ? Duration.zero : _motionDuration, + curve: Curves.easeInOut, + builder: (context, position, child) => Transform.translate( + offset: Offset(segmentWidth * position, 0), + child: child, + ), + child: SizedBox( + width: segmentWidth, + height: 36, + child: DecoratedBox( + key: const ValueKey('avatar-mode-indicator'), + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.full), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ), + Row( + children: [ + for (final mode in ProfileAvatarMode.values) + Expanded( + child: Semantics( + label: switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + button: true, + selected: mode == selected, + onTap: () => onSelected(mode), + child: ExcludeSemantics( + child: InkWell( + key: ValueKey('avatar-mode-${mode.name}'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () => onSelected(mode), + child: SizedBox( + height: 36, + child: Center( + child: Text( + switch (mode) { + ProfileAvatarMode.image => 'Image', + ProfileAvatarMode.emoji => 'Emoji', + ProfileAvatarMode.animated => 'Animated', + }, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: mode == selected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ], + ); + }, + ), + ), + ); +} + +class _ImageMode extends StatelessWidget { + const _ImageMode({ + super.key, + required this.height, + required this.isPicking, + required this.onCamera, + required this.onLibrary, + }); + + final double height; + final bool isPicking; + final VoidCallback onCamera; + final VoidCallback onLibrary; + + @override + Widget build(BuildContext context) => SizedBox( + height: height, + child: Column( + children: [ + const Spacer(), + Row( + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('image-source-camera'), + icon: LucideIcons.camera, + label: 'Camera', + selected: false, + onTap: isPicking ? null : onCamera, + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('image-source-library'), + icon: LucideIcons.images, + label: 'Photo Library', + selected: false, + onTap: isPicking ? null : onLibrary, + labelMaxWidth: 104, + ), + ), + const Spacer(), + ], + ), + ], + ), + ); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart new file mode 100644 index 00000000000..1712c0c99bd --- /dev/null +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -0,0 +1,394 @@ +part of '../profile_avatar_editor.dart'; + +const _emojiPreviewGlyphSize = _previewSize * 258 / 512; +const _skinTones = [ + (label: 'Default', color: Color(0xFFFFC93A)), + (label: 'Light', color: Color(0xFFFFDAB7)), + (label: 'Medium-light', color: Color(0xFFE7B98F)), + (label: 'Medium', color: Color(0xFFC88C61)), + (label: 'Medium-dark', color: Color(0xFFA46134)), + (label: 'Dark', color: Color(0xFF5D4437)), +]; + +enum _EmojiEditorSection { background, emoji } + +class _EmojiMode extends HookConsumerWidget { + const _EmojiMode({ + super.key, + required this.height, + required this.activeSection, + required this.dataset, + required this.selectedEmoji, + required this.selectedColor, + required this.transitionProgress, + required this.transitionDirection, + required this.onSectionChanged, + required this.onEmojiSelected, + required this.onColorSelected, + }); + + final double height; + final _EmojiEditorSection activeSection; + final EmojiDataset dataset; + final String selectedEmoji; + final int selectedColor; + final double transitionProgress; + final double transitionDirection; + final ValueChanged<_EmojiEditorSection> onSectionChanged; + final ValueChanged onEmojiSelected; + final ValueChanged onColorSelected; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final trayOffset = reduceMotion ? 0.0 : 16 * (1 - transitionProgress); + final actionOffset = reduceMotion + ? 0.0 + : transitionDirection * + _modeTransitionDistance * + (1 - transitionProgress); + final skinTone = useState(_skinToneForEmoji(dataset, selectedEmoji)); + final seededSkinTone = useRef(false); + useEffect(() { + if (seededSkinTone.value || dataset.isEmpty) return null; + seededSkinTone.value = true; + final initialTone = _skinToneForEmoji(dataset, selectedEmoji); + if (skinTone.value != initialTone) skinTone.value = initialTone; + return null; + }, [dataset]); + final searchController = useTextEditingController(); + useListenable(searchController); + final visibleEmoji = useMemoized(() { + final entries = _emojiForSkinTone(dataset, skinTone.value); + final query = searchController.text.trim(); + return query.isEmpty ? entries : searchEmoji(query, entries); + }, [dataset, skinTone.value, searchController.text]); + + void selectSkinTone(int next) { + if (skinTone.value == next) return; + unawaited(HapticFeedback.selectionClick()); + skinTone.value = next; + } + + return SizedBox( + key: const ValueKey('emoji-avatar-picker-content'), + height: height, + child: Column( + children: [ + Expanded( + child: ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-tray-transition-transform'), + offset: Offset(0, trayOffset), + child: Opacity( + opacity: transitionProgress, + child: activeSection == _EmojiEditorSection.background + ? AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ) + : Column( + key: const ValueKey('emoji-glyph-editor'), + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const ValueKey('emoji-avatar-search'), + controller: searchController, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search emoji', + filled: true, + fillColor: context + .colors + .surfaceContainerHighest, + contentPadding: + const EdgeInsets.symmetric( + vertical: 10, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + enabledBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all( + Radius.circular(Radii.full), + ), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon( + LucideIcons.search, + size: 20, + ), + suffixIcon: searchController.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + onPressed: searchController.clear, + icon: const Icon( + LucideIcons.x, + size: 18, + ), + ), + ), + ), + ), + const SizedBox(width: Grid.xxs), + _AvatarSkinToneSelector( + value: skinTone.value, + onChanged: selectSkinTone, + ), + ], + ), + const SizedBox(height: Grid.xxs), + Expanded( + child: dataset.isEmpty + ? const Center( + child: CircularProgressIndicator(), + ) + : visibleEmoji.isEmpty + ? Center( + child: Text( + 'No emoji found', + style: context.textTheme.bodyMedium + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + ), + ), + ) + : GridView.builder( + key: const ValueKey('emoji-avatar-grid'), + padding: EdgeInsets.zero, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + defaultTargetPlatform == + TargetPlatform.iOS + ? 8 + : 7, + ), + itemCount: visibleEmoji.length, + itemBuilder: (context, index) { + final entry = visibleEmoji[index]; + final isSelected = + entry.native == selectedEmoji; + void selectEmoji() { + unawaited( + HapticFeedback.selectionClick(), + ); + onEmojiSelected(entry.native); + } + + return EmojiAvatarTile( + emoji: entry.native, + label: entry.name, + tileId: entry.tileId, + isSelected: isSelected, + onTap: selectEmoji, + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: Grid.xs), + ClipRect( + child: Transform.translate( + key: const ValueKey('avatar-mode-transition-transform'), + offset: Offset(actionOffset, 0), + child: Opacity( + opacity: transitionProgress, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-background'), + icon: LucideIcons.palette, + label: 'Background', + selected: + activeSection == _EmojiEditorSection.background, + onTap: () => + onSectionChanged(_EmojiEditorSection.background), + labelMaxWidth: 96, + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: AvatarEditorOptionButton( + key: const ValueKey('emoji-editor-emoji'), + icon: LucideIcons.smile, + label: 'Emoji', + selected: activeSection == _EmojiEditorSection.emoji, + onTap: () => + onSectionChanged(_EmojiEditorSection.emoji), + labelMaxWidth: 80, + ), + ), + const Spacer(), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _EmojiAvatarPreview extends StatelessWidget { + const _EmojiAvatarPreview({ + super.key, + required this.emoji, + required this.color, + required this.animationKey, + required this.reduceMotion, + }); + + final String emoji; + final Color color; + final int animationKey; + final bool reduceMotion; + + @override + Widget build(BuildContext context) => AnimatedContainer( + key: const ValueKey('emoji-avatar-preview'), + width: _previewSize, + height: _previewSize, + duration: reduceMotion ? Duration.zero : _previewSquishDuration, + curve: _entranceCurve, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + child: Center( + child: TweenAnimationBuilder( + key: ValueKey('emoji-avatar-preview-glyph-$animationKey'), + tween: Tween(begin: 0.88, end: 1), + duration: reduceMotion ? Duration.zero : _previewSquishDuration, + curve: _entranceCurve, + builder: (context, scaleY, child) => Transform.scale( + scaleX: 0.96 + ((scaleY - 0.88) / 0.12) * 0.04, + scaleY: scaleY, + child: child, + ), + child: NativeEmojiGlyph( + emoji: emoji, + size: _emojiPreviewGlyphSize, + opticalBoxSize: _emojiPreviewGlyphSize, + ), + ), + ), + ); +} + +class _AvatarSkinToneSelector extends StatelessWidget { + const _AvatarSkinToneSelector({required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = _skinTones[_validSkinTone(value)]; + if (defaultTargetPlatform == TargetPlatform.iOS) { + return IosNativeSkinToneControl( + key: const ValueKey('emoji-avatar-skin-tone'), + value: value, + colors: [for (final tone in _skinTones) tone.color], + labels: [for (final tone in _skinTones) tone.label], + onChanged: onChanged, + ); + } + return PopupMenuButton( + key: const ValueKey('emoji-avatar-skin-tone'), + initialValue: value, + tooltip: 'Skin tone', + position: PopupMenuPosition.under, + onSelected: onChanged, + itemBuilder: (context) => [ + for (final (index, tone) in _skinTones.indexed) + PopupMenuItem( + value: index, + child: Row( + children: [ + _SkinToneDot(color: tone.color), + const SizedBox(width: Grid.xs), + Expanded(child: Text(tone.label)), + if (index == value) + Icon( + LucideIcons.check, + size: 18, + color: context.colors.primary, + ), + ], + ), + ), + ], + child: SizedBox.square( + dimension: 48, + child: Center(child: _SkinToneDot(color: selected.color)), + ), + ); + } +} + +class _SkinToneDot extends StatelessWidget { + const _SkinToneDot({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) => Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + ); +} + +int _validSkinTone(int? value) => + value != null && value >= 0 && value < _skinTones.length ? value : 0; + +int _skinToneForEmoji(EmojiDataset dataset, String emoji) => + dataset.all + .where((entry) => entry.native == emoji) + .map((entry) => _validSkinTone(entry.skinIndex)) + .firstOrNull ?? + 0; + +List _emojiForSkinTone(EmojiDataset dataset, int skinTone) { + final variantsById = >{}; + for (final entry in dataset.all) { + variantsById.putIfAbsent(entry.id, () => []).add(entry); + } + return [ + for (final variants in variantsById.values) + variants.firstWhere( + (entry) => entry.skinIndex == skinTone, + orElse: () => variants.firstWhere( + (entry) => entry.skinIndex == 0, + orElse: () => variants.first, + ), + ), + ]; +} diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart new file mode 100644 index 00000000000..12e4e3efd7b --- /dev/null +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -0,0 +1,584 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/profile/user_profile.dart'; +import '../../shared/relay/relay.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/ios_glass_navigation_action.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/playing_avatar_image.dart'; +import 'ios_profile_text_editor.dart'; +import 'profile_avatar_editor.dart'; +import 'profile_avatar_draft.dart'; +import 'profile_provider.dart'; +import 'profile_text_editor.dart'; +import 'profile_text_edit_sheet.dart'; + +/// Edits the current user's public profile metadata. +class ProfileEditPage extends HookConsumerWidget { + /// Creates the profile details and avatar editing page. + const ProfileEditPage({ + super.key, + this.startInPhotoEditor = false, + this.animatedAvatarCaptureBuilder, + }); + + /// Opens directly into the photo editor when launched from Settings. + final bool startInPhotoEditor; + + /// Overrides animated capture for focused integration tests. + final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder; + + static const _avatarRadius = 64.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final profileAsync = ref.watch(profileProvider); + final profile = profileAsync.asData?.value; + final profileHydrated = profileAsync.hasValue; + final isEditingAvatar = useState(startInPhotoEditor); + final avatarDraft = useState(null); + final avatarDraftMode = useState(null); + final avatarEditConfig = useRef( + startInPhotoEditor ? ref.read(relayConfigProvider) : null, + ); + final isSavingAvatar = useState(false); + final isClosingAvatar = useState(false); + final prepareAnimatedAvatar = + useRef Function()?>(null); + final avatarSaveError = useState(null); + final canPrepareAnimatedAvatar = useState(false); + final avatarMode = useState(ProfileAvatarMode.image); + final avatarTransition = useAnimationController( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 200), + ); + useEffect(() { + if (!startInPhotoEditor) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) unawaited(avatarTransition.forward(from: 0)); + }); + return null; + }, const []); + // Warm the emoji index while the profile is visible so the first editor + // transition never waits for asset decoding. + ref.watch(emojiDatasetProvider); + + Future showFlutterFieldEditor({ + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, + }) => showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + requestFocus: true, + showCloseButton: false, + builder: (_) => ProfileTextEditSheet( + title: title, + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + ), + ); + + Future editField({ + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, + }) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + try { + await IosProfileTextEditor.presentUntilSaved( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + brightness: Theme.of(context).brightness, + onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), + onSaveError: () { + if (!context.mounted || + !(ModalRoute.of(context)?.isCurrent ?? true)) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("We couldn't save this change. Try again."), + ), + ); + }, + ); + return; + } on MissingPluginException { + // Keep the Flutter editor available in previews and older builds. + } on PlatformException { + // Fall back when the native presenter is temporarily unavailable. + } + } + + await showFlutterFieldEditor( + title: title, + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + ); + } + + void openAvatarEditor() { + if (!profileHydrated) return; + avatarEditConfig.value = ref.read(relayConfigProvider); + isEditingAvatar.value = true; + unawaited(avatarTransition.forward(from: 0)); + } + + Future closeAvatarEditor({bool whileSaving = false}) async { + if ((isSavingAvatar.value && !whileSaving) || isClosingAvatar.value) { + return; + } + isClosingAvatar.value = true; + await avatarTransition.reverse(); + if (!context.mounted) return; + if (startInPhotoEditor) { + Navigator.of(context).pop(); + return; + } + isEditingAvatar.value = false; + avatarDraft.value = null; + avatarDraftMode.value = null; + avatarEditConfig.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + avatarMode.value = ProfileAvatarMode.image; + isClosingAvatar.value = false; + } + + Future saveAvatar() async { + if (isSavingAvatar.value || isClosingAvatar.value) return; + final openingConfig = avatarEditConfig.value; + if (openingConfig == null) return; + final saveConfig = ref.read(relayConfigProvider); + final uploadService = ref.read(mediaUploadServiceProvider); + + void requireCurrentCommunity() { + final currentConfig = ref.read(relayConfigProvider); + if (currentConfig.storedOrigin != openingConfig.storedOrigin || + currentConfig.nsec != openingConfig.nsec || + currentConfig.storedOrigin != saveConfig.storedOrigin || + currentConfig.nsec != saveConfig.nsec || + !identical(ref.read(mediaUploadServiceProvider), uploadService)) { + throw ProfileCommunityChangedException(); + } + } + + Future discardStaleEditor() async { + avatarDraft.value = null; + avatarDraftMode.value = null; + prepareAnimatedAvatar.value = null; + canPrepareAnimatedAvatar.value = false; + if (context.mounted) await closeAvatarEditor(whileSaving: true); + } + + isSavingAvatar.value = true; + avatarSaveError.value = null; + try { + requireCurrentCommunity(); + var nextDraft = avatarDraftMode.value == avatarMode.value + ? avatarDraft.value + : null; + if (avatarMode.value == ProfileAvatarMode.animated && + nextDraft == null) { + nextDraft = await prepareAnimatedAvatar.value?.call(); + requireCurrentCommunity(); + if (nextDraft != null) { + avatarDraft.value = nextDraft; + avatarDraftMode.value = ProfileAvatarMode.animated; + } + } + if (nextDraft == null) return; + requireCurrentCommunity(); + final nextAvatar = await nextDraft.upload(uploadService); + requireCurrentCommunity(); + await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); + requireCurrentCommunity(); + if (context.mounted) await closeAvatarEditor(whileSaving: true); + } on ProfileCommunityChangedException { + await discardStaleEditor(); + } catch (_) { + try { + requireCurrentCommunity(); + } on ProfileCommunityChangedException { + await discardStaleEditor(); + return; + } + avatarSaveError.value = + "We couldn't save your profile photo. Try again."; + } finally { + if (context.mounted) isSavingAvatar.value = false; + } + } + + final canSaveAvatar = + profileHydrated && + (avatarMode.value == ProfileAvatarMode.animated + ? canPrepareAnimatedAvatar.value + : avatarDraftMode.value == avatarMode.value && + avatarDraft.value != null); + final activeDraft = avatarDraftMode.value == avatarMode.value + ? avatarDraft.value + : null; + + return PopScope( + canPop: !isEditingAvatar.value, + onPopInvokedWithResult: (didPop, _) { + if (!didPop && isEditingAvatar.value) { + unawaited(closeAvatarEditor()); + } + }, + child: FrostedScaffold( + backgroundColor: context.colors.surface, + resizeToAvoidBottomInset: isEditingAvatar.value ? false : null, + appBar: FrostedAppBar( + centerTitle: true, + title: AnimatedSwitcher( + duration: reduceMotion + ? const Duration(milliseconds: 120) + : const Duration(milliseconds: 220), + child: Text( + isEditingAvatar.value ? 'Edit Photo' : 'Profile', + key: ValueKey(isEditingAvatar.value), + ), + ), + leading: isEditingAvatar.value + ? defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const ValueKey('avatar-editor-back'), + icon: IosGlassNavigationIcon.back, + semanticLabel: 'Back to profile', + onPressed: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), + ) + : IconButton( + key: const ValueKey('avatar-editor-back'), + tooltip: 'Back to profile', + onPressed: isClosingAvatar.value + ? null + : () => unawaited(closeAvatarEditor()), + icon: const Icon(LucideIcons.arrowLeft), + ) + : null, + actions: isEditingAvatar.value + ? [ + if (defaultTargetPlatform == TargetPlatform.iOS) + IosGlassNavigationAction( + key: const ValueKey('avatar-save'), + label: 'Save', + width: 72, + isBusy: isSavingAvatar.value, + onPressed: + canSaveAvatar && + !isSavingAvatar.value && + !isClosingAvatar.value + ? () { + unawaited(HapticFeedback.lightImpact()); + unawaited(saveAvatar()); + } + : null, + ) + else + Padding( + padding: const EdgeInsets.only(right: Grid.xs), + child: _ProfileActionPill( + key: const ValueKey('avatar-save'), + label: 'Save', + isBusy: isSavingAvatar.value, + onTap: + canSaveAvatar && + !isSavingAvatar.value && + !isClosingAvatar.value + ? () { + unawaited(HapticFeedback.lightImpact()); + unawaited(saveAvatar()); + } + : null, + ), + ), + ] + : const [], + ), + body: isEditingAvatar.value + ? Stack( + children: [ + IgnorePointer( + ignoring: !profileHydrated || isSavingAvatar.value, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + key: const ValueKey('avatar-editor-scroll-view'), + physics: constraints.maxHeight < 600 + ? const ClampingScrollPhysics() + : const NeverScrollableScrollPhysics(), + child: SizedBox( + height: constraints.maxHeight < 600 + ? 700 + : constraints.maxHeight, + child: ProfileAvatarEditor( + key: const ValueKey('profile-avatar-editor-page'), + currentAvatarUrl: profile?.avatarUrl, + fallbackInitial: profile?.initial ?? '?', + draft: activeDraft, + mode: avatarMode.value, + transition: avatarTransition, + onModeChanged: (nextMode) => + avatarMode.value = nextMode, + onDraftChanged: (draft) { + avatarDraft.value = draft; + avatarDraftMode.value = draft == null + ? null + : avatarMode.value; + }, + onAnimatedPrepareChanged: (prepare) { + prepareAnimatedAvatar.value = prepare; + canPrepareAnimatedAvatar.value = prepare != null; + if (avatarMode.value == + ProfileAvatarMode.animated) { + avatarDraft.value = null; + avatarDraftMode.value = null; + } + }, + animatedCaptureBuilder: + animatedAvatarCaptureBuilder, + ), + ), + ), + ), + ), + if (!profileHydrated || avatarSaveError.value != null) + Positioned( + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xl, + child: Semantics( + liveRegion: true, + child: Text( + avatarSaveError.value ?? + (profileAsync.hasError + ? "We couldn't load your profile. Go back and try again." + : 'Loading your profile…'), + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: + avatarSaveError.value != null || + profileAsync.hasError + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + ), + ), + ), + ], + ) + : ListView( + key: const ValueKey('profile-information-page'), + padding: EdgeInsets.only( + top: frostedAppBarHeight(context) + Grid.sm, + bottom: Grid.sm, + ), + children: [ + _ProfilePhotoEditor( + profile: profile, + onEditPhoto: profileHydrated ? openAvatarEditor : null, + ), + AppListCard( + key: const ValueKey('profile-info-card'), + dividerIndent: Grid.xs, + verticalPadding: Grid.sm, + children: [ + AppListRow( + key: const ValueKey('profile-display-name-row'), + title: 'Display name', + subtitle: _fieldValue(profile?.displayName), + subtitleMaxLines: 1, + trailing: const _EditChevron(), + onTap: !profileHydrated + ? null + : () { + final container = ProviderScope.containerOf( + context, + listen: false, + ); + unawaited( + editField( + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: bindProfileSaveToOpeningContext( + container, + container + .read(profileProvider.notifier) + .updateDisplayName, + ), + ), + ); + }, + ), + AppListRow( + key: const ValueKey('profile-description-row'), + title: 'Profile description', + subtitle: _fieldValue(profile?.about), + subtitleMaxLines: 3, + trailing: const _EditChevron(), + onTap: !profileHydrated + ? null + : () { + final container = ProviderScope.containerOf( + context, + listen: false, + ); + unawaited( + editField( + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: bindProfileSaveToOpeningContext( + container, + container + .read(profileProvider.notifier) + .updateAbout, + ), + ), + ); + }, + ), + ], + ), + ], + ), + ), + ); + } +} + +String _fieldValue(String? value) { + final trimmed = value?.trim() ?? ''; + return trimmed.isEmpty ? 'Not set' : trimmed; +} + +class _ProfilePhotoEditor extends StatelessWidget { + const _ProfilePhotoEditor({required this.profile, required this.onEditPhoto}); + + final UserProfile? profile; + final VoidCallback? onEditPhoto; + + @override + Widget build(BuildContext context) => Column( + children: [ + PlayingAvatarImage( + key: const ValueKey('profile-edit-avatar'), + imageUrl: profile?.avatarUrl, + radius: ProfileEditPage._avatarRadius, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: Grid.twelve), + _ProfileActionPill( + key: const ValueKey('profile-edit-photo-pill'), + semanticLabel: 'Edit profile photo', + label: 'Edit Photo', + onTap: onEditPhoto, + ), + ], + ); +} + +class _ProfileActionPill extends StatelessWidget { + const _ProfileActionPill({ + super.key, + required this.label, + required this.onTap, + this.semanticLabel, + this.isBusy = false, + }); + + final String label; + final String? semanticLabel; + final VoidCallback? onTap; + final bool isBusy; + + @override + Widget build(BuildContext context) => Semantics( + button: true, + enabled: onTap != null, + label: semanticLabel ?? label, + child: Material( + color: onTap == null + ? context.colors.surfaceContainerHighest.withValues(alpha: 0.5) + : context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: isBusy + ? const BuzzLoadingIndicator( + size: 18, + semanticLabel: 'Saving profile photo', + ) + : Text( + label, + style: context.textTheme.labelLarge?.copyWith( + color: onTap == null + ? context.colors.onSurfaceVariant + : context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); +} + +class _EditChevron extends StatelessWidget { + const _EditChevron(); + + @override + Widget build(BuildContext context) => Icon( + LucideIcons.chevronRight, + size: 18, + color: context.colors.onSurfaceVariant, + ); +} diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 6fa351d630d..38231d0f0d3 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -1,42 +1,240 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/crypto/nip_oa.dart'; +import '../../shared/profile/user_cache_provider.dart'; +import '../../shared/profile/user_profile.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; -import '../../shared/profile/user_profile.dart'; + +/// Signals that a profile write no longer belongs to the active community. +class ProfileCommunityChangedException extends StateError { + /// Creates an error for a profile write invalidated by a community switch. + ProfileCommunityChangedException() + : super('Profile update cancelled because the active community changed.'); +} /// The current user's profile (kind:0 metadata) loaded over the relay /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. class ProfileNotifier extends AsyncNotifier { + Map _metadata = {}; + bool _hasHydrated = false; + int _lastCreatedAt = 0; + Future _patchQueue = Future.value(); + @override Future build() { - ref.watch(relayConfigProvider); + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); ref.watch(relaySessionProvider); - return _fetch(); + final context = _ProfileWriteContext( + config: config, + pubkey: pubkey, + session: ref.read(relaySessionProvider.notifier), + ); + _hasHydrated = false; + return _fetch(context); } - Future _fetch() async { - final myPk = ref.read(myPubkeyProvider); - if (myPk == null) return null; + Future _fetch(_ProfileWriteContext context) async { + final myPk = context.pubkey; + if (myPk == null) { + _requireCurrentWriteContext(context); + _metadata = {}; + _lastCreatedAt = 0; + _hasHydrated = true; + return null; + } - final session = ref.read(relaySessionProvider.notifier); + final session = context.session; final events = await session.fetchHistory(NostrFilters.profile(myPk)); - if (events.isEmpty) return null; - final data = ProfileData.fromEvent(events.first); - return UserProfile( + if (events.isEmpty) { + _requireCurrentWriteContext(context); + _metadata = {}; + _lastCreatedAt = 0; + _hasHydrated = true; + return null; + } + final latest = _latestProfileEvent(events)!; + final metadata = _decodeProfileMetadata(latest); + final data = ProfileData.fromEvent(latest); + final profile = UserProfile( pubkey: data.pubkey, displayName: data.displayName, avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, + ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), ); + _requireCurrentWriteContext(context); + _metadata = metadata; + _lastCreatedAt = latest.createdAt; + _hasHydrated = true; + return profile; } Future refresh() async { - state = await AsyncValue.guard(_fetch); + final context = _currentWriteContext(); + _hasHydrated = false; + state = await AsyncValue.guard(() => _fetch(context)); + } + + /// Updates the current user's display name while preserving the other + /// metadata fields in their latest kind:0 profile event. + Future updateDisplayName(String displayName) => + _publishProfilePatch({'display_name': displayName.trim()}); + + /// Updates the current user's profile description. + Future updateAbout(String about) => + _publishProfilePatch({'about': about.trim()}); + + /// Updates the current user's profile photo URL. + Future updateAvatarUrl(String avatarUrl) => + _publishProfilePatch({'picture': avatarUrl.trim()}); + + Future _publishProfilePatch(Map patch) { + final context = _currentWriteContext(); + final previous = _patchQueue; + final released = Completer(); + _patchQueue = released.future; + return () async { + await previous; + try { + await _publishProfilePatchNow(patch, context); + } finally { + released.complete(); + } + }(); + } + + _ProfileWriteContext _currentWriteContext() => _ProfileWriteContext( + config: ref.read(relayConfigProvider), + pubkey: ref.read(myPubkeyProvider), + session: ref.read(relaySessionProvider.notifier), + ); + + Future _publishProfilePatchNow( + Map patch, + _ProfileWriteContext context, + ) async { + _requireCurrentWriteContext(context); + if (!_hasHydrated || !state.hasValue) { + throw StateError('Cannot update profile before metadata is loaded.'); + } + final pubkey = context.pubkey; + if (pubkey == null) { + throw StateError('Cannot update profile without a signing identity.'); + } + final session = context.session; + final currentEvents = await session.fetchHistory( + NostrFilters.profile(pubkey), + ); + _requireCurrentWriteContext(context); + final currentHead = _latestProfileEvent(currentEvents); + if (_lastCreatedAt > 0 && + (currentHead == null || currentHead.createdAt < _lastCreatedAt)) { + throw StateError('Cannot confirm the latest profile metadata.'); + } + final currentMetadata = currentHead == null + ? {} + : _decodeProfileMetadata(currentHead); + final nextMetadata = {...currentMetadata, ...patch}; + if (patch['display_name'] == '') { + nextMetadata + ..remove('display_name') + ..remove('name'); + } + final relay = SignedEventRelay(session: session, nsec: context.config.nsec); + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final currentCreatedAt = currentHead?.createdAt ?? 0; + final previousCreatedAt = currentCreatedAt > _lastCreatedAt + ? currentCreatedAt + : _lastCreatedAt; + final createdAt = now > previousCreatedAt ? now : previousCreatedAt + 1; + NostrEvent? signedEvent; + await relay.submit( + kind: EventKind.profile, + content: jsonEncode(nextMetadata), + tags: currentHead?.tags ?? const [], + createdAt: createdAt, + onSigned: (event) => signedEvent = event, + ); + _requireCurrentWriteContext(context); + final submittedEvent = signedEvent; + if (submittedEvent == null) { + throw StateError('Profile update was not signed.'); + } + final verifiedHead = _latestProfileEvent( + await session.fetchHistory(NostrFilters.profile(pubkey)), + ); + _requireCurrentWriteContext(context); + if (verifiedHead?.id != submittedEvent.id) { + throw StateError('Profile changed before the update could be confirmed.'); + } + + _metadata = nextMetadata; + _lastCreatedAt = createdAt; + final profile = UserProfile( + pubkey: pubkey, + displayName: + _metadata['display_name'] as String? ?? _metadata['name'] as String?, + avatarUrl: _metadata['picture'] as String?, + about: _metadata['about'] as String?, + nip05Handle: _metadata['nip05'] as String?, + ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), + ); + state = AsyncData(profile); + ref.read(userCacheProvider.notifier).put(profile); + } + + void _requireCurrentWriteContext(_ProfileWriteContext context) { + final currentConfig = ref.read(relayConfigProvider); + final currentSession = ref.read(relaySessionProvider.notifier); + final currentPubkey = ref.read(myPubkeyProvider); + if (currentConfig.storedOrigin != context.config.storedOrigin || + currentConfig.nsec != context.config.nsec || + currentPubkey != context.pubkey || + !identical(currentSession, context.session)) { + throw ProfileCommunityChangedException(); + } + } +} + +class _ProfileWriteContext { + const _ProfileWriteContext({ + required this.config, + required this.pubkey, + required this.session, + }); + + final RelayConfig config; + final String? pubkey; + final RelaySessionNotifier session; +} + +NostrEvent? _latestProfileEvent(List events) { + if (events.isEmpty) return null; + return events.reduce((current, event) { + if (event.createdAt != current.createdAt) { + return event.createdAt > current.createdAt ? event : current; + } + // Match the relay replacement tie-breaker: the lowest event id wins. + return event.id.compareTo(current.id) < 0 ? event : current; + }); +} + +Map _decodeProfileMetadata(NostrEvent event) { + try { + final decoded = jsonDecode(event.content); + return decoded is Map + ? Map.from(decoded) + : {}; + } on FormatException { + return {}; } } diff --git a/mobile/lib/features/profile/profile_text_edit_sheet.dart b/mobile/lib/features/profile/profile_text_edit_sheet.dart new file mode 100644 index 00000000000..939984dbf5f --- /dev/null +++ b/mobile/lib/features/profile/profile_text_edit_sheet.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_sheet_header.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; +import 'profile_provider.dart'; + +/// Flutter fallback sheet for editing one profile text field. +class ProfileTextEditSheet extends HookWidget { + /// Creates a profile text editor. + const ProfileTextEditSheet({ + super.key, + required this.title, + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + /// The heading displayed in the sheet header. + final String title; + + /// The value loaded into the text field when the sheet opens. + final String initialValue; + + /// Placeholder text displayed while the text field is empty. + final String hintText; + + /// Whether the text field accepts and displays multiple lines. + final bool multiline; + + /// Persists the submitted value before the sheet closes. + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future closeAfterSave() async { + isSaving.value = false; + await WidgetsBinding.instance.endOfFrame; + if (context.mounted) Navigator.of(context).pop(); + } + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) await closeAfterSave(); + } on ProfileCommunityChangedException { + if (context.mounted) await closeAfterSave(); + } catch (_) { + if (context.mounted) { + error.value = "We couldn't save this change. Try again."; + } + } finally { + if (context.mounted) isSaving.value = false; + } + } + + final closeButton = Theme.of(context).platform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const ValueKey('profile-field-close'), + icon: IosGlassNavigationIcon.close, + semanticLabel: 'Close sheet', + onPressed: isSaving.value + ? null + : () => Navigator.of(context).pop(), + width: 44, + height: 44, + foregroundColor: context.colors.primary, + ) + : SizedBox.square( + dimension: 44, + child: IconButton( + key: const ValueKey('profile-field-close'), + tooltip: 'Close sheet', + onPressed: isSaving.value + ? null + : () => Navigator.of(context).pop(), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + icon: const Icon(LucideIcons.x, size: 22), + ), + ); + + return PopScope( + canPop: !isSaving.value, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BuzzSheetHeader(title: title, trailing: closeButton), + Flexible( + child: SafeArea( + top: false, + child: SingleChildScrollView( + key: const ValueKey('profile-field-scroll-view'), + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Semantics( + liveRegion: true, + child: Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart new file mode 100644 index 00000000000..f37c113c9f4 --- /dev/null +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import 'ios_profile_text_editor.dart'; +import 'profile_provider.dart'; +import 'profile_text_edit_sheet.dart'; + +/// Opens the current user's display-name editor from a profile action surface. +Future showProfileDisplayNameEditor(BuildContext context) async { + final container = ProviderScope.containerOf(context, listen: false); + try { + await container.read(profileProvider.future); + } catch (_) { + return; + } + if (!context.mounted) return; + final profileState = container.read(profileProvider); + if (!profileState.hasValue) return; + final profile = profileState.requireValue; + final onSave = bindProfileSaveToOpeningContext( + container, + container.read(profileProvider.notifier).updateDisplayName, + ); + await _showProfileTextEditor( + context: context, + title: 'Display name', + initialValue: profile?.displayName ?? '', + hintText: 'Display name', + onSave: onSave, + ); +} + +/// Opens the current user's profile-description editor. +Future showProfileDescriptionEditor(BuildContext context) async { + final container = ProviderScope.containerOf(context, listen: false); + try { + await container.read(profileProvider.future); + } catch (_) { + return; + } + if (!context.mounted) return; + final profileState = container.read(profileProvider); + if (!profileState.hasValue) return; + final profile = profileState.requireValue; + final onSave = bindProfileSaveToOpeningContext( + container, + container.read(profileProvider.notifier).updateAbout, + ); + await _showProfileTextEditor( + context: context, + title: 'Profile description', + initialValue: profile?.about ?? '', + hintText: 'Profile description', + multiline: true, + onSave: onSave, + ); +} + +/// Prevents a profile draft from being published after its community changes. +Future Function(String) bindProfileSaveToOpeningContext( + ProviderContainer container, + Future Function(String value) onSave, +) { + final openingConfig = container.read(relayConfigProvider); + final openingPubkey = container.read(myPubkeyProvider); + final openingSession = container.read(relaySessionProvider.notifier); + return (value) { + final currentConfig = container.read(relayConfigProvider); + final isCurrent = + currentConfig.storedOrigin == openingConfig.storedOrigin && + currentConfig.nsec == openingConfig.nsec && + container.read(myPubkeyProvider) == openingPubkey && + identical( + container.read(relaySessionProvider.notifier), + openingSession, + ); + if (!isCurrent) throw ProfileCommunityChangedException(); + return onSave(value); + }; +} + +Future _showProfileTextEditor({ + required BuildContext context, + required String title, + required String initialValue, + required String hintText, + required Future Function(String value) onSave, + bool multiline = false, +}) async { + if (defaultTargetPlatform == TargetPlatform.iOS) { + try { + await IosProfileTextEditor.presentUntilSaved( + title: title, + initialValue: initialValue, + placeholder: hintText, + multiline: multiline, + brightness: Theme.of(context).brightness, + onSave: onSave, + shouldRetryOnError: (error) => + error is! ProfileCommunityChangedException, + canPresent: () => + context.mounted && (ModalRoute.of(context)?.isCurrent ?? true), + onSaveError: () { + if (context.mounted && (ModalRoute.of(context)?.isCurrent ?? true)) { + _showSaveError(context); + } + }, + ); + return; + } on MissingPluginException { + // Previews and older builds retain the complete Flutter fallback. + } on PlatformException { + // A temporary native presentation failure should not block editing. + } + } + + if (!context.mounted) return; + await showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + requestFocus: true, + showCloseButton: false, + builder: (_) => ProfileTextEditSheet( + title: title, + initialValue: initialValue, + hintText: hintText, + multiline: multiline, + onSave: onSave, + ), + ); +} + +void _showSaveError(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("We couldn't save this change. Try again.")), + ); +} diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 514629b1f72..0e489ed486b 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -214,7 +214,7 @@ class SearchPage extends HookConsumerWidget { automaticallyImplyLeading: false, horizontalInset: Grid.twelve, showBottomDivider: true, - bottomDividerOpacity: 0.06, + bottomDividerOpacity: 0.07, titleStyle: headerTitleStyle, // Keep this mounted through the search-field morph so it can fade in // beneath the returning field rather than popping in afterward. diff --git a/mobile/lib/features/settings/accent_picker_page.dart b/mobile/lib/features/settings/accent_picker_page.dart index 88965e251f9..a0ee79a51df 100644 --- a/mobile/lib/features/settings/accent_picker_page.dart +++ b/mobile/lib/features/settings/accent_picker_page.dart @@ -21,7 +21,10 @@ class AccentPickerPage extends ConsumerWidget { final colorScheme = context.colors; return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Accent Color')), + appBar: const FrostedAppBar( + centerTitle: true, + title: Text('Accent Color'), + ), body: ListView( padding: EdgeInsets.only( top: frostedAppBarHeight(context), diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index eb4194eb155..5fd934f251b 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -19,6 +19,8 @@ import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/ios_glass_navigation_button.dart'; +import '../../shared/widgets/ios_glass_navigation_action.dart'; +import '../../shared/widgets/immediate_page_route.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; @@ -27,6 +29,10 @@ part 'settings_page/appearance_section.dart'; part 'settings_page/community_section.dart'; part 'settings_page/connection_section.dart'; +Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); + +enum _ProfileEditAction { displayName, description, photo } + class SettingsPage extends HookConsumerWidget { /// Creates the settings page. const SettingsPage({ @@ -34,6 +40,9 @@ class SettingsPage extends HookConsumerWidget { required this.profileHeader, required this.invitePageBuilder, required this.identityRecoveryPageBuilder, + this.profileEditPageBuilder = _emptyProfileEditPage, + this.onEditDisplayName, + this.onEditProfileDescription, }); /// Header widget displayed at the top of settings. @@ -45,6 +54,15 @@ class SettingsPage extends HookConsumerWidget { /// Builds the identity-recovery page pushed from the recovery settings row. final WidgetBuilder identityRecoveryPageBuilder; + /// Builds the current-user profile editor opened from the top action. + final WidgetBuilder profileEditPageBuilder; + + /// Opens the display-name editor after the Edit Profile sheet closes. + final Future Function(BuildContext context)? onEditDisplayName; + + /// Opens the profile-description editor after the Edit Profile sheet closes. + final Future Function(BuildContext context)? onEditProfileDescription; + @override Widget build(BuildContext context, WidgetRef ref) { final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform()); @@ -54,6 +72,75 @@ class SettingsPage extends HookConsumerWidget { bottomHeight: Grid.xxs, ); + Future showEditProfileSheet() async { + final action = await showBuzzModalBottomSheet<_ProfileEditAction>( + context: context, + title: 'Edit profile', + builder: (sheetContext) => SafeArea( + top: false, + child: Padding( + key: const ValueKey('edit-profile-sheet-content'), + padding: const EdgeInsets.only(bottom: Grid.xs), + // AppListCard normally fills the height offered by a page section. + // Give it unbounded vertical space here so this compact action sheet + // hugs its three rows instead of filling the modal height cap. + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppListCard( + key: const ValueKey('edit-profile-options'), + dividerIndent: Grid.xs, + verticalPadding: 0, + children: [ + AppListRow( + key: const ValueKey('edit-profile-display-name'), + title: 'Display name', + trailing: const _RowChevron(), + onTap: () => Navigator.pop( + sheetContext, + _ProfileEditAction.displayName, + ), + ), + AppListRow( + key: const ValueKey('edit-profile-description'), + title: 'Profile description', + trailing: const _RowChevron(), + onTap: () => Navigator.pop( + sheetContext, + _ProfileEditAction.description, + ), + ), + AppListRow( + key: const ValueKey('edit-profile-photo'), + title: 'Photo', + trailing: const _RowChevron(), + onTap: () => + Navigator.pop(sheetContext, _ProfileEditAction.photo), + ), + ], + ), + ], + ), + ), + ), + ); + if (!context.mounted || action == null) return; + unawaited(HapticFeedback.selectionClick()); + switch (action) { + case _ProfileEditAction.displayName: + await onEditDisplayName?.call(context); + break; + case _ProfileEditAction.description: + await onEditProfileDescription?.call(context); + break; + case _ProfileEditAction.photo: + await Navigator.of( + context, + ).push(immediatePageRoute(builder: profileEditPageBuilder)); + break; + } + } + return FrostedScaffold( backgroundColor: context.colors.surface, appBar: FrostedAppBar( @@ -84,6 +171,38 @@ class SettingsPage extends HookConsumerWidget { icon: const Icon(LucideIcons.x), ), ), + actions: [ + if (Theme.of(context).platform == TargetPlatform.iOS) + IosGlassNavigationAction( + key: const ValueKey('settings-edit-profile'), + label: 'Edit', + foregroundColor: navigationPrimaryForeground(context), + onPressed: () => unawaited(showEditProfileSheet()), + ) + else + Material( + key: const ValueKey('settings-edit-profile'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => unawaited(showEditProfileSheet()), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Text( + 'Edit', + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], bottomHeight: Grid.xxs, bottom: const SizedBox.expand(), ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 8ab89f9e963..7f989b29db0 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -16,11 +16,6 @@ class _ConnectionSection extends ConsumerWidget { label: 'Connection', verticalPadding: Grid.twelve, children: [ - AppListRow( - icon: LucideIcons.server, - title: 'Connected to', - subtitle: config.baseUrl, - ), if (nsec != null && nsec.isNotEmpty && community != null) ...[ _IdentityRow(nsec: nsec), AppListRow( @@ -128,19 +123,19 @@ class _IdentityRow extends StatelessWidget { final privHex = nostr.Nip19.decode(payload: nsec).data; final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown'; - return AppListRow( - icon: LucideIcons.key, - title: 'Identity (pubkey)', - subtitle: pubkey, - subtitleStyle: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontFamily: 'GeistMono', - fontSize: 11, - ), - subtitleMaxLines: 2, - trailing: IconButton( - icon: const Icon(LucideIcons.copy, size: 16), - onPressed: () async { + return Semantics( + button: true, + label: 'Copy identity public key', + value: pubkey, + child: AppListRow( + icon: LucideIcons.key, + title: 'Identity (pubkey)', + trailing: Icon( + LucideIcons.copy, + size: 18, + color: context.colors.onSurfaceVariant, + ), + onTap: () async { await copyToClipboard(context, pubkey, message: 'Pubkey copied'); }, ), diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index bd2c8fae325..ce0e0d3bf56 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -73,7 +73,7 @@ class ThemePickerPage extends HookConsumerWidget { }, const []); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Theme')), + appBar: const FrostedAppBar(centerTitle: true, title: Text('Theme')), body: Column( children: [ SizedBox(height: frostedAppBarHeight(context)), diff --git a/mobile/lib/shared/animated_avatar.dart b/mobile/lib/shared/animated_avatar.dart index 41efcecb1e9..f44321746e9 100644 --- a/mobile/lib/shared/animated_avatar.dart +++ b/mobile/lib/shared/animated_avatar.dart @@ -18,6 +18,11 @@ class AnimatedAvatarDescriptor { final String animationUrl; } +/// Builds the desktop-compatible animated-avatar URL from a static poster and +/// an animated image. +String buildAnimatedAvatarUrl(String posterUrl, String animationUrl) => + '$posterUrl$_animatedAvatarSeparator${Uri.encodeComponent(animationUrl)}'; + /// Parses the Buzz animated-avatar fragment scheme from [url]. /// /// Returns `null` when the poster or animation URL is missing, malformed, or diff --git a/mobile/lib/shared/emoji/emoji_avatar.dart b/mobile/lib/shared/emoji/emoji_avatar.dart new file mode 100644 index 00000000000..e1395a78b6c --- /dev/null +++ b/mobile/lib/shared/emoji/emoji_avatar.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +/// Desktop-compatible colors offered for emoji avatar backgrounds. +const emojiAvatarColors = [ + 0xFFFFF4CC, + 0xFFFFE75C, + 0xFFFFB84D, + 0xFFFF8652, + 0xFFF6534F, + 0xFFFF6B9A, + 0xFFFB60C4, + 0xFFD66BFF, + 0xFFB141FF, + 0xFF7C5CFF, + 0xFF476CFF, + 0xFF3399FF, + 0xFF63C6F2, + 0xFF41EBC1, + 0xFF2ED3A2, + 0xFF73EF75, + 0xFF9FE870, + 0xFFC7D36F, + 0xFFCCCCCC, + 0xFF8A8F98, + 0xFF4B5563, + 0xFF000000, + 0xFFFFFFFF, +]; + +/// Creates the same inline SVG avatar URL used by the desktop editor. +String emojiAvatarDataUrl(String emoji, int colorValue) { + final color = + '#${(colorValue & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}'; + final escapedEmoji = emoji + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>'); + final svg = + '' + '' + '$escapedEmoji' + ''; + return 'data:image/svg+xml,${Uri.encodeComponent(svg)}'; +} + +/// The editable values encoded by an inline emoji avatar. +class EmojiAvatarData { + /// Creates decoded emoji avatar values. + const EmojiAvatarData({required this.emoji, required this.colorValue}); + + /// The system emoji rendered in the avatar. + final String emoji; + + /// The ARGB background color. + final int colorValue; +} + +/// Decodes an inline emoji-avatar data URL, or returns null for other images. +EmojiAvatarData? parseEmojiAvatarDataUrl(String? value) { + final url = value?.trim(); + if (url == null || !url.startsWith('data:image/')) return null; + try { + final data = UriData.parse(url); + if (data.mimeType != 'image/svg+xml') return null; + return parseEmojiAvatarSvg(utf8.decode(data.contentAsBytes())); + } on FormatException { + return null; + } +} + +/// Decodes the editable values from an emoji-avatar SVG. +EmojiAvatarData? parseEmojiAvatarSvg(String svg) { + final colorValue = RegExp( + r']*\sfill="([^"]+)"', + ).firstMatch(svg)?[1]; + final emojiValue = RegExp(r']*>(.*?)').firstMatch(svg)?[1]; + if (colorValue == null || emojiValue == null) return null; + + final hex = colorValue.startsWith('#') ? colorValue.substring(1) : colorValue; + if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; + final rgb = int.tryParse(hex, radix: 16); + if (rgb == null) return null; + return EmojiAvatarData( + emoji: emojiValue + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&'), + colorValue: 0xFF000000 | rgb, + ); +} diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 7831c1b32b8..40b6f81b798 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -7,16 +7,44 @@ import 'package:flutter/material.dart'; /// layout box unchanged and lift only the painted glyph on iOS; Android's /// system emoji metrics are already visually centred. class NativeEmojiGlyph extends StatelessWidget { + /// Creates a system emoji with optional optical bounds. + const NativeEmojiGlyph({ + super.key, + required this.emoji, + required this.size, + this.opticalBoxSize, + }); + + /// Emoji text rendered by the platform font. final String emoji; + + /// Font size used to paint the emoji. final double size; - const NativeEmojiGlyph({super.key, required this.emoji, required this.size}); + /// Optional square that normalizes the apparent bounds of wide emoji. + final double? opticalBoxSize; @override Widget build(BuildContext context) { - final glyph = Text(emoji, style: TextStyle(fontSize: size)); - if (defaultTargetPlatform != TargetPlatform.iOS) return glyph; + Widget glyph = Text( + emoji, + maxLines: 1, + softWrap: false, + textScaler: TextScaler.noScaling, + style: TextStyle(fontSize: size, height: 1), + ); + if (defaultTargetPlatform == TargetPlatform.iOS) { + glyph = Transform.translate(offset: const Offset(0, -1), child: glyph); + } + final boxSize = opticalBoxSize; + if (boxSize == null) return glyph; - return Transform.translate(offset: const Offset(0, -1), child: glyph); + // Emoji sequences have very different typographic advances. Constrain + // them to the same square optical box so wide glyphs scale down around the + // same center instead of appearing to lean toward one edge. + return SizedBox.square( + dimension: boxSize, + child: FittedBox(fit: BoxFit.scaleDown, child: glyph), + ); } } diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c04bf952ae1..fd8d3fafcd7 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -33,6 +33,11 @@ class UserCacheNotifier extends Notifier> { return null; } + /// Stores a profile that was fetched or updated outside the batch loader. + void put(UserProfile profile) { + state = {...state, profile.pubkey.toLowerCase(): profile}; + } + /// Preload profiles for a list of pubkeys (e.g. channel members). void preload(List pubkeys) { final uncached = pubkeys diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 3161a70362d..cbe59bcd33a 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -17,6 +17,8 @@ import 'media_auth.dart'; import 'mp4_fast_start.dart'; import 'relay_provider.dart'; +part 'media_upload/platform_bindings.dart'; + const _mediaUploadPath = '/upload'; const _legacyMediaUploadPath = '/media/upload'; const _mediaUploadPlatformChannelName = 'buzz/media_upload'; @@ -44,17 +46,6 @@ final _mediaUploadPlatformChannel = MethodChannel( _mediaUploadPlatformChannelName, ); -/// Whether saving media needs Android's pre-scoped-storage runtime permission. -Future requiresLegacyMediaStoragePermission() async { - if (defaultTargetPlatform != TargetPlatform.android) { - return false; - } - return await _mediaUploadPlatformChannel.invokeMethod( - _requiresLegacyMediaStoragePermissionMethod, - ) ?? - false; -} - const _allowedImageMimeTypes = { 'image/jpeg', 'image/png', @@ -68,6 +59,9 @@ const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); +/// Captures one image with the system camera, or returns null when cancelled. +typedef PickCameraImage = Future Function(); + /// Selects multiple gallery images for upload in picker order. typedef PickGalleryImages = Future> Function(); typedef PickGalleryVideo = Future Function(); @@ -218,6 +212,7 @@ class MediaUploadService { final String _baseUrl; final String? _nsec; final PickGalleryImage _pickGalleryImage; + final PickCameraImage? _pickCameraImage; final PickGalleryImages _pickGalleryImages; final PickGalleryVideo _pickGalleryVideo; final PickAttachmentFile? _pickAttachmentFile; @@ -234,6 +229,7 @@ class MediaUploadService { required String baseUrl, required String? nsec, required PickGalleryImage pickGalleryImage, + PickCameraImage? pickCameraImage, PickGalleryImages? pickGalleryImages, required PickGalleryVideo pickGalleryVideo, PickAttachmentFile? pickAttachmentFile, @@ -247,6 +243,7 @@ class MediaUploadService { }) : _baseUrl = baseUrl, _nsec = nsec, _pickGalleryImage = pickGalleryImage, + _pickCameraImage = pickCameraImage, _pickGalleryImages = pickGalleryImages ?? (() async { @@ -277,6 +274,23 @@ class MediaUploadService { return uploadImage(pickedImage); } + /// Opens the system photo library without uploading the selected image. + Future pickGalleryImage() => _pickGalleryImage(); + + /// Opens the system camera without uploading the captured image. + Future captureImage() async => _pickCameraImage?.call(); + + /// Produces displayable, sanitized image bytes before an upload begins. + Future prepareImageBytes(XFile image) async => + (await _prepareUploadImage(image)).bytes; + + /// Opens the system camera and uploads the captured image. + Future captureAndUploadImage() async { + final pickedImage = await _pickCameraImage?.call(); + if (pickedImage == null) return null; + return uploadImage(pickedImage); + } + /// Opens the system picker with multi-selection enabled. Future> pickGalleryImages() => _pickGalleryImages(); @@ -979,21 +993,3 @@ Future _invokeRequiredPlatformBytesMethod( } return result; } - -final mediaUploadServiceProvider = Provider((ref) { - final config = ref.watch(relayConfigProvider); - final picker = ImagePicker(); - final service = MediaUploadService( - baseUrl: config.baseUrl, - nsec: config.nsec, - pickGalleryImage: () => picker.pickImage( - source: ImageSource.gallery, - requestFullMetadata: false, - ), - pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false), - pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), - pickAttachmentFile: file_selector.openFile, - ); - ref.onDispose(service.dispose); - return service; -}); diff --git a/mobile/lib/shared/relay/media_upload/platform_bindings.dart b/mobile/lib/shared/relay/media_upload/platform_bindings.dart new file mode 100644 index 00000000000..527871f11f7 --- /dev/null +++ b/mobile/lib/shared/relay/media_upload/platform_bindings.dart @@ -0,0 +1,34 @@ +part of '../media_upload.dart'; + +/// Whether saving media needs Android's pre-scoped-storage runtime permission. +Future requiresLegacyMediaStoragePermission() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return false; + } + return await _mediaUploadPlatformChannel.invokeMethod( + _requiresLegacyMediaStoragePermissionMethod, + ) ?? + false; +} + +final mediaUploadServiceProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final picker = ImagePicker(); + final service = MediaUploadService( + baseUrl: config.baseUrl, + nsec: config.nsec, + pickGalleryImage: () => picker.pickImage( + source: ImageSource.gallery, + requestFullMetadata: false, + ), + pickCameraImage: () => picker.pickImage( + source: ImageSource.camera, + requestFullMetadata: false, + ), + pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false), + pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), + pickAttachmentFile: file_selector.openFile, + ); + ref.onDispose(service.dispose); + return service; +}); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 98039ff2ab2..c041e4399fa 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -6,6 +6,8 @@ import 'package:flutter/foundation.dart'; /// /// Keep in sync with `desktop/src/shared/constants/kinds.ts`. abstract final class EventKind { + /// Kind:0 user profile metadata. + static const profile = 0; static const note = 1; static const contactList = 3; static const deletion = 5; diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index 787e66d577e..9c46e3bec35 100644 --- a/mobile/lib/shared/widgets/app_list.dart +++ b/mobile/lib/shared/widgets/app_list.dart @@ -26,6 +26,7 @@ class AppListRow extends StatelessWidget { this.trailing, this.titleColor, this.onTap, + this.verticalPadding = _rowVerticalPadding, }); final IconData? icon; @@ -42,12 +43,16 @@ class AppListRow extends StatelessWidget { final Color? titleColor; final VoidCallback? onTap; + /// Vertical inset around the row content. Compact sheets may use + /// [Grid.twelve] while standard settings rows retain the default. + final double verticalPadding; + @override Widget build(BuildContext context) { final row = Padding( padding: EdgeInsets.symmetric( horizontal: AppListInset.of(context), - vertical: _rowVerticalPadding, + vertical: verticalPadding, ), child: Row( // Centred rather than baseline-aligned to the title: on a two-line row @@ -137,6 +142,8 @@ class AppListRowRaw extends StatelessWidget { final Widget? subtitle; final Widget? trailing; final VoidCallback? onTap; + + /// Vertical inset around the row content. final double verticalPadding; @override diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 7b40739c4c7..0a5379cf333 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -5,6 +5,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../animated_avatar.dart'; +import '../emoji/emoji_avatar.dart'; +import '../emoji/native_emoji_glyph.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -85,16 +87,16 @@ class _AvatarImageContentState extends State { _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( color: color, child: LayoutBuilder( - builder: (_, constraints) => Center( - child: Text( - emoji, - textScaler: TextScaler.noScaling, - style: TextStyle( - fontSize: constraints.biggest.shortestSide * 258 / 512, - height: 1, + builder: (_, constraints) { + final glyphSize = constraints.biggest.shortestSide * 258 / 512; + return Center( + child: NativeEmojiGlyph( + emoji: emoji, + size: glyphSize, + opticalBoxSize: glyphSize, ), - ), - ), + ); + }, ), ), _SvgAvatarSource(:final svg) => SvgPicture.string( @@ -131,7 +133,13 @@ sealed class _AvatarSource { if (data.mimeType == 'image/svg+xml') { final Uint8List bytes = data.contentAsBytes(); final svg = utf8.decode(bytes); - return _parseEmojiAvatar(svg) ?? _SvgAvatarSource(svg); + final emojiAvatar = parseEmojiAvatarSvg(svg); + return emojiAvatar == null + ? _SvgAvatarSource(svg) + : _EmojiAvatarSource( + emojiAvatar.emoji, + Color(emojiAvatar.colorValue), + ); } return _RasterDataAvatarSource(data.contentAsBytes()); } on FormatException { @@ -140,29 +148,6 @@ sealed class _AvatarSource { } } -_EmojiAvatarSource? _parseEmojiAvatar(String svg) { - final colorValue = RegExp( - r']*\sfill="([^"]+)"', - ).firstMatch(svg)?[1]; - final emojiValue = RegExp(r']*>(.*?)').firstMatch(svg)?[1]; - if (colorValue == null || emojiValue == null) return null; - - final color = _parseHexColor(colorValue); - if (color == null) return null; - final emoji = emojiValue - .replaceAll('>', '>') - .replaceAll('<', '<') - .replaceAll('&', '&'); - return _EmojiAvatarSource(emoji, color); -} - -Color? _parseHexColor(String value) { - final hex = value.startsWith('#') ? value.substring(1) : value; - if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) return null; - final rgb = int.tryParse(hex, radix: 16); - return rgb == null ? null : Color(0xFF000000 | rgb); -} - class _EmojiAvatarSource extends _AvatarSource { final String emoji; final Color color; diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart index fed46a8d607..14551f76fdc 100644 --- a/mobile/lib/shared/widgets/buzz_sheet_header.dart +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -89,6 +89,7 @@ class _SheetCloseButton extends StatelessWidget { onPressed: closeSheet, width: 44, height: 44, + foregroundColor: context.colors.primary, ); } @@ -100,7 +101,7 @@ class _SheetCloseButton extends StatelessWidget { style: IconButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: context.colors.surfaceContainerHighest, - foregroundColor: context.colors.onSurface, + foregroundColor: context.colors.primary, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(Radii.dialog), ), diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index d20b4dcf99b..42d0b541811 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -13,8 +13,8 @@ const _kBottomBorderWidth = 1.0; TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) { final baseStyle = - context.textTheme.titleMedium ?? - const TextStyle(fontSize: 20, height: 1.3); + context.textTheme.titleSmall ?? + const TextStyle(fontSize: 16, height: 1.4); return baseStyle.copyWith(fontWeight: FontWeight.w600).merge(titleStyle); } @@ -26,7 +26,7 @@ double _barContentHeight( final style = _effectiveTitleStyle(context, titleStyle); final scaledFontSize = MediaQuery.textScalerOf( context, - ).scale(style.fontSize ?? 20); + ).scale(style.fontSize ?? 16); final scaledTitleHeight = scaledFontSize * (style.height ?? 1); final effectiveTitleHeight = titleContentHeight > scaledTitleHeight ? titleContentHeight @@ -48,7 +48,7 @@ double frostedAppBarLowerTitleHeight( final style = _effectiveTitleStyle(context, titleStyle); final scaledFontSize = MediaQuery.textScalerOf( context, - ).scale(style.fontSize ?? 20); + ).scale(style.fontSize ?? 16); final titleHeight = scaledFontSize * (style.height ?? 1); return titleHeight > 40 ? titleHeight : 40; } @@ -85,6 +85,11 @@ class FrostedAppBar extends StatelessWidget { /// Widget displayed in the center/title area. final Widget? title; + /// Whether [title] is centered in the full bar rather than flowing after the + /// leading control. Page titles should keep the default; identity-style + /// headers can opt out. + final bool centerTitle; + /// Optional style merged over the default title style. final TextStyle? titleStyle; @@ -141,20 +146,21 @@ class FrostedAppBar extends StatelessWidget { this.leading, this.automaticallyImplyLeading = true, this.title, + this.centerTitle = false, this.titleStyle, this.titleContentHeight = 0, this.bottom, this.bottomHeight = 0, this.bottomOverlap = 0, this.actions = const [], - this.horizontalInset = Grid.quarter, + this.horizontalInset = Grid.xxs, this.iconColor, this.gradient, this.frosted = true, this.frostedSurfaceOpacity = 0.5, this.frostedBlurSigma = 20, this.showBottomDivider = true, - this.bottomDividerOpacity = 0.15, + this.bottomDividerOpacity = 0.07, }) : assert(bottom == null || bottomHeight > 0), assert(bottomOverlap >= 0), assert(bottom != null || bottomOverlap == 0), @@ -176,6 +182,7 @@ class FrostedAppBar extends StatelessWidget { automaticallyImplyLeading && canPop && Theme.of(context).platform == TargetPlatform.iOS; + final effectiveIconColor = iconColor ?? context.colors.primary; final effectiveLeading = leading ?? @@ -187,14 +194,14 @@ class FrostedAppBar extends StatelessWidget { onPressed: () => Navigator.of(context).maybePop(), width: iosGlassChannelHeaderLeadingWidth, buttonCenterX: iosGlassChannelHeaderButtonCenterX, - foregroundColor: iconColor, + foregroundColor: effectiveIconColor, ) : SizedBox( width: 48, height: 48, child: IconButton( onPressed: () => Navigator.of(context).pop(), - color: iconColor, + color: effectiveIconColor, icon: const Icon(LucideIcons.chevronLeft), tooltip: 'Back', ), @@ -206,39 +213,40 @@ class FrostedAppBar extends StatelessWidget { child: Padding( padding: EdgeInsets.symmetric(horizontal: horizontalInset), child: IconTheme.merge( - data: IconThemeData(color: iconColor), - child: Row( - children: [ - ?effectiveLeading, - if (title != null) - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: effectiveLeading != null - ? usesAutomaticIosGlassBackButton - ? iosGlassChannelHeaderTitleSpacing - : 0 - : horizontalInset < Grid.gutter - ? Grid.gutter - horizontalInset - : 0, - right: actions.isEmpty - ? horizontalInset < Grid.gutter - ? Grid.gutter - horizontalInset - : 0 - : 0, - ), - child: DefaultTextStyle.merge( - style: effectiveTitleStyle, - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: title!, - ), + data: IconThemeData(color: effectiveIconColor), + child: _CenteredNavigationLayout( + leading: effectiveLeading, + title: title == null + ? null + : DefaultTextStyle.merge( + style: effectiveTitleStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: centerTitle ? TextAlign.center : TextAlign.start, + child: centerTitle + ? title! + : Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null + ? usesAutomaticIosGlassBackButton + ? iosGlassChannelHeaderTitleSpacing + : 0 + : horizontalInset < Grid.gutter + ? Grid.gutter - horizontalInset + : 0, + right: + actions.isEmpty && + horizontalInset < Grid.gutter + ? Grid.gutter - horizontalInset + : 0, + ), + child: title!, + ), ), - ) - else - const Spacer(), - ...actions, - ], + actions: actions.isEmpty + ? null + : Row(mainAxisSize: MainAxisSize.min, children: actions), + centered: centerTitle, ), ), ), @@ -312,3 +320,100 @@ class FrostedAppBar extends StatelessWidget { return Positioned(top: 0, left: 0, right: 0, child: child); } } + +enum _NavigationSlot { leading, title, actions } + +class _CenteredNavigationLayout extends StatelessWidget { + const _CenteredNavigationLayout({ + this.leading, + this.title, + this.actions, + required this.centered, + }); + + final Widget? leading; + final Widget? title; + final Widget? actions; + final bool centered; + + @override + Widget build(BuildContext context) { + if (!centered) { + return Row( + children: [ + ?leading, + if (title != null) Expanded(child: title!) else const Spacer(), + ?actions, + ], + ); + } + return CustomMultiChildLayout( + delegate: _CenteredNavigationLayoutDelegate(), + children: [ + if (leading != null) + LayoutId(id: _NavigationSlot.leading, child: leading!), + if (title != null) LayoutId(id: _NavigationSlot.title, child: title!), + if (actions != null) + LayoutId(id: _NavigationSlot.actions, child: actions!), + ], + ); + } +} + +class _CenteredNavigationLayoutDelegate extends MultiChildLayoutDelegate { + @override + void performLayout(Size size) { + Size leadingSize = Size.zero; + if (hasChild(_NavigationSlot.leading)) { + leadingSize = layoutChild( + _NavigationSlot.leading, + BoxConstraints.loose(size), + ); + positionChild( + _NavigationSlot.leading, + Offset(0, (size.height - leadingSize.height) / 2), + ); + } + + Size actionsSize = Size.zero; + if (hasChild(_NavigationSlot.actions)) { + actionsSize = layoutChild( + _NavigationSlot.actions, + BoxConstraints.loose(size), + ); + positionChild( + _NavigationSlot.actions, + Offset( + size.width - actionsSize.width, + (size.height - actionsSize.height) / 2, + ), + ); + } + + if (hasChild(_NavigationSlot.title)) { + final occupiedSideWidth = leadingSize.width > actionsSize.width + ? leadingSize.width + : actionsSize.width; + final sideWidth = occupiedSideWidth == 0 + ? 0.0 + : occupiedSideWidth + Grid.xs; + final titleWidth = (size.width - sideWidth * 2).clamp(0.0, size.width); + final titleSize = layoutChild( + _NavigationSlot.title, + BoxConstraints(maxWidth: titleWidth, maxHeight: size.height), + ); + positionChild( + _NavigationSlot.title, + Offset( + (size.width - titleSize.width) / 2, + (size.height - titleSize.height) / 2, + ), + ); + } + } + + @override + bool shouldRelayout( + covariant _CenteredNavigationLayoutDelegate oldDelegate, + ) => false; +} diff --git a/mobile/lib/shared/widgets/immediate_page_route.dart b/mobile/lib/shared/widgets/immediate_page_route.dart new file mode 100644 index 00000000000..0282ea62bb0 --- /dev/null +++ b/mobile/lib/shared/widgets/immediate_page_route.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +/// A route for destinations that provide their own in-page transition. +PageRoute immediatePageRoute({required WidgetBuilder builder}) => + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, _, _) => builder(context), + transitionsBuilder: (_, _, _, child) => child, + ); diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_action.dart b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart new file mode 100644 index 00000000000..59a296f2b94 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_glass_navigation_action.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../theme/theme.dart'; +import 'ios_glass_navigation_button.dart'; + +/// A native iOS glass capsule for a short navigation-bar text action. +class IosGlassNavigationAction extends HookWidget { + /// Creates a native glass navigation action. + const IosGlassNavigationAction({ + super.key, + required this.label, + required this.onPressed, + this.width = 64, + this.height = 48, + this.foregroundColor, + this.isBusy = false, + }); + + /// The visible and accessible action label. + final String label; + + /// Called when the action is pressed, or null when disabled. + final VoidCallback? onPressed; + + /// The width of the Flutter platform-view region. + final double width; + + /// The height of the Flutter platform-view region. + final double height; + + /// Optional foreground tint; defaults to the active theme color. + final Color? foregroundColor; + + /// Whether the native action should replace its label with progress. + final bool isBusy; + + @override + Widget build(BuildContext context) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final nativeChannel = useState(null); + final onPressedRef = useRef(onPressed)..value = onPressed; + final brightness = context.theme.brightness.name; + final effectiveForeground = foregroundColor ?? context.colors.primary; + final foregroundValue = effectiveForeground.toARGB32(); + final enabled = onPressed != null; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'pressed') onPressedRef.value?.call(); + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited( + channel.invokeMethod('setAppearance', { + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + }), + ); + } + return null; + }, [nativeChannel.value, brightness, foregroundValue, enabled, isBusy]); + + return Tooltip( + message: label, + excludeFromSemantics: true, + child: SizedBox( + width: width, + height: height, + child: UiKitView( + viewType: IosGlassNavigationButton.viewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: { + 'label': label, + 'accessibilityLabel': label, + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + 'buttonCenterX': width / 2, + 'controlWidth': width - 8, + 'hitTargetWidth': width, + 'hitTargetHeight': height, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel( + '${IosGlassNavigationButton.viewType}/$viewId', + ); + }, + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/ios_native_segmented_control.dart b/mobile/lib/shared/widgets/ios_native_segmented_control.dart new file mode 100644 index 00000000000..656a1dfaee4 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_segmented_control.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// A platform-native iOS segmented control embedded in Flutter. +class IosNativeSegmentedControl extends HookWidget { + /// Creates a native segmented control for the supplied labels. + const IosNativeSegmentedControl({ + super.key, + required this.items, + required this.selectedIndex, + required this.onChanged, + this.height = 40, + }); + + /// The registered iOS platform-view identifier. + static const viewType = 'buzz/native_segmented_control'; + + /// Labels displayed by the native segments. + final List items; + + /// The selected segment index. + final int selectedIndex; + + /// Called with a new index, or null when the control is disabled. + final ValueChanged? onChanged; + + /// The height of the embedded native control. + final double height; + + @override + Widget build(BuildContext context) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final nativeChannel = useState(null); + final onChangedRef = useRef(onChanged)..value = onChanged; + final brightness = Theme.of(context).brightness.name; + final enabled = onChanged != null; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'changed' && call.arguments is int) { + onChangedRef.value?.call(call.arguments as int); + } + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited( + channel.invokeMethod('setState', { + 'selectedIndex': selectedIndex, + 'brightness': brightness, + 'enabled': enabled, + }), + ); + } + return null; + }, [nativeChannel.value, selectedIndex, brightness, enabled]); + + return SizedBox( + height: height, + child: UiKitView( + viewType: viewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: { + 'items': items, + 'selectedIndex': selectedIndex, + 'brightness': brightness, + 'enabled': enabled, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel('$viewType/$viewId'); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart new file mode 100644 index 00000000000..c2ab6f4dec1 --- /dev/null +++ b/mobile/lib/shared/widgets/ios_native_skin_tone_control.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart' show PlatformViewHitTestBehavior; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// A native iOS glass menu for selecting an emoji skin tone. +class IosNativeSkinToneControl extends HookWidget { + /// Creates a native skin-tone selection menu. + const IosNativeSkinToneControl({ + super.key, + required this.value, + required this.colors, + required this.labels, + required this.onChanged, + }); + + /// The registered iOS platform-view identifier. + static const viewType = 'buzz/native_skin_tone_control'; + + /// The selected skin-tone index. + final int value; + + /// Colors displayed for the available tone options. + final List colors; + + /// Accessible labels corresponding to [colors]. + final List labels; + + /// Called with the newly selected tone index. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + assert(defaultTargetPlatform == TargetPlatform.iOS); + final nativeChannel = useState(null); + final onChangedRef = useRef(onChanged)..value = onChanged; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'changed' && call.arguments is int) { + onChangedRef.value(call.arguments as int); + } + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited(channel.invokeMethod('setValue', value)); + } + return null; + }, [nativeChannel.value, value]); + + return SizedBox.square( + dimension: 48, + child: UiKitView( + viewType: viewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: { + 'value': value, + 'colors': [for (final color in colors) color.toARGB32()], + 'labels': labels, + 'brightness': Theme.of(context).brightness.name, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel('$viewType/$viewId'); + }, + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart new file mode 100644 index 00000000000..ef84d4549e7 --- /dev/null +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../animated_avatar.dart'; +import 'avatar_image.dart'; +import 'progressive_animated_avatar.dart'; + +/// A circular avatar that opts into playback for animated profile descriptors. +class PlayingAvatarImage extends StatelessWidget { + /// Creates an avatar that plays animation when the profile URL describes it. + const PlayingAvatarImage({ + super.key, + required this.imageUrl, + required this.radius, + required this.fallback, + this.backgroundColor, + }); + + /// The still-image URL or encoded animated-avatar descriptor. + final String? imageUrl; + + /// The radius of the circular avatar. + final double radius; + + /// Optional background shown behind still-image content. + final Color? backgroundColor; + + /// Content shown while media is unavailable or still loading. + final Widget fallback; + + @override + Widget build(BuildContext context) { + final descriptor = parseAnimatedAvatarUrl(imageUrl); + if (descriptor == null || MediaQuery.disableAnimationsOf(context)) { + return AvatarImage( + imageUrl: descriptor?.posterUrl ?? imageUrl, + radius: radius, + backgroundColor: backgroundColor, + fallback: fallback, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: Colors.transparent, + child: ClipOval( + child: SizedBox.square( + dimension: radius * 2, + child: ProgressiveAnimatedAvatar( + descriptor: descriptor, + fallback: fallback, + ), + ), + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d83c73b98d0..0d4e95a80c9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: be169cf6ac481e052c4538715d88841d567150dfe1df38aaec76461a4e7b39f2 + url: "https://pub.dev" + source: hosted + version: "4.1.0" args: dependency: transitive description: @@ -584,6 +592,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: "3e69fea4211727732cc385104e675ad1e40b29f12edd492ee52fa108423a6124" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + google_mlkit_selfie_segmentation: + dependency: "direct main" + description: + name: google_mlkit_selfie_segmentation + sha256: "1688273f3bd9d3c78cdb6f4136c2b2c40c7013bd53d56392c0d52a84cdf3cb29" + url: "https://pub.dev" + source: hosted + version: "0.10.1" gpt_markdown: dependency: "direct main" description: @@ -656,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" image_picker: dependency: "direct main" description: @@ -1088,6 +1120,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" provider: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cd1eac39ac1..6c51dc9cc5c 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: file_selector: ^1.1.0 camera: ^0.12.0+2 image_picker: ^1.1.2 + image: ^4.8.0 + google_mlkit_selfie_segmentation: ^0.10.1 photo_manager: ^3.11.0 video_player: ^2.10.1 package_info_plus: ^10.0.0 diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 96ad80aa003..f770e1ccb11 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -197,6 +197,7 @@ void main() { expect(appBar.frosted, isTrue); expect(appBar.showBottomDivider, isTrue); expect(appBar.bottomHeight, Grid.xxs); + expect(appBar.centerTitle, isFalse); expect(find.byTooltip('Back'), findsNothing); }); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 4178dbc76a3..195dcc25ad8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -8119,6 +8119,7 @@ void main() { expect(detailsAppBar.frostedSurfaceOpacity, 0); expect(detailsAppBar.frostedBlurSigma, 0); expect(detailsAppBar.showBottomDivider, isFalse); + expect(detailsAppBar.centerTitle, isTrue); final descriptionBottom = tester .getRect(find.byKey(const ValueKey('channel-details-description'))) @@ -8173,7 +8174,7 @@ void main() { expect(detailsAppBar.frostedSurfaceOpacity, 0.5); expect(detailsAppBar.frostedBlurSigma, 20); expect(detailsAppBar.showBottomDivider, isTrue); - expect(detailsAppBar.bottomDividerOpacity, 0.15); + expect(detailsAppBar.bottomDividerOpacity, 0.07); expect( tester .widget( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 68fa3152365..44f8714c64f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -628,6 +628,11 @@ void main() { expect(tester.getSize(communityAvatar), const Size.square(40)); expect(tester.getSize(profileAvatar), const Size.square(36)); + expect( + tester.widget(profileAvatar).badge, + isNull, + reason: 'The current user does not need an online dot on Home.', + ); final communityRect = tester.getRect(communityAvatar); final profileRect = tester.getRect(profileAvatar); expect(profileRect.center.dy, communityRect.center.dy); @@ -749,6 +754,8 @@ void main() { expect(route, isNot(isA>())); expect(route?.opaque, isFalse); expect(route?.allowSnapshotting, isFalse); + expect(route?.transitionDuration, const Duration(milliseconds: 150)); + expect(route?.reverseTransitionDuration, const Duration(milliseconds: 150)); }); testWidgets('reports monotonic Settings route progress', (tester) async { @@ -835,7 +842,7 @@ void main() { final forwardScaleProgress = (1.04 - forwardScale) / 0.04; expect( forwardOpacity, - closeTo(Curves.easeOutQuad.transform(95 / 220), 0.02), + closeTo(Curves.easeOutQuad.transform(95 / 150), 0.02), ); expect(forwardScaleProgress, closeTo(forwardOpacity, 0.001)); await tester.pumpAndSettle(); diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart new file mode 100644 index 00000000000..63059266321 --- /dev/null +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -0,0 +1,233 @@ +import 'dart:io'; +import 'dart:ui' show SemanticsAction; + +import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:buzz/features/profile/animated_avatar_capture.dart'; +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:camera/camera.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; + +void main() { + test('encoded poster preserves avatar scales below one', () { + final source = image.Image(width: 256, height: 256, numChannels: 4); + image.fill(source, color: image.ColorRgba8(255, 0, 0, 255)); + + final poster = image.decodePng( + encodeAnimatedAvatarPoster(frame: image.encodePng(source), scale: 0.75), + )!; + + final edge = poster.getPixel(8, 128); + final center = poster.getPixel(128, 128); + expect(edge.r, isNot(255)); + expect(center.r, 255); + }); + + test('capture frame workspaces are isolated', () async { + final first = await createAnimatedAvatarFrameDirectory( + parent: Directory.systemTemp, + ); + final second = await createAnimatedAvatarFrameDirectory( + parent: Directory.systemTemp, + ); + addTearDown(() async { + if (await first.exists()) await first.delete(recursive: true); + if (await second.exists()) await second.delete(recursive: true); + }); + + expect(first.path, isNot(second.path)); + }); + + group('animatedAvatarFrameRotationDegrees', () { + test('compensates front camera frames for every device orientation', () { + const expected = { + DeviceOrientation.portraitUp: 270, + DeviceOrientation.landscapeRight: 180, + DeviceOrientation.portraitDown: 90, + DeviceOrientation.landscapeLeft: 0, + }; + + for (final entry in expected.entries) { + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: entry.key, + lensDirection: CameraLensDirection.front, + ), + entry.value, + ); + } + }); + + test('compensates back camera frames for every device orientation', () { + const expected = { + DeviceOrientation.portraitUp: 90, + DeviceOrientation.landscapeRight: 180, + DeviceOrientation.portraitDown: 270, + DeviceOrientation.landscapeLeft: 0, + }; + + for (final entry in expected.entries) { + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 90, + deviceOrientation: entry.key, + lensDirection: CameraLensDirection.back, + ), + entry.value, + ); + } + }); + }); + + testWidgets('completed review frames survive lifecycle changes', ( + tester, + ) async { + final lifecycle = _TestLifecycleNotifier(); + Future Function()? prepare; + final frame = image.encodePng(image.Image(width: 2, height: 2)); + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: ExcludeSemantics( + child: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame, frame], + onPrepareChanged: (value) => prepare = value, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('animated-avatar-review-preview')), + findsOneWidget, + ); + expect(prepare, isNotNull); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + + expect( + find.byKey(const ValueKey('animated-avatar-review-preview')), + findsOneWidget, + ); + expect(prepare, isNotNull); + }); + + testWidgets('poster scrubber supports semantic adjustment actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frames = [ + for (var index = 0; index < 3; index++) + image.encodePng(image.Image(width: 2, height: 2)), + ]; + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: frames, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(find.text('Frame')); + await tester.pump(); + + final scrubber = find.bySemanticsLabel('Choose still frame'); + expect(scrubber, findsOneWidget); + final initialSemantics = tester.getSemantics(scrubber); + expect(initialSemantics.value, '1 of 3'); + final initialData = initialSemantics.getSemanticsData(); + expect(initialData.hasAction(SemanticsAction.increase), isTrue); + expect(initialData.hasAction(SemanticsAction.decrease), isFalse); + + final semanticsWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Choose still frame', + ), + ); + semanticsWidget.properties.onIncrease!(); + await tester.pump(); + expect(tester.getSemantics(scrubber).value, '2 of 3'); + semantics.dispose(); + }); + + testWidgets('review preview exposes accessible repositioning actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final frame = image.encodePng(image.Image(width: 2, height: 2)); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame], + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + + final position = find.bySemanticsLabel('Avatar position'); + expect(position, findsOneWidget); + final positionWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Avatar position', + ), + ); + final actions = positionWidget.properties.customSemanticsActions!; + expect( + actions.keys.map((action) => action.label), + containsAll(['Move left', 'Move right', 'Move up', 'Move down']), + ); + actions.entries + .firstWhere((entry) => entry.key.label == 'Move right') + .value(); + await tester.pump(); + expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical'); + semantics.dispose(); + }); +} + +class _TestLifecycleNotifier extends AppLifecycleNotifier { + AppLifecycleState _lifecycle = AppLifecycleState.resumed; + + @override + AppLifecycleState build() => _lifecycle; + + void setLifecycle(AppLifecycleState value) { + _lifecycle = value; + state = value; + } +} diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart new file mode 100644 index 00000000000..1c92ac9fdc3 --- /dev/null +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -0,0 +1,174 @@ +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/shared/animated_avatar.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('animated draft retries only the failed upload part', () async { + final service = _PartiallyFailingUploadService(); + addTearDown(service.dispose); + final draft = ProfileAnimatedAvatarDraft( + poster: Uint8List.fromList([1]), + animation: Uint8List.fromList([2]), + ); + + await expectLater(draft.upload(service), throwsException); + expect(service.uploadedParts, ['poster', 'animation']); + + final url = await draft.upload(service); + expect(service.uploadedParts, ['poster', 'animation', 'animation']); + expect(parseAnimatedAvatarUrl(url)?.posterUrl, 'https://relay/poster.png'); + expect( + parseAnimatedAvatarUrl(url)?.animationUrl, + 'https://relay/animation.png', + ); + + expect(await draft.upload(service), url); + expect(service.uploadedParts, ['poster', 'animation', 'animation']); + }); + + test('animated draft supports one upload at a time', () async { + final service = _SingleUploadService(); + addTearDown(service.dispose); + final draft = ProfileAnimatedAvatarDraft( + poster: Uint8List.fromList([1]), + animation: Uint8List.fromList([2]), + ); + + final url = await draft.upload(service); + + expect(service.maxInFlight, 1); + expect(service.uploadedParts, ['poster', 'animation']); + expect(parseAnimatedAvatarUrl(url)?.posterUrl, 'https://relay/poster.png'); + expect( + parseAnimatedAvatarUrl(url)?.animationUrl, + 'https://relay/animation.png', + ); + }); + + test('animated draft reuploads every part for a new community', () async { + final first = _RecordingUploadService('first'); + final second = _RecordingUploadService('second'); + addTearDown(first.dispose); + addTearDown(second.dispose); + final draft = ProfileAnimatedAvatarDraft( + poster: Uint8List.fromList([1]), + animation: Uint8List.fromList([2]), + ); + + final firstUrl = await draft.upload(first); + final secondUrl = await draft.upload(second); + + expect(first.uploadedParts, ['poster', 'animation']); + expect(second.uploadedParts, ['poster', 'animation']); + expect(firstUrl, contains('https://first.example/')); + expect(secondUrl, contains('https://second.example/')); + }); +} + +final class _SingleUploadService extends MediaUploadService { + _SingleUploadService() + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final uploadedParts = []; + int _inFlight = 0; + int maxInFlight = 0; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + _inFlight++; + maxInFlight = _inFlight > maxInFlight ? _inFlight : maxInFlight; + if (_inFlight > 1) { + _inFlight--; + throw Exception('upload concurrency limit reached'); + } + final part = bytes.single == 1 ? 'poster' : 'animation'; + uploadedParts.add(part); + await Future.delayed(Duration.zero); + _inFlight--; + return BlobDescriptor( + url: 'https://relay/$part.png', + sha256: '$part-hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} + +final class _RecordingUploadService extends MediaUploadService { + _RecordingUploadService(this.community) + : super( + baseUrl: 'https://$community.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final String community; + final uploadedParts = []; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + final part = bytes.single == 1 ? 'poster' : 'animation'; + uploadedParts.add(part); + return BlobDescriptor( + url: 'https://$community.example/$part.png', + sha256: '$community-$part', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} + +final class _PartiallyFailingUploadService extends MediaUploadService { + _PartiallyFailingUploadService() + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final uploadedParts = []; + var _failedAnimationOnce = false; + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + final part = bytes.single == 1 ? 'poster' : 'animation'; + uploadedParts.add(part); + if (part == 'animation' && !_failedAnimationOnce) { + _failedAnimationOnce = true; + throw Exception('animation upload failed'); + } + return BlobDescriptor( + url: 'https://relay/$part.png', + sha256: '$part-hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart new file mode 100644 index 00000000000..d100e0f5ae4 --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -0,0 +1,964 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/profile_avatar_crop_page.dart'; +import 'package:buzz/features/profile/avatar_background_grid.dart'; +import 'package:buzz/features/profile/avatar_editor_option_button.dart'; +import 'package:buzz/features/profile/emoji_avatar_tile.dart'; +import 'package:buzz/shared/widgets/immediate_page_route.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/emoji/emoji_avatar.dart'; +import 'package:buzz/shared/emoji/emoji_data.dart'; +import 'package:buzz/shared/emoji/emoji_data_provider.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/ios_native_segmented_control.dart'; +import 'package:buzz/shared/widgets/playing_avatar_image.dart'; +import 'package:buzz/shared/widgets/progressive_animated_avatar.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; +import 'package:image_picker/image_picker.dart'; + +import '../../helpers/widget_helpers.dart'; + +part 'profile_edit_page_test/motion_and_accessibility_tests.dart'; +part 'profile_edit_page_test/image_selection_tests.dart'; + +const _editorControlBottomForTest = Grid.xl + Grid.xxs; + +void main() { + testWidgets('keeps crop Save disabled while dimensions decode', ( + tester, + ) async { + final bytes = Uint8List.fromList( + image.encodePng(image.Image(width: 20, height: 10)), + ); + await tester.pumpWidget( + MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final saveButton = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('avatar-crop-use-photo')), + matching: find.byType(TextButton), + ), + ); + expect(saveButton.onPressed, isNull); + }); + + testWidgets('can open directly into the photo editor from Settings', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pump(); + + expect(find.text('Edit Photo'), findsOneWidget); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('profile-information-page')), + findsNothing, + ); + + final entrance = find.byKey( + const ValueKey('avatar-editor-entrance-transform'), + ); + final initialOffset = tester + .widget(entrance) + .transform + .getTranslation() + .y; + final screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + final appBarHeight = frostedAppBarHeight(tester.element(entrance)); + final expectedOffset = appBarHeight + 96 - screenHeight / 2; + expect(initialOffset, closeTo(expectedOffset, 0.01)); + + await tester.pump(const Duration(milliseconds: 100)); + final midpointOffset = tester + .widget(entrance) + .transform + .getTranslation() + .y; + expect(midpointOffset, greaterThan(initialOffset)); + expect(midpointOffset, lessThan(0)); + + await tester.pumpAndSettle(); + expect( + tester.widget(entrance).transform.getTranslation().y, + closeTo(0, 0.01), + ); + expect( + tester + .getCenter(find.byKey(const ValueKey('avatar-editor-fixed-preview'))) + .dy, + closeTo(screenHeight / 2, 0.01), + ); + }); + + testWidgets('return motion eases out into the Settings avatar position', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + immediatePageRoute( + builder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + ), + ), + child: const Text('Open editor'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + final entrance = find.byKey( + const ValueKey('avatar-editor-entrance-transform'), + ); + expect( + tester.widget(entrance).transform.getTranslation().y, + closeTo(0, 0.01), + ); + + await tester.tap(find.byKey(const ValueKey('avatar-editor-back'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + final quarterOffset = tester + .widget(entrance) + .transform + .getTranslation() + .y; + final screenHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + final appBarHeight = frostedAppBarHeight(tester.element(entrance)); + final collapsedOffset = appBarHeight + 96 - screenHeight / 2; + expect(quarterOffset, lessThan(collapsedOffset / 2)); + expect(quarterOffset, greaterThan(collapsedOffset)); + + await tester.pumpAndSettle(); + expect(find.text('Open editor'), findsOneWidget); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsNothing, + ); + }); + + testWidgets('uses the native segmented control for photo modes on iOS', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pump(); + + expect(find.byType(IosNativeSegmentedControl), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('uses the native profile text form on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('buzz/profile_text_editor'); + MethodCall? receivedCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + receivedCall = call; + return 'Alice Native'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _FakeProfileNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [profileProvider.overrideWith(() => notifier)], + child: MaterialApp( + theme: AppTheme.dark(), + home: const Scaffold(body: ProfileEditPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + + expect(receivedCall?.method, 'present'); + expect(receivedCall?.arguments, { + 'title': 'Display name', + 'initialValue': 'Alice', + 'placeholder': 'Display name', + 'multiline': false, + 'brightness': 'dark', + 'allowUnchangedSubmission': false, + }); + expect(notifier.savedDisplayNames, ['Alice Native']); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('shows profile fields and saves each one from a sheet', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Edit Photo'), findsOneWidget); + expect(find.text('Display name'), findsOneWidget); + expect(find.text('Profile description'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('Building Buzz'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Alice L', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedDisplayNames, ['Alice L']); + expect(find.text('Alice L'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('profile-description-row'))); + await tester.pumpAndSettle(); + final field = tester.widget( + find.byKey(const ValueKey('profile-field-input')), + ); + expect(field.minLines, 4); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Making collaboration feel effortless.', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedDescriptions, [ + 'Making collaboration feel effortless.', + ]); + }); + + testWidgets('uses an icon-free grouped card with inset dividers', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byKey(const ValueKey('profile-info-card')), + matching: find.byType(Icon), + ), + findsNWidgets(2), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('profile-info-card')), + matching: find.byType(Divider), + ), + findsOneWidget, + ); + }); + + testWidgets('uploads a selected photo and updates the profile', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('avatar-mode-control')), findsOneWidget); + expect(find.text('Image'), findsOneWidget); + expect(find.text('Camera'), findsOneWidget); + expect(find.text('Photo Library'), findsOneWidget); + expect(find.text('Emoji'), findsOneWidget); + expect(find.text('Animated'), findsOneWidget); + expect(find.text('Photo'), findsNothing); + final modeControl = find.byKey(const ValueKey('avatar-mode-control')); + final preview = find.descendant( + of: find.byKey(const ValueKey('avatar-editor-content')), + matching: find.byType(AvatarImage), + ); + expect( + tester.getTopLeft(modeControl).dy, + lessThan(tester.getTopLeft(preview).dy), + ); + expect(tester.widget(preview).radius, 110); + await tester.tap(find.text('Photo Library')); + await _waitForAvatarCropToLoad(tester); + expect(find.text('Position Photo'), findsOneWidget); + final cancelButton = find.ancestor( + of: find.text('Cancel'), + matching: find.byType(TextButton), + ); + final saveButton = find.byKey(const ValueKey('avatar-crop-use-photo')); + expect(tester.getRect(cancelButton).left, Grid.gutter); + expect( + tester.getSize(find.text('Cancel')).width, + lessThan(tester.getSize(cancelButton).width), + ); + expect( + tester.getSize(find.byType(Scaffold).last).width - + tester.getRect(saveButton).right, + Grid.gutter, + ); + final cropViewerFinder = find.byKey(const ValueKey('avatar-crop-viewer')); + final cropViewer = tester.widget(cropViewerFinder); + final cropViewport = tester.getSize(cropViewerFinder); + final cropDiameter = math.min(cropViewport.width, cropViewport.height); + final expectedX = (cropViewport.width - cropDiameter) / 2; + final expectedY = (cropViewport.height - cropDiameter) / 2; + await tester.drag(cropViewerFinder, const Offset(1000, 1000)); + await tester.pump(); + expect( + cropViewer.transformationController!.value.storage[12], + closeTo(expectedX, 0.01), + ); + expect( + cropViewer.transformationController!.value.storage[13], + closeTo(expectedY, 0.01), + ); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await _waitForAvatarCropToClose(tester); + expect(notifier.savedAvatarUrls, isEmpty); + expect(uploadService.uploadCount, 0); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + expect(uploadService.uploadCount, 1); + }); + + testWidgets('keeps a failed avatar draft visible and retries publish', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(failedAvatarSaves: 1); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo Library')); + await _waitForAvatarCropToLoad(tester); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await _waitForAvatarCropToClose(tester); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect( + find.text("We couldn't save your profile photo. Try again."), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, + ); + expect(uploadService.uploadCount, 1); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + expect(uploadService.uploadCount, 1); + }); + + testWidgets('centers every preview while controls fill the page gutters', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + final screenSize = tester.view.physicalSize / tester.view.devicePixelRatio; + final expectedContentWidth = screenSize.width - Grid.gutter * 2; + expect( + tester + .getCenter(find.byKey(const ValueKey('avatar-editor-fixed-preview'))) + .dy, + closeTo(screenSize.height / 2, 0.01), + ); + expect( + tester.getSize(find.byKey(const ValueKey(0))).width, + closeTo(expectedContentWidth, 0.01), + ); + expect(find.byKey(const ValueKey('image-source-camera')), findsOneWidget); + expect(find.byKey(const ValueKey('image-source-library')), findsOneWidget); + final cameraSurface = find.descendant( + of: find.byKey(const ValueKey('image-source-camera')), + matching: find.byType(AnimatedContainer), + ); + final librarySurface = find.descendant( + of: find.byKey(const ValueKey('image-source-library')), + matching: find.byType(AnimatedContainer), + ); + final imageControlGap = + tester.getRect(librarySurface).left - + tester.getRect(cameraSurface).right; + expect(imageControlGap, greaterThan(Grid.twelve)); + expect( + screenSize.height - + tester + .getRect(find.byKey(const ValueKey('image-source-camera'))) + .bottom, + _editorControlBottomForTest, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy, + closeTo(screenSize.height / 2 - 140, 0.01), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) + .width, + closeTo(expectedContentWidth, 0.01), + ); + expect( + find.byKey(const ValueKey('emoji-editor-background')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('emoji-editor-emoji')), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('emoji-avatar-picker-content')), + matching: find.byType(AnimatedSwitcher), + ), + findsNothing, + ); + final backgroundSurface = find.descendant( + of: find.byKey(const ValueKey('emoji-editor-background')), + matching: find.byType(AnimatedContainer), + ); + final emojiSurface = find.descendant( + of: find.byKey(const ValueKey('emoji-editor-emoji')), + matching: find.byType(AnimatedContainer), + ); + expect( + tester.getRect(emojiSurface).left - + tester.getRect(backgroundSurface).right, + closeTo(imageControlGap, 0.01), + ); + expect( + screenSize.height - + tester + .getRect(find.byKey(const ValueKey('emoji-editor-background'))) + .bottom, + _editorControlBottomForTest, + ); + + final expandedPickerHeight = tester + .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) + .height; + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy, + closeTo(screenSize.height / 2 - avatarBackgroundPreviewShift, 0.01), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) + .height, + lessThan(expandedPickerHeight), + ); + + await tester.tap(find.text('Animated')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + expect( + tester + .getCenter( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + ) + .dy, + closeTo(screenSize.height / 2, 0.01), + ); + expect( + tester.getSize(find.byKey(const ValueKey(2))).width, + closeTo(expectedContentWidth, 0.01), + ); + final recordButton = find.byKey( + const ValueKey('animated-avatar-record-morph'), + ); + expect(tester.getSize(recordButton).height, 64); + expect( + tester.getSize(recordButton).width, + closeTo(expectedContentWidth, 0.01), + ); + final recordMaterial = tester.widget( + find.descendant(of: recordButton, matching: find.byType(Material)).first, + ); + expect( + recordMaterial.borderRadius, + const BorderRadius.all(Radius.circular(Radii.full)), + ); + expect( + screenSize.height - tester.getRect(recordButton).bottom, + _editorControlBottomForTest, + ); + expect( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + findsNothing, + ); + }); + + testWidgets('waits for the photo picker before opening Position Photo', ( + tester, + ) async { + final uploadService = _FakeMediaUploadService(delayGallery: true); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo Library')); + await tester.pump(); + + expect(find.text('Position Photo'), findsNothing); + expect( + find.byKey(const ValueKey('avatar-image-loading-overlay')), + findsOneWidget, + ); + + uploadService.completeGallerySelection(); + await _waitForAvatarCropToLoad(tester); + expect(find.text('Position Photo'), findsOneWidget); + }); + + testWidgets('captures a camera photo and updates the profile', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Camera')); + await _waitForAvatarCropToLoad(tester); + expect(find.text('Position Photo'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 200)), + ); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, isEmpty); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, ['https://relay.example/camera.png']); + }); + + testWidgets('saves a desktop-compatible emoji avatar', (tester) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + expect(find.byKey(const ValueKey('emoji-avatar-search')), findsOneWidget); + expect( + find.byKey(const ValueKey('emoji-avatar-skin-tone')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('emoji-avatar-preview'))), + const Size.square(220), + ); + await tester.enterText( + find.byKey(const ValueKey('emoji-avatar-search')), + 'rocket', + ); + await tester.pump(const Duration(milliseconds: 300)); + final focusedSearch = tester.widget( + find.byKey(const ValueKey('emoji-avatar-search')), + ); + final focusedBorder = + focusedSearch.decoration?.focusedBorder! as OutlineInputBorder; + expect( + focusedBorder.borderRadius, + const BorderRadius.all(Radius.circular(Radii.full)), + ); + expect( + tester + .widget(find.byKey(const ValueKey('emoji-avatar-search'))) + .controller + ?.text, + 'rocket', + ); + await tester.tap(find.byKey(const ValueKey('emoji-avatar-skin-tone'))); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Dark'), findsOneWidget); + await tester.tapAt(const Offset(8, 8)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.enterText( + find.byKey(const ValueKey('emoji-avatar-search')), + '', + ); + await tester.pump(const Duration(milliseconds: 300)); + final previewFinder = find.byKey(const ValueKey('emoji-avatar-preview')); + final previewBefore = tester.widget(previewFinder); + final decorationBefore = previewBefore.decoration! as BoxDecoration; + expect(decorationBefore.color, isNot(Colors.white)); + final glyph = tester.widget( + find.descendant( + of: previewFinder, + matching: find.byType(NativeEmojiGlyph), + ), + ); + expect(glyph.size, closeTo(220 * 258 / 512, 0.01)); + final nextColorIndex = emojiAvatarColors.indexWhere( + (value) => Color(value) != decorationBefore.color, + ); + final nextColor = find.byKey( + ValueKey('emoji-avatar-color-$nextColorIndex'), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(nextColor); + await tester.pump(); + final previewAfter = tester.widget(previewFinder); + expect(previewAfter.decoration, isNot(previewBefore.decoration)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + final avatarUrl = notifier.savedAvatarUrls.single; + expect(avatarUrl, startsWith('data:image/svg+xml,')); + expect(Uri.decodeComponent(avatarUrl), contains('😊')); + expect( + find.byKey(const ValueKey('profile-information-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsNothing, + ); + }); + + testWidgets('saves the displayed default emoji without another selection', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, hasLength(1)); + expect(notifier.savedAvatarUrls.single, startsWith('data:image/svg+xml,')); + expect( + Uri.decodeComponent(notifier.savedAvatarUrls.single), + contains('😊'), + ); + }); + + testWidgets('keeps emoji drafts scoped to the emoji mode', (tester) async { + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 500)); + + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + await tester.tap(find.byKey(const ValueKey('emoji-avatar-color-1'))); + await tester.pump(); + await tester.tap(find.text('Image')); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect( + find.byKey(const ValueKey('profile-avatar-editor-page')), + findsOneWidget, + ); + }); + + runProfileEditMotionAndAccessibilityTests(); + runProfileEditImageSelectionTests(); +} + +Future _waitForAvatarCropToClose(WidgetTester tester) async { + final cropPage = find.byKey(const ValueKey('avatar-crop-viewer')); + for (var attempt = 0; attempt < 100; attempt += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 25)); + if (cropPage.evaluate().isEmpty) { + await tester.pump(const Duration(milliseconds: 250)); + return; + } + } + fail('Avatar crop did not complete within 5 seconds.'); +} + +Future _waitForAvatarCropToLoad(WidgetTester tester) async { + final cropViewer = find.byKey(const ValueKey('avatar-crop-viewer')); + for (var attempt = 0; attempt < 100; attempt += 1) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await tester.pump(const Duration(milliseconds: 25)); + if (cropViewer.evaluate().isNotEmpty) { + await tester.pumpAndSettle(); + return; + } + } + fail('Avatar crop did not load within 5 seconds.'); +} + +class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({ + this.profile = const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ), + this.failedAvatarSaves = 0, + }); + + final UserProfile profile; + int failedAvatarSaves; + final savedDisplayNames = []; + final savedDescriptions = []; + final savedAvatarUrls = []; + + @override + Future build() async => profile; + + @override + Future updateDisplayName(String displayName) async { + savedDisplayNames.add(displayName); + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: displayName, + avatarUrl: current.avatarUrl, + about: current.about, + nip05Handle: current.nip05Handle, + ), + ); + } + + @override + Future updateAbout(String about) async { + savedDescriptions.add(about); + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: current.displayName, + avatarUrl: current.avatarUrl, + about: about, + nip05Handle: current.nip05Handle, + ), + ); + } + + @override + Future updateAvatarUrl(String avatarUrl) async { + if (failedAvatarSaves > 0) { + failedAvatarSaves--; + throw Exception('profile publish failed'); + } + savedAvatarUrls.add(avatarUrl); + } +} + +class _FailingPreparationMediaUploadService extends _FakeMediaUploadService { + @override + Future prepareImageBytes(XFile image) async { + throw Exception('image preparation failed'); + } +} + +class _FakeMediaUploadService extends MediaUploadService { + _FakeMediaUploadService({this.delayGallery = false}) + : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + var _camera = false; + final bool delayGallery; + final _gallerySelection = Completer(); + int uploadCount = 0; + + XFile _image() => XFile.fromData( + image.encodePng(image.Image(width: 8, height: 8)), + mimeType: 'image/png', + name: 'avatar.png', + ); + + @override + Future pickGalleryImage() async { + _camera = false; + if (delayGallery) return _gallerySelection.future; + return _image(); + } + + void completeGallerySelection() => _gallerySelection.complete(_image()); + + @override + Future captureImage() async { + _camera = true; + return _image(); + } + + @override + Future prepareImageBytes(XFile image) => image.readAsBytes(); + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + uploadCount++; + return BlobDescriptor( + url: _camera + ? 'https://relay.example/camera.png' + : 'https://relay.example/profile.png', + sha256: _camera ? 'camera-hash' : 'hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart new file mode 100644 index 00000000000..62c5455dc1a --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -0,0 +1,240 @@ +part of '../profile_edit_page_test.dart'; + +void runProfileEditImageSelectionTests() { + testWidgets('duplicate avatar Back taps pop only the editor route', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + const ProfileEditPage(startInPhotoEditor: true), + ), + ), + ), + child: const Text('Open profile photo'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open profile photo')); + await tester.pumpAndSettle(); + + final back = find.byKey(const ValueKey('avatar-editor-back')); + await tester.tap(back); + await tester.tap(back); + await tester.pumpAndSettle(); + + expect(find.text('Open profile photo'), findsOneWidget); + }); + + testWidgets('photo crop exposes accessible move and zoom actions', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final bytes = Uint8List.fromList( + image.encodePng(image.Image(width: 20, height: 10)), + ); + await tester.pumpWidget( + MaterialApp( + home: ProfileAvatarCropPage( + imageBytes: Future.value(bytes), + ), + ), + ); + await _waitForAvatarCropToLoad(tester); + + final cropSemantics = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Photo crop', + ), + ); + final actions = cropSemantics.properties.customSemanticsActions!; + expect( + actions.keys.map((action) => action.label), + containsAll([ + 'Move left', + 'Move right', + 'Move up', + 'Move down', + 'Zoom in', + ]), + ); + final viewer = tester.widget( + find.byKey(const ValueKey('avatar-crop-viewer')), + ); + actions.entries.firstWhere((entry) => entry.key.label == 'Zoom in').value(); + await tester.pump(); + expect(viewer.transformationController!.value.getMaxScaleOnAxis(), 1.1); + semantics.dispose(); + }); + + testWidgets('seeds emoji editing from the current avatar', (tester) async { + final avatarUrl = emojiAvatarDataUrl('🦝', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatarUrl, + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + final preview = find.byKey(const ValueKey('emoji-avatar-preview')); + expect( + tester + .widget( + find.descendant( + of: preview, + matching: find.byType(NativeEmojiGlyph), + ), + ) + .emoji, + '🦝', + ); + expect( + (tester.widget(preview).decoration! as BoxDecoration) + .color, + Color(emojiAvatarColors[11]), + ); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(notifier.savedAvatarUrls, [avatarUrl]); + }); + + testWidgets('seeds the skin-tone filter from the current emoji', ( + tester, + ) async { + const variants = [ + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍', + categoryId: 'people', + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏻', + categoryId: 'people', + skinIndex: 1, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏼', + categoryId: 'people', + skinIndex: 2, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏽', + categoryId: 'people', + skinIndex: 3, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏾', + categoryId: 'people', + skinIndex: 4, + ), + EmojiEntry( + id: '+1', + name: 'Thumbs Up', + keywords: ['thumb'], + native: '👍🏿', + categoryId: 'people', + skinIndex: 5, + ), + ]; + const dataset = EmojiDataset( + categories: [EmojiCategory(id: 'people', emoji: variants)], + all: variants, + nativeToShortcode: {'👍🏽': ':+1:'}, + ); + final avatarUrl = emojiAvatarDataUrl('👍🏽', emojiAvatarColors[11]); + final notifier = _FakeProfileNotifier( + profile: UserProfile(pubkey: 'aabb', avatarUrl: avatarUrl), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + emojiDatasetOrEmptyProvider.overrideWithValue(dataset), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + + expect( + tester + .widget>( + find.byKey(const ValueKey('emoji-avatar-skin-tone')), + ) + .initialValue, + 3, + ); + }); + + testWidgets('discards a delayed image after switching avatar modes', ( + tester, + ) async { + final uploadService = _FakeMediaUploadService(delayGallery: true); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Photo Library')); + await tester.pump(); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + uploadService.completeGallerySelection(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('Position Photo'), findsNothing); + expect(find.byKey(const ValueKey('emoji-avatar-preview')), findsOneWidget); + }); +} diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart new file mode 100644 index 00000000000..2cffbb0891a --- /dev/null +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -0,0 +1,528 @@ +part of '../profile_edit_page_test.dart'; + +void runProfileEditMotionAndAccessibilityTests() { + testWidgets('photo modes remain usable on a compact large-type viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 568); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(2)), + child: ProfileEditPage(startInPhotoEditor: true), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + findsOneWidget, + ); + + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + await tester.ensureVisible( + find.byKey(const ValueKey('emoji-editor-background')), + ); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.takeException(), isNull); + + await tester.drag( + find.byKey(const ValueKey('avatar-editor-scroll-view')), + const Offset(0, 1000), + ); + await tester.pump(); + final animatedMode = find.byKey(const ValueKey('avatar-mode-animated')); + await Scrollable.ensureVisible( + animatedMode.evaluate().single, + alignment: 0.2, + ); + await tester.tap(animatedMode); + await tester.pump(const Duration(milliseconds: 250)); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + findsOneWidget, + ); + }); + + testWidgets( + 'keeps the multiline text editor usable with large text and the keyboard', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 568); + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + addTearDown(tester.view.reset); + final notifier = _FakeProfileNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + final descriptionRow = find.byKey( + const ValueKey('profile-description-row'), + ); + await tester.ensureVisible(descriptionRow); + await tester.pumpAndSettle(); + await tester.tap(descriptionRow); + await tester.pumpAndSettle(); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect( + MediaQuery.textScalerOf( + tester.element(find.byKey(const ValueKey('profile-field-input'))), + ).scale(10), + 20, + ); + expect( + MediaQuery.viewInsetsOf( + tester.element(find.byKey(const ValueKey('profile-field-input'))), + ).bottom, + 300, + ); + expect(tester.takeException(), isNull); + expect( + find.byKey(const ValueKey('profile-field-scroll-view')), + findsOneWidget, + ); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Making collaboration feel effortless.', + ); + await tester.pump(); + final save = find.byKey(const ValueKey('profile-field-save')); + await tester.ensureVisible(save); + await tester.pumpAndSettle(); + expect(save.hitTestable(), findsOneWidget); + await tester.tap(save); + await tester.pumpAndSettle(); + expect(notifier.savedDescriptions, [ + 'Making collaboration feel effortless.', + ]); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets('photo preparation errors are accessibility live regions', ( + tester, + ) async { + final uploadService = _FailingPreparationMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Photo Library')); + await tester.pumpAndSettle(); + + final message = find.text("We couldn't prepare that photo. Try again."); + expect(message, findsOneWidget); + final errorSemantics = tester.widget( + find.ancestor(of: message, matching: find.byType(Semantics)).first, + ); + expect(errorSemantics.properties.liveRegion, isTrue); + }); + + testWidgets('exposes the selected avatar mode on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + Iterable modeSemantics(String label) => + tester.widgetList( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == label && + widget.child is ExcludeSemantics, + ), + ); + + expect(modeSemantics('Image'), isNotEmpty); + expect( + modeSemantics('Image').every((node) => node.properties.selected == true), + isTrue, + ); + expect( + modeSemantics('Emoji').every((node) => node.properties.selected == false), + isTrue, + ); + await tester.tap(find.byKey(const ValueKey('avatar-mode-emoji'))); + await tester.pump(); + expect( + modeSemantics('Image').every((node) => node.properties.selected == false), + isTrue, + ); + expect( + modeSemantics('Emoji').every((node) => node.properties.selected == true), + isTrue, + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('moves segment content in the selected direction', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Emoji')); + await tester.pump(); + final trayTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), + ); + expect(trayTransform.transform.getTranslation().x, 0); + expect(trayTransform.transform.getTranslation().y, greaterThan(0)); + final forwardTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(forwardTransform.transform.getTranslation().x, greaterThan(0)); + await tester.pump(const Duration(milliseconds: 240)); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ) + .transform + .getTranslation() + .x, + closeTo(0, 0.01), + ); + expect( + tester + .widget( + find.byKey(const ValueKey('avatar-mode-tray-transition-transform')), + ) + .transform + .getTranslation() + .y, + closeTo(0, 0.01), + ); + + await tester.tap(find.text('Image')); + await tester.pump(); + final reverseTransform = tester.widget( + find.byKey(const ValueKey('avatar-mode-transition-transform')), + ); + expect(reverseTransform.transform.getTranslation().x, lessThan(0)); + }); + + testWidgets('retains the preview while animated mode initializes', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + final emojiCenter = tester + .getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))) + .dy; + + await tester.tap(find.text('Animated')); + await tester.pump(); + final retained = find.byKey(const ValueKey('avatar-mode-retained-preview')); + expect(retained, findsOneWidget); + expect(tester.getCenter(retained).dy, closeTo(emojiCenter, 0.01)); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(retained).dy, greaterThan(emojiCenter)); + await tester.pump(const Duration(milliseconds: 75)); + expect(retained, findsNothing); + + final animatedCenter = tester + .getCenter( + find.byKey(const ValueKey('animated-avatar-capture-preview')), + ) + .dy; + await tester.tap(find.text('Emoji')); + await tester.pump(); + final returningPreview = find.byKey( + const ValueKey('avatar-preview-position'), + ); + expect( + tester.getCenter(returningPreview).dy, + closeTo(animatedCenter, 0.01), + ); + + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(returningPreview).dy, lessThan(animatedCenter)); + await tester.pump(const Duration(milliseconds: 75)); + expect(tester.getCenter(returningPreview).dy, closeTo(emojiCenter, 0.01)); + }); + + testWidgets('plays an animated avatar on the profile and image editor', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + avatarUrl: avatar, + ), + ), + ), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + + await tester.tap(find.text('Edit Photo')); + await tester.pump(); + expect(find.byType(PlayingAvatarImage), findsOneWidget); + expect(find.byType(ProgressiveAnimatedAvatar), findsOneWidget); + }); + + testWidgets('shows only the animated-avatar poster with Reduce Motion', ( + tester, + ) async { + const avatar = + 'https://relay.example/poster.png#buzz-anim=https%3A%2F%2Frelay.example%2Fanimation.png'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier( + profile: const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + avatarUrl: avatar, + ), + ), + ), + ], + child: const MediaQuery( + data: MediaQueryData(disableAnimations: true), + child: ProfileEditPage(), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ProgressiveAnimatedAvatar), findsNothing); + expect( + tester.widget(find.byType(AvatarImage)).imageUrl, + 'https://relay.example/poster.png', + ); + }); + + testWidgets('keeps emoji actions anchored when search opens the keyboard', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 900); + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Emoji')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + final action = find.byKey(const ValueKey('emoji-editor-background')); + final actionBottomBefore = tester.getRect(action).bottom; + await tester.tap(find.byKey(const ValueKey('emoji-avatar-search'))); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pump(); + + expect(tester.getRect(action).bottom, actionBottomBefore); + expect( + tester.getRect(find.byKey(const ValueKey('emoji-avatar-search'))).bottom, + lessThan(600), + ); + expect( + tester + .widgetList(find.byType(Scaffold)) + .any((scaffold) => scaffold.resizeToAvoidBottomInset == false), + isTrue, + ); + }); + + testWidgets('uses high-contrast inverse colors for avatar action icons', ( + tester, + ) async { + final theme = AppTheme.dark(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Row( + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.palette, + label: 'Inactive', + selected: false, + onTap: () {}, + ), + ), + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.face, + label: 'Active', + selected: true, + onTap: () {}, + ), + ), + ], + ), + ), + ), + ); + + expect( + tester.widget(find.byIcon(Icons.palette)).color, + theme.colorScheme.onSurface, + ); + expect( + tester.widget(find.byIcon(Icons.face)).color, + theme.colorScheme.surface, + ); + final selectedSemantics = tester.widget( + find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == 'Active', + ), + ); + expect(selectedSemantics.properties.button, isTrue); + expect(selectedSemantics.properties.selected, isTrue); + }); + + testWidgets('exposes the selected emoji tile', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: EmojiAvatarTile( + emoji: '😊', + label: 'Smiling Face', + tileId: 'smile', + isSelected: true, + onTap: () {}, + ), + ), + ); + + final selectedTile = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == 'Smiling Face' && + widget.child is ExcludeSemantics, + ), + ); + expect(selectedTile.properties.button, isTrue); + expect(selectedTile.properties.selected, isTrue); + }); + + testWidgets('uses the shared animated background grid for emoji avatars', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(), + ), + ); + await tester.pump(); + await tester.tap(find.text('Edit Photo')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.text('Emoji')); + await tester.pump(const Duration(milliseconds: 250)); + await tester.tap(find.byKey(const ValueKey('emoji-editor-background'))); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byType(AvatarBackgroundGrid), findsOneWidget); + final firstColor = find.byKey(const ValueKey('emoji-avatar-color-0')); + expect(tester.getSize(firstColor), const Size.square(52)); + }); + + testWidgets('background colors remain reachable in compact layouts', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 150, + child: AvatarBackgroundGrid( + selectedColor: emojiAvatarColors.first, + onColorSelected: (_) {}, + ), + ), + ), + ); + + final scrollable = tester.state( + find.byType(Scrollable).first, + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + + await tester.drag(find.byType(AvatarBackgroundGrid), const Offset(0, -400)); + await tester.pumpAndSettle(); + + expect(scrollable.position.pixels, greaterThan(0)); + expect( + find.byKey( + ValueKey('avatar-background-color-${emojiAvatarColors.length - 1}'), + ), + findsOneWidget, + ); + }); +} diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart new file mode 100644 index 00000000000..6eea4c9915f --- /dev/null +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -0,0 +1,819 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/profile_avatar_draft.dart'; +import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/profile_text_editor.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('settings text editor waits for profile hydration', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _DelayedHydrationProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + ), + ), + ); + await tester.tap(find.text('Open editor')); + await tester.pump(); + expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); + + notifier.completeHydration(); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byKey(const ValueKey('profile-field-input'))) + .controller + ?.text, + 'Hydrated name', + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('native text retry retains the failed value', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return calls.length == 1 ? 'Alice Retained' : null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _RetryProfileNotifier(failedTextSaves: 1); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + + expect(notifier.displayNameAttempts, ['Alice Retained']); + expect(calls, hasLength(2)); + expect( + (calls.last.arguments as Map)['initialValue'], + 'Alice Retained', + ); + expect( + (calls.first.arguments + as Map)['allowUnchangedSubmission'], + isFalse, + ); + expect( + (calls.last.arguments + as Map)['allowUnchangedSubmission'], + isTrue, + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('native text editor stops retrying after a community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Old community draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _CommunityChangedProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + + expect(presentations, 1); + expect(notifier.displayNameAttempts, ['Old community draft']); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('native text editor rejects its first save after a switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + final submission = Completer(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) => submission.future); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], + child: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(TextButton)), + ); + final configSubscription = container.listen( + relayConfigProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(configSubscription.close); + await tester.tap(find.text('Open editor')); + await tester.pump(); + + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + submission.complete('Old community draft'); + await tester.pumpAndSettle(); + + expect(notifier.displayNameAttempts, isEmpty); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('native text retry stops when its owner unmounts', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Pending draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pump(); + expect(notifier.displayNameAttempts, ['Pending draft']); + + await tester.pumpWidget(const SizedBox()); + notifier.failSave(); + await tester.pump(); + + expect(presentations, 1); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('native text retry stops when its owner route is covered', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const channel = MethodChannel('buzz/profile_text_editor'); + var presentations = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async { + presentations++; + return 'Pending draft'; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: Builder( + builder: (context) => Column( + children: [ + TextButton( + onPressed: () => + unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + TextButton( + onPressed: () => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Theme')), + ), + ), + ), + child: const Text('Open destination'), + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open editor')); + await tester.pump(); + expect(notifier.displayNameAttempts, ['Pending draft']); + + await tester.tap(find.text('Open destination')); + await tester.pumpAndSettle(); + notifier.failSave(); + await tester.pump(); + + expect(presentations, 1); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('standalone Flutter text draft survives Back while saving', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: Builder( + builder: (context) => TextButton( + onPressed: () => unawaited(showProfileDisplayNameEditor(context)), + child: const Text('Open editor'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open editor')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Pending standalone draft', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget); + expect( + tester + .widget(find.byKey(const ValueKey('profile-field-close'))) + .onPressed, + isNull, + ); + + notifier.failSave(); + await tester.pumpAndSettle(); + expect( + find.text("We couldn't save this change. Try again."), + findsOneWidget, + ); + expect( + tester + .widget( + find + .ancestor( + of: find.text("We couldn't save this change. Try again."), + matching: find.byType(Semantics), + ) + .first, + ) + .properties + .liveRegion, + isTrue, + ); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('in-page Flutter text draft survives Back while saving', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _DeferredFailureProfileNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(() => notifier)], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Pending in-page draft', + ); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pump(); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget); + expect( + tester + .widget(find.byKey(const ValueKey('profile-field-close'))) + .onPressed, + isNull, + ); + + notifier.failSave(); + await tester.pumpAndSettle(); + expect( + find.text("We couldn't save this change. Try again."), + findsOneWidget, + ); + expect( + tester + .widget( + find + .ancestor( + of: find.text("We couldn't save this change. Try again."), + matching: find.byType(Semantics), + ) + .first, + ) + .properties + .liveRegion, + isTrue, + ); + expect(tester.takeException(), isNull); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('in-page text editor rejects its first save after a switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + ], + child: const ProfileEditPage(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('profile-field-input')), + 'Old community draft', + ); + await tester.pump(); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + await tester.tap(find.byKey(const ValueKey('profile-field-save'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('profile-field-input')), findsNothing); + expect(notifier.displayNameAttempts, isEmpty); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('animated save retry reuses its prepared draft', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(failedAvatarSaves: 1); + final uploadService = _RetryMediaUploadService(); + addTearDown(uploadService.dispose); + var prepareCalls = 0; + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged(() async { + prepareCalls++; + return ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ); + }); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect( + find.text("We couldn't save your profile photo. Try again."), + findsOneWidget, + ); + expect(prepareCalls, 1); + expect(uploadService.uploadCount, 1); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + expect(prepareCalls, 1); + expect(uploadService.uploadCount, 1); + expect(notifier.savedAvatarUrls, ['https://relay.example/avatar.jpg']); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('avatar draft cannot save after a prior community switch', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(firstUpload.uploadCount, 0); + expect(secondUpload.uploadCount, 0); + expect(notifier.savedAvatarUrls, isEmpty); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('avatar editor closes when community changes during save', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + delayUpload: true, + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + expect(firstUpload.uploadCount, 1); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + firstUpload.completeUpload(); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect(secondUpload.uploadCount, 0); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('avatar editor closes when a switched upload throws', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final notifier = _RetryProfileNotifier(); + final config = _MutableRelayConfigNotifier(); + final firstUpload = _RetryMediaUploadService( + baseUrl: 'https://first.example', + delayUpload: true, + ); + final secondUpload = _RetryMediaUploadService( + baseUrl: 'https://second.example', + ); + addTearDown(firstUpload.dispose); + addTearDown(secondUpload.dispose); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + relayConfigProvider.overrideWith(() => config), + mediaUploadServiceProvider.overrideWith((ref) { + final current = ref.watch(relayConfigProvider); + return current.baseUrl == 'https://first.example' + ? firstUpload + : secondUpload; + }), + ], + child: ProfileEditPage( + startInPhotoEditor: true, + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) => HookBuilder( + builder: (context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileImageAvatarDraft( + Uint8List.fromList([1, 2, 3]), + ), + ); + }); + return null; + }, const []); + return SizedBox(height: height); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pump(); + config.update(baseUrl: 'https://second.example', nsec: 'second-identity'); + firstUpload.failUpload(); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, isEmpty); + expect(find.byKey(const ValueKey('avatar-save')), findsNothing); + debugDefaultTargetPlatformOverride = null; + }); +} + +class _MutableRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig( + baseUrl: 'https://first.example', + nsec: 'first-identity', + ); +} + +class _DelayedHydrationProfileNotifier extends ProfileNotifier { + final _hydration = Completer(); + + @override + Future build() => _hydration.future; + + void completeHydration() => _hydration.complete( + const UserProfile(pubkey: 'aabb', displayName: 'Hydrated name'), + ); +} + +class _RetryProfileNotifier extends ProfileNotifier { + _RetryProfileNotifier({this.failedTextSaves = 0, this.failedAvatarSaves = 0}); + + int failedTextSaves; + int failedAvatarSaves; + final displayNameAttempts = []; + final savedAvatarUrls = []; + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) async { + displayNameAttempts.add(displayName); + if (failedTextSaves > 0) { + failedTextSaves--; + throw Exception('profile publish failed'); + } + } + + @override + Future updateAvatarUrl(String avatarUrl) async { + if (failedAvatarSaves > 0) { + failedAvatarSaves--; + throw Exception('profile publish failed'); + } + savedAvatarUrls.add(avatarUrl); + } +} + +class _CommunityChangedProfileNotifier extends ProfileNotifier { + final displayNameAttempts = []; + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) async { + displayNameAttempts.add(displayName); + throw ProfileCommunityChangedException(); + } +} + +class _DeferredFailureProfileNotifier extends ProfileNotifier { + final displayNameAttempts = []; + final _save = Completer(); + + @override + Future build() async => const UserProfile( + pubkey: 'aabb', + displayName: 'Alice', + about: 'Building Buzz', + ); + + @override + Future updateDisplayName(String displayName) { + displayNameAttempts.add(displayName); + return _save.future; + } + + void failSave() => _save.completeError(Exception('profile publish failed')); +} + +class _RetryMediaUploadService extends MediaUploadService { + _RetryMediaUploadService({ + this.baseUrl = 'https://relay.example', + this.delayUpload = false, + }) : super( + baseUrl: baseUrl, + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + final String baseUrl; + final bool delayUpload; + final _pendingUpload = Completer(); + int uploadCount = 0; + + void completeUpload() { + if (!_pendingUpload.isCompleted) _pendingUpload.complete(); + } + + void failUpload() { + if (!_pendingUpload.isCompleted) { + _pendingUpload.completeError(Exception('upload client closed')); + } + } + + @override + Future uploadBytes( + Uint8List bytes, { + required String mimeType, + ValueChanged? onProgress, + UploadCancellationToken? cancellationToken, + }) async { + uploadCount++; + if (delayUpload) await _pendingUpload.future; + return BlobDescriptor( + url: '$baseUrl/avatar.jpg', + sha256: 'avatar-hash', + size: bytes.length, + type: mimeType, + uploaded: 1, + ); + } +} diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 4b4f7ed1922..5638656b715 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -1,4 +1,9 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -6,8 +11,512 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; void main() { + test('profile updates preserve existing kind:0 metadata', () async { + final keys = nostr.Keys.generate(); + final owner = nostr.Keys.generate(); + final profileTags = [ + _authTag(owner, keys.public), + const ['custom', 'preserve-tag'], + ]; + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: profileTags, + content: jsonEncode({ + 'name': 'alice', + 'display_name': 'Alice', + 'about': 'Building Buzz', + 'picture': 'https://relay.example/alice.png', + 'nip05': 'alice@example.com', + 'custom': 'preserve-me', + }), + sig: 'sig', + ), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName('Alice L'); + + expect(relaySession.published, hasLength(1)); + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content['display_name'], 'Alice L'); + expect(content['about'], 'Building Buzz'); + expect(content['picture'], 'https://relay.example/alice.png'); + expect(content['nip05'], 'alice@example.com'); + expect(content['custom'], 'preserve-me'); + expect(relaySession.published.single.tags, profileTags); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Alice L', + ); + expect( + container.read(profileProvider).requireValue?.ownerPubkey, + owner.public.toLowerCase(), + ); + expect( + container.read(userCacheProvider)[keys.public]?.ownerPubkey, + owner.public.toLowerCase(), + ); + }); + + test('clearing a display name restores the pubkey label fallback', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'name': 'legacy-alice', + 'display_name': 'Alice', + 'about': 'Building Buzz', + }), + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName(' '); + + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content, {'about': 'Building Buzz'}); + final profile = container.read(profileProvider).requireValue!; + expect(profile.displayName, isNull); + expect(profile.label, '${keys.public.substring(0, 8)}...'); + }); + + test('malformed profile metadata can be repaired by an edit', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ProfileRelaySession( + NostrEvent( + id: 'profile-malformed', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.profile, + tags: const [], + content: 'not-json', + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + expect(await container.read(profileProvider.future), isNotNull); + await container.read(profileProvider.notifier).updateAbout('Repaired'); + + expect(jsonDecode(relaySession.published.single.content), { + 'about': 'Repaired', + }); + }); + + test('profile updates fail closed while hydration is pending', () async { + final keys = nostr.Keys.generate(); + final history = Completer>(); + final relaySession = _ControlledProfileRelaySession( + fetch: () => history.future, + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + container.read(profileProvider); + await expectLater( + container.read(profileProvider.notifier).updateDisplayName('Unsafe'), + throwsStateError, + ); + expect(relaySession.published, isEmpty); + history.complete(const []); + }); + + test('profile updates fail closed after hydration errors', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession( + fetch: () async => throw Exception('relay unavailable'), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + container.read(profileProvider); + await Future.delayed(Duration.zero); + expect(container.read(profileProvider).hasError, isTrue); + await expectLater( + container.read(profileProvider.notifier).updateAbout('Unsafe'), + throwsStateError, + ); + expect(relaySession.published, isEmpty); + }); + + test('a confirmed empty history can publish a new profile', () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession(fetch: () async => []); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + expect(await container.read(profileProvider.future), isNull); + await container.read(profileProvider.notifier).updateDisplayName('Alice'); + + expect(relaySession.published, hasLength(1)); + expect(jsonDecode(relaySession.published.single.content), { + 'display_name': 'Alice', + }); + }); + + test('same-second profile replacements use monotonic timestamps', () async { + final keys = nostr.Keys.generate(); + final futureTimestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 60; + final relaySession = _ControlledProfileRelaySession( + fetch: () async => [ + NostrEvent( + id: 'profile-future', + pubkey: keys.public, + createdAt: futureTimestamp, + kind: EventKind.profile, + tags: const [], + content: '{}', + sig: 'sig', + ), + ], + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await container.read(profileProvider.notifier).updateDisplayName('Alice'); + await container.read(profileProvider.notifier).updateAbout('Hello'); + + expect(relaySession.published.map((event) => event.createdAt), [ + futureTimestamp + 1, + futureTimestamp + 2, + ]); + }); + + test( + 'profile updates merge the current relay head before publishing', + () async { + final keys = nostr.Keys.generate(); + var history = [ + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Initial', + 'about': 'Initial about', + 'custom': 'initial', + }), + sig: 'sig', + ), + ]; + final relaySession = _ControlledProfileRelaySession( + fetch: () async => history, + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + history = [ + NostrEvent( + id: 'profile-remote', + pubkey: keys.public, + createdAt: 20, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Remote', + 'about': 'Remote about', + 'custom': 'remote', + }), + sig: 'sig', + ), + ]; + + await container + .read(profileProvider.notifier) + .updateDisplayName('Mobile'); + + final content = + jsonDecode(relaySession.published.single.content) + as Map; + expect(content, { + 'display_name': 'Mobile', + 'about': 'Remote about', + 'custom': 'remote', + }); + expect(relaySession.published.single.createdAt, greaterThan(20)); + }, + ); + + test( + 'a competing profile head does not become optimistic local state', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _LosingProfileRelaySession( + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ), + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + + await expectLater( + container.read(profileProvider.notifier).updateDisplayName('Mobile'), + throwsStateError, + ); + expect(relaySession.published, hasLength(1)); + expect( + container.read(profileProvider).requireValue?.displayName, + 'Initial', + ); + }, + ); + + test( + 'overlapping profile updates serialize their full merge cycles', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _ControlledProfileRelaySession( + fetch: () async => [ + NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({ + 'display_name': 'Initial', + 'about': 'Initial about', + }), + sig: 'sig', + ), + ], + ); + final container = _profileContainer(keys.nsec, relaySession); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + await Future.wait([ + container.read(profileProvider.notifier).updateDisplayName('Mobile'), + container.read(profileProvider.notifier).updateAbout('Mobile about'), + ]); + + expect(relaySession.published, hasLength(2)); + expect(jsonDecode(relaySession.published.last.content), { + 'display_name': 'Mobile', + 'about': 'Mobile about', + }); + expect( + relaySession.published.last.createdAt, + greaterThan(relaySession.published.first.createdAt), + ); + }, + ); + + test('profile updates abort when the active community changes', () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final initial = NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ); + final patchFetchStarted = Completer(); + final patchHistory = Completer>(); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) return [initial]; + if (!patchFetchStarted.isCompleted) patchFetchStarted.complete(); + return patchHistory.future; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final update = container + .read(profileProvider.notifier) + .updateDisplayName('Mobile'); + await patchFetchStarted.future; + config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); + patchHistory.complete([initial]); + + await expectLater(update, throwsStateError); + expect(relaySession.published, isEmpty); + }); + + test( + 'queued profile updates report a community change before rehydration', + () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final initial = NostrEvent( + id: 'profile-initial', + pubkey: keys.public, + createdAt: 10, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Initial'}), + sig: 'sig', + ); + final patchFetchStarted = Completer(); + final patchHistory = Completer>(); + final rehydration = Completer>(); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) return [initial]; + if (fetchCount == 2) { + patchFetchStarted.complete(); + return patchHistory.future; + } + return rehydration.future; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + await container.read(profileProvider.future); + final firstUpdate = container + .read(profileProvider.notifier) + .updateDisplayName('First'); + await patchFetchStarted.future; + final queuedUpdate = container + .read(profileProvider.notifier) + .updateAbout('Queued'); + config.update( + baseUrl: 'https://other-relay.example', + nsec: otherKeys.nsec, + ); + await Future.delayed(Duration.zero); + patchHistory.complete([initial]); + + await expectLater( + firstUpdate, + throwsA(isA()), + ); + await expectLater( + queuedUpdate, + throwsA(isA()), + ); + expect(relaySession.published, isEmpty); + rehydration.complete(const []); + }, + ); + + test('stale hydration cannot overwrite the active community head', () async { + final keys = nostr.Keys.generate(); + final otherKeys = nostr.Keys.generate(); + final oldFetchStarted = Completer(); + final oldHistory = Completer>(); + final activeProfile = NostrEvent( + id: 'profile-active', + pubkey: otherKeys.public, + createdAt: 20, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Active'}), + sig: 'sig', + ); + var fetchCount = 0; + final relaySession = _ControlledProfileRelaySession( + fetch: () async { + fetchCount += 1; + if (fetchCount == 1) { + oldFetchStarted.complete(); + return oldHistory.future; + } + return [activeProfile]; + }, + ); + final config = _MutableRelayConfigNotifier(keys.nsec); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => config), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + container.read(profileProvider); + await oldFetchStarted.future; + config.update(baseUrl: 'https://other-relay.example', nsec: otherKeys.nsec); + expect( + (await container.read(profileProvider.future))?.displayName, + 'Active', + ); + oldHistory.complete([ + NostrEvent( + id: 'profile-stale', + pubkey: keys.public, + createdAt: 100, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Stale'}), + sig: 'sig', + ), + ]); + await Future.delayed(Duration.zero); + + await container.read(profileProvider.notifier).updateAbout('Active about'); + + expect(relaySession.published, hasLength(1)); + expect(jsonDecode(relaySession.published.single.content), { + 'display_name': 'Active', + 'about': 'Active about', + }); + }); + test( 'manual presence persists until Online restores automatic mode', () async { @@ -62,6 +571,134 @@ void main() { ); } +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList(utf8.encode('nostr:agent-auth:$agentPubkey:')), + ); + final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: message), + ]; +} + +class _FixedRelayConfigNotifier extends RelayConfigNotifier { + _FixedRelayConfigNotifier(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _MutableRelayConfigNotifier extends RelayConfigNotifier { + _MutableRelayConfigNotifier(this.initialNsec); + + final String initialNsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: initialNsec); +} + +class _ProfileRelaySession extends RelaySessionNotifier { + _ProfileRelaySession(this.profile); + + final NostrEvent profile; + final List published = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [profile, ...published]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + +ProviderContainer _profileContainer( + String nsec, + RelaySessionNotifier relaySession, +) => ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(() => _FixedRelayConfigNotifier(nsec)), + relaySessionProvider.overrideWith(() => relaySession), + ], +); + +class _ControlledProfileRelaySession extends RelaySessionNotifier { + _ControlledProfileRelaySession({required this.fetch}); + + final Future> Function() fetch; + final List published = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [...await fetch(), ...published]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + +class _LosingProfileRelaySession extends RelaySessionNotifier { + _LosingProfileRelaySession(this.initial); + + final NostrEvent initial; + final List published = []; + NostrEvent? competing; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [competing ?? initial]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + competing = NostrEvent( + id: 'profile-competing', + pubkey: initial.pubkey, + createdAt: event.createdAt + 1, + kind: EventKind.profile, + tags: const [], + content: jsonEncode({'display_name': 'Remote'}), + sig: 'sig', + ); + return event; + } +} + ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( overrides: [ savedPrefsProvider.overrideWithValue(prefs), diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index 63be8d81e34..a22c8c58f81 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -40,6 +40,31 @@ void main() { expect((header.padding as EdgeInsets).bottom, Grid.twelve); }); + testWidgets('shows the display name directly beneath the avatar', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + + final avatar = find.byKey(const ValueKey('settings-profile-avatar')); + final name = find.text('Test'); + expect(name, findsOneWidget); + expect( + tester.getTopLeft(name).dy, + greaterThan(tester.getBottomLeft(avatar).dy), + ); + }); + testWidgets('shows the poster until the animated avatar is ready', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 8b4c903b564..8e8c4f81ec6 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -95,9 +95,10 @@ void main() { expect(appBar.gradient, isNull); expect(appBar.frosted, isTrue); expect(appBar.showBottomDivider, isTrue); - expect(appBar.bottomDividerOpacity, 0.06); + expect(appBar.bottomDividerOpacity, 0.07); expect(appBar.bottomHeight, 57); expect(appBar.leading, isNull); + expect(appBar.centerTitle, isFalse); expect(find.text('Search'), findsOneWidget); final promptText = find.descendant( of: find.byKey(const Key('search-field-container')), diff --git a/mobile/test/features/settings/connection_section_test.dart b/mobile/test/features/settings/connection_section_test.dart index 2a197222ab5..b8e3464c2e9 100644 --- a/mobile/test/features/settings/connection_section_test.dart +++ b/mobile/test/features/settings/connection_section_test.dart @@ -6,13 +6,67 @@ import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('shows a compact copyable identity row', (tester) async { + MethodCall? clipboardCall; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') clipboardCall = call; + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + relayConfigProvider.overrideWith(_RelayConfigNotifier.new), + authProvider.overrideWith(_AuthNotifier.new), + pairingProvider.overrideWith( + () => _PairingNotifier(Future.value(true)), + ), + savedPrefsProvider.overrideWithValue(prefs), + ], + child: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ); + await tester.pump(); + await tester.ensureVisible(find.text('Identity (pubkey)')); + await tester.pumpAndSettle(); + + final expectedPubkey = nostr.Keys( + '1111111111111111111111111111111111111111111111111111111111111111', + ).public; + expect(find.text('Connected to'), findsNothing); + expect(find.text('https://relay.test'), findsNothing); + expect(find.text(expectedPubkey), findsNothing); + final copy = tester.getRect(find.byIcon(LucideIcons.copy)); + final chevron = tester.getRect(find.byIcon(LucideIcons.chevronRight).first); + expect(copy.center.dx, closeTo(chevron.center.dx, 0.5)); + + await tester.tap(find.text('Identity (pubkey)')); + await tester.pump(); + expect(clipboardCall?.method, 'Clipboard.setData'); + expect(clipboardCall?.arguments, {'text': expectedPubkey}); + expect(find.text('Pubkey copied'), findsOneWidget); + }); + testWidgets('waits for a resumed frame before navigating after auth', ( tester, ) async { diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 9242c5b4bc8..018aeca586d 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,6 +1,7 @@ import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; @@ -9,6 +10,112 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + testWidgets('opens profile edit choices and routes photo directly', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.square(dimension: 128), + profileEditPageBuilder: (_) => + const Scaffold(body: Text('Profile editor destination')), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Profile'), findsNothing); + await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); + await tester.pumpAndSettle(); + expect(find.text('Edit profile'), findsOneWidget); + expect(find.text('Display name'), findsOneWidget); + expect(find.text('Profile description'), findsOneWidget); + expect(find.text('Photo'), findsOneWidget); + final optionCard = find.descendant( + of: find.byKey(const ValueKey('edit-profile-options')), + matching: find.byType(Material), + ); + expect(tester.getSize(optionCard.first).height, greaterThan(150)); + for (final key in const [ + 'edit-profile-display-name', + 'edit-profile-description', + 'edit-profile-photo', + ]) { + expect( + tester.widget(find.byKey(ValueKey(key))).verticalPadding, + Grid.xs, + ); + } + final sheetContent = tester.widget( + find.byKey(const ValueKey('edit-profile-sheet-content')), + ); + expect(sheetContent.padding, const EdgeInsets.only(bottom: Grid.xs)); + final sheetSafeArea = tester.widget( + find.ancestor( + of: find.byKey(const ValueKey('edit-profile-sheet-content')), + matching: find.byType(SafeArea), + ), + ); + expect(sheetSafeArea.top, isFalse); + expect(sheetSafeArea.bottom, isTrue); + final sheetRect = tester.getRect(find.byType(BottomSheet)); + final optionRect = tester.getRect( + find.byKey(const ValueKey('edit-profile-options')), + ); + expect(sheetRect.height, lessThan(340)); + expect(sheetRect.bottom - optionRect.bottom, Grid.xs); + await tester.tap(find.byKey(const ValueKey('edit-profile-photo'))); + await tester.pumpAndSettle(); + expect(find.text('Profile editor destination'), findsOneWidget); + }); + + testWidgets('opens profile text editors after dismissing the choices', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final opened = []; + + await tester.pumpWidget( + ProviderScope( + overrides: [savedPrefsProvider.overrideWithValue(prefs)], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + onEditDisplayName: (_) async => opened.add('name'), + onEditProfileDescription: (_) async => opened.add('description'), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('edit-profile-display-name'))); + await tester.pumpAndSettle(); + expect(opened, ['name']); + expect(find.text('Edit profile'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('settings-edit-profile'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('edit-profile-description'))); + await tester.pumpAndSettle(); + expect(opened, ['name', 'description']); + }); + testWidgets('uses the native glass close control on iOS', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -30,9 +137,19 @@ void main() { ); await tester.pumpAndSettle(); - final nativeClose = tester.widget(find.byType(UiKitView)); + final nativeViews = tester.widgetList(find.byType(UiKitView)); + final nativeClose = nativeViews.singleWhere( + (view) => + (view.creationParams as Map?)?['icon'] == 'close', + ); + final nativeEdit = nativeViews.singleWhere( + (view) => + (view.creationParams as Map?)?['label'] == 'Edit', + ); expect(nativeClose.viewType, 'buzz/navigation_glass'); expect(nativeClose.creationParams, containsPair('icon', 'close')); + expect(nativeEdit.viewType, 'buzz/navigation_glass'); + expect(nativeEdit.creationParams, containsPair('controlWidth', 56.0)); expect(find.byTooltip('Close settings'), findsOneWidget); debugDefaultTargetPlatformOverride = null; }); diff --git a/mobile/test/shared/animated_avatar_test.dart b/mobile/test/shared/animated_avatar_test.dart index 773f957a4b0..e3b66ec6010 100644 --- a/mobile/test/shared/animated_avatar_test.dart +++ b/mobile/test/shared/animated_avatar_test.dart @@ -6,7 +6,7 @@ void main() { const animationUrl = 'https://relay.example/media/animation.png?loop=1'; test('parses the selected poster and animated PNG URLs', () { - final url = '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final url = buildAnimatedAvatarUrl(posterUrl, animationUrl); final parsed = parseAnimatedAvatarUrl(url); @@ -14,6 +14,13 @@ void main() { expect(parsed?.animationUrl, animationUrl); }); + test('builds the shared desktop and mobile fragment format', () { + expect( + buildAnimatedAvatarUrl(posterUrl, animationUrl), + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}', + ); + }); + test('rejects malformed and non-http animated avatar URLs', () { expect(parseAnimatedAvatarUrl(posterUrl), isNull); expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim='), isNull); diff --git a/mobile/test/shared/emoji/native_emoji_glyph_test.dart b/mobile/test/shared/emoji/native_emoji_glyph_test.dart index 435423905fa..8d8e02ee8a1 100644 --- a/mobile/test/shared/emoji/native_emoji_glyph_test.dart +++ b/mobile/test/shared/emoji/native_emoji_glyph_test.dart @@ -1,37 +1,51 @@ import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('lifts the glyph one logical pixel on iOS', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - try { - await tester.pumpWidget( - const MaterialApp( - home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), + testWidgets('uses one centered optical box for narrow and wide emoji', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Row( + children: [ + NativeEmojiGlyph( + key: ValueKey('narrow-emoji'), + emoji: '🙂', + size: 60, + opticalBoxSize: 60, + ), + NativeEmojiGlyph( + key: ValueKey('wide-emoji'), + emoji: '👩‍👩‍👧‍👦', + size: 60, + opticalBoxSize: 60, + ), + ], + ), ), - ); + ), + ); - final transform = tester.widget(find.byType(Transform)); - expect(transform.transform.getTranslation().y, -1); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }); - - testWidgets('keeps the glyph unshifted on Android', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - try { - await tester.pumpWidget( - const MaterialApp( - home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), - ), - ); - - expect(find.byType(Transform), findsNothing); - } finally { - debugDefaultTargetPlatformOverride = null; + expect( + tester.getSize(find.byKey(const ValueKey('narrow-emoji'))), + const Size.square(60), + ); + expect( + tester.getSize(find.byKey(const ValueKey('wide-emoji'))), + const Size.square(60), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('wide-emoji')), + matching: find.byType(FittedBox), + ), + findsOneWidget, + ); + for (final text in tester.widgetList(find.byType(Text))) { + expect(text.textScaler, TextScaler.noScaling); } }); } diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index cfaab6d4f1c..4631cf58dab 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -32,6 +33,11 @@ void main() { final emoji = tester.widget(find.text('🦝')); expect(emoji.style?.height, 1); + final glyph = tester.widget( + find.byType(NativeEmojiGlyph), + ); + expect(glyph.size, closeTo(32 * 258 / 512, 0.001)); + expect(glyph.opticalBoxSize, glyph.size); expect(find.byType(SvgPicture), findsNothing); expect(find.text('R'), findsNothing); expect(tester.takeException(), isNull); diff --git a/mobile/test/shared/widgets/frosted_app_bar_test.dart b/mobile/test/shared/widgets/frosted_app_bar_test.dart index 8f1f40a8b1b..e7a4d534c89 100644 --- a/mobile/test/shared/widgets/frosted_app_bar_test.dart +++ b/mobile/test/shared/widgets/frosted_app_bar_test.dart @@ -87,6 +87,37 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('centers a title between asymmetric navigation controls', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Stack( + children: [ + FrostedAppBar( + centerTitle: true, + leading: SizedBox(width: 48, height: 48), + title: Text('Profile', key: ValueKey('centered-title')), + actions: [SizedBox(width: 96, height: 48)], + ), + ], + ), + ), + ); + + final titleRect = tester.getRect( + find.byKey(const ValueKey('centered-title')), + ); + expect( + titleRect.center.dx, + closeTo( + tester.view.physicalSize.width / tester.view.devicePixelRatio / 2, + 0.01, + ), + ); + }); + testWidgets('uses the native glass back control on iOS', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -148,6 +179,38 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('uses the theme primary color for automatic navigation glyphs', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const Stack( + children: [FrostedAppBar(title: Text('Destination'))], + ), + ), + ), + child: const Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final backButton = tester.widget( + find.byWidgetPredicate( + (widget) => widget is IconButton && widget.tooltip == 'Back', + ), + ); + expect(backButton.color, AppTheme.light().colorScheme.primary); + }); + testWidgets('replaces native glass while a Flutter backdrop is active', ( tester, ) async { From 6eff84d1271eb1b90e07c5a0673343a76a0753fc Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 24 Aug 2026 12:59:21 -0700 Subject: [PATCH 011/101] fix(mobile): join starter channels after accepting invite (#5915) ### What changed? After a new mobile invite claim succeeds, Buzz now best-effort ensures membership in the same public starter channels as desktop: - `#general` - `#welcome-everyone` Missing starters use desktop's deterministic per-relay IDs and exact public channel configuration, so concurrent mobile and desktop setup converges safely. Setup failures do not invalidate or retry an already successful invite claim, and failure for one starter does not block the other. The success sheet offers **Continue to #welcome-everyone** when that channel is available. Mobile does not create the private `Welcome` channel because it cannot provision the desktop Welcome agents that make that channel useful. This PR is stacked on #6145 because it deliberately reuses that PR's open-channel directory and join behavior. Once #6145 merges, this PR can be retargeted to `main` without changing its BUZZ-12 diff. Fixes [BUZZ-12](https://linear.app/squareup/issue/BUZZ-12/bug-community-appears-empty-after-using-invite-link-on-mobile). ### How is it tested? - Desktop/mobile deterministic starter-ID parity coverage. - Existing-channel join and missing-channel creation coverage. - Duplicate-create convergence and per-channel failure isolation coverage. - Invite success remains successful when starter setup fails. - Widget coverage for continuing directly into `#welcome-everyone`. - Focused invite/deep-link tests: 23 passed. - `just mobile-check`: passed. - `just mobile-test`: 1,483 passed. - Pre-push repository checks: passed. --------- Signed-off-by: Tom Brow Co-authored-by: Codex --- mobile/lib/app.dart | 233 ++++++++++ .../channels/channel_management_actions.dart | 7 +- .../features/channels/channels_provider.dart | 10 +- .../channels/deep_link_dispatcher.dart | 36 +- .../invites/invite_join_provider.dart | 192 +++++++- .../features/invites/invite_join_sheet.dart | 78 +++- mobile/lib/main.dart | 9 +- mobile/lib/shared/community/community.dart | 11 + .../channel_management_provider_test.dart | 36 ++ .../channels/channels_provider_test.dart | 26 ++ .../features/channels/compose_bar_test.dart | 2 +- .../channels/deep_link_dispatcher_test.dart | 282 ++++++++++++ .../invites/invite_join_provider_test.dart | 423 ++++++++++++++++++ .../invites/invite_join_sheet_test.dart | 119 +++++ .../test/shared/community/community_test.dart | 11 +- 15 files changed, 1438 insertions(+), 37 deletions(-) create mode 100644 mobile/test/features/invites/invite_join_sheet_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 5ea55521522..655095045ef 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,15 +1,22 @@ +import 'dart:async'; + import 'package:app_badge_plus/app_badge_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:uuid/uuid.dart'; import 'features/activity/activity_provider.dart'; import 'features/activity/inbox_local_state_provider.dart'; import 'features/activity/inbox_read_state.dart'; +import 'features/channels/channel.dart'; +import 'features/channels/channel_management_provider.dart'; +import 'features/channels/channels_provider.dart'; import 'features/channels/unread_badge/unread_badge_provider.dart'; import 'features/home/home_page.dart'; import 'features/invites/invite_create_page.dart'; +import 'features/invites/invite_join_provider.dart'; import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; @@ -27,6 +34,232 @@ import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; import 'shared/widgets/buzz_loading_indicator.dart'; +const _starterChannelNamespace = '3ce33bea-8f09-5f1b-9c85-8a7d2659e6b0'; + +const _starterChannels = [ + (slug: 'general', description: 'General conversation and community updates.'), + ( + slug: 'welcome-everyone', + description: 'Say hi, ask a question, or share what brought you here.', + ), +]; + +final _inviteRelayConnectedProvider = FutureProvider.family(( + ref, + expectedRelayUrl, +) async { + final currentConfig = ref.read(relayConfigProvider); + if (currentConfig.baseUrl != expectedRelayUrl) { + throw StateError('Active community changed before invite recovery'); + } + if (ref.read(relaySessionProvider).status == SessionStatus.connected) return; + + final connected = Completer(); + ref.listen(relaySessionProvider, (_, next) { + if (connected.isCompleted) return; + if (ref.read(relayConfigProvider).baseUrl != expectedRelayUrl) { + connected.completeError( + StateError('Active community changed during invite recovery'), + ); + } else if (next.status == SessionStatus.connected) { + connected.complete(); + } + }); + await connected.future; +}); + +/// App-level bridge from invite joining to the channels feature. +class MobileInviteJoinRecovery implements InviteJoinRecovery { + final Future> Function() _loadChannels; + final Future Function({ + required String channelId, + required String name, + required String channelType, + required String visibility, + String? description, + int? ttlSeconds, + }) + _createChannel; + final Future Function(String channelId) _joinChannel; + final String _relayHttpOrigin; + final bool Function() _isScopeCurrent; + + /// Creates recovery from channel-loading, creation, and join operations. + MobileInviteJoinRecovery({ + required Future> Function() loadChannels, + required Future Function({ + required String channelId, + required String name, + required String channelType, + required String visibility, + String? description, + int? ttlSeconds, + }) + createChannel, + required Future Function(String channelId) joinChannel, + required String relayHttpOrigin, + bool Function()? isScopeCurrent, + }) : _loadChannels = loadChannels, + _createChannel = createChannel, + _joinChannel = joinChannel, + _relayHttpOrigin = relayHttpOrigin, + _isScopeCurrent = isScopeCurrent ?? _alwaysCurrent; + + /// Ensures memberships in the same public starter channels as desktop. + /// + /// All starter work is fenced to the community and identity that started it. + /// A partial setup remains recoverable, so a failed operation deliberately + /// propagates to the invite flow instead of being reported as success. + @override + Future ensureStarterChannels() async { + _ensureScopeCurrent(); + var channels = await _loadChannels(); + _ensureScopeCurrent(); + String? welcomeEveryoneId; + + for (final starter in _starterChannels) { + _ensureScopeCurrent(); + var channel = _findStarterChannel(channels, starter.slug); + if (channel == null) { + final channelId = desktopStarterChannelId( + relayHttpOrigin: _relayHttpOrigin, + slug: starter.slug, + ); + try { + channel = await _createChannel( + channelId: channelId, + name: starter.slug, + channelType: 'stream', + visibility: 'open', + description: starter.description, + ); + _ensureScopeCurrent(); + } catch (error) { + if (!_isDuplicateChannelError(error)) rethrow; + _ensureScopeCurrent(); + channels = await _loadChannels(); + _ensureScopeCurrent(); + channel = + _findStarterChannel(channels, starter.slug) ?? + channels + .where((candidate) => candidate.id == channelId) + .firstOrNull; + if (channel == null) rethrow; + } + } + + _ensureScopeCurrent(); + if (!channel.isMember) { + await _joinChannel(channel.id); + _ensureScopeCurrent(); + } + if (starter.slug == 'welcome-everyone') { + welcomeEveryoneId = channel.id; + } + } + + return welcomeEveryoneId; + } + + static bool _alwaysCurrent() => true; + + void _ensureScopeCurrent() { + if (!_isScopeCurrent()) { + throw StateError('Active community changed during invite recovery'); + } + } +} + +Channel? _findStarterChannel(List channels, String name) { + final normalizedName = name.trim().toLowerCase(); + return channels + .where( + (channel) => + channel.name.trim().toLowerCase() == normalizedName && + channel.isStream && + channel.visibility == 'open' && + !channel.isArchived, + ) + .firstOrNull; +} + +bool _isDuplicateChannelError(Object error) => + error.toString().contains('duplicate: channel already exists'); + +/// Returns desktop's deterministic per-relay UUID for a public starter. +@visibleForTesting +String desktopStarterChannelId({ + required String relayHttpOrigin, + required String slug, +}) { + final scope = relayHttpOrigin.trim().replaceFirst(RegExp(r'/+$'), ''); + return const Uuid().v5( + _starterChannelNamespace, + 'starter-channel:v1:$scope:$slug', + ); +} + +/// Builds one fresh, identity-scoped invite recovery against the app container. +InviteJoinRecovery buildMobileInviteJoinRecovery( + Ref ref, + InviteJoinRecoveryScope scope, +) { + final expectedConfig = ref.read(relayConfigProvider); + if (expectedConfig.baseUrl != scope.relayHttpOrigin || + expectedConfig.nsec != scope.nsec) { + throw StateError('Active community changed before invite recovery'); + } + final channelActions = ref.read(channelActionsProvider); + + bool isScopeCurrent() { + final currentConfig = ref.read(relayConfigProvider); + return currentConfig.baseUrl == scope.relayHttpOrigin && + currentConfig.nsec == scope.nsec; + } + + void ensureScopeCurrent() { + if (!isScopeCurrent()) { + throw StateError('Active community changed during invite recovery'); + } + } + + return MobileInviteJoinRecovery( + loadChannels: () async { + ensureScopeCurrent(); + await ref.read(activeCommunityProvider.future); + ensureScopeCurrent(); + await ref + .read(_inviteRelayConnectedProvider(scope.relayHttpOrigin).future) + .timeout(const Duration(seconds: 15)); + ensureScopeCurrent(); + await ref.read(channelsProvider.notifier).refresh(fetchDirectory: true); + ensureScopeCurrent(); + final channels = await ref.read(channelsProvider.future); + ensureScopeCurrent(); + return channels; + }, + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) => channelActions.createChannel( + channelId: channelId, + name: name, + channelType: channelType, + visibility: visibility, + description: description, + ttlSeconds: ttlSeconds, + ), + joinChannel: channelActions.joinChannel, + relayHttpOrigin: scope.relayHttpOrigin, + isScopeCurrent: isScopeCurrent, + ); +} + /// App-shell projection that joins Activity state for the Home navigation. /// /// This belongs at the composition root because it deliberately aggregates diff --git a/mobile/lib/features/channels/channel_management_actions.dart b/mobile/lib/features/channels/channel_management_actions.dart index 34d359040bc..e0879eb0222 100644 --- a/mobile/lib/features/channels/channel_management_actions.dart +++ b/mobile/lib/features/channels/channel_management_actions.dart @@ -34,15 +34,16 @@ class ChannelActions { _isCommunityValid = isCommunityValid; Future createChannel({ + String? channelId, required String name, required String channelType, required String visibility, String? description, int? ttlSeconds, }) async { - final channelId = _newUuidV4(); + final resolvedChannelId = channelId ?? _newUuidV4(); final tags = buildCreateChannelTags( - channelId: channelId, + channelId: resolvedChannelId, name: name, channelType: channelType, visibility: visibility, @@ -52,7 +53,7 @@ class ChannelActions { _ensureCommunityValid(); await _signedEventRelay.submit(kind: 9007, content: '', tags: tags); _ensureCommunityValid(); - return _refreshChannelsAndRead(channelId); + return _refreshChannelsAndRead(resolvedChannelId); } /// Open (or create) a DM channel with the given pubkeys. diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 2258a493c99..03f29c58a1b 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -906,7 +906,10 @@ class ChannelsNotifier extends AsyncNotifier> { } } - Future refresh() async { + /// Refreshes memberships and, when [fetchDirectory], the open directory. + /// Invite starter recovery opts in to converge on another identity's + /// starters instead of attempting duplicate creation from memberships. + Future refresh({bool fetchDirectory = false}) async { final sessionState = ref.read(relaySessionProvider); // Don't attempt to fetch when the session isn't connected — fetchHistory // would send REQs over an unauthenticated socket that either time out @@ -915,7 +918,10 @@ class ChannelsNotifier extends AsyncNotifier> { // when the session transitions to connected. if (sessionState.status != SessionStatus.connected) return; try { - final channels = await _fetch(subscribeLive: true); + final channels = await _fetch( + subscribeLive: true, + fetchDirectory: fetchDirectory, + ); state = AsyncData(channels); } on _StaleChannelRefresh { return; diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index 391a4f1a495..a7f509de13b 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -99,6 +101,11 @@ class _DeepLinkDispatcherState extends ConsumerState { } if (!context.mounted) return; + _pushChannel(channel, link); + ref.read(pendingDeepLinkProvider.notifier).consume(); + } + + void _pushChannel(Channel channel, BuzzDeepLink link) { Navigator.of(context).push( MaterialPageRoute( builder: (_) => @@ -112,7 +119,6 @@ class _DeepLinkDispatcherState extends ConsumerState { ), ), ); - ref.read(pendingDeepLinkProvider.notifier).consume(); } void _maybeDispatchInvite(InviteDeepLink link) { @@ -127,9 +133,31 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); consumed = true; if (!navigatorContext.mounted) return; - final status = ref.read(inviteJoinProvider).status; - if (status == InviteJoinStatus.confirming) { - await showInviteJoinSheet(navigatorContext, ref); + final inviteState = ref.read(inviteJoinProvider); + final status = inviteState.status; + if (status == InviteJoinStatus.confirming || + inviteState.isStarterSetupRecovery) { + final sheet = showInviteJoinSheet(navigatorContext); + if (status == InviteJoinStatus.claiming && + inviteState.isStarterSetupRecovery) { + unawaited( + ref.read(inviteJoinProvider.notifier).startStarterSetupRecovery(), + ); + } + final shouldFocusStarter = await sheet; + final focusChannelId = ref.read(inviteJoinProvider).focusChannelId; + if (shouldFocusStarter == true && + focusChannelId != null && + ref.read(pendingDeepLinkProvider) == null && + navigatorContext.mounted) { + final channels = ref.read(channelsProvider).asData?.value; + final channel = channels + ?.where((candidate) => candidate.id == focusChannelId) + .firstOrNull; + if (channel != null) { + _pushChannel(channel, ChannelDeepLink(channelId: focusChannelId)); + } + } } else if (status == InviteJoinStatus.switchedExisting) { messenger?.showSnackBar( const SnackBar(content: Text('Switched to this community')), diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index b3b1e581447..a665dded1b1 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -6,6 +6,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; +import '../../shared/relay/relay_provider.dart'; import '../../shared/relay/relay_session.dart'; import '../../shared/relay/relay_validation.dart'; @@ -21,6 +22,39 @@ final inviteKeyGeneratorProvider = Provider((ref) { typedef InviteKeyGenerator = nostr.Keys Function(); +const _unset = Object(); + +/// Ensures starter-channel memberships after an invite membership claim. +abstract interface class InviteJoinRecovery { + /// Ensures the public starters and returns the preferred focus. + Future ensureStarterChannels(); +} + +/// The active relay and signing identity a recovery instance must be bound to. +class InviteJoinRecoveryScope { + const InviteJoinRecoveryScope({ + required this.relayHttpOrigin, + required this.nsec, + }); + + /// Canonical HTTP(S) origin used by the active relay session. + final String relayHttpOrigin; + + /// Signing identity that must remain active for the recovery lifetime. + final String? nsec; +} + +/// Constructs a fresh recovery operation for one scoped setup attempt. +typedef InviteJoinRecoveryFactory = + InviteJoinRecovery Function(InviteJoinRecoveryScope scope); + +/// Provides recovery construction after the active community is authenticated. +final inviteJoinRecoveryProvider = Provider((ref) { + throw StateError( + 'inviteJoinRecoveryProvider must be configured by the app root', + ); +}); + enum InviteJoinStatus { idle, confirming, @@ -37,6 +71,8 @@ class InviteJoinState { final String? communityName; final String? errorMessage; final bool requiresFreshInvite; + final bool isStarterSetupRecovery; + final String? focusChannelId; const InviteJoinState({ this.status = InviteJoinStatus.idle, @@ -45,6 +81,8 @@ class InviteJoinState { this.communityName, this.errorMessage, this.requiresFreshInvite = false, + this.isStarterSetupRecovery = false, + this.focusChannelId, }); InviteJoinState copyWith({ @@ -52,19 +90,31 @@ class InviteJoinState { InviteDeepLink? invite, String? host, String? communityName, - String? errorMessage, + Object? errorMessage = _unset, bool? requiresFreshInvite, + bool? isStarterSetupRecovery, + Object? focusChannelId = _unset, }) => InviteJoinState( status: status ?? this.status, invite: invite ?? this.invite, host: host ?? this.host, communityName: communityName ?? this.communityName, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: identical(errorMessage, _unset) + ? this.errorMessage + : errorMessage as String?, requiresFreshInvite: requiresFreshInvite ?? this.requiresFreshInvite, + isStarterSetupRecovery: + isStarterSetupRecovery ?? this.isStarterSetupRecovery, + focusChannelId: identical(focusChannelId, _unset) + ? this.focusChannelId + : focusChannelId as String?, ); } class InviteJoinNotifier extends Notifier { + Community? _pendingStarterSetupCommunity; + Future? _starterSetupInFlight; + @override InviteJoinState build() => const InviteJoinState(); @@ -76,6 +126,18 @@ class InviteJoinNotifier extends Notifier { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); + if (existing.starterSetupIncomplete) { + _pendingStarterSetupCommunity = existing; + state = InviteJoinState( + status: InviteJoinStatus.claiming, + invite: invite, + host: _hostFromRelay(invite.relayUrl), + communityName: existing.name, + isStarterSetupRecovery: true, + ); + return; + } + _pendingStarterSetupCommunity = null; state = InviteJoinState( status: InviteJoinStatus.switchedExisting, invite: invite, @@ -85,6 +147,7 @@ class InviteJoinNotifier extends Notifier { return; } + _pendingStarterSetupCommunity = null; state = InviteJoinState( status: InviteJoinStatus.confirming, invite: invite, @@ -102,7 +165,11 @@ class InviteJoinNotifier extends Notifier { return; } - state = state.copyWith(status: InviteJoinStatus.claiming); + state = state.copyWith( + status: InviteJoinStatus.claiming, + errorMessage: null, + requiresFreshInvite: false, + ); try { final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); @@ -110,9 +177,24 @@ class InviteJoinNotifier extends Notifier { await ref .read(communityListProvider.notifier) .switchCommunity(existing.id); + if (existing.starterSetupIncomplete || state.isStarterSetupRecovery) { + _pendingStarterSetupCommunity = existing; + await startStarterSetupRecovery(); + } else { + state = state.copyWith( + status: InviteJoinStatus.switchedExisting, + communityName: existing.name, + isStarterSetupRecovery: false, + ); + } + return; + } + + if (state.isStarterSetupRecovery) { state = state.copyWith( - status: InviteJoinStatus.switchedExisting, - communityName: existing.name, + status: InviteJoinStatus.error, + errorMessage: + 'This community is no longer available. Re-open the invite link to try again.', ); return; } @@ -160,25 +242,108 @@ class InviteJoinNotifier extends Notifier { pubkey: keys.public, nsec: keys.nsec, sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser, + starterSetupIncomplete: true, ); await ref .read(authProvider.notifier) .authenticateWithCommunity(community); + _pendingStarterSetupCommunity = community; + state = state.copyWith(isStarterSetupRecovery: true); + await startStarterSetupRecovery(); + } catch (error) { + final requiresFreshInvite = _requiresFreshInvite(error); + state = state.copyWith( + status: InviteJoinStatus.error, + errorMessage: _friendlyInviteError(error), + requiresFreshInvite: requiresFreshInvite, + ); + } + } + + /// Runs the pending recovery after its progress UI has been presented. + Future startStarterSetupRecovery() async { + final community = _pendingStarterSetupCommunity; + if (community == null || + state.status != InviteJoinStatus.claiming || + !state.isStarterSetupRecovery) { + return; + } + final inFlight = _starterSetupInFlight; + if (inFlight != null) return inFlight; + + final setup = _finishStarterSetup(community); + _starterSetupInFlight = setup; + try { + await setup; + } finally { + if (identical(_starterSetupInFlight, setup)) { + _starterSetupInFlight = null; + } + } + } + + Future _finishStarterSetup(Community community) async { + try { + // Authentication and community switches invalidate the scoped providers. + // Wait for the active community projection to settle before constructing + // the recovery, otherwise it could capture the previous relay's actions. + final activeCommunity = await ref.read(activeCommunityProvider.future); + if (activeCommunity?.id != community.id || + activeCommunity?.relayUrl != community.relayUrl || + activeCommunity?.nsec != community.nsec) { + throw StateError('Active community changed before invite recovery'); + } + final config = RelayConfig( + baseUrl: community.relayUrl, + nsec: community.nsec, + ); + final focusChannelId = await ref + .read(inviteJoinRecoveryProvider)( + InviteJoinRecoveryScope( + relayHttpOrigin: config.baseUrl, + nsec: config.nsec, + ), + ) + .ensureStarterChannels(); + await _saveStarterSetupState(community, incomplete: false); state = state.copyWith( status: InviteJoinStatus.success, communityName: community.name, + isStarterSetupRecovery: false, + focusChannelId: focusChannelId, ); } catch (error) { - final requiresFreshInvite = _requiresFreshInvite(error); + Object visibleError = error; + try { + await _saveStarterSetupState(community, incomplete: true); + } catch (storageError) { + visibleError = storageError; + } state = state.copyWith( status: InviteJoinStatus.error, - errorMessage: _friendlyInviteError(error), - requiresFreshInvite: requiresFreshInvite, + errorMessage: _friendlyStarterSetupError(visibleError), + requiresFreshInvite: false, + isStarterSetupRecovery: true, + focusChannelId: null, ); } } + Future _saveStarterSetupState( + Community community, { + required bool incomplete, + }) { + return ref.read(communityTransitionProvider).runExclusive(() async { + final updated = community.copyWith(starterSetupIncomplete: incomplete); + await ref.read(communityStorageProvider).save(updated); + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); + ref.invalidate(authProvider); + }); + } + void reset() { + _pendingStarterSetupCommunity = null; state = const InviteJoinState(); } } @@ -285,3 +450,14 @@ String _friendlyInviteError(Object error) { } return 'Could not join this community: $message'; } + +String _friendlyStarterSetupError(Object error) { + final message = error.toString(); + if (message.contains('SocketException') || + message.contains('Connection refused') || + message.contains('Network is unreachable') || + message.contains('No route to host')) { + return 'Starter channels could not reach the relay. Check your connection and retry setup.'; + } + return 'Starter channels could not be set up. Retry setup to try again.'; +} diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index b0e0cb6f7b4..deec5ba7ef0 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -8,15 +8,31 @@ import '../../shared/widgets/modal_presentation.dart'; import '../pairing/pairing_page.dart'; import 'invite_join_provider.dart'; -Future showInviteJoinSheet(BuildContext context, WidgetRef ref) { - return showBuzzModalBottomSheet( +Future showInviteJoinSheet(BuildContext context) { + return showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, - builder: (_) => const InviteJoinSheet(), + // The route remains a normal modal while idle, but its contents install a + // PopScope once a membership claim or starter setup begins. Keep drag and + // the shared close affordance out of this sheet so they cannot bypass that + // in-flight guard. + enableDrag: false, + showCloseButton: false, + builder: (_) => const _InviteJoinSheetRoute(), ); } +class _InviteJoinSheetRoute extends ConsumerWidget { + const _InviteJoinSheetRoute(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isClaiming = + ref.watch(inviteJoinProvider).status == InviteJoinStatus.claiming; + return PopScope(canPop: !isClaiming, child: const InviteJoinSheet()); + } +} + class InviteJoinSheet extends ConsumerWidget { const InviteJoinSheet({super.key}); @@ -24,15 +40,31 @@ class InviteJoinSheet extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(inviteJoinProvider); final isClaiming = state.status == InviteJoinStatus.claiming; + final isStarterSetupRecovery = state.isStarterSetupRecovery; final host = state.host ?? 'unknown host'; final derivedName = state.communityName; + final primaryLabel = switch (( + isClaiming, + isStarterSetupRecovery, + state.status, + )) { + (true, true, _) => 'Finishing setup…', + (true, false, _) => 'Joining…', + (false, true, InviteJoinStatus.error) => 'Retry setup', + (false, true, _) => 'Finish setting up', + _ => 'Join', + }; if (state.status == InviteJoinStatus.success) { - return _InviteJoinSuccess(host: host, communityName: derivedName); + return _InviteJoinSuccess( + host: host, + communityName: derivedName, + hasFocusChannel: state.focusChannelId != null, + ); } return SafeArea( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(Grid.sm, 0, Grid.sm, Grid.sm), child: Column( mainAxisSize: MainAxisSize.min, @@ -41,7 +73,9 @@ class InviteJoinSheet extends ConsumerWidget { Icon(LucideIcons.userPlus, size: 40, color: context.colors.primary), const SizedBox(height: Grid.sm), Text( - 'Join this Buzz community?', + isStarterSetupRecovery + ? 'Finish setting up' + : 'Join this Buzz community?', style: context.textTheme.titleLarge, ), const SizedBox(height: Grid.xxs), @@ -79,6 +113,15 @@ class InviteJoinSheet extends ConsumerWidget { ), ], const SizedBox(height: Grid.sm), + if (isStarterSetupRecovery) ...[ + Text( + 'Your membership is ready, but starter channels still need to be set up. You can safely retry without claiming the invite again.', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.sm), + ], Text( 'This phone is the only copy of this identity. If you lose it before pairing or backing up, you’ll lose access as this member.', style: context.textTheme.bodyMedium?.copyWith( @@ -111,7 +154,7 @@ class InviteJoinSheet extends ConsumerWidget { child: FilledButton.icon( onPressed: isClaiming || state.requiresFreshInvite ? null - : () => ref + : () async => ref .read(inviteJoinProvider.notifier) .confirmJoin(), icon: isClaiming @@ -120,11 +163,13 @@ class InviteJoinSheet extends ConsumerWidget { height: 16, child: BuzzLoadingIndicator( size: 16, - semanticLabel: 'Joining community', + semanticLabel: isStarterSetupRecovery + ? 'Finishing setup' + : 'Joining community', ), ) : const Icon(LucideIcons.check), - label: Text(isClaiming ? 'Joining…' : 'Join'), + label: Text(primaryLabel), ), ), ], @@ -139,8 +184,13 @@ class InviteJoinSheet extends ConsumerWidget { class _InviteJoinSuccess extends StatelessWidget { final String host; final String? communityName; + final bool hasFocusChannel; - const _InviteJoinSuccess({required this.host, this.communityName}); + const _InviteJoinSuccess({ + required this.host, + required this.hasFocusChannel, + this.communityName, + }); @override Widget build(BuildContext context) { @@ -183,8 +233,10 @@ class _InviteJoinSuccess extends StatelessWidget { ), const SizedBox(height: Grid.xs), TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Not now'), + onPressed: () => Navigator.of(context).pop(hasFocusChannel), + child: Text( + hasFocusChannel ? 'Continue to #welcome-everyone' : 'Not now', + ), ), ], ), diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b207c..83ded086f20 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'features/invites/invite_join_provider.dart'; import 'shared/theme/theme_provider.dart'; void main() async { @@ -13,7 +14,13 @@ void main() async { runApp( ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + inviteJoinRecoveryProvider.overrideWith( + (ref) => + (scope) => buildMobileInviteJoinRecovery(ref, scope), + ), + ], child: const App(), ), ); diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 409de10e399..6763f953ee5 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -12,6 +12,9 @@ class Community { final String? pubkey; final String? nsec; final SensitiveActionPolicy sensitiveActionPolicy; + + /// Whether invite-created starter channels still need to be recovered. + final bool starterSetupIncomplete; final DateTime addedAt; const Community({ @@ -21,6 +24,7 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.starterSetupIncomplete = false, required this.addedAt, }); @@ -31,6 +35,7 @@ class Community { String? nsec, SensitiveActionPolicy sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + bool starterSetupIncomplete = false, }) { return Community( id: _uuid.v4(), @@ -39,6 +44,7 @@ class Community { pubkey: pubkey, nsec: nsec, sensitiveActionPolicy: sensitiveActionPolicy, + starterSetupIncomplete: starterSetupIncomplete, addedAt: DateTime.now(), ); } @@ -49,6 +55,7 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + bool? starterSetupIncomplete, }) { return Community( id: id, @@ -58,6 +65,8 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + starterSetupIncomplete: + starterSetupIncomplete ?? this.starterSetupIncomplete, addedAt: addedAt, ); } @@ -69,6 +78,7 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'starterSetupIncomplete': starterSetupIncomplete, 'addedAt': addedAt.toIso8601String(), }; @@ -82,6 +92,7 @@ class Community { (value) => value.name == json['sensitiveActionPolicy'], orElse: () => SensitiveActionPolicy.disabledByUser, ), + starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, addedAt: DateTime.parse(json['addedAt'] as String), ); diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 5700bfae966..04b8c166718 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -246,6 +246,42 @@ void main() { }); }); + test( + 'create and join stop before submitting after a community switch', + () async { + final keys = nostr.Keys.generate(); + final session = _RecordingPublishRelaySession(); + final actionsProvider = Provider((ref) { + return ChannelActions( + ref: ref, + session: session, + signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec), + currentPubkey: keys.public, + isCommunityValid: () => false, + ); + }); + final container = ProviderContainer(retry: (_, _) => null); + addTearDown(container.dispose); + + final actions = container.read(actionsProvider); + await expectLater( + actions.createChannel( + channelId: _channelId, + name: 'general', + channelType: 'stream', + visibility: 'open', + ), + throwsA(isA()), + ); + await expectLater( + actions.joinChannel(_channelId), + throwsA(isA()), + ); + + expect(session.publishedEvents, isEmpty); + }, + ); + group('Huddle channel lifecycle', () { test( 'starts from accepted signed events without refreshing all channels', diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index ea6c38cdfd1..04f7cd917ba 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1486,6 +1486,32 @@ void main() { ); }); + test('directory-aware refresh discovers open starter channels', () async { + final session = _FakeRelaySession( + memberships: const [], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'welcome-everyone'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect(await container.read(channelsProvider.future), isEmpty); + + await container + .read(channelsProvider.notifier) + .refresh(fetchDirectory: true); + + final channels = container.read(channelsProvider).requireValue; + expect(channels.map((channel) => channel.name), [ + 'general', + 'welcome-everyone', + ]); + expect(channels.every((channel) => channel.isMember), isFalse); + expect(session.directoryQueryFilters, isNotEmpty); + }); + test('deduplicates joined channels from directory discovery', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index d1e2e633840..e7170c2afe1 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -447,7 +447,7 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Future> build() async => _channels; @override - Future refresh() async { + Future refresh({bool fetchDirectory = false}) async { state = AsyncData(_channels); } diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index ea6bc226c21..a26308b284e 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -228,6 +228,170 @@ void main() { expect(find.text('Join this Buzz community?'), findsOneWidget); }); + testWidgets('opens retry setup after durable starter recovery fails', ( + tester, + ) async { + const link = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-code', + ); + final container = ProviderContainer( + overrides: [ + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + inviteJoinProvider.overrideWith( + _FailedStarterRecoveryInviteJoinNotifier.new, + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Finish setting up'), findsOneWidget); + expect(find.text('Retry setup'), findsOneWidget); + expect(find.text('Starter setup failed'), findsOneWidget); + }); + + testWidgets( + 'shows saved starter recovery progress, then opens welcome-everyone', + (tester) async { + const relayUrl = 'wss://relay.example.com'; + const welcomeId = 'welcome-everyone-id'; + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.save( + Community( + id: 'incomplete-community', + name: 'Relay', + relayUrl: relayUrl, + pubkey: 'pubkey', + nsec: 'nsec', + addedAt: DateTime.utc(2026), + starterSetupIncomplete: true, + ), + ); + final recovery = _DeferredInviteJoinRecovery(); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier( + const InviteDeepLink(relayUrl: relayUrl, code: 'invite-code'), + ), + ), + inviteJoinRecoveryProvider.overrideWithValue((_) => recovery), + channelsProvider.overrideWith( + () => + _FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await recovery.started.future; + + expect(find.text('Finish setting up'), findsOneWidget); + expect(find.text('Finishing setup…'), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Finishing setup…'), + ) + .onPressed, + isNull, + ); + + recovery.complete(welcomeId); + await tester.pumpAndSettle(); + + expect(find.text('Continue to #welcome-everyone'), findsOneWidget); + await tester.tap(find.text('Continue to #welcome-everyone')); + await tester.pumpAndSettle(); + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, const ChannelDeepLink(channelId: welcomeId)); + }, + ); + + testWidgets('renders setup-specific recovery failures after membership', ( + tester, + ) async { + const relayUrl = 'wss://relay.example.com'; + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.save( + Community( + id: 'incomplete-community', + name: 'Relay', + relayUrl: relayUrl, + pubkey: 'pubkey', + nsec: 'nsec', + addedAt: DateTime.utc(2026), + starterSetupIncomplete: true, + ), + ); + final recovery = _DeferredInviteJoinRecovery(); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier( + const InviteDeepLink(relayUrl: relayUrl, code: 'invite-code'), + ), + ), + inviteJoinRecoveryProvider.overrideWithValue((_) => recovery), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: DeepLinkDispatcher(child: Scaffold(body: SizedBox())), + ), + ), + ); + await tester.pump(); + await tester.pump(); + await recovery.started.future; + + recovery.completeError(Exception('relay did not respond')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Starter channels could not be set up. Retry setup to try again.', + ), + findsOneWidget, + ); + expect(find.text('Retry setup'), findsOneWidget); + expect(find.textContaining('Could not join this community'), findsNothing); + }); + testWidgets('waits for an invite modal before preparing the next invite', ( tester, ) async { @@ -390,6 +554,57 @@ void main() { expect(find.text('Pairing'), findsOneWidget); }, ); + + testWidgets('continues a successful invite into welcome-everyone', ( + tester, + ) async { + const invite = InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-code', + ); + final container = ProviderContainer( + overrides: [ + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(invite), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_welcomeEveryoneChannel])), + ), + inviteJoinProvider.overrideWith(_SuccessfulInviteJoinNotifier.new), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + expect(find.text('Continue to #welcome-everyone'), findsOneWidget); + + await tester.tap(find.text('Continue to #welcome-everyone')); + await tester.pumpAndSettle(); + + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.channel.id, 'welcome-everyone-id'); + expect( + destination.link, + const ChannelDeepLink(channelId: 'welcome-everyone-id'), + ); + }); } final _channel = Channel( @@ -404,6 +619,58 @@ final _channel = Channel( isMember: true, ); +final _welcomeEveryoneChannel = Channel( + id: 'welcome-everyone-id', + name: 'welcome-everyone', + channelType: 'stream', + visibility: 'open', + description: 'Say hi', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, +); + +class _SuccessfulInviteJoinNotifier extends InviteJoinNotifier { + @override + InviteJoinState build() => const InviteJoinState(); + + @override + Future prepare(InviteDeepLink invite) async { + state = InviteJoinState( + status: InviteJoinStatus.confirming, + invite: invite, + host: 'relay.example.com', + communityName: 'Example', + ); + } + + @override + Future confirmJoin() async { + state = state.copyWith( + status: InviteJoinStatus.success, + focusChannelId: 'welcome-everyone-id', + ); + } +} + +class _FailedStarterRecoveryInviteJoinNotifier extends InviteJoinNotifier { + @override + InviteJoinState build() => const InviteJoinState(); + + @override + Future prepare(InviteDeepLink invite) async { + state = InviteJoinState( + status: InviteJoinStatus.error, + invite: invite, + host: 'relay.example.com', + communityName: 'Relay', + errorMessage: 'Starter setup failed', + isStarterSetupRecovery: true, + ); + } +} + class _CountingCommunityStorage extends CommunityStorage { int loadCalls = 0; @@ -476,6 +743,21 @@ class _FakeChannelsNotifier extends ChannelsNotifier { Future> build() => channels; } +class _DeferredInviteJoinRecovery implements InviteJoinRecovery { + final Completer _result = Completer(); + final Completer started = Completer(); + + @override + Future ensureStarterChannels() { + started.complete(); + return _result.future; + } + + void complete(String? focusChannelId) => _result.complete(focusChannelId); + + void completeError(Object error) => _result.completeError(error); +} + class _CapturedDestination extends StatelessWidget { const _CapturedDestination({required this.channel, required this.link}); diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 2e52f794906..83f7dc70c99 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -7,6 +8,8 @@ import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import 'package:buzz/app.dart'; +import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; import 'package:buzz/shared/deeplink/deep_link.dart'; @@ -88,6 +91,9 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { capturedRequest = request; @@ -123,6 +129,7 @@ void main() { final state = container.read(inviteJoinProvider); expect(state.status, InviteJoinStatus.success); + expect(state.focusChannelId, 'welcome-everyone-id'); expect(capturedRequest, isNotNull); expect( capturedRequest!.url.toString(), @@ -178,6 +185,306 @@ void main() { expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle); }); + test('starter channel ids match desktop for the same relay scope', () async { + expect( + desktopStarterChannelId( + relayHttpOrigin: 'https://relay.example.com/', + slug: 'general', + ), + '9ed9563a-84d6-586d-8007-ae294a6dfdaf', + ); + expect( + desktopStarterChannelId( + relayHttpOrigin: 'https://relay.example.com', + slug: 'welcome-everyone', + ), + '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a', + ); + }); + + test( + 'invite recovery joins existing public general and welcome-everyone', + () async { + final joined = []; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => [ + _channel(id: 'general-id', name: ' General '), + _channel(id: 'welcome-id', name: 'WELCOME-EVERYONE'), + _channel( + id: 'private-welcome-id', + name: 'Welcome', + visibility: 'private', + ), + ], + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw StateError('starters already exist'), + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: 'https://relay.example.com', + ); + + final focusChannelId = await recovery.ensureStarterChannels(); + + expect(joined, ['general-id', 'welcome-id']); + expect(focusChannelId, 'welcome-id'); + }, + ); + + test( + 'invite recovery creates missing public starters with desktop ids', + () async { + final created = >{}; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => const [], + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async { + created[name] = { + 'id': channelId, + 'channelType': channelType, + 'visibility': visibility, + 'description': description, + 'ttlSeconds': ttlSeconds, + }; + return _channel(id: channelId, name: name, isMember: true); + }, + joinChannel: (_) async => fail('creator is already a member'), + relayHttpOrigin: 'https://relay.example.com/', + ); + + final focusChannelId = await recovery.ensureStarterChannels(); + + expect(created.keys, ['general', 'welcome-everyone']); + expect(created['general'], { + 'id': '9ed9563a-84d6-586d-8007-ae294a6dfdaf', + 'channelType': 'stream', + 'visibility': 'open', + 'description': 'General conversation and community updates.', + 'ttlSeconds': null, + }); + expect(created['welcome-everyone'], { + 'id': '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a', + 'channelType': 'stream', + 'visibility': 'open', + 'description': + 'Say hi, ask a question, or share what brought you here.', + 'ttlSeconds': null, + }); + expect(focusChannelId, '1e288e10-2f7d-5c2c-9a9f-dee58c8daa7a'); + }, + ); + + test( + 'duplicate starter creation converges and joins relay channels', + () async { + var loadCount = 0; + final joined = []; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async { + loadCount++; + if (loadCount == 1) return const []; + return [ + _channel(id: 'relay-general', name: 'general'), + _channel(id: 'relay-welcome', name: 'welcome-everyone'), + ]; + }, + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw Exception( + 'relay rejected event: duplicate: channel already exists', + ), + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: 'https://relay.example.com', + ); + + final focusChannelId = await recovery.ensureStarterChannels(); + + expect(loadCount, 2); + expect(joined, ['relay-general', 'relay-welcome']); + expect(focusChannelId, 'relay-welcome'); + }, + ); + + test('starter setup surfaces an unavailable starter for recovery', () async { + final joined = []; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => [ + _channel(id: 'welcome-id', name: 'welcome-everyone'), + ], + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw Exception('relay unavailable'), + joinChannel: (channelId) async => joined.add(channelId), + relayHttpOrigin: 'https://relay.example.com', + ); + + await expectLater( + recovery.ensureStarterChannels(), + throwsA(isA()), + ); + + expect(joined, isEmpty); + }); + + test( + 'starter setup recovery survives dismissal and container recreation', + () async { + final keys = nostr.Keys.generate(); + var generatedKeys = 0; + var claimRequests = 0; + final storage = CommunityStorage(secure: FakeSecureStorage()); + final firstContainer = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + inviteKeyGeneratorProvider.overrideWithValue(() { + generatedKeys++; + return keys; + }), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => + _FakeInviteJoinRecovery(error: Exception('relay disconnected')), + ), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + claimRequests++; + return http.Response( + jsonEncode({ + 'status': 'joined', + 'host': 'relay.example.com', + 'role': 'member', + }), + 200, + ); + }), + ), + ], + ); + await firstContainer + .read(inviteJoinProvider.notifier) + .prepare( + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'code', + ), + ); + await firstContainer.read(inviteJoinProvider.notifier).confirmJoin(); + + final failed = firstContainer.read(inviteJoinProvider); + expect(failed.status, InviteJoinStatus.error); + expect(failed.isStarterSetupRecovery, isTrue); + expect(generatedKeys, 1); + expect(claimRequests, 1); + expect((await storage.loadAll()).single.starterSetupIncomplete, isTrue); + + firstContainer.dispose(); + + final secondContainer = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + inviteKeyGeneratorProvider.overrideWithValue(() { + generatedKeys++; + return nostr.Keys.generate(); + }), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + claimRequests++; + return http.Response('{}', 500); + }), + ), + ], + ); + addTearDown(secondContainer.dispose); + + await secondContainer + .read(inviteJoinProvider.notifier) + .prepare( + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'code', + ), + ); + await secondContainer + .read(inviteJoinProvider.notifier) + .startStarterSetupRecovery(); + + final recovered = secondContainer.read(inviteJoinProvider); + expect(recovered.status, InviteJoinStatus.success); + expect(recovered.focusChannelId, 'welcome-everyone-id'); + expect((await storage.loadAll()).single.starterSetupIncomplete, isFalse); + expect(generatedKeys, 1); + expect(claimRequests, 1); + }, + ); + + test( + 'starter recovery makes no replacement-tenant submission after a scope switch', + () async { + var isScopeCurrent = true; + final firstJoinStarted = Completer(); + final releaseFirstJoin = Completer(); + final submitted = []; + final recovery = MobileInviteJoinRecovery( + loadChannels: () async => [ + _channel(id: 'general-id', name: 'general'), + _channel(id: 'welcome-id', name: 'welcome-everyone'), + ], + createChannel: + ({ + required channelId, + required name, + required channelType, + required visibility, + description, + ttlSeconds, + }) async => throw StateError('starters already exist'), + joinChannel: (channelId) async { + submitted.add(channelId); + if (channelId == 'general-id') { + firstJoinStarted.complete(); + await releaseFirstJoin.future; + } + }, + relayHttpOrigin: 'https://relay.example.com', + isScopeCurrent: () => isScopeCurrent, + ); + + final setup = recovery.ensureStarterChannels(); + await firstJoinStarted.future; + isScopeCurrent = false; + releaseFirstJoin.complete(); + + await expectLater(setup, throwsA(isA())); + expect(submitted, ['general-id']); + }, + ); + test('join_policy_required requires a fresh link and cannot retry', () async { final keys = nostr.Keys.generate(); var attempts = 0; @@ -276,6 +583,9 @@ void main() { communityStorageProvider.overrideWithValue(storage), authProvider.overrideWith(() => auth), inviteKeyGeneratorProvider.overrideWithValue(() => keys), + inviteJoinRecoveryProvider.overrideWithValue( + (_) => _successfulRecovery(), + ), inviteJoinHttpClientProvider.overrideWithValue( http_testing.MockClient((request) async { attempts++; @@ -321,8 +631,116 @@ void main() { ); expect(auth.authenticatedCommunities, hasLength(1)); }); + + test('builds a fresh recovery with the second community identity', () async { + final firstKeys = nostr.Keys.generate(); + final secondKeys = nostr.Keys.generate(); + final scopes = []; + final recoveries = []; + var nextKeys = 0; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue( + CommunityStorage(secure: FakeSecureStorage()), + ), + authProvider.overrideWith(_RecordingAuthNotifier.new), + inviteKeyGeneratorProvider.overrideWithValue(() { + final keys = nextKeys == 0 ? firstKeys : secondKeys; + nextKeys++; + return keys; + }), + inviteJoinRecoveryProvider.overrideWithValue((scope) { + scopes.add(scope); + return _RecordingInviteJoinRecovery(() async { + recoveries.add(scope); + return 'welcome-everyone-id'; + }); + }), + inviteJoinHttpClientProvider.overrideWithValue( + http_testing.MockClient( + (request) async => http.Response( + jsonEncode({ + 'status': 'joined', + 'host': request.url.host, + 'role': 'member', + }), + 200, + ), + ), + ), + ], + ); + addTearDown(container.dispose); + + for (final invite in const [ + InviteDeepLink(relayUrl: 'wss://first.example.com', code: 'first'), + InviteDeepLink(relayUrl: 'wss://second.example.com', code: 'second'), + ]) { + await container.read(inviteJoinProvider.notifier).prepare(invite); + await container.read(inviteJoinProvider.notifier).confirmJoin(); + expect( + container.read(inviteJoinProvider).status, + InviteJoinStatus.success, + ); + } + + expect(scopes.map((scope) => scope.relayHttpOrigin), [ + 'https://first.example.com', + 'https://second.example.com', + ]); + expect(scopes.map((scope) => scope.nsec), [ + firstKeys.nsec, + secondKeys.nsec, + ]); + expect(recoveries, hasLength(2)); + expect(identical(recoveries[0], scopes[0]), isTrue); + expect(identical(recoveries[1], scopes[1]), isTrue); + expect(identical(recoveries[1], scopes[0]), isFalse); + }); +} + +InviteJoinRecovery _successfulRecovery() => + const _FakeInviteJoinRecovery(focusChannelId: 'welcome-everyone-id'); + +class _FakeInviteJoinRecovery implements InviteJoinRecovery { + final String? focusChannelId; + final Object? error; + + const _FakeInviteJoinRecovery({this.focusChannelId, this.error}); + + @override + Future ensureStarterChannels() async { + if (error case final failure?) throw failure; + return focusChannelId; + } } +class _RecordingInviteJoinRecovery implements InviteJoinRecovery { + const _RecordingInviteJoinRecovery(this._ensure); + + final Future Function() _ensure; + + @override + Future ensureStarterChannels() => _ensure(); +} + +Channel _channel({ + required String id, + required String name, + String visibility = 'open', + bool isMember = false, +}) => Channel( + id: id, + name: name, + channelType: 'stream', + visibility: visibility, + description: '', + createdBy: 'me', + createdAt: DateTime.utc(2026), + memberCount: isMember ? 1 : 0, + isMember: isMember, +); + class _RecordingAuthNotifier extends AuthNotifier { final List authenticatedCommunities = []; @@ -332,6 +750,11 @@ class _RecordingAuthNotifier extends AuthNotifier { @override Future authenticateWithCommunity(Community community) async { + final storage = ref.read(communityStorageProvider); + await storage.save(community); + await storage.saveActiveId(community.id); + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); authenticatedCommunities.add(community); state = AsyncData( AuthState(status: AuthStatus.authenticated, community: community), diff --git a/mobile/test/features/invites/invite_join_sheet_test.dart b/mobile/test/features/invites/invite_join_sheet_test.dart new file mode 100644 index 00000000000..876429b7d96 --- /dev/null +++ b/mobile/test/features/invites/invite_join_sheet_test.dart @@ -0,0 +1,119 @@ +import 'package:buzz/features/invites/invite_join_provider.dart'; +import 'package:buzz/features/invites/invite_join_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('recovery error is scrollable and exposes retry setup', ( + tester, + ) async { + tester.view.physicalSize = const Size(375, 400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + WidgetHelpers.testable( + child: const InviteJoinSheet(), + overrides: [ + inviteJoinProvider.overrideWith(_RecoveryErrorInviteJoinNotifier.new), + ], + ), + ); + await tester.pump(); + + expect(find.text('Finish setting up'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Retry setup'), findsOneWidget); + expect(find.byType(SingleChildScrollView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + for (final fixture in [ + ( + name: 'membership claim', + state: const InviteJoinState( + status: InviteJoinStatus.claiming, + host: 'relay.example.com', + ), + label: 'Joining…', + ), + ( + name: 'starter recovery', + state: const InviteJoinState( + status: InviteJoinStatus.claiming, + host: 'relay.example.com', + isStarterSetupRecovery: true, + ), + label: 'Finishing setup…', + ), + ]) { + testWidgets('${fixture.name} cannot dismiss the in-flight invite sheet', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: const _InviteJoinSheetLauncher(), + overrides: [ + inviteJoinProvider.overrideWith( + () => _StaticInviteJoinNotifier(fixture.state), + ), + ], + ), + ); + + await tester.tap(find.text('Open invite')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text(fixture.label), findsOneWidget); + expect( + find.byKey(const ValueKey('buzz-sheet-drag-handle')), + findsNothing, + ); + expect(find.byTooltip('Close sheet'), findsNothing); + + await tester.tapAt(const Offset(8, 8)); + await tester.pump(); + expect(find.text(fixture.label), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text(fixture.label), findsOneWidget); + }); + } +} + +class _InviteJoinSheetLauncher extends StatelessWidget { + const _InviteJoinSheetLauncher(); + + @override + Widget build(BuildContext context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () => showInviteJoinSheet(context), + child: const Text('Open invite'), + ), + ), + ); +} + +class _StaticInviteJoinNotifier extends InviteJoinNotifier { + _StaticInviteJoinNotifier(this._state); + + final InviteJoinState _state; + + @override + InviteJoinState build() => _state; +} + +class _RecoveryErrorInviteJoinNotifier extends InviteJoinNotifier { + @override + InviteJoinState build() => const InviteJoinState( + status: InviteJoinStatus.error, + host: 'relay.example.com', + communityName: 'Example', + errorMessage: + 'Starter setup could not reach the relay. Retry when the connection is available.', + isStarterSetupRecovery: true, + ); +} diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart index e00f3cb17d0..5c5b337a453 100644 --- a/mobile/test/shared/community/community_test.dart +++ b/mobile/test/shared/community/community_test.dart @@ -14,20 +14,21 @@ void main() { community.sensitiveActionPolicy, SensitiveActionPolicy.disabledByUser, ); + expect(community.starterSetupIncomplete, isFalse); }); - test('sensitive action policy round trips', () { + test('community settings round trip', () { final community = Community( id: 'one', name: 'Buzz', relayUrl: 'https://relay.test', sensitiveActionPolicy: SensitiveActionPolicy.enabled, + starterSetupIncomplete: true, addedAt: DateTime.utc(2026, 8, 5), ); - expect( - Community.fromJson(community.toJson()).sensitiveActionPolicy, - SensitiveActionPolicy.enabled, - ); + final roundTrip = Community.fromJson(community.toJson()); + expect(roundTrip.sensitiveActionPolicy, SensitiveActionPolicy.enabled); + expect(roundTrip.starterSetupIncomplete, isTrue); }); } From 4bf80978f52981f0035e6c0b86bdf1108bbf64c8 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 24 Aug 2026 13:06:00 -0700 Subject: [PATCH 012/101] fix(messages): route edits to the owning composer (#6575) **Category:** fix **User Impact:** Editing a channel message now opens in the main composer, while editing a thread reply stays in the thread composer, with focus ready for typing. **Problem:** When a thread was open, Buzz treated its root message as thread-owned and opened edits in the thread composer. Menu-driven edits also lacked regression coverage for immediate focus. **Solution:** Carry the message's semantic root/reply classification into the edit target, route only actual replies to the thread composer, and use the menu primitive's selection event for a reliable handoff. End-to-end tests cover placement and focus for both paths.
File changes **desktop/src/features/channels/ui/ChannelPane.tsx** Routes edit targets by semantic thread ownership rather than membership in the open thread panel. **desktop/src/features/channels/ui/ChannelPane.types.ts** Uses the shared composer edit-target type so routing metadata stays attached to the target. **desktop/src/features/messages/lib/draftMentionRefs.ts** Classifies each edit target as a root or true thread reply from its event tags. **desktop/src/features/messages/lib/draftMentionRefs.test.mjs** Covers semantic ownership for root and reply edit targets. **desktop/src/features/messages/ui/MessageActionBar.tsx** Handles Edit through the dropdown menu's selection event so focus restoration and edit startup share the intended lifecycle. **desktop/src/features/messages/ui/MessageComposer.types.ts** Adds semantic thread ownership to the edit-target contract. **desktop/tests/e2e/messaging.spec.ts** Verifies root edits use and focus the main composer, while reply edits use and focus the thread composer.
### Reproduction Steps 1. Send a channel message and open its thread. 2. From the thread panel, edit the root message; confirm its content loads in the main composer and the editor is focused. 3. Send a reply in that thread. 4. Edit the reply; confirm its content loads in the thread composer and the editor is focused. ### Screenshots **Editing a channel-root message uses the main composer** ![Channel-root message editing in the main composer, dark theme with purple accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-root-main-dark.png) **Editing an actual thread reply uses the thread composer** ![Thread reply editing in the thread composer, dark theme with purple accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6575/message-edit-reply-thread-dark.png) --------- Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: Carl --- .../app/navigation/navigationGuard.test.mjs | 100 ++ desktop/src/app/navigation/navigationGuard.ts | 54 + .../src/app/navigation/useAppNavigation.ts | 48 +- .../app/navigation/useBackForwardControls.ts | 5 +- .../src/app/routes/WorkflowsRouteScreen.tsx | 5 +- .../src/features/channels/ui/ChannelPane.tsx | 106 +- .../features/channels/ui/ChannelPane.types.ts | 11 +- .../features/channels/ui/ChannelScreen.tsx | 64 +- .../channels/ui/FocusThreadDrawer.tsx | 12 +- .../channels/ui/GuardedChannelPane.tsx | 9 + .../channels/ui/useChannelAgentSessions.ts | 4 + .../channels/ui/useChannelProfilePanel.ts | 4 + .../channels/ui/useChannelRouteTarget.ts | 15 +- .../channels/ui/useChannelTargetReset.ts | 30 + .../ui/useHuddleThreadIsolation.test.mjs | 34 + .../channels/ui/useHuddleThreadIsolation.ts | 33 +- .../channels/ui/useNavigationGuard.ts | 10 + .../channels/useChannelPaneHandlers.ts | 20 + .../src/features/home/ui/InboxDetailPane.tsx | 1 + desktop/src/features/messages/hooks.ts | 10 +- .../messages/lib/draftMentionRefs.test.mjs | 31 + .../features/messages/lib/draftMentionRefs.ts | 2 + .../features/messages/ui/MessageActionBar.tsx | 2 +- .../messages/ui/MessageComposer.types.ts | 1 + desktop/src/shared/deep-link.test.mjs | 52 + desktop/src/shared/ui/markdown.tsx | 8 +- desktop/src/shared/useMessageDeepLinks.ts | 3 +- desktop/tests/e2e/messaging.spec.ts | 1219 +++++++++++++++++ 28 files changed, 1799 insertions(+), 94 deletions(-) create mode 100644 desktop/src/app/navigation/navigationGuard.test.mjs create mode 100644 desktop/src/app/navigation/navigationGuard.ts create mode 100644 desktop/src/features/channels/ui/GuardedChannelPane.tsx create mode 100644 desktop/src/features/channels/ui/useChannelTargetReset.ts create mode 100644 desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs create mode 100644 desktop/src/features/channels/ui/useNavigationGuard.ts diff --git a/desktop/src/app/navigation/navigationGuard.test.mjs b/desktop/src/app/navigation/navigationGuard.test.mjs new file mode 100644 index 00000000000..4fb72329b7c --- /dev/null +++ b/desktop/src/app/navigation/navigationGuard.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const target = { + kind: "channel-message", + channelId: "general", + messageId: "message-a", + threadRootId: "thread-a", +}; + +const { allowNavigation, registerNavigationGuard, traverseHistory } = + await import("./navigationGuard.ts"); + +test("all navigation consults the registered boundary guard", () => { + let received; + const unregister = registerNavigationGuard((nextTarget) => { + received = nextTarget; + return false; + }); + + assert.equal(allowNavigation(target), false); + assert.deepEqual(received, target); + unregister(); + assert.equal(allowNavigation(target), true); +}); + +test("guarded history traversal blocks before mutating history", () => { + let received; + let backCalls = 0; + const unregister = registerNavigationGuard((nextTarget) => { + received = nextTarget; + return false; + }); + + assert.equal( + traverseHistory( + { + back: () => { + backCalls += 1; + }, + forward: () => {}, + }, + "back", + ), + false, + ); + assert.deepEqual(received, { kind: "history", direction: "back" }); + assert.equal(backCalls, 0); + unregister(); +}); + +test("guarded history traversal invokes the selected direction when allowed", () => { + let forwardCalls = 0; + + assert.equal( + traverseHistory( + { + back: () => {}, + forward: () => { + forwardCalls += 1; + }, + }, + "forward", + ), + true, + ); + assert.equal(forwardCalls, 1); +}); + +test("unregistering the newer guard restores the prior live guard", () => { + const unregisterFirst = registerNavigationGuard(() => false); + const unregisterSecond = registerNavigationGuard(() => true); + + assert.equal(allowNavigation(target), true); + unregisterSecond(); + assert.equal(allowNavigation(target), false); + unregisterFirst(); + assert.equal(allowNavigation(target), true); +}); + +test("stale cleanup cannot unregister a newer guard", () => { + const unregisterFirst = registerNavigationGuard(() => false); + const unregisterSecond = registerNavigationGuard(() => true); + + unregisterFirst(); + assert.equal(allowNavigation(target), true); + unregisterSecond(); + assert.equal(allowNavigation(target), true); +}); + +test("duplicate callback registrations clean up by registration identity", () => { + const sharedGuard = () => false; + const unregisterFirst = registerNavigationGuard(sharedGuard); + const unregisterSecond = registerNavigationGuard(sharedGuard); + + unregisterFirst(); + assert.equal(allowNavigation(target), false); + unregisterSecond(); + assert.equal(allowNavigation(target), true); +}); diff --git a/desktop/src/app/navigation/navigationGuard.ts b/desktop/src/app/navigation/navigationGuard.ts new file mode 100644 index 00000000000..5ff853720b4 --- /dev/null +++ b/desktop/src/app/navigation/navigationGuard.ts @@ -0,0 +1,54 @@ +export type GuardedNavigation = + | { + kind: "history"; + direction: "back" | "forward"; + } + | { + kind: "route"; + href: string; + } + | { + kind: "channel-message"; + channelId: string; + messageId: string; + threadRootId: string | null; + } + | { + kind: "forum-post"; + channelId: string; + postId: string; + replyId: string | null; + }; + +type NavigationGuard = (target: GuardedNavigation) => boolean; + +type GuardRegistration = { + guard: NavigationGuard; +}; + +const activeGuards: GuardRegistration[] = []; + +export function allowNavigation(target: GuardedNavigation): boolean { + return activeGuards.at(-1)?.guard(target) ?? true; +} + +export function traverseHistory( + history: Pick, + direction: "back" | "forward", +): boolean { + if (!allowNavigation({ kind: "history", direction })) { + return false; + } + + history[direction](); + return true; +} + +export function registerNavigationGuard(guard: NavigationGuard): () => void { + const registration = { guard }; + activeGuards.push(registration); + return () => { + const index = activeGuards.lastIndexOf(registration); + if (index >= 0) activeGuards.splice(index, 1); + }; +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c4776564e34..7a21f0dfbe1 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -7,6 +7,11 @@ import { } from "@tanstack/react-router"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; +import { + allowNavigation, + type GuardedNavigation, + traverseHistory, +} from "@/app/navigation/navigationGuard"; import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { @@ -30,6 +35,7 @@ export function useAppNavigation() { state?: Record; }, behavior: NavigationBehavior = {}, + guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); @@ -37,6 +43,14 @@ export function useAppNavigation() { return false; } + if ( + !allowNavigation( + guardedTarget ?? { kind: "route", href: nextLocation.href }, + ) + ) { + return false; + } + await navigate({ ...next, replace: behavior.replace, @@ -256,8 +270,8 @@ export function useAppNavigation() { thread?: string; threadRootId?: string | null; }, - ) => - commitNavigation( + ) => { + return commitNavigation( { to: "/channels/$channelId", params: { @@ -282,7 +296,16 @@ export function useAppNavigation() { replace: options?.replace, resetScroll: options?.messageId ? true : undefined, }, - ), + options?.messageId + ? { + kind: "channel-message", + channelId, + messageId: options.messageId, + threadRootId: options.threadRootId ?? null, + } + : undefined, + ); + }, [commitNavigation], ); @@ -307,8 +330,8 @@ export function useAppNavigation() { replace?: boolean; replyId?: string; }, - ) => - commitNavigation( + ) => { + return commitNavigation( { to: "/channels/$channelId/posts/$postId", params: { @@ -322,7 +345,14 @@ export function useAppNavigation() { replace: options?.replace, resetScroll: false, }, - ), + { + kind: "forum-post", + channelId, + postId, + replyId: options?.replyId ?? null, + }, + ); + }, [commitNavigation], ); @@ -340,7 +370,7 @@ export function useAppNavigation() { const closeSettings = React.useCallback(() => { if (canGoBack) { - router.history.back(); + traverseHistory(router.history, "back"); return; } @@ -349,7 +379,7 @@ export function useAppNavigation() { const closeWorkflowDetail = React.useCallback(() => { if (canGoBack) { - router.history.back(); + traverseHistory(router.history, "back"); return; } @@ -359,7 +389,7 @@ export function useAppNavigation() { const closeForumPost = React.useCallback( (channelId: string) => { if (canGoBack) { - router.history.back(); + traverseHistory(router.history, "back"); return; } diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts index e5513247d50..717e62153e1 100644 --- a/desktop/src/app/navigation/useBackForwardControls.ts +++ b/desktop/src/app/navigation/useBackForwardControls.ts @@ -8,6 +8,7 @@ import { isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { matchBackForwardChord } from "@/app/navigation/backForwardChords"; +import { traverseHistory } from "@/app/navigation/navigationGuard"; import { isMacPlatform } from "@/shared/lib/platform"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; @@ -59,7 +60,7 @@ export function useBackForwardControls() { return; } - router.history.back(); + traverseHistory(router.history, "back"); }, [canGoBack, router.history]); const goForward = React.useCallback(() => { @@ -67,7 +68,7 @@ export function useBackForwardControls() { return; } - router.history.forward(); + traverseHistory(router.history, "forward"); }, [canGoForward, router.history]); const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => { diff --git a/desktop/src/app/routes/WorkflowsRouteScreen.tsx b/desktop/src/app/routes/WorkflowsRouteScreen.tsx index 193695f0cd2..8c476b2863f 100644 --- a/desktop/src/app/routes/WorkflowsRouteScreen.tsx +++ b/desktop/src/app/routes/WorkflowsRouteScreen.tsx @@ -18,6 +18,7 @@ export function WorkflowsRouteScreen({ onEditorPaneChange, }: WorkflowsRouteScreenProps) { const { + closeWorkflowDetail, goDuplicateWorkflow, goEditWorkflow, goNewWorkflow, @@ -26,11 +27,11 @@ export function WorkflowsRouteScreen({ } = useAppNavigation(); const closeEditor = React.useCallback(() => { if (editor?.hasOrigin) { - window.history.back(); + closeWorkflowDetail(); return; } void goWorkflows({ replace: true }); - }, [editor?.hasOrigin, goWorkflows]); + }, [closeWorkflowDetail, editor?.hasOrigin, goWorkflows]); const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; const memberChannels = channels.filter((channel) => channel.isMember); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed40..dbdb2ed2345 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { toast } from "sonner"; import { Hash, LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -23,6 +24,7 @@ import { hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext"; +import { isThreadReply } from "@/features/messages/lib/threading"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -220,11 +222,7 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel, currentPubkey ?? null, ); - const isEditInThread = - editTarget != null && - threadHeadMessage != null && - (editTarget.id === threadHeadMessage.id || - threadMessages.some((entry) => entry.message.id === editTarget.id)); + const isEditInThread = editTarget?.isThreadReply === true; const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; const findLastOwnEditable = React.useCallback( @@ -247,23 +245,7 @@ export const ChannelPane = React.memo(function ChannelPane({ }, [onEdit, currentPubkey], ); - const handleEditLastOwnMainMessage = React.useCallback((): boolean => { - const target = findLastOwnEditable(messages); - if (!target || !onEdit) return false; - onEdit(target); - return true; - }, [findLastOwnEditable, messages, onEdit]); - const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { - if (!onEdit) return false; - const scope: TimelineMessage[] = []; - if (threadHeadMessage) scope.push(threadHeadMessage); - for (const entry of threadMessages) scope.push(entry.message); - const target = findLastOwnEditable(scope); - if (!target) return false; - onEdit(target); - return true; - }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); const timeoutState = useTimeoutState(); // A moderation DM (1:1 with the relay identity) is read-only for the member; // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → @@ -461,6 +443,81 @@ export const ChannelPane = React.memo(function ChannelPane({ useFocusThreadDrawer, onCloseThread, ); + const pendingMainEditRef = React.useRef(null); + const editTargetRef = React.useRef(editTarget); + editTargetRef.current = editTarget; + const pendingMainEditContextRef = React.useRef({ + channelId: activeChannel?.id ?? null, + threadId: threadHeadMessage?.id ?? null, + }); + const pendingMainEditContext = { + channelId: activeChannel?.id ?? null, + threadId: threadHeadMessage?.id ?? null, + }; + const previousPendingContext = pendingMainEditContextRef.current; + if ( + previousPendingContext.channelId !== pendingMainEditContext.channelId || + (previousPendingContext.threadId !== null && + pendingMainEditContext.threadId !== null && + previousPendingContext.threadId !== pendingMainEditContext.threadId) + ) { + pendingMainEditRef.current = null; + } + pendingMainEditContextRef.current = pendingMainEditContext; + const handleRoutedEdit = React.useCallback( + (message: TimelineMessage): boolean => { + const currentEditTarget = editTargetRef.current; + if ( + currentEditTarget && + currentEditTarget.id !== message.id && + currentEditTarget.isThreadReply !== isThreadReply(message.tags ?? []) + ) { + pendingMainEditRef.current = null; + toast.info("Finish or cancel your edit first."); + return false; + } + if (currentEditTarget?.id === message.id) { + pendingMainEditRef.current = null; + onEdit?.(message); + return true; + } + if ( + !isThreadReply(message.tags ?? []) && + (isSinglePanelView || useFocusThreadDrawer) + ) { + pendingMainEditRef.current = message; + onCloseThread(); + return true; + } + onEdit?.(message); + return Boolean(onEdit); + }, + [isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer], + ); + const handleEditLastOwnMainMessage = React.useCallback((): boolean => { + const target = findLastOwnEditable( + mainTimelineEntries.map((entry) => entry.message), + ); + return target ? handleRoutedEdit(target) : false; + }, [findLastOwnEditable, handleRoutedEdit, mainTimelineEntries]); + const handleEditLastOwnThreadMessage = React.useCallback((): boolean => { + const scope: TimelineMessage[] = []; + if (threadHeadMessage) scope.push(threadHeadMessage); + for (const entry of threadMessages) scope.push(entry.message); + const target = findLastOwnEditable(scope); + return target ? handleRoutedEdit(target) : false; + }, [ + findLastOwnEditable, + handleRoutedEdit, + threadHeadMessage, + threadMessages, + ]); + React.useEffect(() => { + const pendingMainEdit = pendingMainEditRef.current; + if (!pendingMainEdit || isSinglePanelView || channelIsCovered) return; + pendingMainEditRef.current = null; + onEdit?.(pendingMainEdit); + }, [channelIsCovered, isSinglePanelView, onEdit]); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ activeThreadHeadId: threadHeadMessage?.id ?? null, @@ -508,6 +565,7 @@ export const ChannelPane = React.memo(function ChannelPane({ useFocusThreadDrawer ? ( @@ -542,7 +600,6 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-shared-header-backdrop" /> ) : null} - {!isSinglePanelView ? (
) : null} - {/* * `AnimatePresence` keeps the focus thread drawer mounted through its exit * animation — without it the drawer's own existence condition @@ -808,7 +864,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelReply={onCancelThreadReply} onClose={onCloseThread} onDelete={onDelete} - onEdit={onEdit} + onEdit={handleRoutedEdit} onEditLastOwnMessage={handleEditLastOwnThreadMessage} onEditSave={onEditSave} onFollowThread={onFollowThread} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073b..7ac3930b84e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -1,13 +1,12 @@ import type * as React from "react"; import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar"; import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; -import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { ProfilePanelTab, ProfilePanelView, @@ -37,13 +36,7 @@ export type ChannelPaneProps = { botTypingEntries: TypingIndicatorEntry[]; channelManagementOpen?: boolean; currentPubkey?: string; - editTarget?: { - author: string; - body: string; - id: string; - imetaMedia?: ImetaMedia[]; - mentionRefs?: DraftMentionRef[]; - } | null; + editTarget?: MessageComposerEditTarget | null; fetchOlder?: () => Promise; header?: React.ReactNode; hasOlderMessages?: boolean; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 68df9bc05c6..aac93d7f8d7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -17,7 +17,6 @@ import { } from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; -import { ChannelPane } from "@/features/channels/ui/ChannelScreenLazyViews"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; @@ -45,7 +44,10 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { getThreadReference } from "@/features/messages/lib/threading"; +import { + getThreadReference, + isThreadReply, +} from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -80,10 +82,13 @@ import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useMessageProfiles } from "./useMessageProfiles"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; +import { useChannelTargetReset } from "./useChannelTargetReset"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; +import { GuardedChannelPane } from "./GuardedChannelPane"; +import { useNavigationGuard } from "./useNavigationGuard"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -168,11 +173,15 @@ export function ChannelScreen({ const activeChannelId = activeChannel?.id ?? null; const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; + const requireThreadEditResolutionRef = React.useRef<() => boolean>( + () => true, + ); const effectiveOpenThreadHeadId = useHuddleThreadIsolation({ closeThread: setOpenThreadHeadId, isHuddleTranscript, openThreadHeadId, optimisticOpenThreadHeadId, + requireThreadEditResolutionRef, }); const isNotifiedForEffectiveThread = effectiveOpenThreadHeadId != null @@ -462,8 +471,10 @@ export function ChannelScreen({ }); const editTargetMessage = React.useMemo( () => - timelineMessages.find((message) => message.id === editTargetId) ?? null, - [editTargetId, timelineMessages], + timelineMessages.find((message) => message.id === editTargetId) ?? + threadPanelData.messages.find((message) => message.id === editTargetId) ?? + null, + [editTargetId, threadPanelData.messages, timelineMessages], ); const [emptyDeleteId, setEmptyDeleteId] = React.useState(null); const { @@ -475,6 +486,7 @@ export function ChannelScreen({ handleEditSave, handleExpandThreadReplies, handleOpenThread, + requireThreadEditResolution, handleSendMessage, handleSendToChannel, handleSendThreadReply, @@ -484,6 +496,8 @@ export function ChannelScreen({ deleteMessageMutation, editMessageMutation, editTargetId, + editTargetIsThreadReply: + editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -502,6 +516,7 @@ export function ChannelScreen({ threadReplyTargetId, toggleReactionMutation, }); + requireThreadEditResolutionRef.current = requireThreadEditResolution; const effectiveToggleReaction = React.useMemo( () => activeChannel && !activeChannel.archivedAt && activeChannel.isMember @@ -577,6 +592,7 @@ export function ChannelScreen({ openAgentSessionPubkey, openThreadHeadId: effectiveOpenThreadHeadId, profilePanelPubkey, + requireThreadEditResolution, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenAgentSessionChannelId, @@ -590,6 +606,7 @@ export function ChannelScreen({ useChannelProfilePanel({ closeAgentSession: handleCloseAgentSession, openProfilePanel, + requireThreadEditResolution, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -630,28 +647,19 @@ export function ChannelScreen({ timelineMessages, isTimelineLoading, ); - const resetComposerTargets = React.useCallback( - (_channelId: string | null) => { - setExpandedThreadReplyIds(new Set()); - setThreadScrollTargetId(null); - setThreadReplyTargetId(null); - setEditTargetId(null); - }, - [], - ); - const handleThreadScrollTargetResolved = React.useCallback(() => { - setThreadScrollTargetId(null); - }, []); - const handleTargetReached = React.useCallback(() => { - clearMessageRouteTarget({ replace: true }); - }, [clearMessageRouteTarget]); - React.useEffect(() => { - resetComposerTargets(activeChannelId); - }, [activeChannelId, resetComposerTargets]); + useChannelTargetReset({ + activeChannelId, + setEditTargetId, + setExpandedThreadReplyIds, + setThreadReplyTargetId, + setThreadScrollTargetId, + }); + useNavigationGuard(requireThreadEditResolution); const mainTimelineTargetMessageId = useChannelRouteTarget({ activeChannel, activeChannelId, closeAgentSession: handleCloseAgentSession, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -710,6 +718,7 @@ export function ChannelScreen({ enabled: !isSinglePanelView, }); const handleManageChannel = React.useCallback(() => { + if (!requireThreadEditResolution()) return; if (activeChannel?.channelType === "forum") { openGlobalChannelManagement(); return; @@ -729,6 +738,7 @@ export function ChannelScreen({ activeChannel?.channelType, channelManagementOpen, openGlobalChannelManagement, + requireThreadEditResolution, setChannelManagementOpen, setOpenThreadHeadId, handleCloseAgentSession, @@ -839,7 +849,7 @@ export function ChannelScreen({ /> } > - + setThreadScrollTargetId(null) } onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={handleTargetReached} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } onToggleReaction={effectiveToggleReaction} openAgentSessionChannelId={openAgentSessionChannelId} openAgentSessionPubkey={openAgentSessionPubkey} diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index 1aaad0e6093..410f2d8672d 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -11,6 +11,7 @@ import { cn } from "@/shared/lib/cn"; type FocusThreadDrawerProps = { channelName: string; children: React.ReactNode; + hasActiveEdit: boolean; onClose: () => void; }; @@ -139,6 +140,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; export function FocusThreadDrawer({ channelName, children, + hasActiveEdit, onClose, }: FocusThreadDrawerProps) { const prefersReducedMotion = useReducedMotion(); @@ -149,6 +151,14 @@ export function FocusThreadDrawer({ React.useEffect(() => { function handleEscape(event: KeyboardEvent) { if (event.key !== "Escape") return; + const target = event.target; + if ( + hasActiveEdit && + target instanceof Node && + drawerRef.current?.contains(target) + ) { + return; + } event.preventDefault(); event.stopImmediatePropagation(); onClose(); @@ -158,7 +168,7 @@ export function FocusThreadDrawer({ return () => { window.removeEventListener("keydown", handleEscape, { capture: true }); }; - }, [onClose]); + }, [hasActiveEdit, onClose]); React.useLayoutEffect(() => { previousFocusRef.current = diff --git a/desktop/src/features/channels/ui/GuardedChannelPane.tsx b/desktop/src/features/channels/ui/GuardedChannelPane.tsx new file mode 100644 index 00000000000..2e9455a1e54 --- /dev/null +++ b/desktop/src/features/channels/ui/GuardedChannelPane.tsx @@ -0,0 +1,9 @@ +import type * as React from "react"; + +import { ChannelPane } from "./ChannelScreenLazyViews"; + +export function GuardedChannelPane( + props: React.ComponentProps, +) { + return ; +} diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 20c561e7981..8dd22bb94a9 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -39,6 +39,7 @@ type UseChannelAgentSessionsOptions = { openAgentSessionPubkey: string | null; openThreadHeadId: string | null; profilePanelPubkey?: string | null; + requireThreadEditResolution: () => boolean; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; setOpenAgentSessionChannelId: PanelValueSetter; @@ -173,6 +174,7 @@ export function useChannelAgentSessions({ openAgentSessionPubkey, openThreadHeadId, profilePanelPubkey = null, + requireThreadEditResolution, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenAgentSessionChannelId, @@ -209,6 +211,7 @@ export function useChannelAgentSessions({ const openAgentSession = React.useCallback( (pubkey: string, channelId?: string | null) => { + if (!requireThreadEditResolution()) return; if (!isAgentSessionOpen) { returnTarget.capture( resolveAgentSessionReturnTarget({ @@ -234,6 +237,7 @@ export function useChannelAgentSessions({ isAgentSessionOpen, openThreadHeadId, profilePanelPubkey, + requireThreadEditResolution, returnTarget, setChannelManagementOpen, setExpandedThreadReplyIds, diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts index 61e9211480b..1a35666478a 100644 --- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -7,6 +7,7 @@ import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelConte type UseChannelProfilePanelOptions = { closeAgentSession: () => void; openProfilePanel: (pubkey: string, options?: ProfilePanelOpenOptions) => void; + requireThreadEditResolution: () => boolean; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; @@ -18,6 +19,7 @@ type UseChannelProfilePanelOptions = { export function useChannelProfilePanel({ closeAgentSession, openProfilePanel, + requireThreadEditResolution, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -30,6 +32,7 @@ export function useChannelProfilePanel({ const handleOpenProfilePanel = React.useCallback( (pubkey: string, options?: ProfilePanelOpenOptions) => { + if (!requireThreadEditResolution()) return; setOpenThreadHeadId(null); setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); @@ -41,6 +44,7 @@ export function useChannelProfilePanel({ [ closeAgentSession, openProfilePanel, + requireThreadEditResolution, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 0dc4b0e4d6d..39e8a6688d5 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -56,6 +56,7 @@ export function useChannelRouteTarget({ activeChannel, activeChannelId, closeAgentSession, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -68,6 +69,7 @@ export function useChannelRouteTarget({ activeChannel: Channel | null; activeChannelId: string | null; closeAgentSession: () => void; + requireThreadEditResolution: () => boolean; setEditTargetId: React.Dispatch>; setExpandedThreadReplyIds: React.Dispatch>>; setOpenThreadHeadId: PanelValueSetter; @@ -115,13 +117,14 @@ export function useChannelRouteTarget({ } if (!targetMessage.parentId) { + if (!requireThreadEditResolution()) { + return; + } closeAgentSession(); - // Root message links should open the reply panel for that root. The - // timeline scroll/highlight target alone is not enough: root links have - // no parent/thread metadata, so the reply-only branch below cannot infer - // a thread head. setProfilePanelPubkey(null, { replace: true }); setEditTargetId(null); + // Root message links open the reply panel. Navigation is refused before + // this route target is accepted when another composer owns a dirty edit. setOpenThreadHeadId(targetMessage.id, { replace: true }); setThreadReplyTargetId(targetMessage.id); setThreadScrollTargetId(null); @@ -141,6 +144,9 @@ export function useChannelRouteTarget({ if (!routeTarget) { return; } + if (!requireThreadEditResolution()) { + return; + } closeAgentSession(); // Replace so the deep-link entry itself carries the opened thread — @@ -156,6 +162,7 @@ export function useChannelRouteTarget({ activeChannel, activeChannelId, closeAgentSession, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, diff --git a/desktop/src/features/channels/ui/useChannelTargetReset.ts b/desktop/src/features/channels/ui/useChannelTargetReset.ts new file mode 100644 index 00000000000..83e1343db63 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelTargetReset.ts @@ -0,0 +1,30 @@ +import * as React from "react"; + +export function useChannelTargetReset({ + activeChannelId, + setEditTargetId, + setExpandedThreadReplyIds, + setThreadReplyTargetId, + setThreadScrollTargetId, +}: { + activeChannelId: string | null; + setEditTargetId: (id: string | null) => void; + setExpandedThreadReplyIds: (ids: Set) => void; + setThreadReplyTargetId: (id: string | null) => void; + setThreadScrollTargetId: (id: string | null) => void; +}) { + React.useEffect(() => { + // The channel identity is intentionally the reset trigger. + void activeChannelId; + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + setEditTargetId(null); + }, [ + activeChannelId, + setEditTargetId, + setExpandedThreadReplyIds, + setThreadReplyTargetId, + setThreadScrollTargetId, + ]); +} diff --git a/desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs b/desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs new file mode 100644 index 00000000000..f75bc04b706 --- /dev/null +++ b/desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveHuddleOpenThreadHeadId } from "./useHuddleThreadIsolation.ts"; + +test("huddle transcripts synchronously hide URL thread state", () => { + assert.equal( + resolveHuddleOpenThreadHeadId({ + isHuddleTranscript: true, + openThreadHeadId: "url-thread", + optimisticOpenThreadHeadId: undefined, + }), + null, + ); +}); + +test("an optimistic null overrides the URL thread until navigation settles", () => { + assert.equal( + resolveHuddleOpenThreadHeadId({ + isHuddleTranscript: false, + openThreadHeadId: "url-thread", + optimisticOpenThreadHeadId: null, + }), + null, + ); + assert.equal( + resolveHuddleOpenThreadHeadId({ + isHuddleTranscript: false, + openThreadHeadId: "url-thread", + optimisticOpenThreadHeadId: undefined, + }), + "url-thread", + ); +}); diff --git a/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts b/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts index c32c809a887..b794ce87be2 100644 --- a/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts +++ b/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts @@ -5,20 +5,43 @@ type HuddleThreadIsolationOptions = { isHuddleTranscript: boolean; openThreadHeadId: string | null; optimisticOpenThreadHeadId: string | null | undefined; + requireThreadEditResolutionRef: React.RefObject<() => boolean>; }; +export function resolveHuddleOpenThreadHeadId({ + isHuddleTranscript, + openThreadHeadId, + optimisticOpenThreadHeadId, +}: Pick< + HuddleThreadIsolationOptions, + "isHuddleTranscript" | "openThreadHeadId" | "optimisticOpenThreadHeadId" +>): string | null { + if (isHuddleTranscript) return null; + return optimisticOpenThreadHeadId === undefined + ? openThreadHeadId + : optimisticOpenThreadHeadId; +} + export function useHuddleThreadIsolation({ closeThread, isHuddleTranscript, openThreadHeadId, optimisticOpenThreadHeadId, + requireThreadEditResolutionRef, }: HuddleThreadIsolationOptions): string | null { React.useEffect(() => { if (!isHuddleTranscript || openThreadHeadId === null) return; + if (!requireThreadEditResolutionRef.current()) return; closeThread(null); - }, [closeThread, isHuddleTranscript, openThreadHeadId]); - if (isHuddleTranscript) return null; - return optimisticOpenThreadHeadId === undefined - ? openThreadHeadId - : optimisticOpenThreadHeadId; + }, [ + closeThread, + isHuddleTranscript, + openThreadHeadId, + requireThreadEditResolutionRef, + ]); + return resolveHuddleOpenThreadHeadId({ + isHuddleTranscript, + openThreadHeadId, + optimisticOpenThreadHeadId, + }); } diff --git a/desktop/src/features/channels/ui/useNavigationGuard.ts b/desktop/src/features/channels/ui/useNavigationGuard.ts new file mode 100644 index 00000000000..bda71513c07 --- /dev/null +++ b/desktop/src/features/channels/ui/useNavigationGuard.ts @@ -0,0 +1,10 @@ +import * as React from "react"; + +import { registerNavigationGuard } from "@/app/navigation/navigationGuard"; + +export function useNavigationGuard(requireThreadEditResolution: () => boolean) { + React.useLayoutEffect( + () => registerNavigationGuard(() => requireThreadEditResolution()), + [requireThreadEditResolution], + ); +} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index f9c57f6656a..465a0a6b612 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { toast } from "sonner"; import type { useDeleteMessageMutation, @@ -24,6 +25,7 @@ export function useChannelPaneHandlers({ deleteMessageMutation, editMessageMutation, editTargetId, + editTargetIsThreadReply, expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -45,6 +47,7 @@ export function useChannelPaneHandlers({ deleteMessageMutation: ReturnType; editMessageMutation: ReturnType; editTargetId: string | null; + editTargetIsThreadReply: boolean; expandedThreadReplyIds: ReadonlySet; getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; @@ -74,6 +77,8 @@ export function useChannelPaneHandlers({ const editTargetIdRef = React.useRef(editTargetId); editTargetIdRef.current = editTargetId; + const editTargetIsThreadReplyRef = React.useRef(editTargetIsThreadReply); + editTargetIsThreadReplyRef.current = editTargetIsThreadReply; const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; @@ -117,7 +122,16 @@ export function useChannelPaneHandlers({ setThreadReplyTargetId(openThreadHeadIdRef.current); }, [setThreadReplyTargetId]); + const requireThreadEditResolution = React.useCallback(() => { + if (!editTargetIsThreadReplyRef.current) return true; + toast.info("Finish or cancel your edit before leaving the thread."); + return false; + }, []); + const handleCloseThread = React.useCallback(() => { + if (!requireThreadEditResolution()) { + return; + } deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); setOpenThreadHeadId(null); @@ -128,6 +142,7 @@ export function useChannelPaneHandlers({ }, [ deferPanelState, onOptimisticOpenThreadHeadIdChange, + requireThreadEditResolution, setExpandedThreadReplyIds, setOpenThreadHeadId, setThreadReplyTargetId, @@ -135,6 +150,8 @@ export function useChannelPaneHandlers({ ]); const handleCancelEdit = React.useCallback(() => { + editTargetIdRef.current = null; + editTargetIsThreadReplyRef.current = false; setEditTargetId(null); }, [setEditTargetId]); @@ -198,6 +215,7 @@ export function useChannelPaneHandlers({ const handleOpenThread = React.useCallback( (message: { id: string }) => { + if (!requireThreadEditResolution()) return; if (openThreadHeadIdRef.current === message.id) { deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); @@ -222,6 +240,7 @@ export function useChannelPaneHandlers({ [ deferPanelState, onOptimisticOpenThreadHeadIdChange, + requireThreadEditResolution, setEditTargetId, setExpandedThreadReplyIds, setOpenThreadHeadId, @@ -413,6 +432,7 @@ export function useChannelPaneHandlers({ handleEditSave, handleExpandThreadReplies, handleOpenThread, + requireThreadEditResolution, handleSendMessage, handleSendToChannel, handleSendThreadReply, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9192c649521..373ef80f452 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -461,6 +461,7 @@ function InboxMessageDetailPane({ author: editTarget.authorLabel, body: editTarget.content, id: editTarget.id, + isThreadReply: false, imetaMedia: imetaMediaFromTags(editTarget.tags), ...editMentionState, } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index a3c1e7f172b..d28f2926081 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -683,11 +683,17 @@ export function useSendMessageMutation( } const queryKey = channelMessagesKey(effectiveChannel.id); - await queryClient.cancelQueries({ queryKey }); + const windowKey = channelWindowKey(effectiveChannel.id); + // The rendered timeline is projected from the channel-window cache. Cancel + // both reads before snapshotting either cache so an older window response + // cannot replace the optimistic row between onMutate and onSuccess. + await Promise.all([ + queryClient.cancelQueries({ queryKey }), + queryClient.cancelQueries({ queryKey: windowKey }), + ]); const previousMessages = queryClient.getQueryData(queryKey) ?? []; - const windowKey = channelWindowKey(effectiveChannel.id); const previousWindow = queryClient.getQueryData(windowKey); const optimisticMessage = createOptimisticMessage( diff --git a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs index 87ec604464b..ac1ed2d8c3a 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs +++ b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs @@ -69,6 +69,37 @@ test("edit target preserves tagged identities while profiles are unavailable", ( assert.deepEqual(target.unresolvedMentionPubkeys, [ALICE, BOB]); }); +test("edit target records semantic thread ownership", () => { + const root = buildMessageComposerEditTarget( + message("Root", [["h", "channel-id"]]), + undefined, + () => false, + ); + const reply = buildMessageComposerEditTarget( + message("Reply", [ + ["h", "channel-id"], + ["e", "root-id", "", "root"], + ["e", "root-id", "", "reply"], + ]), + undefined, + () => false, + ); + + const broadcastReply = buildMessageComposerEditTarget( + message("Broadcast reply", [ + ["h", "channel-id"], + ["e", "root-id", "", "reply"], + ["broadcast", "1"], + ]), + undefined, + () => false, + ); + + assert.equal(root.isThreadReply, false); + assert.equal(reply.isThreadReply, true); + assert.equal(broadcastReply.isThreadReply, false); +}); + test("edit target separates resolved refs from identities missing profiles", () => { const target = buildMessageComposerEditTarget( message("Please review this, @Alice and @Bob.", [ diff --git a/desktop/src/features/messages/lib/draftMentionRefs.ts b/desktop/src/features/messages/lib/draftMentionRefs.ts index 65c7a68fec9..7aebf86b2f8 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.ts +++ b/desktop/src/features/messages/lib/draftMentionRefs.ts @@ -1,5 +1,6 @@ import { hasMention } from "@/features/messages/lib/hasMention"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { isThreadReply } from "@/features/messages/lib/threading"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { TimelineMessage } from "@/features/messages/types"; import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; @@ -95,6 +96,7 @@ export function buildMessageComposerEditTarget( author: message.author, body: message.body, id: message.id, + isThreadReply: isThreadReply(message.tags ?? []), imetaMedia: imetaMediaFromTags(message.tags), ...mentionState, }; diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 11163aa8c7a..4fcf0f067ab 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -143,7 +143,7 @@ function MoreActionsMenu({ {onEdit ? ( { + onSelect={() => { editJustSelectedRef.current = true; onEdit(message); }} diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index 517b9afb00e..e1bc4098020 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -10,6 +10,7 @@ export type MessageComposerEditTarget = { author: string; body: string; id: string; + isThreadReply: boolean; /** * NIP-92 imeta attachments on the original event, in tag order. Loaded * into the composer's pending-imeta state on edit-open so the user sees diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index ea478aff4ef..b6cad59567a 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -359,6 +359,58 @@ test("failed community clear quarantines stale navigation from the next listener await resetNavigationDeepLinkDrain(); }); +test("refused navigation stays at the FIFO head and retries with one acknowledgement", async () => { + const pending = { + id: "retry-me", + kind: "message", + channelId: "channel-1", + messageId: "message-1", + threadRootId: "root-1", + }; + const queue = [pending]; + const opened = []; + const acknowledged = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", ({ id }) => { + assert.equal(queue[0]?.id, id); + acknowledged.push(id); + queue.shift(); + return true; + }); + + const firstUnlisten = await listenForNavigationDeepLinks( + () => true, + (payload) => { + opened.push(`refused:${payload.messageId}`); + return false; + }, + ); + await settle(); + + assert.deepEqual(opened, ["refused:message-1"]); + assert.equal(queue[0], pending); + assert.deepEqual(acknowledged, []); + firstUnlisten(); + + const secondUnlisten = await listenForNavigationDeepLinks( + () => true, + (payload) => { + opened.push(`accepted:${payload.messageId}`); + return true; + }, + ); + await settle(); + await settle(); + + assert.deepEqual(opened, ["refused:message-1", "accepted:message-1"]); + assert.deepEqual(acknowledged, ["retry-me"]); + assert.equal(queue.length, 0); + secondUnlisten(); +}); + test("rejected navigation remains queued and is not acknowledged", async () => { const pending = { id: "retry-me", diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index adb525ae74e..af3997f8155 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1752,13 +1752,9 @@ function MarkdownInner({ const onOpenEntityLink = useOpenEntityLink(); const onOpenMessageLink = React.useCallback( (link: ParsedMessageLink) => { - // Always route through `goChannel` with `messageId` set: the channel - // route already handles scroll-into-view + highlight via + // Always route through `goChannel` with `messageId` set: the navigation + // boundary guards every message-targeting caller before URL mutation. // `useAnchoredScroll` + `getEventById` backfill, and works for - // both stream-message replies and forum threads. Detecting "the thread - // root is a forum post" up front would require an event lookup we don't - // currently have synchronously; the brief explicitly allows skipping - // that detection and falling through. void goChannel(link.channelId, { messageId: link.messageId, threadRootId: link.threadRootId, diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index fbbe4b9f67a..288b3812919 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -32,11 +32,10 @@ export function useMessageDeepLinks(enabled = true) { }, async (payload) => { if (cancelled) return false; - await goChannel(payload.channelId, { + return goChannel(payload.channelId, { messageId: payload.messageId, threadRootId: payload.threadRootId, }); - return true; }, ); return () => { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 9a93086cf87..d24c2744133 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2970,6 +2970,1225 @@ test("thread composer keeps focus after sending a thread reply", async ({ await expect(threadInput).toBeFocused(); }); +test("editing the thread root uses and focuses the main composer", async ({ + page, +}) => { + const root = `Root edit routing ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + + const timeline = page.getByTestId("message-timeline"); + const timelineRoot = timeline.getByTestId("message-row").last(); + await expect(timelineRoot).toContainText(root); + await timelineRoot.hover(); + await timelineRoot.getByRole("button", { name: "Reply" }).click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + const threadRoot = threadPanel.getByTestId("message-row").first(); + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(page.getByTestId("edit-target")).toHaveCount(1); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("editing a pre-seeded thread reply uses and focuses the thread composer", async ({ + page, +}) => { + const root = `Reply edit routing root ${Date.now()}`; + const reply = `Reply edit routing ${Date.now()}`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { replyId, rootId } = await page.evaluate( + ({ replyContent, rootContent }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const rootEvent = emit({ + channelName: "general", + content: rootContent, + }); + const replyEvent = emit({ + channelName: "general", + content: replyContent, + parentEventId: rootEvent.id, + }); + return { replyId: replyEvent.id, rootId: rootEvent.id }; + }, + { replyContent: reply, rootContent: root }, + ); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const timelineRoot = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${rootId}"]`); + await expect(timelineRoot).toContainText(root); + await timelineRoot.hover(); + await timelineRoot.getByRole("button", { name: "Reply" }).click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const threadReply = threadPanel.locator(`[data-message-id="${replyId}"]`); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(reply); + await expect(threadInput).toBeFocused(); +}); + +test("thread composer switches directly between visible reply edits", async ({ + page, +}) => { + const root = `Thread edit switch root ${Date.now()}`; + const first = `Thread edit switch first ${Date.now()}`; + const second = `Thread edit switch second ${Date.now()}`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { firstId, rootId, secondId } = await page.evaluate( + ({ firstContent, rootContent, secondContent }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const rootEvent = emit({ + channelName: "general", + content: rootContent, + }); + const firstEvent = emit({ + channelName: "general", + content: firstContent, + parentEventId: rootEvent.id, + }); + const secondEvent = emit({ + channelName: "general", + content: secondContent, + parentEventId: rootEvent.id, + }); + return { + firstId: firstEvent.id, + rootId: rootEvent.id, + secondId: secondEvent.id, + }; + }, + { firstContent: first, rootContent: root, secondContent: second }, + ); + + await page.getByTestId("channel-general").click(); + const timelineRoot = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${rootId}"]`); + await timelineRoot.hover(); + await timelineRoot.getByRole("button", { name: "Reply" }).click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const secondReply = threadPanel.locator(`[data-message-id="${secondId}"]`); + await secondReply.hover(); + await secondReply.getByRole("button", { name: "More actions" }).click(); + await page.getByTestId(`edit-message-${secondId}`).click(); + await expect(threadInput).toHaveText(second); + + const firstReply = threadPanel.locator(`[data-message-id="${firstId}"]`); + await firstReply.hover(); + await firstReply.getByRole("button", { name: "More actions" }).click(); + await page.getByTestId(`edit-message-${firstId}`).click(); + + await expect(threadInput).toHaveText(first); + await expect(threadInput).toBeFocused(); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount( + 0, + ); +}); + +test("editing a broadcast reply from a thread returns to the main composer", async ({ + page, +}) => { + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { broadcastId, rootId } = await page.evaluate(() => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const rootEvent = emit({ + channelName: "general", + content: "Broadcast edit root", + }); + const broadcastEvent = emit({ + channelName: "general", + content: "Broadcast reply to edit", + parentEventId: rootEvent.id, + extraTags: [["broadcast", "1"]], + }); + return { broadcastId: broadcastEvent.id, rootId: rootEvent.id }; + }); + + await page.getByTestId("channel-general").click(); + const timelineRoot = page.locator(`[data-message-id="${rootId}"]`); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + const broadcastReply = threadPanel.locator( + `[data-message-id="${broadcastId}"]`, + ); + await expect(broadcastReply).toContainText("Broadcast reply to edit"); + await broadcastReply.hover(); + await broadcastReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await expect(mainInput).toHaveText("Broadcast reply to edit"); + await expect(mainInput).toBeFocused(); +}); + +test("editing a live thread reply uses and focuses the thread composer", async ({ + page, +}) => { + const root = `Live reply edit root ${Date.now()}`; + const reply = `Live reply edit ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = threadPanel.getByTestId("message-row").last(); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(reply); + await expect(threadInput).toBeFocused(); +}); + +test("editing a thread root in single-panel view returns to the main composer", async ({ + page, +}) => { + await page.setViewportSize({ width: 860, height: 720 }); + const root = `Narrow root edit ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(root); + await input.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadRoot = threadPanel.getByTestId("message-row").first(); + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel).toBeHidden(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("editing a thread root in focus mode dismisses the drawer before focusing the main composer", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem("buzz.channels.threadViewMode", "focus"); + }); + const root = `Focus root edit ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const drawer = page.getByTestId("focus-thread-drawer"); + const threadRoot = drawer.getByTestId("message-row").first(); + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(drawer).toBeHidden(); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("focus mode preserves an active reply edit, then Escape makes root editing available", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem("buzz.channels.threadViewMode", "focus"); + }); + const root = `Focus guarded root ${Date.now()}`; + const reply = `Focus guarded reply ${Date.now()}`; + const unsaved = `${reply} unsaved`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const drawer = page.getByTestId("focus-thread-drawer"); + const threadInput = drawer.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = drawer + .getByTestId("message-row") + .filter({ hasText: reply }) + .last(); + await expect(threadReply).toContainText(reply); + const threadReplyId = await threadReply.getAttribute("data-message-id"); + expect(threadReplyId).not.toBeNull(); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page + .locator('[role="menu"]:visible') + .getByTestId(`edit-message-${threadReplyId}`) + .click(); + await expect(page.locator('[role="menu"]:visible')).toHaveCount(0); + await threadInput.fill(unsaved); + + const threadRoot = drawer + .getByTestId("message-thread-head") + .getByTestId("message-row"); + const rootMessageId = await threadRoot.getAttribute("data-message-id"); + expect(rootMessageId).not.toBeNull(); + expect(rootMessageId).not.toBe(threadReplyId); + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page + .locator('[role="menu"]:visible') + .getByTestId(`edit-message-${rootMessageId}`) + .click(); + await expect(page.locator('[role="menu"]:visible')).toHaveCount(0); + await expect(drawer).toBeVisible(); + await expect(threadInput).toHaveText(unsaved); + await expect( + page.getByText("Finish or cancel your edit first."), + ).toBeVisible(); + + // A refused cross-message edit must not remain deferred and appear later. + await page.getByTestId("focus-thread-drawer-scrim").click({ + force: true, + position: { x: 10, y: 360 }, + }); + await expect(drawer).toBeVisible(); + await expect(threadInput).toHaveText(unsaved); + + // Selecting Edit for the active message keeps the existing toggle-to-cancel behavior. + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page + .locator('[role="menu"]:visible') + .getByTestId(`edit-message-${threadReplyId}`) + .click(); + await expect(drawer.getByTestId("edit-target")).toHaveCount(0); + await expect(threadInput).toHaveText(""); + + // Focus-mode Escape reaches the composer before the drawer close handler. + await threadInput.click(); + await page.keyboard.press("ArrowUp"); + await expect(drawer.getByTestId("edit-target")).toBeVisible(); + await threadInput.fill(unsaved); + await page.keyboard.press("Escape"); + await expect(drawer.getByTestId("edit-target")).toHaveCount(0); + await expect(drawer).toBeVisible(); + + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page + .locator('[role="menu"]:visible') + .getByTestId(`edit-message-${rootMessageId}`) + .click(); + await expect(drawer).toBeHidden(); + await expect(mainInput).toHaveText(root); +}); + +test("ArrowUp routes a narrow thread root without consuming into a hidden composer", async ({ + page, +}) => { + await page.setViewportSize({ width: 860, height: 720 }); + const root = `Narrow ArrowUp root ${Date.now()}`; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(root); + await input.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + const threadInput = page + .getByTestId("message-thread-panel") + .getByTestId("message-input"); + await expect(threadInput).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(page.getByTestId("message-thread-panel")).toBeHidden(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("closing a thread while editing a reply preserves the typed edit", async ({ + page, +}) => { + const root = `Close guard root ${Date.now()}`; + const reply = `Close guard reply ${Date.now()}`; + const edited = `${reply} with unsaved text`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = threadPanel.getByTestId("message-row").last(); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(edited); + + await threadPanel.getByTestId("auxiliary-panel-close").click(); + + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(edited); + await expect( + page.getByText("Finish or cancel your edit before leaving the thread."), + ).toBeVisible(); +}); + +test("main ArrowUp ignores closed-thread replies and edits the visible timeline message", async ({ + page, +}) => { + const root = `Main ArrowUp root ${Date.now()}`; + const reply = `Main ArrowUp hidden reply ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + await expect(threadPanel).toContainText(reply); + await threadPanel.getByTestId("auxiliary-panel-close").click(); + await expect(threadPanel).toBeHidden(); + + await mainInput.click(); + await page.keyboard.press("ArrowUp"); + await expect(page.getByTestId("edit-target")).toBeVisible(); + await expect(mainInput).toHaveText(root); + + // No hidden reply edit may block reopening its thread. + await mainInput.press("Escape"); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + await expect(threadPanel).toBeVisible(); +}); + +test("main ArrowUp refuses to replace a dirty thread edit", async ({ + page, +}) => { + const root = `Main ArrowUp refusal root ${Date.now()}`; + const reply = `Main ArrowUp refusal reply ${Date.now()}`; + const unsaved = `${reply} with unsaved text`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = threadPanel.getByTestId("message-row").last(); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(unsaved); + + await mainInput.click(); + await page.keyboard.press("ArrowUp"); + await expect( + page.getByText("Finish or cancel your edit first."), + ).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(unsaved); + await expect(mainInput).toHaveText(""); + + // Refusal must not arm a deferred edit that appears after cancellation. + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await expect(mainInput).toHaveText(""); +}); + +test("main composer switches directly between visible message edits", async ({ + page, +}) => { + const first = `Main edit switch first ${Date.now()}`; + const second = `Main edit switch second ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(first); + await mainInput.press("Enter"); + await expect( + page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: first }), + ).toBeVisible(); + await page.waitForTimeout(1_100); + await mainInput.fill(second); + await mainInput.press("Enter"); + await expect( + page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: second }), + ).toBeVisible(); + + await mainInput.click(); + await page.keyboard.press("ArrowUp"); + await expect(mainInput).toHaveText(second); + + const firstMessage = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: first }) + .last(); + await firstMessage.hover(); + await firstMessage.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(mainInput).toHaveText(first); + await expect(mainInput).toBeFocused(); + await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount( + 0, + ); +}); + +test("a refused message deep link retries after the thread edit is canceled", async ({ + page, +}) => { + const sourceRoot = `Deep link retry source ${Date.now()}`; + const reply = `Deep link retry reply ${Date.now()}`; + const destinationRoot = `Deep link retry destination ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(destinationRoot); + await mainInput.press("Enter"); + const destination = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: destinationRoot }) + .last(); + const destinationId = await destination.getAttribute("data-message-id"); + expect(destinationId).not.toBeNull(); + await mainInput.fill( + `Retry link buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${destinationId}`, + ); + await mainInput.press("Enter"); + const destinationLink = page + .getByTestId("message-row") + .filter({ hasText: "Retry link" }) + .last() + .getByRole("button", { name: "Open message in channel general" }); + await expect(destinationLink).toBeVisible(); + + await mainInput.fill(sourceRoot); + await mainInput.press("Enter"); + const source = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: sourceRoot }) + .last(); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = threadPanel + .getByTestId("message-row") + .filter({ hasText: reply }) + .last(); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(`${reply} unsaved`); + + const threadUrl = page.url(); + expect(threadUrl).toContain( + `thread=${await source.getAttribute("data-message-id")}`, + ); + await destinationLink.click(); + await expect( + page.getByText("Finish or cancel your edit before leaving the thread."), + ).toBeVisible(); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(`${reply} unsaved`); + await expect(page).toHaveURL(threadUrl); + + // The preserved edit remains rendered and cancelable rather than becoming a + // hidden target that soft-locks the route. + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await expect(threadInput).toHaveText(""); + await destinationLink.click(); + await expect(page).not.toHaveURL(threadUrl); + const routedDestination = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${destinationId}"]`); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + destinationRoot, + ); + await expect(routedDestination).toBeVisible(); + await expect(routedDestination).toHaveClass(/route-target-highlight-fade/); +}); + +test("a refused sent-from-thread link preserves the edit and retries after cancel", async ({ + page, +}) => { + const sourceRoot = `Sent-from-thread guard source ${Date.now()}`; + const sourceReply = `Sent-from-thread guard reply ${Date.now()}`; + const destinationRoot = `Sent-from-thread guard destination ${Date.now()}`; + const sharedMessage = `Sent-from-thread guard shared ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { destinationRootId, sourceRootId } = await page.evaluate( + ({ destinationRoot, sharedMessage, sourceReply, sourceRoot }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const destination = emit({ + channelName: "general", + content: destinationRoot, + }); + const source = emit({ channelName: "general", content: sourceRoot }); + emit({ + channelName: "general", + content: sourceReply, + parentEventId: source.id, + }); + emit({ + channelName: "general", + content: sharedMessage, + extraTags: [["buzz:sent-from-thread", destination.id, destinationRoot]], + }); + return { destinationRootId: destination.id, sourceRootId: source.id }; + }, + { destinationRoot, sharedMessage, sourceReply, sourceRoot }, + ); + + await page.getByTestId("channel-general").click(); + const timeline = page.getByTestId("message-timeline"); + const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel + .getByTestId("message-row") + .filter({ hasText: sourceReply }) + .last(); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const threadUrl = page.url(); + expect(threadUrl).toContain(`thread=${sourceRootId}`); + const sentFromThreadLink = timeline + .getByTestId("message-row") + .filter({ hasText: sharedMessage }) + .getByTestId("sent-from-thread") + .locator("[data-message-link]"); + await sentFromThreadLink.click(); + await expect( + page.getByText("Finish or cancel your edit before leaving the thread."), + ).toBeVisible(); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + await expect(page).toHaveURL(threadUrl); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await sentFromThreadLink.click(); + await expect(page).not.toHaveURL(threadUrl); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + destinationRoot, + ); + await expect(page).toHaveURL(new RegExp(`thread=${destinationRootId}`)); +}); + +test("a refused search result preserves the edit and retries after cancel", async ({ + page, +}) => { + const sourceRoot = `Search guard source ${Date.now()}`; + const sourceReply = `Search guard reply ${Date.now()}`; + const destinationRoot = `Search guard destination ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved byte-for-byte`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { destinationRootId, sourceRootId } = await page.evaluate( + ({ destinationRoot, sourceReply, sourceRoot }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const destination = emit({ + channelName: "general", + content: destinationRoot, + }); + const source = emit({ channelName: "general", content: sourceRoot }); + emit({ + channelName: "general", + content: sourceReply, + parentEventId: source.id, + }); + return { destinationRootId: destination.id, sourceRootId: source.id }; + }, + { destinationRoot, sourceReply, sourceRoot }, + ); + + await page.getByTestId("channel-general").click(); + const timeline = page.getByTestId("message-timeline"); + const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel + .getByTestId("message-row") + .filter({ hasText: sourceReply }) + .last(); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const threadUrl = page.url(); + expect(threadUrl).toContain(`thread=${sourceRootId}`); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill(destinationRoot); + const destinationResult = page.getByTestId( + `search-result-${destinationRootId}`, + ); + await expect(destinationResult).toBeVisible(); + await destinationResult.click(); + + const refusal = page.getByText( + "Finish or cancel your edit before leaving the thread.", + ); + await expect(refusal).toHaveCount(1); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + await expect(page).toHaveURL(threadUrl); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill(destinationRoot); + await destinationResult.click(); + await expect(page).not.toHaveURL(threadUrl); + await expect(threadPanel.getByTestId("message-thread-head")).toContainText( + destinationRoot, + ); + await expect(page).toHaveURL(new RegExp(`thread=${destinationRootId}`)); +}); + +test("a refused forum search result preserves the edit and retries after cancel", async ({ + page, +}) => { + const sourceRoot = `Forum guard source ${Date.now()}`; + const sourceReply = `Forum guard reply ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved byte-for-byte`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const sourceRootId = await page.evaluate( + ({ sourceReply, sourceRoot }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const source = emit({ channelName: "general", content: sourceRoot }); + emit({ + channelName: "general", + content: sourceReply, + parentEventId: source.id, + }); + return source.id; + }, + { sourceReply, sourceRoot }, + ); + + await page.getByTestId("channel-general").click(); + const source = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel + .getByTestId("message-row") + .filter({ hasText: sourceReply }) + .last(); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const threadUrl = page.url(); + const editTarget = threadPanel.getByTestId("edit-target"); + await expect(editTarget).toBeVisible(); + await page.getByTestId("open-search").click(); + await page + .getByTestId("search-dialog-input") + .fill("Release checklist: async feedback thread."); + const forumResult = page.getByTestId( + "search-result-mock-forum-release-thread", + ); + await expect(forumResult).toBeVisible(); + await forumResult.click(); + + const refusal = page.getByText( + "Finish or cancel your edit before leaving the thread.", + ); + await expect(refusal).toHaveCount(1); + await expect(threadPanel).toBeVisible(); + await expect(editTarget).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + await expect(page).toHaveURL(threadUrl); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await page.getByTestId("open-search").click(); + await page + .getByTestId("search-dialog-input") + .fill("Release checklist: async feedback thread."); + await forumResult.click(); + await expect(page).toHaveURL( + /#\/channels\/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11\/posts\/mock-forum-release-thread$/, + ); + await expect( + page.locator('[data-forum-event-id="mock-forum-release-thread"]'), + ).toContainText("Release checklist: async feedback thread."); +}); + +for (const targetKind of ["reply", "root"] as const) { + test(`a refused same-thread ${targetKind} target preserves the edit and retries after cancel`, async ({ + page, + }) => { + const sourceRoot = `Same-thread ${targetKind} guard root ${Date.now()}`; + const sourceReply = `Same-thread ${targetKind} guard reply ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { sourceReplyId, sourceRootId } = await page.evaluate( + ({ sourceReply, sourceRoot, targetKind }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const root = emit({ channelName: "general", content: sourceRoot }); + const reply = emit({ + channelName: "general", + content: sourceReply, + parentEventId: root.id, + }); + const targetId = targetKind === "reply" ? reply.id : root.id; + emit({ + channelName: "general", + content: `Same-thread ${targetKind} target buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${targetId}&thread=${root.id}`, + }); + return { sourceReplyId: reply.id, sourceRootId: root.id }; + }, + { sourceReply, sourceRoot, targetKind }, + ); + + await page.getByTestId("channel-general").click(); + const timeline = page.getByTestId("message-timeline"); + const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const targetLink = timeline + .getByTestId("message-row") + .filter({ hasText: `Same-thread ${targetKind} target` }) + .getByRole("button", { name: "Open message in channel general" }); + const navigationBefore = await page.evaluate(() => ({ + historyLength: history.length, + url: location.href, + })); + const sendsBefore = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ); + + await targetLink.click(); + + const refusal = page.getByText( + "Finish or cancel your edit before leaving the thread.", + ); + await expect(refusal).toHaveCount(1); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + expect(await threadInput.textContent()).toBe(dirtyReply); + await expect(page).toHaveURL(navigationBefore.url); + expect(await page.evaluate(() => history.length)).toBe( + navigationBefore.historyLength, + ); + expect( + await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ), + ).toBe(sendsBefore); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await targetLink.click(); + await expect + .poll(() => page.evaluate(() => history.length)) + .toBeGreaterThan(navigationBefore.historyLength); + await expect(threadPanel).toBeVisible(); + await expect( + threadPanel.locator( + `[data-message-id="${targetKind === "reply" ? sourceReplyId : sourceRootId}"]`, + ), + ).toBeVisible(); + }); +} + +test("a refused channel switch preserves the reply edit and retries after cancel", async ({ + page, +}) => { + const sourceRoot = `Channel-switch guard root ${Date.now()}`; + const sourceReply = `Channel-switch guard reply ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { sourceReplyId, sourceRootId } = await page.evaluate( + ({ sourceReply, sourceRoot }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const root = emit({ channelName: "general", content: sourceRoot }); + const reply = emit({ + channelName: "general", + content: sourceReply, + parentEventId: root.id, + }); + return { sourceReplyId: reply.id, sourceRootId: root.id }; + }, + { sourceReply, sourceRoot }, + ); + + await page.getByTestId("channel-general").click(); + const source = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const navigationBefore = await page.evaluate(() => ({ + historyLength: history.length, + url: location.href, + })); + const sendsBefore = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ); + + await page.getByTestId("channel-random").click(); + + await expect( + page.getByText("Finish or cancel your edit before leaving the thread."), + ).toHaveCount(1); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + expect(await threadInput.textContent()).toBe(dirtyReply); + await expect(page).toHaveURL(navigationBefore.url); + expect(await page.evaluate(() => history.length)).toBe( + navigationBefore.historyLength, + ); + expect( + await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ), + ).toBe(sendsBefore); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page).not.toHaveURL(navigationBefore.url); +}); + +for (const backInput of ["button", "keyboard"] as const) { + test(`a refused ${backInput} Back preserves the reply edit and retries after cancel`, async ({ + page, + }) => { + const sourceRoot = `History guard root ${backInput} ${Date.now()}`; + const sourceReply = `History guard reply ${backInput} ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { sourceReplyId, sourceRootId } = await page.evaluate( + ({ sourceReply, sourceRoot }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const root = emit({ channelName: "general", content: sourceRoot }); + const reply = emit({ + channelName: "general", + content: sourceReply, + parentEventId: root.id, + }); + return { sourceReplyId: reply.id, sourceRootId: root.id }; + }, + { sourceReply, sourceRoot }, + ); + + await page.getByTestId("channel-random").click(); + await page.getByTestId("channel-general").click(); + const source = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const navigationBefore = await page.evaluate(() => ({ + historyLength: history.length, + url: location.href, + })); + const sendsBefore = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ); + const invokeBack = async () => { + if (backInput === "button") { + await page.getByTestId("global-back").click(); + return; + } + await page.keyboard.press( + process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft", + ); + }; + + await invokeBack(); + + await expect( + page.getByText("Finish or cancel your edit before leaving the thread."), + ).toHaveCount(1); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + expect(await threadInput.textContent()).toBe(dirtyReply); + await expect(page).toHaveURL(navigationBefore.url); + expect(await page.evaluate(() => history.length)).toBe( + navigationBefore.historyLength, + ); + expect( + await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ), + ).toBe(sendsBefore); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await expect(page.getByTestId("global-back")).toBeEnabled(); + await invokeBack(); + await expect(page).not.toHaveURL(navigationBefore.url); + }); +} + test("ArrowUp in an empty composer edits your last message right after sending", async ({ page, }) => { From f6e6617a9dcc2308d5039f8afaab974b49fb9577 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 24 Aug 2026 16:13:03 -0400 Subject: [PATCH 013/101] fix(desktop): bound thread /query and surface load errors, not false-empty (#6447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long threads in the desktop app sometimes never load (stuck on a skeleton until you close and reopen the panel), and a failed load silently renders as "No replies in this branch yet" — presenting a broken fetch as an authoritative empty thread with no way to recover. This fixes both, the two IMPORTANT findings from the thread-load investigation. ## Defect 1 — unbounded `/query` request The shared `reqwest::Client` in `relay.rs` sets no timeout, and neither `/query` request builder set a per-request `.timeout(...)`. A stalled or half-open connection (headers or body never arrive) leaves the request pending forever, so a thread-history load hangs on the skeleton indefinitely. Fix: a 30s per-request deadline on both `/query` builders, funnelled through one `send_query_request` helper so the timeout can never be applied to one builder and dropped from the other. Scoped per-request rather than client-level because the same client also serves STT/TTS model downloads, builderlab auth, and the media proxy — a client-level timeout would cut those off. The deadline sits above the 25s WS `HISTORY_TIMEOUT_MS` so a slow-but-live relay isn't cut off before the WebSocket path would be. A timeout surfaces through `classify_request_error` as the stable `"relay unreachable: request timed out"` string. `send()` resolves as soon as response headers arrive, so a relay that returns headers and then stalls the body trips the deadline during body consumption, not at `send()` — and that consumption happens on two paths: `parse_json_response` for 2xx, and `relay_error_message` for a non-success status (500/429/…). Both paths route their body-consumption error through one shared `classify_body_timeout` helper so they can't drift: a stalled body surfaces the stable `"relay unreachable: request timed out"` string on either path rather than the malformed-response bucket (2xx) or a bare `"relay returned 500"` status label (non-2xx). A genuinely non-stalled error still keeps its status classification. ## Defect 2 — terminal error painted as empty `ChannelScreen` consumed only `isPending`/`data` from the thread-replies query. Once React Query exhausted its one retry, `isPending` was false and the zero-length data fell through `selectDeferredListRenderState` to the `"empty"` state — indistinguishable from a genuinely empty branch, with no retry affordance. Fix: plumb `isError` + `refetch` through `ChannelScreen` → `ChannelPane` → `MessageThreadPanel`. A pure `selectThreadRepliesSurface` helper decides the paint in strict precedence — the load-bearing invariant is that a terminal error **never** resolves to `"empty"`, and cached replies stay visible non-destructively under a later error (the error card only surfaces when there is nothing to show). The panel renders an explicit "Couldn't load replies" + Retry card (testids `message-thread-replies-error` / `message-thread-replies-retry`). `ProjectConversationPanel` is a second producer of the same shared panel and used to hard-code `threadRepliesPending={false}` with no error/retry, so a failed load in a Projects conversation still painted the false-empty. It now propagates the same `isPending`/`isError`/`refetch` from its `useThreadReplies` query. The multi-root `useThreadRepliesForRoots` hook (the Huddle transcript and Projects-agent conversation surfaces) had the same gap in its `useQueries` `combine`: it returned only `{ events, isPending }`, so a failed reply subtree contributed zero rows and vanished. The combine is now a pure, unit-testable `combineThreadRepliesResults` that exposes aggregate `isError`/`error` plus a `refetch` that re-runs only the failed subtrees. Both multi-root consumers render the shared "Couldn't load replies" + Retry card when a subtree fails: the Projects-agent conversation after its transcript, and the Huddle transcript as a non-destructive banner above the timeline. `useHuddleChannelMessages` used to read only `.events` and discard the aggregate state, so one summarized root failing left the flattened transcript presenting as complete; it now propagates `threadRepliesError`/`onRetryThreadReplies` through `ChannelScreen` into `ChannelPane`, where successful rows stay visible and `onRetry` re-runs only the failed subtrees. The error card carries `role="alert"` so its asynchronous appearance is announced to assistive tech — without a live region a screen-reader user parked in the composer never learns the load failed or that Retry became available. ## Tests - `stalled_query_request_times_out_with_classified_error` — a loopback server that never responds; asserts the stable classified timeout string. - `stalled_response_body_times_out_with_classified_error` — a loopback server that writes valid 2xx JSON headers then stalls the body past the deadline; asserts the classified timeout string, not the malformed bucket. - `stalled_error_response_body_times_out_with_classified_error` — a loopback that writes `500` headers promising a body it never sends; asserts the classified timeout string rather than the `500` status label. - `non_stalled_error_response_yields_status_message` — a promptly-served `500` still surfaces `"relay returned 500 Internal Server Error"`, pinning that timeout preservation is scoped to actual timeouts. - `selectThreadRepliesSurface` — pending→skeleton, terminal error→error (never empty), page-2 failure never empty, cached rows stay visible under error, successful-empty→empty, retry-success→list, streaming→pending, and huddle-transcript collapse. - `MessageThreadReplyState` mounted test — terminal error renders the error card (asserting `role="alert"`), never the empty card. - `combineThreadRepliesResults` — multi-root aggregation/order, a failed subtree surfaces the aggregate error and never drops rows, aggregate pending, refetch re-runs only failed queries, all-success yields no error. - `thread-load-failure.spec.ts` (smoke E2E) — binds the real channel-thread panel wiring: forces a terminal `get_thread_replies` failure at the IPC boundary, asserts the error card renders (never the false-empty) and Retry recovers. - `project-conversation-load-failure.spec.ts` (smoke E2E) — the same guard for the Projects conversation producer, driven through the Projects Channels-tab row. - `huddle-thread-load-failure.spec.ts` (smoke E2E) — the consumer-level guard the combine unit test can't provide: drives the real Huddle wiring (`useHuddleChannelMessages` → `ChannelScreen` → `ChannelPane`) with two summarized roots, fails one subtree's fetch at the IPC boundary, asserts the surviving root's reply stays visible while the retry alert surfaces, then Retry recovers the failed subtree and clears the alert. ## Structure To stay under the desktop file-size ratchet, `relay.rs`'s inline test module moved to `relay/tests.rs`, and two pure pieces were extracted from the panel: the empty/error reply cards (`MessageThreadReplyState`) and the per-row branch-highlight derivation (`selectThreadRowHighlight`). --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/playwright.config.ts | 3 + desktop/src-tauri/src/relay.rs | 501 +++----------- desktop/src-tauri/src/relay/tests.rs | 615 ++++++++++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 12 + .../features/channels/ui/ChannelPane.types.ts | 10 + .../features/channels/ui/ChannelScreen.tsx | 13 +- .../channels/ui/useHuddleChannelMessages.ts | 10 +- .../combineThreadRepliesResults.test.mjs | 104 +++ .../src/features/messages/lib/threadPanel.ts | 56 ++ .../lib/threadReplyHighlight.test.mjs | 86 +++ .../messages/lib/threadReplyHighlight.ts | 45 ++ .../messages/lib/timelineSnapshot.test.mjs | 121 ++++ .../features/messages/lib/timelineSnapshot.ts | 68 ++ .../messages/ui/MessageThreadPanel.tsx | 394 +++++------ .../ui/MessageThreadReplyState.test.mjs | 162 +++++ .../messages/ui/MessageThreadReplyState.tsx | 127 ++++ .../src/features/messages/useThreadReplies.ts | 38 +- .../projects/ui/ProjectConversationPanel.tsx | 6 +- .../projects/ui/ProjectsAgentPromptPage.tsx | 4 + .../e2e/huddle-thread-load-failure.spec.ts | 182 ++++++ .../project-conversation-load-failure.spec.ts | 192 ++++++ desktop/tests/e2e/thread-load-failure.spec.ts | 161 +++++ 22 files changed, 2273 insertions(+), 637 deletions(-) create mode 100644 desktop/src-tauri/src/relay/tests.rs create mode 100644 desktop/src/features/messages/combineThreadRepliesResults.test.mjs create mode 100644 desktop/src/features/messages/lib/threadReplyHighlight.test.mjs create mode 100644 desktop/src/features/messages/lib/threadReplyHighlight.ts create mode 100644 desktop/src/features/messages/ui/MessageThreadReplyState.test.mjs create mode 100644 desktop/src/features/messages/ui/MessageThreadReplyState.tsx create mode 100644 desktop/tests/e2e/huddle-thread-load-failure.spec.ts create mode 100644 desktop/tests/e2e/project-conversation-load-failure.spec.ts create mode 100644 desktop/tests/e2e/thread-load-failure.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 9099beff69e..69250c4b537 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -86,6 +86,9 @@ export default defineConfig({ "**/home-collapsed-top-chrome.spec.ts", "**/top-chrome-zoom-clearance.spec.ts", "**/thread-unread.spec.ts", + "**/thread-load-failure.spec.ts", + "**/project-conversation-load-failure.spec.ts", + "**/huddle-thread-load-failure.spec.ts", "**/workspace-rail.spec.ts", "**/community-rail.spec.ts", "**/boot-splash.spec.ts", diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index bd3fefb1259..f408ef2afda 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -16,6 +16,19 @@ const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000"; // classifier keys on. Extracted to a const so a test can pin that contract. const MALFORMED_RESPONSE_MESSAGE: &str = "relay returned malformed response: not valid JSON"; +// Per-request deadline for the `POST /query` HTTP bridge, covering both the +// header exchange and full body consumption. The shared `http_client` sets no +// client-level timeout — deliberately, because it is also used for long-running +// STT/TTS model downloads, builderlab auth, and the media proxy — so a stalled +// or half-open `/query` connection would otherwise leave the request pending +// forever, hanging the caller (e.g. a thread-history load that never resolves +// and shows a permanent skeleton). A per-request timeout scoped to `/query` +// bounds that without affecting the client's other users. A timeout surfaces +// through `classify_request_error` as the stable `"relay unreachable: request +// timed out"` string. Set above the 25s WS history timeout so a slow-but-live +// relay is not cut off before the WebSocket path would be. +const QUERY_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + fn configured_env_var(name: &str) -> Option { std::env::var(name) .ok() @@ -167,6 +180,22 @@ pub(crate) fn classify_request_error(e: &reqwest::Error) -> String { } } +/// Preserve a body-consumption timeout as the stable connectivity classification. +/// +/// `send()` resolves once response headers arrive, so a body that stalls past +/// the request deadline trips the timeout during body consumption rather than +/// at `send()`. That is a connectivity failure, not a malformed body or a plain +/// status error. Both body-consumption paths — the 2xx `parse_json_response` +/// and the non-2xx `relay_error_message` — route their consumption error +/// through this one helper so a stalled body can never be classified as +/// "request timed out" on one path while the other buries it under a malformed +/// or status label. Returns `Some("relay unreachable: request timed out")` for +/// a timeout; `None` otherwise, leaving the caller to apply its own non-timeout +/// label. +fn classify_body_timeout(e: &reqwest::Error) -> Option { + e.is_timeout().then(|| classify_request_error(e)) +} + /// Detect responses that were intercepted by a captive portal or auth proxy. /// /// Returns `Some(msg)` when the response clearly did not come from the relay: @@ -230,10 +259,16 @@ pub(crate) async fn parse_json_response( // "relay unreachable:" bucket so it surfaces loudly instead of being treated // as a transient unreachable-relay condition. The reqwest error detail is // dropped because it contains the raw URL. - response - .json::() - .await - .map_err(|_| MALFORMED_RESPONSE_MESSAGE.to_string()) + // + // A body-consumption timeout is the exception: `send()` resolves once + // headers arrive, so a body that stalls past the request deadline trips the + // timeout HERE rather than at send(). That is a connectivity failure, not a + // malformed body, so route it through `classify_body_timeout` — the same + // helper the non-2xx error-body path uses — to preserve the stable + // "relay unreachable: request timed out" label. + response.json::().await.map_err(|e| { + classify_body_timeout(&e).unwrap_or_else(|| MALFORMED_RESPONSE_MESSAGE.to_string()) + }) } /// Extract the `retry in Ns` hint from a rate-limit error string. @@ -264,7 +299,21 @@ pub async fn relay_error_message(response: reqwest::Response) -> String { } // Real relay error: extract the structured message field if available. - let body = response.text().await.unwrap_or_default(); + // `text()` consumes the body, which — like the 2xx path — can trip the + // request deadline if the relay sends status headers then stalls the body. + // Preserve that timeout as the stable connectivity classification via the + // shared helper instead of letting `unwrap_or_default` swallow it into a + // bare status label. A non-timeout body error still degrades to an empty + // body → status-only message, exactly as before. + let body = match response.text().await { + Ok(body) => body, + Err(e) => { + if let Some(timeout) = classify_body_timeout(&e) { + return timeout; + } + String::new() + } + }; // 429 Too Many Requests → typed `relay rate-limited:` prefix so the TS // client can activate the rate-limit gate without confusing it with a @@ -328,22 +377,15 @@ pub async fn query_relay_at( let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; - - let response = state - .http_client - .post(&url) - .header("Authorization", auth) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; - - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - parse_json_response(response).await + send_query_request( + &state.http_client, + &url, + &auth, + None, + body_bytes, + QUERY_REQUEST_TIMEOUT, + ) + .await } pub async fn query_relay_at_with_keys( @@ -358,11 +400,38 @@ pub async fn query_relay_at_with_keys( let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let mut request = state - .http_client - .post(&url) + send_query_request( + &state.http_client, + &url, + &auth, + auth_tag, + body_bytes, + QUERY_REQUEST_TIMEOUT, + ) + .await +} + +/// Issue an authenticated `POST /query` and parse the response, applying the +/// per-request `timeout` that bounds a stalled or half-open relay connection. +/// +/// Both `/query` builders funnel through this one helper so the timeout can +/// never be applied to one builder and dropped from the other, and so a test +/// can drive the real send/timeout/classify path with a short deadline against +/// a stalled loopback. A timeout surfaces through `classify_request_error` as +/// the stable `"relay unreachable: request timed out"` string. +async fn send_query_request( + http_client: &reqwest::Client, + url: &str, + auth: &str, + auth_tag: Option<&str>, + body_bytes: Vec, + timeout: std::time::Duration, +) -> Result, String> { + let mut request = http_client + .post(url) .header("Authorization", auth) - .header("Content-Type", "application/json"); + .header("Content-Type", "application/json") + .timeout(timeout); if let Some(tag) = auth_tag { request = request.header("x-auth-tag", tag); } @@ -611,384 +680,4 @@ pub async fn submit_signed_event_with_keys( // ── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::{ - build_profile_event, classify_intercepted_response, effective_agent_relay_url, - extract_retry_in_hint, parse_command_response, relay_http_base_url, - MALFORMED_RESPONSE_MESSAGE, - }; - use serde::Deserialize; - - // ── extract_retry_in_hint ──────────────────────────────────────────────── - - #[test] - fn extracts_hint_from_429_body() { - assert_eq!( - extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#), - Some(4) - ); - } - - #[test] - fn extracts_hint_when_no_json_wrapper() { - assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30)); - } - - #[test] - fn returns_none_when_no_hint_present() { - assert_eq!( - extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#), - None - ); - assert_eq!(extract_retry_in_hint(""), None); - } - - #[test] - fn overlong_digit_string_returns_none() { - // A digit sequence that exceeds u64::MAX cannot be parsed; the function - // must return None (→ caller uses the default) rather than panicking. - assert_eq!( - extract_retry_in_hint("retry in 99999999999999999999999s"), - None - ); - } - - // ── relay_error_message: hint capping ──────────────────────────────────── - // - // Verify that an oversized relay hint is capped in the returned message - // string, not just inside `activate_rate_limit()`. This guarantees every - // consumer — including the TS gate via `applyTauriRateLimitIfNeeded` — - // receives the capped value rather than the raw untrusted relay value. - - #[tokio::test] - async fn oversized_hint_is_capped_in_relay_error_message_string() { - use crate::relay_admission::{reset_rate_limit_gate, MAX_HINT_SECONDS, TEST_SERIAL}; - use std::io::{Read as _, Write as _}; - - let _serial = TEST_SERIAL.lock().await; - reset_rate_limit_gate(); - - // Use a std::net listener on a std::thread — the same pattern as the - // relay_admission loopback tests. This avoids two races that cause CI - // failures with tokio::net + into_std(): - // 1. No request read: the client is still sending when the response - // arrives → hyper `UnexpectedMessage`/`Canceled` under load. - // 2. into_std() leaves the socket in nonblocking mode → write_all - // may return WouldBlock and silently drop the response. - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - - // Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300). - let oversized = 1_000_000u64; - let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#); - let body_len = body.len(); - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - // Read the request first so the client finishes sending before - // we write the response — mirrors relay_admission.rs pattern. - let mut buf = [0u8; 4096]; - let _ = stream.read(&mut buf); - let response = format!( - "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}" - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - } - }); - - let client = reqwest::Client::new(); - let response = client - .get(format!("http://{addr}/")) - .send() - .await - .expect("request must succeed"); - - let msg = super::relay_error_message(response).await; - - // The message must embed the CAPPED hint, not the raw 1 000 000. - assert_eq!( - msg, - format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"), - "relay_error_message must embed the capped hint, not the raw untrusted value" - ); - assert!( - !msg.contains(&oversized.to_string()), - "raw oversized hint must not appear in the message string" - ); - reset_rate_limit_gate(); - } - - // ── effective_agent_relay_url: legacy pin ignored ───────────────────────── - - #[test] - fn stored_relay_pin_is_ignored() { - // Zero-touch cutover (#2122): a creation-era per-record relay pin is - // parsed and persisted but never consulted — the workspace relay wins. - assert_eq!( - effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), - "wss://staging.example.com" - ); - } - - #[test] - fn empty_relay_resolves_to_workspace() { - // A never-set record resolves to the active workspace relay at read-time, - // so a stale stored default can never make it load-bearing. - assert_eq!( - effective_agent_relay_url("", "wss://staging.example.com"), - "wss://staging.example.com" - ); - } - - #[test] - fn whitespace_only_relay_resolves_to_workspace() { - // Whitespace-only behaves identically — no value survives. - assert_eq!( - effective_agent_relay_url(" ", "wss://staging.example.com"), - "wss://staging.example.com" - ); - } - - // ── relay_http_base_url scheme conversion ──────────────────────────────── - - #[test] - fn loopback_ws_localhost_preserves_authority() { - // Tenant host-binding keys off the HTTP Host/authority. The desktop must - // not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a - // different unmapped community than the WebSocket URL. - assert_eq!( - relay_http_base_url("ws://localhost:3000"), - "http://localhost:3000" - ); - } - - #[test] - fn loopback_trailing_slash_removed_authority_preserved() { - assert_eq!( - relay_http_base_url("ws://localhost:3000/"), - "http://localhost:3000" - ); - } - - #[test] - fn remote_wss_host_unchanged() { - assert_eq!( - relay_http_base_url("wss://relay.example.com"), - "https://relay.example.com" - ); - } - - #[test] - fn loopback_ipv4_literal_unchanged() { - assert_eq!( - relay_http_base_url("ws://127.0.0.1:3000"), - "http://127.0.0.1:3000" - ); - } - - #[test] - fn localhost_substring_host_unchanged() { - assert_eq!( - relay_http_base_url("ws://localhost.evil.com:3000"), - "http://localhost.evil.com:3000" - ); - } - - #[test] - fn loopback_wss_localhost_preserves_authority() { - assert_eq!( - relay_http_base_url("wss://localhost:3000"), - "https://localhost:3000" - ); - } - - // ── classify_intercepted_response ──────────────────────────────────────── - - #[test] - fn intercepted_cloudflare_host_returns_some() { - let result = classify_intercepted_response("sqprod.cloudflareaccess.com", "text/html"); - assert!(result.is_some()); - let msg = result.unwrap(); - assert!( - msg.starts_with("relay unreachable:"), - "should have unreachable prefix" - ); - assert!(msg.contains("Cloudflare"), "should mention Cloudflare"); - } - - #[test] - fn intercepted_cloudflare_apex_host_returns_some() { - // The apex domain itself should also match. - let result = classify_intercepted_response("cloudflareaccess.com", "application/json"); - assert!(result.is_some()); - let msg = result.unwrap(); - assert!(msg.starts_with("relay unreachable:")); - assert!(msg.contains("Cloudflare")); - } - - #[test] - fn intercepted_non_cloudflare_html_returns_some() { - let result = - classify_intercepted_response("proxy.corporate.example", "text/html; charset=utf-8"); - assert!(result.is_some()); - let msg = result.unwrap(); - assert!(msg.starts_with("relay unreachable:")); - } - - #[test] - fn normal_relay_json_returns_none() { - let result = classify_intercepted_response("relay.myapp.example.com", "application/json"); - assert!(result.is_none()); - } - - #[test] - fn content_type_case_insensitive() { - // Uppercase content-type must still be detected. - let result = classify_intercepted_response("proxy.example.com", "TEXT/HTML"); - assert!(result.is_some()); - assert!(result.unwrap().starts_with("relay unreachable:")); - } - - #[test] - fn evil_suffix_does_not_match_cloudflare() { - // A host whose suffix happens to contain the Cloudflare string but is - // not actually a subdomain must NOT match. - let result = classify_intercepted_response( - "notcloudflareaccess.com.evil.example", - "application/json", - ); - assert!( - result.is_none(), - "false suffix match should not trigger Cloudflare branch" - ); - } - - // classify_request_error requires a real reqwest::Error (not publicly - // constructable) — tested indirectly through integration; skipped here. - - // ── parse_json_response malformed-body contract ────────────────────────── - - #[test] - fn malformed_response_message_stays_off_unreachable_bucket() { - // A reached-but-malformed 2xx body is not a connectivity failure. If this - // message ever regains the "relay unreachable:" prefix, the frontend - // classifier would misroute it as unreachable — pin that it never does. - assert!( - !MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"), - "malformed-response message must not match the unreachable prefix" - ); - } - - // ── parse_command_response ─────────────────────────────────────────────── - - #[derive(Debug, Deserialize, PartialEq)] - struct ChannelCreated { - channel_id: String, - } - - #[test] - fn parse_command_response_decodes_typed_payload() { - let msg = r#"response:{"channel_id":"abc123"}"#; - let parsed: ChannelCreated = parse_command_response(msg).expect("should parse"); - assert_eq!( - parsed, - ChannelCreated { - channel_id: "abc123".to_string() - } - ); - } - - #[test] - fn parse_command_response_accepts_raw_json_fallback() { - // Backward-compat: relays that emit raw JSON (no prefix) still work. - let msg = r#"{"channel_id":"abc"}"#; - let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse"); - assert_eq!( - parsed, - ChannelCreated { - channel_id: "abc".to_string() - } - ); - } - - #[test] - fn parse_command_response_rejects_invalid_prefixed_json() { - let msg = "response:not-json"; - let result: Result = parse_command_response(msg); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("response parse failed")); - } - - #[test] - fn parse_command_response_rejects_garbage() { - let msg = "totally not json or response"; - let result: Result = parse_command_response(msg); - assert!(result.is_err()); - } - - // ── build_profile_event ────────────────────────────────────────────────── - - /// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key - /// and addressed to `agent_keys`. - /// - /// Uses `nostr_compat` (nostr 0.36) for the owner keys because - /// `buzz_sdk_pkg::nip_oa::compute_auth_tag` expects nostr 0.36 types. - /// The agent pubkey is bridged via hex encoding. - fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { - let owner_keys = nostr::Keys::generate(); - let agent_pubkey_hex = agent_keys.public_key().to_hex(); - let agent_compat_pubkey = - nostr::PublicKey::from_hex(&agent_pubkey_hex).expect("valid hex pubkey should parse"); - buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "") - .expect("compute_auth_tag should not fail with distinct keys") - } - - #[test] - fn profile_event_with_valid_auth_tag() { - let agent_keys = nostr::Keys::generate(); - let tag_json = make_valid_auth_tag(&agent_keys); - let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) - .expect("should succeed with a valid auth tag"); - - // Exactly one "auth" tag must be present. - let auth_tags: Vec<_> = event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) - .collect(); - assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag"); - - // Must be a kind:0 (Metadata) event. - assert_eq!(event.kind, nostr::Kind::Metadata); - } - - #[test] - fn profile_event_without_auth_tag() { - let agent_keys = nostr::Keys::generate(); - let event = build_profile_event(&agent_keys, "TestBot", None, None) - .expect("should succeed without an auth tag"); - - // No "auth" tags should be present. - let auth_tags: Vec<_> = event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) - .collect(); - assert_eq!(auth_tags.len(), 0, "expected no auth tags"); - - assert_eq!(event.kind, nostr::Kind::Metadata); - } - - #[test] - fn profile_event_rejects_invalid_auth_tag() { - let agent_keys = nostr::Keys::generate(); - // Structurally valid JSON array but with a bogus signature — verification must fail. - let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); - let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); - assert!(result.is_err(), "should reject an invalid auth tag"); - assert!( - result.unwrap_err().contains("verification failed"), - "error message should mention verification failure" - ); - } -} +mod tests; diff --git a/desktop/src-tauri/src/relay/tests.rs b/desktop/src-tauri/src/relay/tests.rs new file mode 100644 index 00000000000..4ae39249328 --- /dev/null +++ b/desktop/src-tauri/src/relay/tests.rs @@ -0,0 +1,615 @@ +//! Unit tests for the relay HTTP/command bridge helpers. +//! Extracted from `relay.rs` to keep that module under the file-size ratchet. + +use super::{ + build_profile_event, classify_intercepted_response, effective_agent_relay_url, + extract_retry_in_hint, parse_command_response, relay_http_base_url, MALFORMED_RESPONSE_MESSAGE, +}; +use serde::Deserialize; + +// ── extract_retry_in_hint ──────────────────────────────────────────────── + +#[test] +fn extracts_hint_from_429_body() { + assert_eq!( + extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded; retry in 4s"}"#), + Some(4) + ); +} + +#[test] +fn extracts_hint_when_no_json_wrapper() { + assert_eq!(extract_retry_in_hint("retry in 30s"), Some(30)); +} + +#[test] +fn returns_none_when_no_hint_present() { + assert_eq!( + extract_retry_in_hint(r#"{"error":"rate-limited: quota exceeded"}"#), + None + ); + assert_eq!(extract_retry_in_hint(""), None); +} + +#[test] +fn overlong_digit_string_returns_none() { + // A digit sequence that exceeds u64::MAX cannot be parsed; the function + // must return None (→ caller uses the default) rather than panicking. + assert_eq!( + extract_retry_in_hint("retry in 99999999999999999999999s"), + None + ); +} + +// ── relay_error_message: hint capping ──────────────────────────────────── +// +// Verify that an oversized relay hint is capped in the returned message +// string, not just inside `activate_rate_limit()`. This guarantees every +// consumer — including the TS gate via `applyTauriRateLimitIfNeeded` — +// receives the capped value rather than the raw untrusted relay value. + +#[tokio::test] +async fn oversized_hint_is_capped_in_relay_error_message_string() { + use crate::relay_admission::{reset_rate_limit_gate, MAX_HINT_SECONDS, TEST_SERIAL}; + use std::io::{Read as _, Write as _}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + // Use a std::net listener on a std::thread — the same pattern as the + // relay_admission loopback tests. This avoids two races that cause CI + // failures with tokio::net + into_std(): + // 1. No request read: the client is still sending when the response + // arrives → hyper `UnexpectedMessage`/`Canceled` under load. + // 2. into_std() leaves the socket in nonblocking mode → write_all + // may return WouldBlock and silently drop the response. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + + // Serve a 429 with a hint far exceeding MAX_HINT_SECONDS (300). + let oversized = 1_000_000u64; + let body = format!(r#"{{"error":"rate-limited: quota exceeded; retry in {oversized}s"}}"#); + let body_len = body.len(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Read the request first so the client finishes sending before + // we write the response — mirrors relay_admission.rs pattern. + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let response = format!( + "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n{body}" + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + let client = reqwest::Client::new(); + let response = client + .get(format!("http://{addr}/")) + .send() + .await + .expect("request must succeed"); + + let msg = super::relay_error_message(response).await; + + // The message must embed the CAPPED hint, not the raw 1 000 000. + assert_eq!( + msg, + format!("relay rate-limited: retry in {MAX_HINT_SECONDS}s"), + "relay_error_message must embed the capped hint, not the raw untrusted value" + ); + assert!( + !msg.contains(&oversized.to_string()), + "raw oversized hint must not appear in the message string" + ); + reset_rate_limit_gate(); +} + +// ── effective_agent_relay_url: legacy pin ignored ───────────────────────── + +#[test] +fn stored_relay_pin_is_ignored() { + // Zero-touch cutover (#2122): a creation-era per-record relay pin is + // parsed and persisted but never consulted — the workspace relay wins. + assert_eq!( + effective_agent_relay_url("wss://relay.other.com", "wss://staging.example.com"), + "wss://staging.example.com" + ); +} + +#[test] +fn empty_relay_resolves_to_workspace() { + // A never-set record resolves to the active workspace relay at read-time, + // so a stale stored default can never make it load-bearing. + assert_eq!( + effective_agent_relay_url("", "wss://staging.example.com"), + "wss://staging.example.com" + ); +} + +#[test] +fn whitespace_only_relay_resolves_to_workspace() { + // Whitespace-only behaves identically — no value survives. + assert_eq!( + effective_agent_relay_url(" ", "wss://staging.example.com"), + "wss://staging.example.com" + ); +} + +// ── relay_http_base_url scheme conversion ──────────────────────────────── + +#[test] +fn loopback_ws_localhost_preserves_authority() { + // Tenant host-binding keys off the HTTP Host/authority. The desktop must + // not rewrite localhost to 127.0.0.1, or local dev HTTP calls target a + // different unmapped community than the WebSocket URL. + assert_eq!( + relay_http_base_url("ws://localhost:3000"), + "http://localhost:3000" + ); +} + +#[test] +fn loopback_trailing_slash_removed_authority_preserved() { + assert_eq!( + relay_http_base_url("ws://localhost:3000/"), + "http://localhost:3000" + ); +} + +#[test] +fn remote_wss_host_unchanged() { + assert_eq!( + relay_http_base_url("wss://relay.example.com"), + "https://relay.example.com" + ); +} + +#[test] +fn loopback_ipv4_literal_unchanged() { + assert_eq!( + relay_http_base_url("ws://127.0.0.1:3000"), + "http://127.0.0.1:3000" + ); +} + +#[test] +fn localhost_substring_host_unchanged() { + assert_eq!( + relay_http_base_url("ws://localhost.evil.com:3000"), + "http://localhost.evil.com:3000" + ); +} + +#[test] +fn loopback_wss_localhost_preserves_authority() { + assert_eq!( + relay_http_base_url("wss://localhost:3000"), + "https://localhost:3000" + ); +} + +// ── classify_intercepted_response ──────────────────────────────────────── + +#[test] +fn intercepted_cloudflare_host_returns_some() { + let result = classify_intercepted_response("sqprod.cloudflareaccess.com", "text/html"); + assert!(result.is_some()); + let msg = result.unwrap(); + assert!( + msg.starts_with("relay unreachable:"), + "should have unreachable prefix" + ); + assert!(msg.contains("Cloudflare"), "should mention Cloudflare"); +} + +#[test] +fn intercepted_cloudflare_apex_host_returns_some() { + // The apex domain itself should also match. + let result = classify_intercepted_response("cloudflareaccess.com", "application/json"); + assert!(result.is_some()); + let msg = result.unwrap(); + assert!(msg.starts_with("relay unreachable:")); + assert!(msg.contains("Cloudflare")); +} + +#[test] +fn intercepted_non_cloudflare_html_returns_some() { + let result = + classify_intercepted_response("proxy.corporate.example", "text/html; charset=utf-8"); + assert!(result.is_some()); + let msg = result.unwrap(); + assert!(msg.starts_with("relay unreachable:")); +} + +#[test] +fn normal_relay_json_returns_none() { + let result = classify_intercepted_response("relay.myapp.example.com", "application/json"); + assert!(result.is_none()); +} + +#[test] +fn content_type_case_insensitive() { + // Uppercase content-type must still be detected. + let result = classify_intercepted_response("proxy.example.com", "TEXT/HTML"); + assert!(result.is_some()); + assert!(result.unwrap().starts_with("relay unreachable:")); +} + +#[test] +fn evil_suffix_does_not_match_cloudflare() { + // A host whose suffix happens to contain the Cloudflare string but is + // not actually a subdomain must NOT match. + let result = + classify_intercepted_response("notcloudflareaccess.com.evil.example", "application/json"); + assert!( + result.is_none(), + "false suffix match should not trigger Cloudflare branch" + ); +} + +// classify_request_error requires a real reqwest::Error (not publicly +// constructable) — tested indirectly through integration; skipped here. + +// ── /query per-request timeout → classified error ──────────────────────── +// +// A stalled `/query` connection (headers never arrive) must not hang the +// caller forever. Both production `/query` builders funnel through +// `send_query_request`, which owns the per-request `.timeout(...)`; this test +// drives that exact helper against a loopback server that accepts the +// connection but never responds. It asserts two things the frontend depends +// on: (1) the helper returns instead of hanging, and (2) the failure is the +// stable `"relay unreachable: request timed out"` classified string. +// +// The outer `tokio::time::timeout` is the regression guard: if the production +// `.timeout(...)` is ever removed from `send_query_request`, this call would +// hang forever, so the guard fires and the test fails fast rather than +// stalling CI. A short 200ms deadline keeps the happy path fast. +#[tokio::test] +async fn stalled_query_request_times_out_with_classified_error() { + use std::io::Read as _; + use std::time::Duration; + + // A listener that accepts the connection and then holds it open without + // ever writing a response — the "headers never arrive" stall. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Drain the request but deliberately never respond, then hold + // the socket until the client aborts on its own timeout. + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + std::thread::sleep(Duration::from_secs(2)); + } + }); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/query"); + let result = tokio::time::timeout( + Duration::from_secs(5), + super::send_query_request( + &client, + &url, + "Nostr test-auth", + None, + b"[]".to_vec(), + Duration::from_millis(200), + ), + ) + .await + .expect( + "send_query_request must honor its per-request timeout and resolve within 5s; \ + if this guard fires, the production .timeout(...) was lost", + ); + + let err = result.expect_err("a stalled /query must surface an error, not succeed"); + assert_eq!( + err, "relay unreachable: request timed out", + "a timed-out /query must surface the stable classified string" + ); + + let _ = handle.join(); +} + +// ── /query body-stall timeout → classified error (not malformed) ───────── +// +// `send()` resolves once response headers arrive, so a relay that returns a +// valid 2xx JSON header block and then stalls the body trips the request +// deadline inside `response.json()` — the branch the pre-header stall above +// cannot reach. That is a connectivity failure, not a malformed body, so it +// must surface the stable "relay unreachable: request timed out" string rather +// than the malformed-response bucket. This drives `send_query_request` against +// a loopback that writes headers promising a body it never sends. +#[tokio::test] +async fn stalled_response_body_times_out_with_classified_error() { + use std::io::{Read as _, Write as _}; + use std::time::Duration; + + // Accept, drain the request, write a complete 2xx JSON header block that + // promises a body (Content-Length), then send nothing and hold the socket + // — the "headers arrive, body stalls" half-open case. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 64\r\n\r\n", + ); + let _ = stream.flush(); + // Never write the promised body; hold past the client deadline. + std::thread::sleep(Duration::from_secs(2)); + } + }); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/query"); + let result = tokio::time::timeout( + Duration::from_secs(5), + super::send_query_request( + &client, + &url, + "Nostr test-auth", + None, + b"[]".to_vec(), + Duration::from_millis(200), + ), + ) + .await + .expect( + "send_query_request must honor its per-request timeout through body \ + consumption and resolve within 5s", + ); + + let err = result.expect_err("a stalled response body must surface an error, not succeed"); + assert_eq!( + err, "relay unreachable: request timed out", + "a body-stall timeout must surface the classified timeout string, not the \ + malformed-response bucket" + ); + + let _ = handle.join(); +} + +// ── /query non-2xx body-stall timeout → classified error (not status) ──── +// +// The 2xx path is not the only body-consuming path. A relay that returns a +// non-success status (500, 429, …) routes through `relay_error_message`, which +// consumes the body via `text()` to extract the structured error field. If the +// relay sends the status headers and then stalls the promised body, that +// consumption trips the same request deadline — and it must surface the stable +// "relay unreachable: request timed out" classification, not a bare +// "relay returned 500" that hides the connectivity failure. This drives +// `send_query_request` against a loopback that writes 500 headers promising a +// body it never sends. +#[tokio::test] +async fn stalled_error_response_body_times_out_with_classified_error() { + use std::io::{Read as _, Write as _}; + use std::time::Duration; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + // 500 status headers promising a body (Content-Length) that never + // arrives — the "error headers arrive, body stalls" half-open case. + let _ = stream.write_all( + b"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: 64\r\n\r\n", + ); + let _ = stream.flush(); + std::thread::sleep(Duration::from_secs(2)); + } + }); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/query"); + let result = tokio::time::timeout( + Duration::from_secs(5), + super::send_query_request( + &client, + &url, + "Nostr test-auth", + None, + b"[]".to_vec(), + Duration::from_millis(200), + ), + ) + .await + .expect( + "send_query_request must honor its per-request timeout through error-body \ + consumption and resolve within 5s", + ); + + let err = result.expect_err("a stalled error-response body must surface an error, not succeed"); + assert_eq!( + err, "relay unreachable: request timed out", + "a non-2xx body-stall timeout must surface the classified timeout string, not the \ + status bucket" + ); + + let _ = handle.join(); +} + +// ── /query non-stalled 500 → status message (timeout preservation is scoped) ─ +// +// The timeout preservation above must not swallow genuine relay errors: a 500 +// whose body arrives promptly still surfaces as "relay returned 500". This +// pins that `classify_body_timeout` only fires on an actual timeout, so the +// error-classification path stays intact for live relay failures. +#[tokio::test] +async fn non_stalled_error_response_yields_status_message() { + use std::io::{Read as _, Write as _}; + use std::time::Duration; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + // A complete 500 with a non-JSON body delivered immediately. + let body = "internal error"; + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/query"); + let result = tokio::time::timeout( + Duration::from_secs(5), + super::send_query_request( + &client, + &url, + "Nostr test-auth", + None, + b"[]".to_vec(), + Duration::from_millis(200), + ), + ) + .await + .expect("a promptly-served 500 must resolve well within 5s"); + + let err = result.expect_err("a 500 must surface an error, not succeed"); + assert_eq!( + err, "relay returned 500 Internal Server Error", + "a non-stalled 500 must keep its status classification, not be reclassified as a timeout" + ); + + let _ = handle.join(); +} + +// ── parse_json_response malformed-body contract ────────────────────────── + +#[test] +fn malformed_response_message_stays_off_unreachable_bucket() { + // A reached-but-malformed 2xx body is not a connectivity failure. If this + // message ever regains the "relay unreachable:" prefix, the frontend + // classifier would misroute it as unreachable — pin that it never does. + assert!( + !MALFORMED_RESPONSE_MESSAGE.starts_with("relay unreachable:"), + "malformed-response message must not match the unreachable prefix" + ); +} + +// ── parse_command_response ─────────────────────────────────────────────── + +#[derive(Debug, Deserialize, PartialEq)] +struct ChannelCreated { + channel_id: String, +} + +#[test] +fn parse_command_response_decodes_typed_payload() { + let msg = r#"response:{"channel_id":"abc123"}"#; + let parsed: ChannelCreated = parse_command_response(msg).expect("should parse"); + assert_eq!( + parsed, + ChannelCreated { + channel_id: "abc123".to_string() + } + ); +} + +#[test] +fn parse_command_response_accepts_raw_json_fallback() { + // Backward-compat: relays that emit raw JSON (no prefix) still work. + let msg = r#"{"channel_id":"abc"}"#; + let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse"); + assert_eq!( + parsed, + ChannelCreated { + channel_id: "abc".to_string() + } + ); +} + +#[test] +fn parse_command_response_rejects_invalid_prefixed_json() { + let msg = "response:not-json"; + let result: Result = parse_command_response(msg); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("response parse failed")); +} + +#[test] +fn parse_command_response_rejects_garbage() { + let msg = "totally not json or response"; + let result: Result = parse_command_response(msg); + assert!(result.is_err()); +} + +// ── build_profile_event ────────────────────────────────────────────────── + +/// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key +/// and addressed to `agent_keys`. +/// +/// Uses `nostr_compat` (nostr 0.36) for the owner keys because +/// `buzz_sdk_pkg::nip_oa::compute_auth_tag` expects nostr 0.36 types. +/// The agent pubkey is bridged via hex encoding. +fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { + let owner_keys = nostr::Keys::generate(); + let agent_pubkey_hex = agent_keys.public_key().to_hex(); + let agent_compat_pubkey = + nostr::PublicKey::from_hex(&agent_pubkey_hex).expect("valid hex pubkey should parse"); + buzz_sdk_pkg::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "") + .expect("compute_auth_tag should not fail with distinct keys") +} + +#[test] +fn profile_event_with_valid_auth_tag() { + let agent_keys = nostr::Keys::generate(); + let tag_json = make_valid_auth_tag(&agent_keys); + let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) + .expect("should succeed with a valid auth tag"); + + // Exactly one "auth" tag must be present. + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag"); + + // Must be a kind:0 (Metadata) event. + assert_eq!(event.kind, nostr::Kind::Metadata); +} + +#[test] +fn profile_event_without_auth_tag() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event(&agent_keys, "TestBot", None, None) + .expect("should succeed without an auth tag"); + + // No "auth" tags should be present. + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!(auth_tags.len(), 0, "expected no auth tags"); + + assert_eq!(event.kind, nostr::Kind::Metadata); +} + +#[test] +fn profile_event_rejects_invalid_auth_tag() { + let agent_keys = nostr::Keys::generate(); + // Structurally valid JSON array but with a bogus signature — verification must fail. + let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); + let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + assert!(result.is_err(), "should reject an invalid auth tag"); + assert!( + result.unwrap_err().contains("verification failed"), + "error message should mention verification failure" + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index dbdb2ed2345..4086a4e1c1a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -14,6 +14,7 @@ import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageThreadPanelSkeleton } from "@/features/messages/ui/MessageThreadPanelSkeleton"; +import { ThreadRepliesErrorCard } from "@/features/messages/ui/MessageThreadReplyState"; import { MessageTimeline, type MessageTimelineHandle, @@ -97,6 +98,8 @@ export const ChannelPane = React.memo(function ChannelPane({ welcomeKickoffSettingUp = false, messages, threadSummaries, + huddleThreadRepliesError = false, + onRetryHuddleThreadReplies, firstUnreadMessageId = null, unreadCount = 0, canResetThreadPanelWidth, @@ -153,6 +156,8 @@ export const ChannelPane = React.memo(function ChannelPane({ threadHeadMessage, threadMessages, threadMessagesPending = false, + threadMessagesError = false, + onRetryThreadReplies, threadPanelWidthPx, threadScrollTargetId, threadTypingPubkeys, @@ -624,6 +629,11 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {isHuddleTranscript ? null : header} + {isHuddleTranscript && huddleThreadRepliesError ? ( +
+ +
+ ) : null}
; + /** + * A Huddle transcript flattens summarized reply subtrees into the chat + * timeline. When one of those subtree loads fails, this reports the aggregate + * failure so the transcript can surface a non-destructive retry alert instead + * of silently presenting a partial conversation as complete. + */ + huddleThreadRepliesError?: boolean; + onRetryHuddleThreadReplies?: () => void; firstUnreadMessageId?: string | null; unreadCount?: number; canResetThreadPanelWidth: boolean; @@ -163,6 +171,8 @@ export type ChannelPaneProps = { threadAllMessages: TimelineMessage[]; threadMessages: MainTimelineEntry[]; threadMessagesPending?: boolean; + threadMessagesError?: boolean; + onRetryThreadReplies?: () => void; threadPanelWidthPx: number; threadTypingPubkeys: string[]; threadReplyTargetMessage: TimelineMessage | null; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index aac93d7f8d7..240a9ad70c1 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -254,7 +254,12 @@ export function ChannelScreen({ const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); const joinChannelMutation = useJoinChannelMutation(activeChannelId); - const { resolvedMessages, threadSummaries } = useHuddleChannelMessages({ + const { + resolvedMessages, + threadSummaries, + threadRepliesError: huddleThreadRepliesError, + onRetryThreadReplies: onRetryHuddleThreadReplies, + } = useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -896,6 +901,8 @@ export function ChannelScreen({ isTimelineLoading={isTimelineLoading} messages={timelineMessages} threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} onChannelManagementDeleted={handleChannelManagementDeleted} @@ -967,6 +974,10 @@ export function ChannelScreen({ threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} threadPanelWidthPx={threadPanelWidthPx} threadTypingPubkeys={threadTypingPubkeys} threadReplyTargetMessage={displayedThreadReplyTargetMessage} diff --git a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts index 2a90971ddec..5a3a7c40419 100644 --- a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts +++ b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts @@ -66,5 +66,13 @@ export function useHuddleChannelMessages({ [huddleThreadReplies.events, isHuddleTranscript, resolvedChannelMessages], ); - return { resolvedMessages, threadSummaries }; + return { + resolvedMessages, + threadSummaries, + // A summarized reply subtree failing must not leave the transcript reading + // as complete: surface the aggregate failure so the consumer can show a + // non-destructive retry alert alongside the rows that did load. + threadRepliesError: isHuddleTranscript && huddleThreadReplies.isError, + onRetryThreadReplies: huddleThreadReplies.refetch, + }; } diff --git a/desktop/src/features/messages/combineThreadRepliesResults.test.mjs b/desktop/src/features/messages/combineThreadRepliesResults.test.mjs new file mode 100644 index 00000000000..47beb1c7a83 --- /dev/null +++ b/desktop/src/features/messages/combineThreadRepliesResults.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { combineThreadRepliesResults } from "./useThreadReplies.ts"; + +const CHANNEL_A = "a".repeat(64); +const CHANNEL_B = "b".repeat(64); + +function event(id, createdAt) { + return { + id, + pubkey: "c".repeat(64), + kind: 9, + created_at: createdAt, + content: "reply", + tags: [], + sig: "sig", + }; +} + +function ok(data) { + return { + data, + isPending: false, + isError: false, + error: null, + refetch: () => { + throw new Error("a successful subtree must not be refetched"); + }, + }; +} + +function failed(refetch) { + return { + data: undefined, + isPending: false, + isError: true, + error: new Error("subtree load failed"), + refetch, + }; +} + +function pending() { + return { + data: undefined, + isPending: true, + isError: false, + error: null, + refetch: () => {}, + }; +} + +test("aggregates events across roots in chronological order", () => { + const combined = combineThreadRepliesResults([ + ok([event(CHANNEL_A, 200)]), + ok([event(CHANNEL_B, 100)]), + ]); + assert.deepEqual( + combined.events.map((e) => e.created_at), + [100, 200], + ); + assert.equal(combined.isPending, false); + assert.equal(combined.isError, false); + assert.equal(combined.error, null); +}); + +test("a failed subtree surfaces aggregate error and never silently drops", () => { + // The load-bearing contract: one failed root among successful roots must make + // the aggregate report isError so the consumer can surface a failure instead + // of presenting a partial transcript as complete. + const combined = combineThreadRepliesResults([ + ok([event(CHANNEL_A, 100)]), + failed(() => {}), + ]); + assert.equal(combined.isError, true); + assert.ok(combined.error instanceof Error); + // Successful rows still contribute their events (non-destructive). + assert.equal(combined.events.length, 1); +}); + +test("isPending reflects any still-loading root", () => { + const combined = combineThreadRepliesResults([ok([]), pending()]); + assert.equal(combined.isPending, true); +}); + +test("refetch re-runs only the failed subtrees, not the successful ones", () => { + let failedRefetched = 0; + const combined = combineThreadRepliesResults([ + ok([event(CHANNEL_A, 100)]), + failed(() => { + failedRefetched += 1; + }), + ]); + // ok().refetch throws if called, so a partial-success refetch that touched + // every query would throw here; it must only touch the failed one. + combined.refetch(); + assert.equal(failedRefetched, 1); +}); + +test("all-success aggregate reports no error", () => { + const combined = combineThreadRepliesResults([ok([]), ok([])]); + assert.equal(combined.isError, false); + assert.equal(combined.error, null); +}); diff --git a/desktop/src/features/messages/lib/threadPanel.ts b/desktop/src/features/messages/lib/threadPanel.ts index ebc1b35ae7e..6dc1190910d 100644 --- a/desktop/src/features/messages/lib/threadPanel.ts +++ b/desktop/src/features/messages/lib/threadPanel.ts @@ -542,3 +542,59 @@ export function buildThreadPanelData( expandedReplyIds, ); } + +function hasLaterVisibleSibling( + entries: readonly MainTimelineEntry[], + entryIndex: number, +): boolean { + const depth = entries[entryIndex]?.message.depth; + if (depth == null) { + return false; + } + + for (let index = entryIndex + 1; index < entries.length; index += 1) { + const nextDepth = entries[index].message.depth; + if (nextDepth <= depth) { + return nextDepth === depth; + } + } + + return false; +} + +/** + * Depths at which a vertical thread-branch guide should continue past `message` + * because an ancestor on its path still has a later visible sibling. Pure so + * the branch-guide geometry is unit-tested without the panel. + */ +export function getActiveContinuationDepths({ + ancestors, + entries, + index, + message, +}: { + ancestors: readonly { index: number; message: TimelineMessage }[]; + entries: readonly MainTimelineEntry[]; + index: number; + message: TimelineMessage; +}): number[] { + const depths: number[] = []; + + for (const ancestor of ancestors) { + if (ancestor.message.depth === 0) { + continue; + } + + const childDepth = ancestor.message.depth + 1; + const pathChild = + message.depth === childDepth + ? { index, message } + : ancestors.find((candidate) => candidate.message.depth === childDepth); + + if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) { + depths.push(ancestor.message.depth); + } + } + + return depths; +} diff --git a/desktop/src/features/messages/lib/threadReplyHighlight.test.mjs b/desktop/src/features/messages/lib/threadReplyHighlight.test.mjs new file mode 100644 index 00000000000..bad72b33395 --- /dev/null +++ b/desktop/src/features/messages/lib/threadReplyHighlight.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { selectThreadRowHighlight } from "./threadReplyHighlight.ts"; + +// The hovered branch spans rows in the half-open range (startIndex, endIndex]. +const branch = { id: "b", depth: 1, startIndex: 2, endIndex: 5 }; + +test("row-highlight: null branch highlights nothing", () => { + assert.deepEqual( + selectThreadRowHighlight({ + branch: null, + index: 3, + messageId: "x", + messageDepth: 2, + showGuides: true, + }), + { + isBranchOwner: false, + isInsideBranch: false, + isDirectChild: false, + lineDepths: undefined, + }, + ); +}); + +test("row-highlight: the branch owner is flagged but is not inside its own range", () => { + const h = selectThreadRowHighlight({ + branch, + index: 2, + messageId: "b", + messageDepth: 1, + showGuides: true, + }); + assert.equal(h.isBranchOwner, true); + // startIndex is excluded, so the owner row itself is not "inside". + assert.equal(h.isInsideBranch, false); + assert.equal(h.lineDepths, undefined); +}); + +test("row-highlight: a direct child inside the branch draws the guide line", () => { + const h = selectThreadRowHighlight({ + branch, + index: 3, + messageId: "c", + messageDepth: 2, + showGuides: true, + }); + assert.equal(h.isInsideBranch, true); + assert.equal(h.isDirectChild, true); + assert.deepEqual(h.lineDepths, [1]); +}); + +test("row-highlight: a deeper descendant is inside but not a direct child", () => { + const h = selectThreadRowHighlight({ + branch, + index: 4, + messageId: "d", + messageDepth: 3, + showGuides: true, + }); + assert.equal(h.isInsideBranch, true); + assert.equal(h.isDirectChild, false); +}); + +test("row-highlight: a row past endIndex is outside the branch", () => { + const h = selectThreadRowHighlight({ + branch, + index: 6, + messageId: "e", + messageDepth: 2, + showGuides: true, + }); + assert.equal(h.isInsideBranch, false); +}); + +test("row-highlight: guides suppressed → no line depths even inside the branch", () => { + const h = selectThreadRowHighlight({ + branch, + index: 3, + messageId: "c", + messageDepth: 2, + showGuides: false, + }); + assert.equal(h.isInsideBranch, true); + assert.equal(h.lineDepths, undefined); +}); diff --git a/desktop/src/features/messages/lib/threadReplyHighlight.ts b/desktop/src/features/messages/lib/threadReplyHighlight.ts new file mode 100644 index 00000000000..0ce446daecb --- /dev/null +++ b/desktop/src/features/messages/lib/threadReplyHighlight.ts @@ -0,0 +1,45 @@ +/** + * The hovered collapse-branch range, or null when nothing is hovered. Rows whose + * index falls inside `(startIndex, endIndex]` belong to the branch. + */ +export type HighlightedThreadBranch = { + id: string; + depth: number; + startIndex: number; + endIndex: number; +} | null; + +/** Per-row branch-highlight flags derived from the hovered collapse branch. */ +export type ThreadRowHighlight = { + isBranchOwner: boolean; + isInsideBranch: boolean; + isDirectChild: boolean; + lineDepths: number[] | undefined; +}; + +/** + * Which highlight decorations a reply row shows for the hovered collapse branch. + * Pure so the index-range logic is unit-testable without rendering the panel. + */ +export function selectThreadRowHighlight({ + branch, + index, + messageId, + messageDepth, + showGuides, +}: { + branch: HighlightedThreadBranch; + index: number; + messageId: string; + messageDepth: number; + showGuides: boolean; +}): ThreadRowHighlight { + const isBranchOwner = branch?.id === messageId; + const isInsideBranch = + branch != null && index > branch.startIndex && index <= branch.endIndex; + const isDirectChild = + isInsideBranch && branch != null && messageDepth === branch.depth + 1; + const lineDepths = + showGuides && isInsideBranch && branch ? [branch.depth] : undefined; + return { isBranchOwner, isInsideBranch, isDirectChild, lineDepths }; +} diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a0374fbe2b0..5334d58875f 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -14,6 +14,7 @@ import { selectLatestMessageKey, selectTimelineBodySurface, selectTimelineIntroSurface, + selectThreadRepliesSurface, } from "./timelineSnapshot.ts"; // Local-midnight unix-second timestamps so isSameDay (local time) is stable @@ -399,6 +400,126 @@ test("deferred-render: keys the empty decision off the live count, not deferred" assert.equal(selectDeferredListRenderState(0, 1), "pending"); }); +// ── selectThreadRepliesSurface ────────────────────────────────────────────── +// PR-1 defect 2: a terminal thread-load error must NEVER be presented as the +// authoritative "No replies in this branch yet" empty state. These pin the +// paint precedence that gates that in MessageThreadPanel. + +test("thread-surface: pending query paints the skeleton", () => { + assert.equal( + selectThreadRepliesSurface({ + isPending: true, + isError: false, + renderState: "empty", + }), + "skeleton", + ); +}); + +test("thread-surface: terminal error with no data paints error, never empty", () => { + // The core false-empty guard: the load failed (isError) and there is nothing + // cached (renderState "empty"). This MUST be "error" so the UI shows + // "Couldn't load replies" + Retry instead of an authoritative empty thread. + const surface = selectThreadRepliesSurface({ + isPending: false, + isError: true, + renderState: "empty", + }); + assert.equal(surface, "error"); + assert.notEqual(surface, "empty"); +}); + +test("thread-surface: page-2 failure with no committed rows never claims empty", () => { + // A later-page fetch rejects the whole attempt; partial rows are never + // committed, so the deferred+live lists are empty and isError is set. The + // surface must be "error", never "empty" — the thread is not known-empty. + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError: true, + renderState: "empty", + }), + "error", + ); +}); + +test("thread-surface: cached rows stay visible even under a load error", () => { + // An error with cached replies (renderState "list") keeps painting the rows + // non-destructively rather than blanking them for the error card. + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError: true, + renderState: "list", + }), + "list", + ); +}); + +test("thread-surface: successful empty load paints the empty state", () => { + // No error, genuinely no replies → the real empty affordance is correct. + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError: false, + renderState: "empty", + }), + "empty", + ); +}); + +test("thread-surface: retry success renders the reply list", () => { + // After a Retry re-fetch succeeds, isError clears and rows commit + // (renderState "list") → the list body paints, replacing the error card. + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError: false, + renderState: "list", + }), + "list", + ); +}); + +test("thread-surface: streaming-in rows paint nothing (pending), not empty", () => { + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError: false, + renderState: "pending", + }), + "pending", + ); +}); + +test("thread-surface: huddle transcripts collapse non-list surfaces to pending", () => { + // Huddle transcripts flatten replies into the chat timeline, so they never + // show the skeleton/error/empty affordances — only the list body or nothing. + for (const isError of [false, true]) { + for (const renderState of ["empty", "pending"]) { + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError, + renderState, + isHuddleTranscript: true, + }), + "pending", + ); + } + } + // The list body still paints for a transcript with rows. + assert.equal( + selectThreadRepliesSurface({ + isPending: false, + isError: false, + renderState: "list", + isHuddleTranscript: true, + }), + "list", + ); +}); + test("timeline-body-surface: loading and deferred-pending both paint the single static skeleton", () => { assert.equal( selectTimelineBodySurface({ diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 3bfd9349476..4e23fb22453 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -209,6 +209,74 @@ export function selectTimelineBodySurface({ return renderState; } +/** + * Which surface the thread-reply body should paint, in strict precedence. + * + * Extracted as a pure function so the load-bearing invariant — a terminal fetch + * error must NEVER be shown as the "empty" (no-replies) state — is unit-tested + * without a DOM. The precedence mirrors the JSX branch order in + * `MessageThreadPanel`: + * + * 1. "skeleton" → the query is still pending (first load, no cache) + * 2. "list" → the deferred snapshot has rows; paint them (even under a + * later error, cached replies stay visible non-destructively) + * 3. "error" → the load terminally failed and there is nothing to show; + * paint "Couldn't load replies" + Retry, never the empty state + * 4. "empty" → the load succeeded and the branch is genuinely empty + * 5. "pending" → deferred is empty but the live list has content; paint + * nothing yet (rows are streaming in on the deferred commit) + * + * Huddle transcripts flatten replies into the chat timeline and never show the + * skeleton/error/empty affordances, so their non-list surfaces collapse to + * "pending" (render nothing). + */ +export type ThreadRepliesSurface = + | "skeleton" + | "list" + | "error" + | "empty" + | "pending"; + +export function selectThreadRepliesSurface({ + isPending, + isError, + renderState, + isHuddleTranscript = false, +}: { + isPending: boolean; + isError: boolean; + renderState: DeferredListRenderState; + isHuddleTranscript?: boolean; +}): ThreadRepliesSurface { + const surface = resolveThreadRepliesSurface({ + isPending, + isError, + renderState, + }); + return isHuddleTranscript && surface !== "list" ? "pending" : surface; +} + +function resolveThreadRepliesSurface({ + isPending, + isError, + renderState, +}: { + isPending: boolean; + isError: boolean; + renderState: DeferredListRenderState; +}): ThreadRepliesSurface { + if (isPending) { + return "skeleton"; + } + if (renderState === "list") { + return "list"; + } + if (isError) { + return "error"; + } + return renderState; +} + export type TimelineMessageDelta = "prepend" | "append" | "replace" | "none"; export function classifyTimelineMessageDelta({ diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d2650c84ac4..01fbcda7ffb 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -4,6 +4,7 @@ import { ArrowDown } from "lucide-react"; import { HuddleTranscriptIntro } from "@/features/huddle/components/HuddleTranscriptIntro"; import { buildThreadSummaryFromVisibleEntries, + getActiveContinuationDepths, hasNestedThreadBranches, type MainTimelineEntry, } from "@/features/messages/lib/threadPanel"; @@ -41,12 +42,14 @@ import { import type { ThreadDepthGuideAction } from "./MessageRow"; import { MessageThreadRow } from "./MessageThreadRow"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; +import { ThreadReplyRegion } from "./MessageThreadReplyState"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; import { useComposerHeightPadding } from "./useComposerHeightPadding"; import { useStableSendToChannel } from "./useStableSendToChannel"; import { useAnchoredScroll } from "./useAnchoredScroll"; import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; +import { selectThreadRowHighlight } from "@/features/messages/lib/threadReplyHighlight"; type MessageThreadPanelProps = ThreadPanelLayoutProps & { channel: Channel | null; @@ -106,6 +109,10 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadHead: TimelineMessage | null; threadReplies: MainTimelineEntry[]; threadRepliesPending?: boolean; + /** True when the thread-reply query terminally failed (all retries exhausted). */ + threadRepliesError?: boolean; + /** Retries the failed thread-reply load; wired to the query's `refetch`. */ + onRetryThreadReplies?: () => void; threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; threadTypingPubkeys: string[]; @@ -131,57 +138,6 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = []; const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0; -function hasLaterVisibleSibling( - entries: readonly MainTimelineEntry[], - entryIndex: number, -): boolean { - const depth = entries[entryIndex]?.message.depth; - if (depth == null) { - return false; - } - - for (let index = entryIndex + 1; index < entries.length; index += 1) { - const nextDepth = entries[index].message.depth; - if (nextDepth <= depth) { - return nextDepth === depth; - } - } - - return false; -} - -function getActiveContinuationDepths({ - ancestors, - entries, - index, - message, -}: { - ancestors: readonly { index: number; message: TimelineMessage }[]; - entries: readonly MainTimelineEntry[]; - index: number; - message: TimelineMessage; -}): number[] { - const depths: number[] = []; - - for (const ancestor of ancestors) { - if (ancestor.message.depth === 0) { - continue; - } - - const childDepth = ancestor.message.depth + 1; - const pathChild = - message.depth === childDepth - ? { index, message } - : ancestors.find((candidate) => candidate.message.depth === childDepth); - - if (pathChild && hasLaterVisibleSibling(entries, pathChild.index)) { - depths.push(ancestor.message.depth); - } - } - - return depths; -} - export function MessageThreadPanel({ channel, channelId, @@ -233,6 +189,8 @@ export function MessageThreadPanel({ videoReviewPresentation, threadReplies, threadRepliesPending = false, + threadRepliesError = false, + onRetryThreadReplies, threadUnreadCount, threadReplyUnreadCounts, threadTypingPubkeys, @@ -629,199 +587,187 @@ export function MessageThreadPanel({ className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")} data-testid="message-thread-replies" > - {threadRepliesPending && !isHuddleTranscript ? ( -
- - -
- ) : repliesRenderState === "list" ? ( - visibleThreadHeadSummary ? ( + (
- + +
- ) : ( -
- {threadReplyRenderItems.map((item) => { - const { - collapseDepthGuideActions, - connectsToVisibleChild, - continuationDepths, - entry, - index, - isContinuation, - } = item; - const showUnreadDivider = - index > 0 && entry.message.id === firstUnreadReplyId; - const isHighlightedBranchOwner = - highlightedBranch?.id === entry.message.id; - const isInsideHighlightedBranch = - highlightedBranch != null && - index > highlightedBranch.startIndex && - index <= highlightedBranch.endIndex; - const isDirectChildOfHighlightedBranch = - isInsideHighlightedBranch && - highlightedBranch != null && - index > highlightedBranch.startIndex && - index <= highlightedBranch.endIndex && - entry.message.depth === highlightedBranch.depth + 1; - const highlightedLineDepths = - shouldShowThreadBranchGuides && - isInsideHighlightedBranch && - highlightedBranch - ? [highlightedBranch.depth] - : undefined; - return ( -
- {showUnreadDivider ? : null} - + visibleThreadHeadSummary ? ( +
+ +
+ ) : ( +
+ {threadReplyRenderItems.map((item) => { + const { + collapseDepthGuideActions, + connectsToVisibleChild, + continuationDepths, + entry, + index, + isContinuation, + } = item; + const showUnreadDivider = + index > 0 && entry.message.id === firstUnreadReplyId; + const highlight = selectThreadRowHighlight({ + branch: highlightedBranch, + index, + messageId: entry.message.id, + messageDepth: entry.message.depth, + showGuides: shouldShowThreadBranchGuides, + }); + return ( +
- {entry.summary ? ( - + {showUnreadDivider ? : null} + - ) : null} -
- ); - })} -
- ) - ) : repliesRenderState === "empty" && !isHuddleTranscript ? ( - // Only show the empty state when the thread is GENUINELY empty. - // Keying off `deferredThreadReplies` would flash "No replies" for a - // frame while a non-empty list streams in on the deferred commit. -
-

- No replies in this branch yet -

-

- Reply in the thread to continue this branch. -

-
- ) : // "pending": deferred list is empty but the live list has content — - // rows are streaming in on the deferred commit. Paint nothing rather - // than flashing the empty state. - null} + {entry.summary ? ( + + ) : null} +
+ ); + })} +
+ ) + } + />
diff --git a/desktop/src/features/messages/ui/MessageThreadReplyState.test.mjs b/desktop/src/features/messages/ui/MessageThreadReplyState.test.mjs new file mode 100644 index 00000000000..f70143f87b1 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageThreadReplyState.test.mjs @@ -0,0 +1,162 @@ +/** + * Mount regressions for the thread reply region, wired through the + * panel-owned ThreadReplyRegion dispatcher. + * + * Bug this pins: a terminal thread-replies fetch error used to fall through to + * the "No replies in this branch yet" empty card, silently presenting a broken + * load as an authoritative empty branch with no recovery. The fix maps a + * terminal error to the retry card and NEVER the empty card, and routes the + * Retry button back to the query's refetch. + * + * Why this component, and why raw inputs: ThreadReplyRegion now owns BOTH the + * surface selection (selectThreadRepliesSurface, also unit-tested against its + * 8-case matrix in timelineSnapshot.test.mjs) AND the surface→content dispatch. + * MessageThreadPanel passes only its raw query/render state — the pending/error + * flags and the deferred vs. live reply counts — so there is no precomputed + * `surface` prop at the panel boundary to statically mis-set. This file mounts + * ThreadReplyRegion and drives the real raw-state→surface→content mapping, so a + * false-empty regression cannot land at either the selection or the dispatch + * with these tests green. Mounting the full panel is infeasible in node:test + * (its Tiptap composer / React Query stack is unavailable, see + * MessageComposerAutoSend.test.mjs); the render callbacks keep that heavy + * construction in the panel and out of this cheap mount. + * + * CI surface: pnpm test (node:test with @testing-library/react over JSDOM). + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +// Sentinels for the two heavy branches the panel owns. If ThreadReplyRegion +// ever routes error/empty/pending through a render callback, these appear where +// a card is expected and the assertions catch it. +const SKELETON_MARK = "SKELETON_BRANCH_MARKER"; +const LIST_MARK = "LIST_BRANCH_MARKER"; + +async function renderRegion(props) { + const { createElement } = await import("react"); + const { render } = await import("@testing-library/react"); + const { ThreadReplyRegion } = await import("./MessageThreadReplyState.tsx"); + return render( + createElement(ThreadReplyRegion, { + isPending: false, + isError: false, + deferredCount: 0, + liveCount: 0, + renderSkeleton: () => createElement("div", null, SKELETON_MARK), + renderList: () => createElement("div", null, LIST_MARK), + ...props, + }), + ); +} + +test("terminal error renders the retry card, never the empty card", async () => { + const { screen } = await import("@testing-library/react"); + // Raw terminal-failure state: not pending, load errored, nothing to show. + await renderRegion({ isError: true, onRetry: () => {} }); + + const card = screen.getByTestId("message-thread-replies-error"); + assert.ok(card, "a terminal error must render the error card"); + // The card appears asynchronously (after the query/retry lifecycle), so it + // must be an alert live region or a screen-reader user never hears it. + assert.equal( + card.getAttribute("role"), + "alert", + "the async error card must be an alert live region for assistive tech", + ); + assert.equal( + document.body.textContent.includes("No replies in this branch yet"), + false, + "a terminal error must NEVER render the empty state", + ); +}); + +test("Retry button invokes the supplied refetch callback", async () => { + const { fireEvent, screen } = await import("@testing-library/react"); + let retryCount = 0; + await renderRegion({ + isError: true, + onRetry: () => { + retryCount += 1; + }, + }); + + fireEvent.click(screen.getByTestId("message-thread-replies-retry")); + + assert.equal(retryCount, 1, "clicking Retry must call the refetch callback"); +}); + +test("genuine empty surface renders the empty card, not the error card", async () => { + const { screen } = await import("@testing-library/react"); + // Load succeeded (no error), branch is genuinely empty. + await renderRegion({}); + + assert.ok( + document.body.textContent.includes("No replies in this branch yet"), + "a genuine empty branch must render the empty card", + ); + assert.equal( + screen.queryByTestId("message-thread-replies-error"), + null, + "a genuine empty branch must NOT render the error card", + ); +}); + +test("pending surface paints nothing", async () => { + // Deferred snapshot is empty but the live list has content: rows are + // streaming in on the deferred commit, so paint nothing yet. + const { container } = await renderRegion({ deferredCount: 0, liveCount: 1 }); + + assert.equal( + container.textContent, + "", + "the pending surface must render nothing while rows stream in", + ); +}); + +test("skeleton surface renders the panel's skeleton branch", async () => { + const { container } = await renderRegion({ isPending: true }); + + assert.equal( + container.textContent, + SKELETON_MARK, + "the skeleton surface must render the panel's skeleton branch", + ); +}); + +test("list surface renders the panel's list branch", async () => { + const { container } = await renderRegion({ deferredCount: 1, liveCount: 1 }); + + assert.equal( + container.textContent, + LIST_MARK, + "the list surface must render the panel's list branch", + ); +}); diff --git a/desktop/src/features/messages/ui/MessageThreadReplyState.tsx b/desktop/src/features/messages/ui/MessageThreadReplyState.tsx new file mode 100644 index 00000000000..a13d46286ef --- /dev/null +++ b/desktop/src/features/messages/ui/MessageThreadReplyState.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from "react"; + +import { + selectDeferredListRenderState, + selectThreadRepliesSurface, +} from "@/features/messages/lib/timelineSnapshot"; +import { Button } from "@/shared/ui/button"; + +/** + * Terminal empty/error states for the thread reply region. + * + * These are the two non-list, non-loading outcomes of a thread-reply load. They + * live here (rather than inline in `MessageThreadPanel`) so the load-bearing + * distinction between them stays legible: a genuinely empty branch and a failed + * fetch look similar but must never be confused — see `selectThreadRepliesSurface`. + */ + +/** + * A terminal load failure. This must NEVER be painted as the empty state — that + * silently presents a broken fetch as an authoritative "no replies" and offers + * no recovery. Any cached replies still render via the panel's "list" branch, so + * this only surfaces when the failed load left nothing to show. + * + * `role="alert"` (implicit `aria-live="assertive"`, `aria-atomic="true"`) makes + * the asynchronous failure audible to assistive tech: the card appears only after + * the query/retry lifecycle reaches a terminal error, so without a live region a + * screen-reader user parked in the composer never learns the load failed or that + * Retry became available. + */ +export function ThreadRepliesErrorCard({ onRetry }: { onRetry?: () => void }) { + return ( +
+

+ Couldn't load replies +

+

+ The thread history didn't load. Check your connection and try + again. +

+ {onRetry ? ( + + ) : null} +
+ ); +} + +/** + * A branch that genuinely has no replies (the load succeeded and returned none). + * Only ever painted off the committed render state, never the raw deferred list, + * so it can't flash while a non-empty list streams in on the deferred commit. + */ +export function ThreadRepliesEmptyCard() { + return ( +
+

+ No replies in this branch yet +

+

+ Reply in the thread to continue this branch. +

+
+ ); +} + +/** + * The single paint decision for the thread reply region. This unit owns BOTH + * the surface selection (`selectThreadRepliesSurface`, keyed off the same raw + * query/render state the panel already holds) AND the surface→content dispatch. + * Fusing them here removes the last falsifiable seam: the panel passes only its + * raw state — pending/error flags, the deferred vs. live reply counts, and the + * huddle-transcript flag — so there is no precomputed `surface` prop at the + * panel boundary to statically mis-set (e.g. a stray `surface="empty"` that would + * silently restore the false-empty bug on every fetch failure). The load-bearing + * invariant holds by construction: a terminal fetch "error" renders the retry + * card and NEVER the "empty" "No replies" state, while "pending" paints nothing + * (rows stream in on the deferred commit). + * + * The two heavy branches take render callbacks so the panel keeps ownership of + * its skeleton and list construction (Tiptap/React-Query bound, not mountable in + * node:test) without dragging them into this component. The mount test drives + * the real raw-state→surface→content mapping through this exported unit, so both + * the selection and the dispatch are covered under a cheap mount. + */ +export function ThreadReplyRegion({ + isPending, + isError, + deferredCount, + liveCount, + isHuddleTranscript = false, + onRetry, + renderSkeleton, + renderList, +}: { + isPending: boolean; + isError: boolean; + deferredCount: number; + liveCount: number; + isHuddleTranscript?: boolean; + onRetry?: () => void; + renderSkeleton: () => ReactNode; + renderList: () => ReactNode; +}) { + const surface = selectThreadRepliesSurface({ + isPending, + isError, + renderState: selectDeferredListRenderState(deferredCount, liveCount), + isHuddleTranscript, + }); + if (surface === "skeleton") return <>{renderSkeleton()}; + if (surface === "list") return <>{renderList()}; + if (surface === "error") return ; + if (surface === "empty") return ; + return null; +} diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index 25a6b68986b..4d602348f95 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -67,6 +67,39 @@ export function useThreadReplies( }); } +/** + * Aggregate a set of per-root thread-reply query results into one view for a + * multi-root consumer. Pure over the results array so the load-bearing + * error-surfacing contract is unit-testable without a live QueryClient. + * + * `isError`/`error` expose aggregate terminal failure so a consumer never + * silently drops a failed reply subtree — the same false-empty class the + * single-root panel guards against. `error` carries the first failed subtree's + * error; `refetch` re-runs only the failed queries so a partial success is not + * needlessly re-fetched. + */ +export function combineThreadRepliesResults( + results: readonly { + data?: RelayEvent[]; + isPending: boolean; + isError: boolean; + error: unknown; + refetch: () => unknown; + }[], +) { + return { + events: sortMessages(results.flatMap((result) => result.data ?? [])), + isPending: results.some((result) => result.isPending), + isError: results.some((result) => result.isError), + error: results.find((result) => result.isError)?.error ?? null, + refetch: () => { + for (const result of results) { + if (result.isError) void result.refetch(); + } + }, + }; +} + /** * Load every summarized reply subtree for a channel-style Huddle transcript. * Ordinary channels keep replies in their thread panels; Huddles flatten those @@ -87,9 +120,6 @@ export function useThreadRepliesForRoots( staleTime: 0, gcTime: 60 * 60 * 1_000, })), - combine: (results) => ({ - events: sortMessages(results.flatMap((result) => result.data ?? [])), - isPending: results.some((result) => result.isPending), - }), + combine: combineThreadRepliesResults, }); } diff --git a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx index 59f3264b025..11344ea574d 100644 --- a/desktop/src/features/projects/ui/ProjectConversationPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectConversationPanel.tsx @@ -279,7 +279,11 @@ export function ProjectConversationPanel({ scrollTargetId={scrollTargetId} threadHead={panelData.threadHead} threadReplies={panelData.visibleReplies} - threadRepliesPending={false} + threadRepliesPending={threadRepliesQuery.isPending} + threadRepliesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} threadTypingPubkeys={[]} widthPx={widthPx} />, diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 9a465d3da0e..f667fcd2a21 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -37,6 +37,7 @@ import { } from "@/features/messages/lib/useRichTextEditor"; import { FormattingToolbar } from "@/features/messages/ui/FormattingToolbar"; import { MessageThreadTranscript } from "@/features/messages/ui/MessageThreadTranscript"; +import { ThreadRepliesErrorCard } from "@/features/messages/ui/MessageThreadReplyState"; import type { TimelineMessage } from "@/features/messages/types"; import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; @@ -363,6 +364,9 @@ export function ConversationThread({ profiles={profiles} renderAfterMessage={renderSubmittedContext} /> + {threadReplies.isError ? ( + + ) : null} {agentWorking.working ? (
diff --git a/desktop/tests/e2e/huddle-thread-load-failure.spec.ts b/desktop/tests/e2e/huddle-thread-load-failure.spec.ts new file mode 100644 index 00000000000..8d3777201a9 --- /dev/null +++ b/desktop/tests/e2e/huddle-thread-load-failure.spec.ts @@ -0,0 +1,182 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +/** + * Consumer-level guard for the false-empty thread-load bug on the Huddle + * transcript (PR #6447, Carl r3). The transcript flattens summarized reply + * subtrees into the chat timeline via `useThreadRepliesForRoots`, whose combine + * now reports an aggregate `isError`/`refetch`. `useHuddleChannelMessages` used + * to read only `.events` and drop that state, so one failed subtree left the + * partial transcript presenting as complete with no warning or recovery. + * + * The combine unit test proves the hook REPORTS failure; it cannot catch a + * consumer discarding it. This drives the REAL Huddle wiring + * (useHuddleChannelMessages -> ChannelScreen -> ChannelPane) through the mock + * bridge: two summarized roots, fail only ONE subtree's fetch at the IPC + * boundary, assert the surviving root's reply still renders AND the retry alert + * appears, then Retry recovers the failed subtree. Dropping the propagation + * turns this red. + */ + +const HUDDLE_CHANNEL_ID = "11111111-1111-4111-8111-111111111111"; +const HUDDLE_PARENT_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +const ROOT_A_CONTENT = "Huddle root A"; +const REPLY_A_CONTENT = "Huddle reply A survives"; +const ROOT_B_CONTENT = "Huddle root B"; +const REPLY_B_CONTENT = "Huddle reply B recovered"; + +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + (name) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +// Fail get_thread_replies for exactly one root while the flag names it, letting +// the other subtree and the eventual retry succeed. Wrapping the real +// __TAURI_INTERNALS__.invoke exercises the whole per-root query path with no +// source seam to bypass — the aggregate error and the failed-only refetch are +// production behavior, not a test stub. +async function installPerRootThreadFailureSwitch(page: Page) { + await page.evaluate(() => { + const w = window as typeof window & { + __FAIL_THREAD_ROOT__?: string | null; + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + }; + w.__FAIL_THREAD_ROOT__ = null; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + if ( + command === "get_thread_replies" && + w.__FAIL_THREAD_ROOT__ != null && + (payload as { rootEventId?: string })?.rootEventId === + w.__FAIL_THREAD_ROOT__ + ) { + throw new Error("relay unreachable: request timed out"); + } + return original(command, payload, options); + }; + }); +} + +async function setFailingThreadRoot(page: Page, rootId: string | null) { + await page.evaluate((rootId) => { + ( + window as typeof window & { __FAIL_THREAD_ROOT__?: string | null } + ).__FAIL_THREAD_ROOT__ = rootId; + }, rootId); +} + +test.describe("huddle thread load failure", () => { + test("a failed reply subtree shows the retry alert beside surviving rows; Retry recovers", async ({ + page, + }) => { + await installMockBridge(page, { + windowLabel: `huddle-${HUDDLE_CHANNEL_ID}`, + huddle: { + parentChannelId: HUDDLE_PARENT_ID, + ephemeralChannelId: HUDDLE_CHANNEL_ID, + members: [ + { pubkey: TEST_IDENTITIES.tyler.pubkey, role: "member" }, + { pubkey: TEST_IDENTITIES.alice.pubkey, role: "bot" }, + ], + transcriptionEnabled: true, + }, + }); + await page.goto("/"); + + await expect(page.getByTestId("huddle-transcript-intro")).toBeVisible(); + await installPerRootThreadFailureSwitch(page); + await waitForMockLiveSubscription(page, "huddle"); + + // Seed two summarized roots. Each threaded reply emits a live thread + // summary (descendant_count > 0), so both roots enter the transcript's + // useThreadRepliesForRoots fan-out and each gets its own subtree fetch. Fail + // root B's fetch BEFORE seeding so its first fan-out fetch reaches the + // terminal error while root A resolves — the aggregate reports failure with + // A's reply already merged, exactly the partial-transcript case. + const rootB = "b".repeat(64); + await setFailingThreadRoot(page, rootB); + const seeded = await page.evaluate( + ({ + agentPubkey, + rootAContent, + replyAContent, + rootBContent, + replyBContent, + rootBId, + }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is not installed."); + const rootA = emit({ channelName: "huddle", content: rootAContent }); + emit({ + channelName: "huddle", + content: replyAContent, + parentEventId: rootA.id, + pubkey: agentPubkey, + }); + emit({ channelName: "huddle", content: rootBContent, id: rootBId }); + emit({ + channelName: "huddle", + content: replyBContent, + parentEventId: rootBId, + pubkey: agentPubkey, + }); + return { rootA: rootA.id }; + }, + { + agentPubkey: TEST_IDENTITIES.alice.pubkey, + rootAContent: ROOT_A_CONTENT, + replyAContent: REPLY_A_CONTENT, + rootBContent: ROOT_B_CONTENT, + replyBContent: REPLY_B_CONTENT, + rootBId: rootB, + }, + ); + expect(seeded.rootA).toBeTruthy(); + + // The load-bearing assertion: root A's reply still renders (non-destructive + // — a failed subtree does not blank the surviving rows) AND root B's failed + // fan-out surfaces the retry alert. That alert is rendered ONLY by the + // Huddle transcript's `huddleThreadRepliesError` propagation; without it the + // partial transcript would present as complete. (Reply rows themselves flow + // through the live channel window, so their presence is not the signal — the + // alert is.) + await expect( + page.getByTestId("message-row").filter({ hasText: REPLY_A_CONTENT }), + ).toBeVisible({ timeout: 15_000 }); + await expect( + page.getByTestId("message-thread-replies-error"), + ).toContainText("Couldn't load replies", { timeout: 15_000 }); + + // Retry with fetches succeeding: the failed-only refetch recovers root B's + // subtree, the aggregate error clears, and the alert is dismissed while both + // replies stay visible. + await setFailingThreadRoot(page, null); + await page.getByTestId("message-thread-replies-retry").click(); + await expect(page.getByTestId("message-thread-replies-error")).toHaveCount( + 0, + ); + await expect( + page.getByTestId("message-row").filter({ hasText: REPLY_B_CONTENT }), + ).toBeVisible(); + await expect( + page.getByTestId("message-row").filter({ hasText: REPLY_A_CONTENT }), + ).toBeVisible(); + }); +}); diff --git a/desktop/tests/e2e/project-conversation-load-failure.spec.ts b/desktop/tests/e2e/project-conversation-load-failure.spec.ts new file mode 100644 index 00000000000..d76bb709ec3 --- /dev/null +++ b/desktop/tests/e2e/project-conversation-load-failure.spec.ts @@ -0,0 +1,192 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +/** + * End-to-end guard for the false-empty thread-load bug on the SECOND producer + * of the shared thread panel: the Projects "channel conversation" panel + * (`ProjectConversationPanel`). PR #6447 wired the query failure state through + * `ChannelScreen`, but the Projects surface calls the same `useThreadReplies` + * and used to hard-code `threadRepliesPending={false}` with no error/retry — so + * a terminal `/query` failure there painted "No replies in this branch yet" + * with no recovery, the exact defect the PR fixed one surface over. + * + * This drives the REAL Projects panel wiring through the mock bridge: open the + * project's Channels tab, open a conversation, fail every `get_thread_replies` + * fetch at the IPC boundary, and assert the error/Retry card renders (never the + * false-empty). Any regression at the Projects call site (e.g. dropping + * `threadRepliesError` again, or a static `isError={false}`) ships a visible + * false-empty and turns this case red. + */ + +const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); +const ROOT_CONTENT = `Projects conversation root ${DEFAULT_MOCK_PUBKEY} buzz`; +const REPLY_CONTENT = "Projects conversation reply body"; + +// The projects surface is a preview feature — opt in before the app mounts. +async function enableProjectsFeature(page: Page) { + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); +} + +// Fail every get_thread_replies fetch at the IPC boundary while the flag is on, +// then let the real mock handler answer once it is cleared. Wrapping +// __TAURI_INTERNALS__.invoke exercises the whole Projects thread-load path — +// the panel's useThreadReplies query, its retry, and the shared region — with +// no source seam to bypass. +async function installThreadFailureSwitch(page: Page) { + await page.evaluate(() => { + const w = window as typeof window & { + __FAIL_THREAD_REPLIES__?: boolean; + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + }; + w.__FAIL_THREAD_REPLIES__ = false; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + if (command === "get_thread_replies" && w.__FAIL_THREAD_REPLIES__) { + throw new Error("relay unreachable: request timed out"); + } + return original(command, payload, options); + }; + }); +} + +async function setThreadRepliesFailing(page: Page, failing: boolean) { + await page.evaluate((failing) => { + ( + window as typeof window & { __FAIL_THREAD_REPLIES__?: boolean } + ).__FAIL_THREAD_REPLIES__ = failing; + }, failing); +} + +test.describe("project conversation load failure", () => { + test("terminal fetch failure shows error card, never false-empty; Retry recovers", async ({ + page, + }) => { + await enableProjectsFeature(page); + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + + // Seed the conversation BEFORE opening general — i.e. before its live + // subscription exists — so the reply lands in the mock store (searchable, + // and returnable by a successful get_thread_replies) but is never live + // pushed into the thread-replies cache. If general were open first, the + // live handler would seed that cache and the panel would render the list + // branch, masking the error card. The root carries the repository discovery + // token so it surfaces as the channel's latest discussion hit (what the + // Channels-tab row opens); its reply omits the token so it never competes + // to be the opened hit. + // + // Wait for the emitter first: emitting before the app boots is a silent + // no-op (the helper is undefined), which would leave the store empty. + await expect + .poll(() => + page.evaluate( + () => + typeof ( + window as typeof window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ), + ) + .toBe(true); + const rootId = await page.evaluate( + ({ author, rootContent, replyContent }) => { + const now = Math.floor(Date.now() / 1000); + const emit = ( + window as typeof window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__: (input: { + channelName: string; + content: string; + parentEventId?: string; + pubkey?: string; + createdAt?: number; + }) => { id: string }; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + const root = emit({ + channelName: "general", + content: rootContent, + pubkey: author, + createdAt: now, + }); + emit({ + channelName: "general", + content: replyContent, + parentEventId: root.id, + pubkey: author, + createdAt: now + 1, + }); + return root.id; + }, + { + author: TEST_IDENTITIES.alice.pubkey, + rootContent: ROOT_CONTENT, + replyContent: REPLY_CONTENT, + }, + ); + expect(rootId).toBeTruthy(); + + // Navigate to the project's Channels tab and open the general conversation. + await page.getByTestId("open-projects-view").click(); + await page.getByTestId("projects-section-projects").click(); + const projectEntry = page + .locator( + '[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]', + ) + .first(); + await expect(projectEntry).toBeVisible({ timeout: 10_000 }); + await projectEntry.click(); + await page.getByRole("tab", { name: "Channels", exact: true }).click(); + const channelRow = page + .getByTestId("project-channel-row") + .filter({ hasText: "#general" }) + .first(); + await expect(channelRow).toBeVisible({ timeout: 10_000 }); + + // Fail every thread-replies fetch, THEN open the conversation: the panel's + // query and its retry both fail, driving the terminal error state with an + // empty reply cache and nothing to fall back to. + await installThreadFailureSwitch(page); + await setThreadRepliesFailing(page, true); + await channelRow.click(); + + const panel = page.getByTestId("project-conversation-panel"); + await expect(panel).toBeVisible(); + + // The load-bearing assertion: a terminal failure paints the error/Retry + // card and NEVER the false-empty "No replies in this branch yet" state. + await expect( + page.getByTestId("message-thread-replies-error"), + ).toContainText("Couldn't load replies", { timeout: 15_000 }); + await expect(page.getByTestId("message-thread-replies-retry")).toHaveText( + "Retry", + ); + await expect(page.getByText("No replies in this branch yet")).toHaveCount( + 0, + ); + + // Retry with fetches succeeding: the reply loads and renders — the error + // card is gone and no false-empty appears. + await setThreadRepliesFailing(page, false); + await page.getByTestId("message-thread-replies-retry").click(); + await expect(page.getByTestId("message-thread-replies-error")).toHaveCount( + 0, + ); + await expect(page.getByText(REPLY_CONTENT)).toBeVisible(); + await expect(page.getByText("No replies in this branch yet")).toHaveCount( + 0, + ); + }); +}); diff --git a/desktop/tests/e2e/thread-load-failure.spec.ts b/desktop/tests/e2e/thread-load-failure.spec.ts new file mode 100644 index 00000000000..291600e736b --- /dev/null +++ b/desktop/tests/e2e/thread-load-failure.spec.ts @@ -0,0 +1,161 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge"; + +/** + * End-to-end guard for the false-empty thread-load bug (PR #6447). A terminal + * thread-replies fetch failure must paint the error/Retry card and NEVER the + * "No replies in this branch yet" empty card — the two look similar but a failed + * load presented as an authoritative empty is the user-visible defect. + * + * Unit tests cover `ThreadReplyRegion` in isolation, but they cannot mount the + * full panel (Tiptap/React-Query), so they never observe the production + * panel→region handoff. This spec drives the REAL panel wiring through the mock + * bridge, so any regression at that seam (e.g. a static `isError={false}` at the + * call site) ships a visible false-empty and turns this case red. + */ + +// Fail every get_thread_replies fetch at the IPC boundary while the flag is on, +// then let the real mock handler answer once it is cleared. Wrapping +// __TAURI_INTERNALS__.invoke (installed by the mock bridge) exercises the whole +// thread-load path — query hook, retry, panel, region — exactly as production +// does, with no source seam to bypass. A boolean gate (rather than a failure +// countdown) is deterministic: stray thread-reply prefetches during channel +// open can't drain it, so the panel's own load reliably reaches the terminal +// error state, and clearing the flag makes the Retry fetch reliably succeed. +async function installThreadFailureSwitch(page: Page) { + await page.evaluate(() => { + const w = window as typeof window & { + __FAIL_THREAD_REPLIES__?: boolean; + __TAURI_INTERNALS__: { + invoke: ( + command: string, + payload: unknown, + options: unknown, + ) => Promise; + }; + }; + w.__FAIL_THREAD_REPLIES__ = false; + const original = w.__TAURI_INTERNALS__.invoke.bind(w.__TAURI_INTERNALS__); + w.__TAURI_INTERNALS__.invoke = async (command, payload, options) => { + if (command === "get_thread_replies" && w.__FAIL_THREAD_REPLIES__) { + throw new Error("relay unreachable: request timed out"); + } + return original(command, payload, options); + }; + }); +} + +async function setThreadRepliesFailing(page: Page, failing: boolean) { + await page.evaluate((failing) => { + ( + window as typeof window & { __FAIL_THREAD_REPLIES__?: boolean } + ).__FAIL_THREAD_REPLIES__ = failing; + }, failing); +} + +// Open the welcome thread deterministically: prefer its summary row, but fall +// back to hovering the root message and clicking Reply. The summary row depends +// on the channel-window query having materialized the seeded reply, which can +// lag; the root Reply affordance opens the same thread panel without that race, +// so the panel is reliably open before the error-card assertions run. +async function openWelcomeThread(page: Page) { + const summary = page.locator( + '[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]', + ); + if (await summary.count()) { + await summary.first().click(); + } else { + const root = page.locator( + '[data-testid="message-row"][data-message-id="mock-general-welcome"]', + ); + await root.hover(); + await root.getByRole("button", { name: "Reply" }).click(); + } + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); +} + +test.describe("thread load failure", () => { + test("terminal fetch failure shows error card, never false-empty; Retry recovers", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + + // Seed a reply into the mock store BEFORE opening general, i.e. before its + // live subscription exists. The reply lands in the channel window (so the + // "1 reply" thread summary renders) but is never live-pushed into the + // thread-replies cache — so the thread opens with an empty reply cache and + // the failed get_thread_replies has nothing to fall back to. If it were + // emitted while general was open, the live handler would seed the thread + // cache and the panel would render the list branch, masking the error card. + // + // Wait for the emitter to be installed first: emitting before the app has + // booted is a silent no-op (the helper is undefined), which would leave the + // store empty and the recovery assertion with nothing to render. + await expect + .poll(() => + page.evaluate( + () => + typeof ( + window as typeof window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ), + ) + .toBe(true); + await page.evaluate((pubkey) => { + ( + window as typeof window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string; + pubkey?: string; + createdAt?: number; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "First reply to welcome", + parentEventId: "mock-general-welcome", + pubkey, + createdAt: Math.floor(Date.now() / 1000) - 10, + }); + }, TEST_IDENTITIES.alice.pubkey); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await installThreadFailureSwitch(page); + + // Fail every thread-replies fetch, then open the thread: the panel query and + // its retry both fail, driving the terminal error state. + await setThreadRepliesFailing(page, true); + await openWelcomeThread(page); + + // The load-bearing assertion: a terminal failure paints the error/Retry + // card and NEVER the false-empty "No replies in this branch yet" state. + await expect( + page.getByTestId("message-thread-replies-error"), + ).toContainText("Couldn't load replies", { timeout: 15_000 }); + await expect(page.getByTestId("message-thread-replies-retry")).toHaveText( + "Retry", + ); + await expect(page.getByText("No replies in this branch yet")).toHaveCount( + 0, + ); + + // Retry with fetches succeeding again: the reply loads and renders — the + // error card is gone and no false-empty appears. + await setThreadRepliesFailing(page, false); + await page.getByTestId("message-thread-replies-retry").click(); + await expect(page.getByTestId("message-thread-replies-error")).toHaveCount( + 0, + ); + await expect(page.getByText("First reply to welcome")).toBeVisible(); + await expect(page.getByText("No replies in this branch yet")).toHaveCount( + 0, + ); + }); +}); From f79d346a178408661fcad85122364ac2ad7e9cb2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 24 Aug 2026 14:55:05 -0700 Subject: [PATCH 014/101] fix(composer): wrap Buzz chip labels without orphaning icons (#6581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Long Buzz link chips now wrap without overflowing in composers and sent messages; icon-bearing stubs use at most five graphemes when no earlier separator exists, so some sent chips attach the icon to a shorter prefix than before. Labels over 48 graphemes are visibly truncated in the composer while their full identity remains available to assistive technology and in the tooltip. **Problem:** Long repository, issue, pull request, and channel chip labels could orphan their icon or overflow narrow composers and sent messages. **Solution:** Keep the icon with a bounded, grapheme-safe leading fragment while allowing the remaining text to break anywhere; cap visible labels at 48 graphemes without changing the full tooltip or accessible identity.
File changes **desktop/src/features/messages/lib/composerMessageLinkNode.ts** Splits composer chip content into an icon-bearing leading fragment and a freely wrapping remainder while preserving the semantic label and link metadata. **desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs** Updates renderer assertions for the fragment structure and verifies every supported Buzz link kind retains the intended visible label. **desktop/src/shared/styles/globals/composer.css** Keeps ordinary composer mention decorations inline while relying on the existing shared markdown chip wrapping rules for Buzz links. **desktop/src/shared/ui/mentionChip.ts** Centralizes grapheme-aware leading-fragment boundaries and label truncation so composer and sent chips share the same visible identity. **desktop/src/shared/ui/markdown/BuzzLinkChip.tsx** Uses the shared grapheme-aware boundary when rendering sent-message chip fragments. **desktop/tests/e2e/navigation.spec.ts** Covers increasing wrap depth across constrained widths, icon attachment, the sent-message wrap, accessible labeling, and tooltip positioning over both edge fragments.
## Reproduction steps 1. Open a desktop channel and paste a Buzz link with a long repository or channel name into the composer. 2. Narrow the composer until the chip spans two or more lines. 3. Confirm the label breaks mid-string while the icon remains attached to the first label fragment. 4. Send the message, hover both the first and last rendered fragments, and confirm the tooltip follows the hovered fragment. ## Screenshots **Before — the icon drops onto a separate line from its chip label** ![Composer chip with an orphaned icon before the fix](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/00-before-inline-chip-icon-wrap.png) **After — the icon stays attached while the remaining label wraps** The same long repository chip at three composer widths. Its label gains line breaks as space contracts, while the icon remains attached to the leading fragment. **420px — one line** ![Long repository chip on one line at 420px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/01-wide-420px.png) **210px — two lines** ![Long repository chip on two lines at 210px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/02-medium-210px.png) **150px — three lines** ![Long repository chip on three lines at 150px](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6581/03-narrow-150px.png) --------- Signed-off-by: Taylor Ho Co-authored-by: Carl --- .../lib/composerMessageLinkNode.test.mjs | 57 ++++- .../messages/lib/composerMessageLinkNode.ts | 45 +++- .../src/shared/styles/globals/composer.css | 10 +- .../src/shared/styles/globals/markdown.css | 5 + desktop/src/shared/ui/markdown.test.mjs | 6 +- .../src/shared/ui/markdown/BuzzLinkChip.tsx | 22 +- .../useInlineTooltipPosition.test.mjs | 147 +++++++++++++ .../ui/markdown/useInlineTooltipPosition.ts | 49 ++++- desktop/src/shared/ui/mentionChip.ts | 41 +++- desktop/tests/e2e/navigation.spec.ts | 200 +++++++++++++++++- 10 files changed, 545 insertions(+), 37 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/useInlineTooltipPosition.test.mjs diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs index 3695275762d..57212aeafeb 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs @@ -19,6 +19,7 @@ const CHANNEL_MESSAGE_ID = "a".repeat(64); const CHANNEL_MESSAGE_HREF = `buzz://channel/${CHANNEL_ID}/${CHANNEL_MESSAGE_ID}`; const OWNER = "a".repeat(64); const REPO_HREF = `buzz://repo?owner=${OWNER}&d=buzz-world`; +const PROJECT_HREF = `buzz://project?owner=${OWNER}&d=buzz-world`; const ISSUE_ID = "b".repeat(64); const ISSUE_HREF = `buzz://issue?id=${ISSUE_ID}&owner=${OWNER}&d=buzz-world`; const PR_ID = "c".repeat(64); @@ -188,6 +189,10 @@ test("markdown parsing stops message links before emphasis delimiters", () => { assert.deepEqual(token.meta, { channelName: "general", href: HREF }); }); +function renderedChipLabel(rendered) { + return `${rendered[2][2]}${rendered[3]}`; +} + test("composer node uses the sent-message chip presentation", () => { const node = { attrs: { channelName: "general", href: HREF }, @@ -209,7 +214,44 @@ test("composer node uses the sent-message chip presentation", () => { assert.equal(rendered[1]["data-buzz-link"], ""); // Channel label only — no event hash, so the chip does not change width when // the draft is sent and the rendered chip resolves its metadata. - assert.equal(rendered[2], "general"); + assert.match(rendered[1].class, /wrapping-inline-chip/); + assert.match(rendered[2][1].class, /inline-chip-leading-fragment/); + assert.equal(renderedChipLabel(rendered), "general"); +}); + +test("composer node truncates and preserves grapheme-safe leading fragments", () => { + const render = ComposerMessageLinkNode.configure({ + resolveChannelName: () => undefined, + }).config.renderHTML; + assert.ok(render); + + const longName = `relay-${"observability".repeat(5)}`; + const longRendered = render.call( + { options: { resolveChannelName: () => undefined } }, + { + node: { attrs: { channelName: longName, href: HREF } }, + HTMLAttributes: {}, + }, + ); + assert.equal(renderedChipLabel(longRendered), `${longName.slice(0, 47)}…`); + + for (const [label, expectedLeading] of [ + ["🇺🇸channel", "🇺🇸chan"], + ["e\u0301quipe", "e\u0301quip"], + ["relaytoolsobservabilityconsole-main", "relay"], + [" leading-space", ""], + ]) { + const rendered = render.call( + { options: { resolveChannelName: () => undefined } }, + { + node: { attrs: { channelName: label, href: HREF } }, + HTMLAttributes: {}, + }, + ); + assert.equal(rendered[2][2], expectedLeading); + assert.match(rendered[2][1].class, /inline-chip-with-icon/); + assert.equal(renderedChipLabel(rendered), label); + } }); test("composer node renders channel and entity chip presentations", () => { @@ -227,24 +269,29 @@ test("composer node renders channel and entity chip presentations", () => { const channel = render(CHANNEL_HREF); assert.equal(channel[1]["data-channel-deep-link"], ""); assert.match(channel[1].class, /inline-chip-icon-channel/); - assert.equal(channel[2], "general"); + assert.equal(renderedChipLabel(channel), "general"); const repo = render(REPO_HREF); assert.equal(repo[1]["data-buzz-link-kind"], "repo"); assert.match(repo[1].class, /inline-chip-icon-repo/); - assert.equal(repo[2], "buzz-world"); + assert.equal(renderedChipLabel(repo), "buzz-world"); + + const project = render(PROJECT_HREF); + assert.equal(project[1]["data-buzz-link-kind"], "project"); + assert.match(project[1].class, /inline-chip-icon-project/); + assert.equal(renderedChipLabel(project), "buzz-world"); const issue = render(ISSUE_HREF); assert.equal(issue[1]["data-buzz-link-kind"], "issue"); assert.match(issue[1].class, /inline-chip-icon-issue/); // Repository name only — the rendered chip never widens into the issue // title, so the composer must not widen into the event hash either. - assert.equal(issue[2], "buzz-world"); + assert.equal(renderedChipLabel(issue), "buzz-world"); const pullRequest = render(PR_HREF); assert.equal(pullRequest[1]["data-buzz-link-kind"], "pr"); assert.match(pullRequest[1].class, /inline-chip-icon-pr/); - assert.equal(pullRequest[2], "buzz-world"); + assert.equal(renderedChipLabel(pullRequest), "buzz-world"); }); test("markdown rendering stores identity in attributes, not visible id text", () => { diff --git a/desktop/src/features/messages/lib/composerMessageLinkNode.ts b/desktop/src/features/messages/lib/composerMessageLinkNode.ts index 9587c312cab..59b15c78ba6 100644 --- a/desktop/src/features/messages/lib/composerMessageLinkNode.ts +++ b/desktop/src/features/messages/lib/composerMessageLinkNode.ts @@ -12,8 +12,11 @@ import { } from "@/shared/lib/entityLink"; import { inlineChipIconClasses, + inlineChipLeadingEnd, type InlineChipIconKind, MENTION_CHIP_BASE_CLASSES, + truncateInlineChipLabel, + WRAPPING_INLINE_CHIP_CLASSES, } from "@/shared/ui/mentionChip"; import { buildChannelLink, parseChannelLink } from "./channelLink"; import { getMessageLinkLabel } from "./messageLinkLabel"; @@ -285,6 +288,38 @@ function composerLinkPresentation( }; } +function wrappingComposerChipContent( + label: string, + icon: InlineChipIconKind, +): { leading: [string, Record, string]; remainder: string } { + const leadingEnd = inlineChipLeadingEnd(label); + if (!leadingEnd) { + return { + leading: [ + "span", + { + "aria-hidden": "true", + class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`, + }, + "", + ], + remainder: label, + }; + } + + return { + leading: [ + "span", + { + "aria-hidden": "true", + class: `inline-chip-leading-fragment ${inlineChipIconClasses(icon)}`, + }, + label.slice(0, leadingEnd), + ], + remainder: label.slice(leadingEnd), + }; +} + export const ComposerMessageLinkNode = Node.create({ name: COMPOSER_MESSAGE_LINK_NODE_NAME, @@ -328,11 +363,16 @@ export const ComposerMessageLinkNode = String(node.attrs.channelName ?? ""), this.options.resolveChannelName, ); + const visibleLabel = truncateInlineChipLabel(presentation.label); + const content = wrappingComposerChipContent( + visibleLabel, + presentation.icon, + ); return [ "span", mergeAttributes(HTMLAttributes, { "aria-label": presentation.ariaLabel, - class: `${MENTION_CHIP_BASE_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`, + class: `${MENTION_CHIP_BASE_CLASSES} ${WRAPPING_INLINE_CHIP_CLASSES} ${inlineChipIconClasses(presentation.icon)} cursor-text`, "data-buzz-link": "", "data-channel-name": presentation.channelName, "data-composer-buzz-link": "", @@ -340,7 +380,8 @@ export const ComposerMessageLinkNode = ...presentation.dataAttributes, title: presentation.ariaLabel, }), - presentation.label, + content.leading, + content.remainder, ]; }, diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index fbbcdf026c9..781ac8a1968 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -256,11 +256,11 @@ Body copy is text-sm; inline code labels sit one step down (text-xs). All chip variants share one box height so mono labels stay balanced. - Rendered-message chips use inline-flex. The same decoration in the - composer pulls the caret into the chip and swallows the trailing - space after an @mention, so typing "hello" after @quinn becomes - "@quinnhello". Keep composer chips inline; leave ::before icons in - place. */ + Plain-text mention decorations stay inline because inline-flex pulls the + caret into their decorated range and swallows the trailing space after an + @mention. Composer Buzz links are atom nodes, but their labels may still + fragment between characters; only the icon and leading label fragment stay + together. */ .rich-text-composer .tiptap .mention-chip { display: inline; min-height: 0; diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index e443357ae82..9d0c382ac3a 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -211,6 +211,11 @@ white-space: nowrap; } +.message-markdown + .inline-chip-leading-fragment.inline-chip-with-icon:empty::after { + content: "\200b"; +} + .message-markdown .inline-chip-leading-fragment.inline-chip-with-icon::before { display: block; left: 0; diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 8136ff9f08e..6cd15c69b4c 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1121,7 +1121,7 @@ test("bare Buzz permalinks render cohesive icon-prefixed chips", () => { assert.equal((html.match(/data-channel-deep-link=""/g) ?? []).length, 1); assert.match(html, /inline-chip-icon-channel/); assert.match(html, /wrapping-inline-chip/); - assert.match(html, /inline-chip-leading-fragment[^>]*>e]*>engin { ); assert.equal( - (html.match(/inline-chip-leading-fragment[^>]*>5<\/span>80ca78b/g) ?? []) + (html.match(/inline-chip-leading-fragment[^>]*>580ca<\/span>78b/g) ?? []) .length, 2, ); @@ -1332,7 +1332,7 @@ test("channel references replace the authored hash with the channel icon", () => assert.match(html, /inline-chip-icon-channel/); assert.match(html, /wrapping-inline-chip/); - assert.match(html, /inline-chip-leading-fragment[^>]*>e]*>engin]+>/g, ""), /engineering/); assert.doesNotMatch(html, />#engineering + ); } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index ad1acf87c34..8f0300af161 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -410,19 +410,25 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as 0, ); await expect(relayRow).toContainText("agent"); - await expect( - relayRow.getByTestId("mention-agent-provenance"), - ).toHaveAttribute("aria-label", "From another Buzz setup"); - await expect( - relayRow.getByText("Other setup", { exact: true }), - ).toBeVisible(); + const relayProvenanceMarker = relayRow.getByTestId( + "mention-agent-provenance", + ); + await expect(relayProvenanceMarker).toHaveAttribute( + "aria-label", + "From another Buzz setup", + ); + await expect(relayProvenanceMarker).toHaveAttribute( + "title", + "From another Buzz setup", + ); + await expect(relayProvenanceMarker).toBeVisible(); + await expect(relayProvenanceMarker).toHaveText(""); + await expect(relayProvenanceMarker.locator("svg")).toBeVisible(); await expect(managedRow).not.toContainText("managed by you"); await expect(relayRow).not.toContainText("managed by you"); await page.setViewportSize({ width: 760, height: 640 }); - await expect( - relayRow.getByText("Other setup", { exact: true }), - ).toBeVisible(); + await expect(relayProvenanceMarker).toBeVisible(); const rowBox = await relayRow.boundingBox(); const dropdownBox = await dropdown.boundingBox(); expect(rowBox).not.toBeNull(); @@ -495,7 +501,8 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as "aria-label", "From another Buzz setup", ); - await expect(remoteSidebarMarker).toHaveText("Other setup"); + await expect(remoteSidebarMarker).toHaveText(""); + await expect(remoteSidebarMarker.locator("svg")).toBeVisible(); const remoteSidebarRow = page.getByTestId(`sidebar-member-${relayPubkey}`); const localSidebarMarker = page.getByTestId( `sidebar-member-agent-provenance-${managedPubkey}`, From c5166f2164035ca96787daee6528d5dc04c4a02e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:28:51 -0700 Subject: [PATCH 018/101] feat(desktop): simplify the message action rail (#6529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Quick-reaction shortcuts make the message action rail visually noisy. Reaction access should remain first-class, but through one predictable Add reaction action rather than several learned emoji shortcuts. ## Change The rail is now: **Add reaction · Reply · Copy link · More** - Remove all quick-reaction shortcut buttons and their divider from the message action rail. - Preserve Add reaction as the first action, including its existing picker, tooltip, accessible name, keyboard behavior, reaction behavior, and feedback. - Keep the existing Link2 Copy link action, shared copy handler, More-menu entry, eligibility guards, and success feedback unchanged. - Keep reveal, positioning, responsiveness, styling, and remaining menu paths unchanged. The now-unused quick-reaction rendering component and imports were removed from `MessageActionBar`; shared reaction learning remains intact for other reaction surfaces. ## Tests Focused smoke coverage verifies: - zero `React with …` shortcut buttons; - exact ordered rail: `Open reactions → Reply → Copy link → More actions`; - the rail and More-menu paths emit the same canonical thread-aware `buzz://message` URL; - existing success feedback; - pending and huddle rows omit both copy-link surfaces; - the action bar stays within the open thread panel. The zero-shortcut contract was mutation-checked by restoring the prior production action bar: the focused test failed causally with expected 0 versus received 3 quick-reaction buttons. ## Validation At `4f062e0d60b0f0c16b6862cdea7137c287060947`: - `pnpm exec biome check src/features/messages/ui/MessageActionBar.tsx tests/e2e/message-copy-link.spec.ts` — passed. - `pnpm exec tsc --noEmit` — passed. - `pnpm test` — 5,432 passed, 0 failed. - `pnpm build:e2e` — passed; existing dynamic-import and chunk-size warnings only. - `pnpm exec playwright test tests/e2e/message-copy-link.spec.ts --project=smoke` — 2 passed. - Pre-push `file-size-check`, `desktop-check`, `desktop-typecheck`, and `desktop-test` — passed. Vogue’s design review: **SHIP** — reaction remains discoverable and accessible as the visible first action; the extra click is an intentional efficiency tradeoff for the simpler hierarchy. --------- Signed-off-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Signed-off-by: Rivet Co-authored-by: Trace (Engineer) <9d485ff0c62915e08a801162c195c7ef096f6f6143925407255cea2656c4b8a4@buzz.block.builderlab.xyz> Co-authored-by: Rivet --- desktop/playwright.config.ts | 1 + .../features/messages/ui/MessageActionBar.tsx | 141 +++++--------- .../e2e/channel-activity-popover.spec.ts | 9 +- desktop/tests/e2e/custom-emoji.spec.ts | 21 +- desktop/tests/e2e/inbox-reactions.spec.ts | 10 +- desktop/tests/e2e/message-copy-link.spec.ts | 182 ++++++++++++++++++ desktop/tests/e2e/reaction-order.spec.ts | 25 +-- 7 files changed, 268 insertions(+), 121 deletions(-) create mode 100644 desktop/tests/e2e/message-copy-link.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 69250c4b537..be15c75587d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", "**/message-feedback-snapshots.spec.ts", + "**/message-copy-link.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", "**/custom-emoji-ui.spec.ts", diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 4fcf0f067ab..9c490edb076 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -18,7 +18,6 @@ import { toast } from "sonner"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; -import { useCustomEmoji } from "@/features/custom-emoji/hooks"; import { getThreadReference } from "@/features/messages/lib/threading"; import { ReportMessageDialog } from "@/features/moderation/ui/ReportMessageDialog"; import { MessageModerationMenuItems } from "@/features/moderation/ui/MessageModerationMenuItems"; @@ -26,15 +25,9 @@ import type { TimelineMessage, TimelineReaction, } from "@/features/messages/types"; -import { - recordQuickReactionEmoji, - useQuickReactionEmojis, -} from "@/features/messages/ui/useQuickReactionEmojis"; -import { reactionEmojiUrl } from "@/shared/api/customEmoji"; +import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis"; import { cn } from "@/shared/lib/cn"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; -import { emojiDisplayName } from "@/shared/lib/emojiName"; -import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; import { Button } from "@/shared/ui/button"; import { HashArrowIn } from "@/shared/ui/icons"; @@ -53,6 +46,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const ACTION_BUTTON_CLASS = "h-8 w-8 rounded-full p-0"; const ACTION_ICON_CLASS = "!h-4 !w-4"; +/** Copying a message link is offered from both the hover action bar and the + * More menu; both paths share this exact link-building + toast behavior. */ +function copyMessageLink(channelId: string, message: TimelineMessage) { + const { rootId } = getThreadReference(message.tags ?? []); + const link = buildMessageLink({ + channelId, + messageId: message.id, + threadRootId: rootId, + }); + copyTextToClipboard(link, "Link copied to clipboard"); +} + +/** Gate shared by every copy-link surface: pending sends have no delivered + * event to link to, huddle system rows aren't linkable, and callers without + * a channelId (e.g. inbox preview rows) can't build the link. */ +function canCopyMessageLink( + message: TimelineMessage, + channelId: string | null | undefined, +): channelId is string { + return ( + !message.pending && + message.kind !== KIND_HUDDLE_STARTED && + Boolean(channelId) + ); +} + function MoreActionsMenu({ channelId, message, @@ -242,17 +261,11 @@ function MoreActionsMenu({ ) : null} - {hasCopyActions && channelId ? ( + {canCopyMessageLink(message, channelId) ? ( { - const { rootId } = getThreadReference(message.tags ?? []); - const link = buildMessageLink({ - channelId, - messageId: message.id, - threadRootId: rootId, - }); - copyTextToClipboard(link, "Link copied to clipboard"); + copyMessageLink(channelId, message); }} > @@ -316,51 +329,6 @@ function MoreActionsMenu({ ); } -function QuickReactionButton({ - customEmojiUrl, - emoji, - onSelect, -}: { - customEmojiUrl?: string; - emoji: string; - onSelect: (emoji: string) => void; -}) { - const displayName = emojiDisplayName(emoji); - const mediaUrl = customEmojiUrl ? rewriteRelayUrl(customEmojiUrl) : null; - - return ( - - - - - {displayName} - - ); -} - -function isCustomEmojiShortcode(emoji: string) { - return emoji.startsWith(":") && emoji.endsWith(":"); -} - export const MessageActionBar = React.memo(function MessageActionBar({ channelId, message, @@ -404,20 +372,6 @@ export const MessageActionBar = React.memo(function MessageActionBar({ }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); - const customEmoji = useCustomEmoji(); - const quickReactionEmojis = useQuickReactionEmojis(4, customEmoji); - const quickReactionItems = React.useMemo( - () => - quickReactionEmojis - .map((emoji) => ({ - customEmojiUrl: reactionEmojiUrl(emoji, customEmoji), - emoji, - })) - .filter( - (item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl, - ), - [customEmoji, quickReactionEmojis], - ); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); @@ -482,22 +436,6 @@ export const MessageActionBar = React.memo(function MessageActionBar({ >
- {hasReactionAction && quickReactionItems.length > 0 ? ( - <> -
- {quickReactionItems.map(({ customEmojiUrl, emoji }) => ( - - ))} -
-
- - ) : null} - {hasReactionAction ? ( ) : null} + {canCopyMessageLink(message, channelId) ? ( + + + + + Copy link + + ) : null} + {hasMoreMenuActions ? ( { await rootRow.hover(); const actionBar = page.getByTestId(`message-action-bar-${root.id}`); await expect(actionBar).toBeVisible(); - await actionBar - .getByRole("button", { name: /^React with / }) - .first() - .click(); + await actionBar.getByRole("button", { name: "Open reactions" }).click(); + const picker = page.locator("em-emoji-picker"); + await expect(picker).toBeVisible(); + await picker.locator("input[type='search']").fill("thumbs up"); + await picker.getByRole("button", { name: "👍" }).first().click(); await expect( rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }), ).toBeVisible(); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index 16ca3900ece..9a4bf7f07cc 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -285,7 +285,7 @@ async function quickReactionStorageContains( }, emoji); } -test("message quick reaction tray stays neutral after selecting a tray emoji", async ({ +test("message reaction action stays neutral after selecting from the picker", async ({ page, }) => { await openGeneral(page); @@ -294,19 +294,18 @@ test("message quick reaction tray stays neutral after selecting a tray emoji", a await expect(row).toBeVisible(); await row.hover(); - const quickReactionButton = row.getByRole("button", { - name: "React with :+1:", - }); - await expect(quickReactionButton).toBeVisible(); - await quickReactionButton.click(); + const reactionTrigger = messageReactionTrigger(row); + await expect(reactionTrigger).toBeVisible(); + await reactionTrigger.click(); + const picker = page.locator("em-emoji-picker"); + await expect(picker).toBeVisible(); + await picker.locator("input[type='search']").fill("thumbs up"); + await picker.getByRole("button", { name: "👍" }).first().click(); await expect(row.getByLabel("Toggle 👍 reaction")).toBeVisible(); await row.hover(); - await expect(quickReactionButton).not.toHaveAttribute("aria-pressed", "true"); - await expect(quickReactionButton).not.toHaveClass(SELECTED_ACTION_CLASS); - await expect(messageReactionTrigger(row)).not.toHaveClass( - SELECTED_ACTION_CLASS, - ); + await expect(reactionTrigger).not.toHaveAttribute("aria-pressed", "true"); + await expect(reactionTrigger).not.toHaveClass(SELECTED_ACTION_CLASS); }); test("emoji picker keeps Frequently used live within the app session", async ({ diff --git a/desktop/tests/e2e/inbox-reactions.spec.ts b/desktop/tests/e2e/inbox-reactions.spec.ts index 1b7451cbcc2..2385be0f6c3 100644 --- a/desktop/tests/e2e/inbox-reactions.spec.ts +++ b/desktop/tests/e2e/inbox-reactions.spec.ts @@ -114,7 +114,7 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({ }, ); - // Open the inbox item and react via the hover action bar's quick reaction. + // Open the inbox item and react through Add reaction and the picker. const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`); await item.click(); const detail = page.getByTestId("home-inbox-detail"); @@ -152,9 +152,11 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({ } expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y); - await selectedMessage - .getByRole("button", { name: "React with :+1:" }) - .click(); + await actionBar.getByRole("button", { name: "Open reactions" }).click(); + const picker = page.locator("em-emoji-picker"); + await expect(picker).toBeVisible(); + await picker.locator("input[type='search']").fill("thumbs up"); + await picker.getByRole("button", { name: "👍" }).first().click(); // The pill must appear AND persist: the post-toggle refetch replaces the // optimistic state with fetched reaction events. Give the refetch time to diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts new file mode 100644 index 00000000000..a7ef2f10c62 --- /dev/null +++ b/desktop/tests/e2e/message-copy-link.spec.ts @@ -0,0 +1,182 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { KIND_HUDDLE_STARTED } from "../../src/shared/constants/kinds"; +import { installMockBridge } from "../helpers/bridge"; + +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +async function latestClipboardWrite(page: Page) { + return page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( + ({ command }) => command === "copy_text_to_clipboard", + ), + ); +} + +async function expectCopyLinkUnavailable(row: Locator, messageId: string) { + await row.hover(); + await expect(row.getByTestId(`copy-link-message-${messageId}`)).toHaveCount( + 0, + ); + + const moreActions = row.getByTestId(`more-actions-${messageId}`); + if (await moreActions.count()) { + await moreActions.click({ force: true }); + await expect( + row.page().getByTestId(`copy-message-link-${messageId}`), + ).toHaveCount(0); + await row.page().keyboard.press("Escape"); + } +} + +test.beforeEach(async ({ page }) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + await installMockBridge(page); +}); + +test("message action rail copies the same canonical thread link as More", async ({ + page, +}) => { + await page.setViewportSize({ width: 900, height: 700 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ), + ) + .toBe(true); + + const { replyId, rootId } = await page.evaluate(() => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const root = emit({ + channelName: "general", + content: "Copy-link regression root", + id: "a".repeat(64), + }); + const reply = emit({ + channelName: "general", + content: "Copy-link regression reply", + id: "b".repeat(64), + parentEventId: root.id, + }); + return { replyId: reply.id, rootId: root.id }; + }); + + await page + .locator( + `[data-testid="message-thread-summary"][data-thread-head-id="${rootId}"]`, + ) + .click(); + const threadPanel = page.getByTestId("message-thread-panel"); + const replyRow = threadPanel.locator(`[data-message-id="${replyId}"]`); + await expect(replyRow).toContainText("Copy-link regression reply"); + await replyRow.hover(); + + const actionBar = replyRow.getByTestId(`message-action-bar-${replyId}`); + const quickReactions = actionBar.getByRole("button", { + name: /^React with /, + }); + await expect(quickReactions).toHaveCount(0); + const orderedActionNames = await actionBar + .getByRole("button") + .evaluateAll((buttons) => + buttons.map((button) => button.getAttribute("aria-label")), + ); + expect(orderedActionNames).toEqual([ + "Open reactions", + "Reply", + "Copy link", + "More actions", + ]); + + const copyLink = actionBar.getByTestId(`copy-link-message-${replyId}`); + await expect(copyLink).toHaveAccessibleName("Copy link"); + await copyLink.hover(); + await expect(page.getByRole("tooltip", { name: "Copy link" })).toBeVisible(); + + const expectedLink = `buzz://message?channel=${GENERAL_CHANNEL_ID}&id=${replyId}&thread=${rootId}`; + await copyLink.click(); + await expect + .poll(async () => (await latestClipboardWrite(page))?.payload.text) + .toBe(expectedLink); + await expect( + page.locator("[data-sonner-toast]").filter({ + hasText: "Link copied to clipboard", + }), + ).toBeVisible(); + + await actionBar.getByTestId(`more-actions-${replyId}`).click(); + await page.getByTestId(`copy-message-link-${replyId}`).click(); + await expect + .poll(async () => { + const writes = (await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + ({ command }) => command === "copy_text_to_clipboard", + ), + )) as Array<{ payload: unknown }>; + return writes.map(({ payload }) => payload); + }) + .toEqual([{ text: expectedLink }, { text: expectedLink }]); + + const [barBox, panelBox] = await Promise.all([ + actionBar.boundingBox(), + threadPanel.boundingBox(), + ]); + expect(barBox).not.toBeNull(); + expect(panelBox).not.toBeNull(); + if (!barBox || !panelBox) throw new Error("Message action bounds missing."); + expect(barBox.x).toBeGreaterThanOrEqual(panelBox.x); + expect(barBox.x + barBox.width).toBeLessThanOrEqual( + panelBox.x + panelBox.width, + ); +}); + +test("pending and huddle rows omit both copy-link surfaces", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ), + ) + .toBe(true); + + const { huddleId, pendingId } = await page.evaluate((huddleKind) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const pending = emit({ + channelName: "general", + content: "Pending copy-link regression", + id: "c".repeat(64), + pending: true, + }); + const huddle = emit({ + channelName: "general", + content: JSON.stringify({ + ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", + }), + id: "d".repeat(64), + kind: huddleKind, + }); + return { huddleId: huddle.id, pendingId: pending.id }; + }, KIND_HUDDLE_STARTED); + + await expectCopyLinkUnavailable( + page.locator(`[data-message-id="${pendingId}"]`), + pendingId, + ); + await expectCopyLinkUnavailable( + page.locator(`[data-message-id="${huddleId}"]`), + huddleId, + ); +}); diff --git a/desktop/tests/e2e/reaction-order.spec.ts b/desktop/tests/e2e/reaction-order.spec.ts index 34cf071e771..b37ce56dd13 100644 --- a/desktop/tests/e2e/reaction-order.spec.ts +++ b/desktop/tests/e2e/reaction-order.spec.ts @@ -64,16 +64,19 @@ async function getBodyToReactionGap( return Math.round(reactions.y - (body.y + body.height)); } -/** Click a quick-reaction tray button by emoji (hover → click tray button). */ -async function addQuickReaction( +/** Add a reaction through the preserved message action and emoji picker. */ +async function addReaction( + page: import("@playwright/test").Page, row: import("@playwright/test").Locator, emoji: string, - label: string, + search: string, ) { await row.hover(); - const btn = row.getByRole("button", { name: `React with ${label}` }); - await expect(btn).toBeVisible(); - await btn.click(); + await row.getByRole("button", { name: "Open reactions" }).click(); + const picker = page.locator("em-emoji-picker"); + await expect(picker).toBeVisible(); + await picker.locator("input[type='search']").fill(search); + await picker.getByRole("button", { name: emoji }).first().click(); // Wait for the optimistic pill to appear before continuing. await expect( row @@ -97,11 +100,11 @@ test("reaction pills render left-to-right in the order reactions were added", as // Add three reactions in order: 👍 → ❤️ → 😂. // Wait >1 s between each so mock bridge timestamps differ by at least 1 Unix // second — the formatter sorts by created_at, so distinct seconds matter. - await addQuickReaction(row, "👍", ":+1:"); + await addReaction(page, row, "👍", "thumbs up"); await page.waitForTimeout(1100); - await addQuickReaction(row, "❤️", ":heart:"); + await addReaction(page, row, "❤️", "red heart"); await page.waitForTimeout(1100); - await addQuickReaction(row, "😂", ":joy:"); + await addReaction(page, row, "😂", "face with tears of joy"); const pills = await getPillOrder(row); expect(pills).toEqual(["👍", "❤️", "😂"]); @@ -136,9 +139,9 @@ test("a later emoji that accrues more reactors stays to the right of an earlier // since the key invariant is positional stability: even if ❤️ had higher // count it must stay right of 👍. // We use 🎉 (added second) and verify it stays right of 👍 (added first). - await addQuickReaction(row, "👍", ":+1:"); + await addReaction(page, row, "👍", "thumbs up"); await page.waitForTimeout(1100); - await addQuickReaction(row, "🎉", ":tada:"); + await addReaction(page, row, "🎉", "party popper"); // Both pills present in chronological order: 👍 left, 🎉 right. const afterAdd = await getPillOrder(row); From e760c51820b2103d965c22b44254678e10fb689a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 24 Aug 2026 17:40:23 -0700 Subject: [PATCH 019/101] feat(workflows): discover trigger filter values (#6712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Workflow authors can discover people and messages while configuring trigger filters, then see readable enriched labels instead of raw identifiers. **Problem:** Author and message filters required users to know and paste raw public keys or event IDs, and configured workflows surfaced those opaque values afterward. **Solution:** Add network-backed pickers and presentation enrichment while keeping deterministic local public-key and event-ID fallbacks authoritative whenever discovery is unavailable or untrusted. Related issue: none found.
File changes **desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx** Adds channel-aware author discovery, profile search, keyboard navigation, loading states, and deterministic public-key fallback selection. **desktop/src/features/workflows/ui/WorkflowCard.tsx** Uses enriched trigger presentation when building the workflow card’s readable summary. **desktop/src/features/workflows/ui/WorkflowDialog.tsx** Keeps Escape scoped to an active filter picker before allowing the inspector or dialog to close. **desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx** Threads channel context into trigger filters and renders enriched author/message summaries in the workflow sequence. **desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx** Adds paged channel-history discovery, message search, exact event lookup, profile labels, keyboard navigation, and bounded results. **desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx** Renders compact author identity details and loading presentation inside trigger summaries. **desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx** Connects author and message filter accordions to their pickers while preserving selected and excluded condition semantics. **desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts** Resolves configured author keys to trusted display labels with deterministic fallbacks. **desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts** Enriches configured message IDs only after validating the fetched event and channel. **desktop/src/features/workflows/ui/workflowAuthorCandidates.test.mjs** Covers author candidate normalization, ordering, deduplication, and fallback behavior. **desktop/src/features/workflows/ui/workflowAuthorCandidates.ts** Builds stable author candidates from channel members, profiles, and raw public keys. **desktop/src/features/workflows/ui/workflowConditionExpression.ts** Allows message IDs to participate in basic trigger-filter parsing. **desktop/src/features/workflows/ui/workflowDefinition.ts** Accepts enriched trigger text when generating workflow card labels. **desktop/src/features/workflows/ui/workflowMessageCandidates.test.mjs** Covers event validation, source merging, deterministic ordering, and exact-lookup enrichment boundaries. **desktop/src/features/workflows/ui/workflowMessageCandidates.ts** Validates message candidates by event kind, channel, and exact event ID before permitting enrichment. **desktop/src/features/workflows/ui/workflowTriggerDescription.test.mjs** Covers readable selected/excluded author and message descriptions plus loading fallbacks. **desktop/src/features/workflows/ui/workflowTriggerDescription.ts** Builds concise enriched trigger descriptions while retaining stable raw-ID fallbacks. **desktop/tests/e2e/workflow-local-controls.spec.ts** Exercises picker discovery, selection toggles, Escape ownership, bounded scrolling, and enriched workflow summaries.
## Reproduction steps 1. Open Workflows and create a workflow for a channel with members and message history. 2. Choose **Reaction Added** as the trigger and expand **Author**. 3. Confirm channel members and fetched profile results are discoverable, searchable, and keyboard accessible; choose one. 4. Expand **Message**, confirm recent channel messages appear in a bounded list, and choose one. 5. Toggle either selected filter between **is** and **is not**, then collapse the inspector and confirm the sequence summary stays readable. 6. Add a send-message step and create the workflow; confirm its card uses the resolved author and message labels. 7. Repeat while discovery is unavailable and confirm raw public keys/event IDs remain selectable and authoritative. ## Screenshots ### Author discovery ![Author picker showing discoverable channel members and profile labels](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--01-author-discovery.png) ### Message discovery ![Message picker showing bounded channel history discovery](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--02-message-discovery.png) ### Selected filter summaries ![Workflow builder showing readable selected author and message filters](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--03-selected-filter-summaries.png) ### Enriched workflow card ![Workflow card showing enriched author and message labels](https://raw.githubusercontent.com/block/buzz/df47bdab841e0f52eb1f4ea9ac4e70aa16d240e0/pr-6712--04-enriched-workflow-card.png) --------- Signed-off-by: Taylor Ho Co-authored-by: Carl Co-authored-by: Princess Donut <0366ccd5ee09c2779a9d6bd6683daa17c16a508a51f6a7e7314018dab8fdc49b@buzz.block.builderlab.xyz> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/messages.rs | 19 +- .../src/commands/messages/event_batch.rs | 128 +++++ desktop/src-tauri/src/lib.rs | 1 + .../workflows/ui/WorkflowAuthorPicker.tsx | 361 ++++++++++++++ .../features/workflows/ui/WorkflowCard.tsx | 20 +- .../features/workflows/ui/WorkflowDialog.tsx | 7 + .../workflows/ui/WorkflowFormBuilder.tsx | 34 +- .../workflows/ui/WorkflowMessagePicker.tsx | 469 ++++++++++++++++++ .../ui/WorkflowRichTriggerDescription.tsx | 53 ++ .../ui/WorkflowTriggerConditions.tsx | 220 +++++++- .../features/workflows/ui/WorkflowsView.tsx | 7 + .../ui/useWorkflowAuthorPresentation.ts | 57 +++ ...seWorkflowListAuthorPresentations.test.mjs | 30 ++ .../ui/useWorkflowListAuthorPresentations.ts | 60 +++ ...eWorkflowListMessagePresentations.test.mjs | 67 +++ .../ui/useWorkflowListMessagePresentations.ts | 92 ++++ .../ui/useWorkflowTriggerPresentation.ts | 70 +++ .../ui/workflowAuthorCandidates.test.mjs | 146 ++++++ .../workflows/ui/workflowAuthorCandidates.ts | 130 +++++ .../ui/workflowConditionExpression.ts | 4 +- .../workflows/ui/workflowDefinition.ts | 2 +- .../ui/workflowMessageCandidates.test.mjs | 218 ++++++++ .../workflows/ui/workflowMessageCandidates.ts | 139 ++++++ .../ui/workflowTriggerDescription.test.mjs | 63 ++- .../ui/workflowTriggerDescription.ts | 47 ++ desktop/src/shared/api/tauri.ts | 6 +- desktop/src/shared/api/tauriEvents.ts | 14 + desktop/src/testing/e2eBridge.ts | 9 + .../tests/e2e/workflow-local-controls.spec.ts | 355 ++++++++++++- 29 files changed, 2761 insertions(+), 67 deletions(-) create mode 100644 desktop/src-tauri/src/commands/messages/event_batch.rs create mode 100644 desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx create mode 100644 desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx create mode 100644 desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx create mode 100644 desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts create mode 100644 desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.test.mjs create mode 100644 desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts create mode 100644 desktop/src/features/workflows/ui/useWorkflowListMessagePresentations.test.mjs create mode 100644 desktop/src/features/workflows/ui/useWorkflowListMessagePresentations.ts create mode 100644 desktop/src/features/workflows/ui/useWorkflowTriggerPresentation.ts create mode 100644 desktop/src/features/workflows/ui/workflowAuthorCandidates.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowAuthorCandidates.ts create mode 100644 desktop/src/features/workflows/ui/workflowMessageCandidates.test.mjs create mode 100644 desktop/src/features/workflows/ui/workflowMessageCandidates.ts create mode 100644 desktop/src/shared/api/tauriEvents.ts diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 5de2d32b809..1e221b6bd18 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -396,23 +396,8 @@ pub async fn get_channel_messages_before( }) } -#[tauri::command] -pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result { - let events = query_relay( - &state, - &[serde_json::json!({ - "ids": [event_id], - "kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED], - "limit": 1 - })], - ) - .await?; - - let ev = events - .first() - .ok_or_else(|| "event not found".to_string())?; - serde_json::to_string(ev).map_err(|e| format!("serialize event: {e}")) -} +mod event_batch; +pub use event_batch::{get_event, get_events}; // ── Writes ────────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/messages/event_batch.rs b/desktop/src-tauri/src/commands/messages/event_batch.rs new file mode 100644 index 00000000000..1bb51748683 --- /dev/null +++ b/desktop/src-tauri/src/commands/messages/event_batch.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; + +use tauri::State; + +use crate::{app_state::AppState, relay::query_relay}; + +// The relay clamps a single filter to this many events. Keep exact-ID reads in +// chunks so a large workflow list cannot silently lose late presentations. +const EVENT_QUERY_CHUNK_SIZE: usize = 1_000; + +const GET_EVENT_KINDS: [u32; 15] = [ + 0, + 1, + 3, + 5, + 7, + 9, + 30078, + 40002, + 40003, + 40008, + 40099, + 40100, + 45001, + 45003, + buzz_core_pkg::kind::KIND_HUDDLE_STARTED, +]; + +#[tauri::command] +pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "ids": [event_id], + "kinds": GET_EVENT_KINDS, + "limit": 1 + })], + ) + .await?; + + let event = events + .first() + .ok_or_else(|| "event not found".to_string())?; + serde_json::to_string(event).map_err(|error| format!("serialize event: {error}")) +} + +/// Resolve many exact event IDs in relay-sized chunks. Callers still validate +/// event kind, channel scope, and requested ID before using presentation data. +fn normalized_event_id_chunks(event_ids: Vec) -> Vec> { + let mut seen_ids = HashSet::new(); + let event_ids = event_ids + .into_iter() + .map(|event_id| event_id.trim().to_ascii_lowercase()) + .filter(|event_id| event_id.len() == 64 && event_id.chars().all(|c| c.is_ascii_hexdigit())) + .filter(|event_id| seen_ids.insert(event_id.clone())) + .collect::>(); + event_ids + .chunks(EVENT_QUERY_CHUNK_SIZE) + .map(<[String]>::to_vec) + .collect() +} + +#[tauri::command] +pub async fn get_events( + event_ids: Vec, + state: State<'_, AppState>, +) -> Result, String> { + let event_id_chunks = normalized_event_id_chunks(event_ids); + if event_id_chunks.is_empty() { + return Ok(Vec::new()); + } + + let mut events_by_id = std::collections::HashMap::new(); + for event_ids in event_id_chunks { + let events = query_relay( + &state, + &[serde_json::json!({ + "ids": event_ids, + "kinds": GET_EVENT_KINDS, + "limit": event_ids.len() + })], + ) + .await?; + for event in events { + events_by_id.entry(event.id).or_insert(event); + } + } + + events_by_id + .into_values() + .map(|event| { + serde_json::to_value(event).map_err(|error| format!("serialize event: {error}")) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_exact_relay_ceiling_in_one_chunk() { + let chunks = normalized_event_id_chunks( + (0..EVENT_QUERY_CHUNK_SIZE) + .map(|index| format!("{index:064x}")) + .collect(), + ); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].len(), EVENT_QUERY_CHUNK_SIZE); + } + + #[test] + fn normalizes_deduplicates_and_keeps_ids_beyond_relay_ceiling() { + let last_id = format!("{:064x}", EVENT_QUERY_CHUNK_SIZE); + let mut event_ids = (0..=EVENT_QUERY_CHUNK_SIZE) + .map(|index| format!("{index:064X}")) + .collect::>(); + event_ids.extend(["not-an-event-id".to_string(), format!(" {last_id} ")]); + + let chunks = normalized_event_id_chunks(event_ids); + + assert_eq!(chunks.iter().map(Vec::len).sum::(), 1_001); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].len(), EVENT_QUERY_CHUNK_SIZE); + assert_eq!(chunks[1], [last_id]); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 428aa4d2a78..613040b8095 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -652,6 +652,7 @@ pub fn run() { add_reaction, remove_reaction, get_event, + get_events, show_native_notification, #[cfg(target_os = "macos")] macos_notifications::take_pending_activations, diff --git a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx new file mode 100644 index 00000000000..bcf0b209ffa --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx @@ -0,0 +1,361 @@ +import { Check, LoaderCircle, Search } from "lucide-react"; +import * as React from "react"; + +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { useRelayMembersQuery } from "@/features/community-members/hooks"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + enrichAuthorCandidates, + filterAuthorCandidatePage, + mergeAuthorCandidateSources, + nextWorkflowAuthorIndex, + parseDirectAuthorInput, + type WorkflowAuthorCandidate, +} from "./workflowAuthorCandidates"; + +const PAGE_SIZE = 50; + +export function WorkflowAuthorPicker({ + channelId, + disabled, + id, + onChange, + onEscape, + value, +}: { + channelId?: string | null; + disabled?: boolean; + id: string; + onChange: (pubkey: string) => void; + onEscape?: () => void; + value: string; +}) { + const pickerRef = React.useRef(null); + const optionRefs = React.useRef(new Map()); + const [query, setQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(null); + const [columnCount, setColumnCount] = React.useState(2); + const trimmedQuery = query.trim(); + const deferredQuery = React.useDeferredValue(trimmedQuery); + const normalizedValue = parseDirectAuthorInput(value); + const channelMembersQuery = useChannelMembersQuery(channelId ?? null); + const relayMembersQuery = useRelayMembersQuery(true); + const directoryQuery = useInfiniteUserSearchQuery(deferredQuery, { + allowEmpty: true, + limit: PAGE_SIZE, + }); + const directoryResults = useFlattenedUserSearchResults(directoryQuery.data); + const directPubkey = parseDirectAuthorInput(deferredQuery); + + const baseCandidates = React.useMemo( + () => + mergeAuthorCandidateSources([ + normalizedValue ? [{ pubkey: normalizedValue }] : [], + directPubkey ? [{ pubkey: directPubkey }] : [], + channelMembersQuery.data ?? [], + relayMembersQuery.data ?? [], + directoryResults, + ]), + [ + channelMembersQuery.data, + directPubkey, + directoryResults, + normalizedValue, + relayMembersQuery.data, + ], + ); + const candidatePage = React.useMemo( + () => + filterAuthorCandidatePage( + baseCandidates, + deferredQuery, + directPubkey, + PAGE_SIZE, + ), + [baseCandidates, deferredQuery, directPubkey], + ); + const profileQuery = useUsersBatchQuery( + candidatePage.map(({ pubkey }) => pubkey), + ); + const candidates = React.useMemo( + () => + enrichAuthorCandidates(candidatePage, profileQuery.data?.profiles ?? {}), + [candidatePage, profileQuery.data?.profiles], + ); + const visibleCandidates = candidates; + const listId = `${id}-list`; + + React.useEffect(() => { + if (activeIndex !== null && activeIndex >= visibleCandidates.length) { + setActiveIndex( + visibleCandidates.length > 0 ? visibleCandidates.length - 1 : null, + ); + } + }, [activeIndex, visibleCandidates.length]); + + React.useEffect(() => { + const picker = pickerRef.current; + if (!picker) return; + const updateColumnCount = () => + setColumnCount(picker.clientWidth >= 544 ? 3 : 2); + updateColumnCount(); + const observer = new ResizeObserver(updateColumnCount); + observer.observe(picker); + return () => observer.disconnect(); + }, []); + + React.useEffect(() => { + if (activeIndex === null) return; + const candidate = visibleCandidates[activeIndex]; + if (!candidate) return; + optionRefs.current + .get(candidate.pubkey) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex, visibleCandidates]); + + const loading = + (channelMembersQuery.isLoading || + relayMembersQuery.isLoading || + directoryQuery.isLoading) && + visibleCandidates.length === 0; + const failed = + channelMembersQuery.isError || + relayMembersQuery.isError || + directoryQuery.isError; + + function moveActive(delta: number) { + setActiveIndex((current) => + nextWorkflowAuthorIndex(current, delta, visibleCandidates.length), + ); + } + + return ( +
+
+ + { + setQuery(event.target.value); + setActiveIndex(null); + }} + onKeyDown={(event) => { + const currentQuery = event.currentTarget.value.trim(); + const delta = + event.key === "ArrowDown" + ? columnCount + : event.key === "ArrowUp" + ? -columnCount + : event.key === "ArrowRight" + ? 1 + : event.key === "ArrowLeft" + ? -1 + : 0; + if (delta) { + event.preventDefault(); + moveActive(delta); + } else if ( + event.key === "Enter" && + currentQuery === deferredQuery && + visibleCandidates[activeIndex ?? 0] + ) { + event.preventDefault(); + const candidate = visibleCandidates[activeIndex ?? 0]; + onChange( + candidate.pubkey === normalizedValue ? "" : candidate.pubkey, + ); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (query) { + setQuery(""); + setActiveIndex(null); + } else { + onEscape?.(); + } + } + }} + placeholder="Search people or paste a public key…" + role="combobox" + spellCheck={false} + value={query} + /> +
+ +
{ + const list = event.currentTarget; + if ( + list.scrollHeight - list.scrollTop - list.clientHeight < 64 && + directoryQuery.hasNextPage && + !directoryQuery.isFetchingNextPage + ) { + void directoryQuery.fetchNextPage(); + } + }} + role="listbox" + > + {visibleCandidates.map((candidate, index) => ( + { + setActiveIndex(null); + onChange( + candidate.pubkey === normalizedValue ? "" : candidate.pubkey, + ); + }} + optionRef={(node) => { + if (node) optionRefs.current.set(candidate.pubkey, node); + else optionRefs.current.delete(candidate.pubkey); + }} + selected={candidate.pubkey === normalizedValue} + /> + ))} + {loading ? ( +

+ Loading authors… +

+ ) : visibleCandidates.length === 0 ? ( +

+ {failed ? "Couldn’t load authors." : "No authors found."} +

+ ) : null} + {failed ? ( + + ) : null} + {directoryQuery.hasNextPage ? ( + + ) : null} +
+
+ ); +} + +function AuthorOption({ + active, + candidate, + disabled, + id, + onSelect, + optionRef, + selected, +}: { + active: boolean; + candidate: WorkflowAuthorCandidate; + disabled?: boolean; + id: string; + onSelect: () => void; + optionRef: (node: HTMLButtonElement | null) => void; + selected: boolean; +}) { + const label = resolveUserLabel({ + fallbackName: candidate.displayName, + profiles: { + [candidate.pubkey]: { + displayName: candidate.displayName, + avatarUrl: candidate.avatarUrl, + nip05Handle: candidate.nip05Handle, + ownerPubkey: candidate.ownerPubkey, + isAgent: candidate.isAgent, + }, + }, + pubkey: candidate.pubkey, + }); + return ( + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowCard.tsx b/desktop/src/features/workflows/ui/WorkflowCard.tsx index 48ce8d2db60..8556424f878 100644 --- a/desktop/src/features/workflows/ui/WorkflowCard.tsx +++ b/desktop/src/features/workflows/ui/WorkflowCard.tsx @@ -24,14 +24,20 @@ import { getWorkflowActionTiles, getWorkflowCardLabel, getWorkflowTriggerEmoji, + getWorkflowTriggerConfig, getWorkflowTriggerType, } from "./workflowDefinition"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; +import type { WorkflowCardAuthorPresentation } from "./useWorkflowListAuthorPresentations"; +import type { WorkflowMessagePresentation } from "./useWorkflowListMessagePresentations"; +import { workflowTriggerDescription } from "./workflowTriggerDescription"; type WorkflowCardProps = { workflow: Workflow; + authorPresentation?: WorkflowCardAuthorPresentation; channelName?: string; isTogglingEnabled?: boolean; + messagePresentation?: WorkflowMessagePresentation; onView: (workflow: Workflow) => void; onTrigger: (workflowId: string) => void; onToggleEnabled: (workflow: Workflow) => void; @@ -189,8 +195,10 @@ function ActionTileStack({ export function WorkflowCard({ workflow, + authorPresentation, channelName, isTogglingEnabled = false, + messagePresentation, onView, onTrigger, onToggleEnabled, @@ -201,7 +209,17 @@ export function WorkflowCard({ const [triggerAnimationSequence, setTriggerAnimationSequence] = React.useState(0); const isEnabled = getWorkflowEnabled(workflow.definition); - const cardLabel = getWorkflowCardLabel(workflow.definition); + const configuredTrigger = getWorkflowTriggerConfig(workflow.definition); + const cardLabel = getWorkflowCardLabel(workflow.definition, { + triggerDescription: configuredTrigger + ? workflowTriggerDescription(configuredTrigger, { + authorLabel: authorPresentation?.label ?? undefined, + authorLoading: authorPresentation?.loading, + messageLabel: messagePresentation?.messageLabel ?? undefined, + messageLoading: messagePresentation?.messageLoading, + }) + : undefined, + }); const triggerType = getWorkflowTriggerType(workflow.definition); const actionTiles = getWorkflowActionTiles(workflow.definition); const triggerEmoji = getWorkflowTriggerEmoji(workflow.definition); diff --git a/desktop/src/features/workflows/ui/WorkflowDialog.tsx b/desktop/src/features/workflows/ui/WorkflowDialog.tsx index 3f8655a3aa1..198e0fe91c3 100644 --- a/desktop/src/features/workflows/ui/WorkflowDialog.tsx +++ b/desktop/src/features/workflows/ui/WorkflowDialog.tsx @@ -557,6 +557,13 @@ export function WorkflowDialog({ { + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-workflow-filter-picker-search]") + ) { + event.preventDefault(); + return; + } if (formBuilderRef.current?.closeInspector()) { event.preventDefault(); event.stopPropagation(); diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 58e4b4a960b..8ac97402aa0 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -30,8 +30,10 @@ import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; import { reactionConditionValue } from "./workflowReactionCondition"; import { WorkflowTriggerConditions } from "./WorkflowTriggerConditions"; +import { WorkflowRichTriggerDescription } from "./WorkflowRichTriggerDescription"; +import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation"; +import { compactWorkflowTriggerDescription } from "./workflowTriggerDescription"; import { workflowStepDescription } from "./workflowStepDescription"; -import { workflowTriggerDescription } from "./workflowTriggerDescription"; import { WorkflowScheduleFields } from "./WorkflowScheduleFields"; import { WorkflowStepCard } from "./WorkflowStepCard"; import { @@ -67,12 +69,14 @@ function TriggerConfigFields({ trigger, onConditionDraftsChange, onUpdate, + workflowChannelId, }: { conditionDrafts: ParsedConditionExpression[] | null; disabled?: boolean; trigger: TriggerConfig; onConditionDraftsChange: (drafts: ParsedConditionExpression[] | null) => void; onUpdate: (trigger: TriggerConfig) => void; + workflowChannelId?: string | null; }) { switch (trigger.on) { case "message_posted": @@ -94,6 +98,7 @@ function TriggerConfigFields({ } triggerType={trigger.on} value={reactionConditionValue(trigger)} + workflowChannelId={workflowChannelId} /> ); case "webhook": @@ -220,7 +225,7 @@ function WorkflowNode({ terminal, title, }: { - description: string; + description: React.ReactNode; disabled?: boolean; icon?: React.ReactNode; label: string; @@ -614,10 +619,23 @@ export const WorkflowFormBuilder = React.forwardRef< ) ?.value.trim(); }, [formState.trigger]); - const triggerDescription = workflowTriggerDescription(formState.trigger); - const visibleTriggerDescription = triggerEmoji - ? "Reaction added" - : triggerDescription; + const triggerPresentation = useWorkflowTriggerPresentation( + formState.trigger, + workflowChannelId, + ); + const triggerDescription = triggerPresentation.description; + const compactTriggerDescription = compactWorkflowTriggerDescription( + triggerDescription, + triggerEmoji, + ); + const visibleTriggerDescription = ( + + ); const TriggerIcon = { diff_posted: GitPullRequest, message_posted: MessageSquare, @@ -889,6 +907,7 @@ export const WorkflowFormBuilder = React.forwardRef< > {selectedNode.type === "trigger" ? ( -
+
) : selectedStep ? ( diff --git a/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx new file mode 100644 index 00000000000..6639d85b0ff --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx @@ -0,0 +1,469 @@ +import { useInfiniteQuery, useQueries, useQuery } from "@tanstack/react-query"; +import { Check, LoaderCircle, Search } from "lucide-react"; +import * as React from "react"; + +import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { useSearchMessagesQuery } from "@/features/search/hooks"; +import { getChannelWindowEvents } from "@/shared/api/channelWindow"; +import { getEventById } from "@/shared/api/tauri"; +import type { ChannelPageCursor } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + mergeMessageCandidateSources, + normalizeMessageEventId, + type WorkflowMessageCandidate, + validatedWorkflowMessageCandidate, + validateWorkflowMessageSearchResults, +} from "./workflowMessageCandidates"; + +const PAGE_SIZE = 25; + +function truncateContent(content: string | null): string { + const normalized = content?.trim().replaceAll(/\s+/g, " ") ?? ""; + if (!normalized) return "No message body"; + return normalized.length > 120 + ? `${normalized.slice(0, 117)}...` + : normalized; +} + +function formatTimestamp(unixSeconds: number | null): string | null { + if (unixSeconds === null) return null; + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(unixSeconds * 1_000)); +} + +export function WorkflowMessagePicker({ + channelId, + disabled, + id, + onChange, + onEscape, + value, +}: { + channelId?: string | null; + disabled?: boolean; + id: string; + onChange: (messageId: string) => void; + onEscape?: () => void; + value: string; +}) { + const optionRefs = React.useRef(new Map()); + const [query, setQuery] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(null); + const trimmedQuery = query.trim(); + const deferredQuery = React.useDeferredValue(trimmedQuery); + const normalizedQuery = deferredQuery.toLowerCase(); + const selectedId = normalizeMessageEventId(value); + const directId = normalizeMessageEventId(query); + const lookupId = directId ?? selectedId; + + const historyQuery = useInfiniteQuery({ + enabled: Boolean(channelId), + initialPageParam: null as ChannelPageCursor | null, + queryKey: ["workflow-message-picker", channelId], + queryFn: async ({ pageParam }) => { + if (!channelId) throw new Error("Choose a channel first."); + return parseChannelWindowResponse( + await getChannelWindowEvents(channelId, pageParam, PAGE_SIZE), + channelId, + pageParam, + ); + }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + staleTime: 30_000, + }); + const searchQuery = useSearchMessagesQuery(deferredQuery, { + channelId: channelId ?? undefined, + enabled: Boolean(channelId && normalizedQuery && !directId), + limit: 30, + minimumQueryLength: 1, + }); + const exactQuery = useQuery({ + enabled: Boolean(channelId && lookupId), + queryKey: ["workflow-message-picker-exact", channelId, lookupId], + queryFn: () => getEventById(lookupId ?? ""), + retry: false, + staleTime: 60_000, + }); + + const selectedFallback = selectedId + ? [{ id: selectedId, pubkey: null, content: null, createdAt: null }] + : []; + const directFallback = directId + ? [{ id: directId, pubkey: null, content: null, createdAt: null }] + : []; + const historyCandidates = React.useMemo( + () => + (historyQuery.data?.pages ?? []).flatMap((page) => + page.rows.flatMap(({ event }) => { + const candidate = channelId + ? validatedWorkflowMessageCandidate(event, { channelId }) + : null; + return candidate ? [candidate] : []; + }), + ), + [channelId, historyQuery.data?.pages], + ); + const searchHitIds = React.useMemo( + () => [ + ...new Set( + (searchQuery.data?.hits ?? []).flatMap((hit) => { + const eventId = normalizeMessageEventId(hit.eventId); + return eventId ? [eventId] : []; + }), + ), + ], + [searchQuery.data?.hits], + ); + const searchEventQueries = useQueries({ + queries: searchHitIds.map((eventId) => ({ + enabled: Boolean(channelId), + queryKey: ["workflow-message-picker-search-event", channelId, eventId], + queryFn: () => getEventById(eventId), + retry: false, + staleTime: 60_000, + })), + }); + const searchCandidates = validateWorkflowMessageSearchResults( + searchHitIds.map((requestedId, index) => ({ + requestedId, + event: searchEventQueries[index]?.data, + })), + channelId ?? "", + ); + const exactCandidate = React.useMemo(() => { + if (!channelId || !lookupId) return null; + return validatedWorkflowMessageCandidate(exactQuery.data, { + channelId, + requestedId: lookupId, + }); + }, [channelId, exactQuery.data, lookupId]); + const allCandidates = React.useMemo(() => { + // Preserve the relay-provided history/search order. Exact lookups and raw-ID + // fallbacks only fill gaps; selecting an existing row must not move it. + return mergeMessageCandidateSources([ + historyCandidates, + searchCandidates, + exactCandidate ? [exactCandidate] : [], + selectedFallback, + directFallback, + ]); + }, [ + directFallback, + exactCandidate, + historyCandidates, + searchCandidates, + selectedFallback, + ]); + const visibleCandidates = React.useMemo(() => { + if (directId) return allCandidates.filter(({ id }) => id === directId); + if (!normalizedQuery) return allCandidates; + return allCandidates.filter( + (candidate) => + candidate.id.includes(normalizedQuery) || + candidate.content?.toLowerCase().includes(normalizedQuery), + ); + }, [allCandidates, directId, normalizedQuery]); + const profilePubkeys = React.useMemo( + () => [ + ...new Set( + visibleCandidates.flatMap(({ pubkey }) => (pubkey ? [pubkey] : [])), + ), + ], + [visibleCandidates], + ); + const profilesQuery = useUsersBatchQuery(profilePubkeys); + const listId = `${id}-list`; + + React.useEffect(() => { + if (activeIndex !== null && activeIndex >= visibleCandidates.length) { + setActiveIndex( + visibleCandidates.length > 0 ? visibleCandidates.length - 1 : null, + ); + } + }, [activeIndex, visibleCandidates.length]); + React.useEffect(() => { + if (activeIndex === null) return; + const candidate = visibleCandidates[activeIndex]; + if (!candidate) return; + optionRefs.current + .get(candidate.id) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeIndex, visibleCandidates]); + + const searchEventsFetching = searchEventQueries.some( + ({ isFetching }) => isFetching, + ); + const searchEventsFailed = searchEventQueries.some(({ isError }) => isError); + const loading = + (historyQuery.isLoading || + searchQuery.isFetching || + searchEventsFetching || + exactQuery.isFetching) && + visibleCandidates.length === 0; + const failed = + historyQuery.isError || + searchQuery.isError || + searchEventsFailed || + exactQuery.isError; + const invalidDirectResult = Boolean( + directId && lookupId === directId && exactQuery.data && !exactCandidate, + ); + + return ( +
+
+ + { + setQuery(event.target.value); + setActiveIndex(null); + }} + onKeyDown={(event) => { + const currentQuery = event.currentTarget.value.trim(); + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (visibleCandidates.length > 0) { + setActiveIndex((current) => { + const startingIndex = current ?? -1; + return ( + (startingIndex + + (event.key === "ArrowDown" ? 1 : -1) + + visibleCandidates.length) % + visibleCandidates.length + ); + }); + } + } else if (event.key === "Enter") { + const currentDirectId = normalizeMessageEventId(currentQuery); + if (currentQuery !== deferredQuery && !currentDirectId) return; + const candidate = currentDirectId + ? allCandidates.find(({ id }) => id === currentDirectId) + : visibleCandidates[activeIndex ?? 0]; + if (!candidate) return; + event.preventDefault(); + if (invalidDirectResult) return; + onChange(candidate.id === selectedId ? "" : candidate.id); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + if (query) { + setQuery(""); + setActiveIndex(null); + } else { + onEscape?.(); + } + } + }} + placeholder={ + channelId + ? "Search messages or paste a message ID…" + : "Choose a channel first" + } + role="combobox" + spellCheck={false} + value={query} + /> + {historyQuery.isFetching || + searchQuery.isFetching || + searchEventsFetching || + exactQuery.isFetching ? ( + + ) : null} +
+ {invalidDirectResult ? ( +

+ That message is not available in this channel. +

+ ) : null} +
{ + const list = event.currentTarget; + if ( + !normalizedQuery && + list.scrollHeight - list.scrollTop - list.clientHeight < 64 && + historyQuery.hasNextPage && + !historyQuery.isFetchingNextPage + ) { + void historyQuery.fetchNextPage(); + } + }} + role="listbox" + > + {visibleCandidates.map((candidate, index) => ( + { + setActiveIndex(null); + if (!invalidDirectResult) { + onChange(candidate.id === selectedId ? "" : candidate.id); + } + }} + optionRef={(node) => { + if (node) optionRefs.current.set(candidate.id, node); + else optionRefs.current.delete(candidate.id); + }} + profiles={profilesQuery.data?.profiles} + selected={candidate.id === selectedId} + /> + ))} + {loading ? ( +

+ Loading messages… +

+ ) : visibleCandidates.length === 0 ? ( +

+ {failed + ? "Couldn’t load messages." + : normalizedQuery + ? "No messages found." + : "No messages yet."} +

+ ) : null} + {failed ? ( + + ) : null} + {!normalizedQuery && historyQuery.hasNextPage ? ( + + ) : null} +
+
+ ); +} + +function MessageOption({ + active, + candidate, + disabled, + id, + onSelect, + optionRef, + profiles, + selected, +}: { + active: boolean; + candidate: WorkflowMessageCandidate; + disabled?: boolean; + id: string; + onSelect: () => void; + optionRef: (node: HTMLButtonElement | null) => void; + profiles?: UserProfileLookup; + selected: boolean; +}) { + const author = candidate.pubkey + ? resolveUserLabel({ profiles, pubkey: candidate.pubkey }) + : "Selected message"; + const profile = candidate.pubkey + ? profiles?.[candidate.pubkey.toLowerCase()] + : undefined; + const timestamp = formatTimestamp(candidate.createdAt); + return ( + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx new file mode 100644 index 00000000000..50eb5ef6726 --- /dev/null +++ b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx @@ -0,0 +1,53 @@ +import { LoaderCircle } from "lucide-react"; + +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { splitWorkflowAuthorDescription } from "./workflowTriggerDescription"; + +export function WorkflowRichTriggerDescription({ + avatarUrl, + description, + label, + loading, +}: { + avatarUrl?: string | null; + description: string; + label?: string | null; + loading?: boolean; +}) { + if (loading) { + return ( + + {description} + + + ); + } + + const segments = label + ? splitWorkflowAuthorDescription(description, label) + : null; + if (!label || !segments) return description; + + const { prefix, suffix } = segments; + return ( + + {prefix ? {prefix} : null} + + + {label} + {suffix ? ` ${suffix}` : ""} + + + ); +} diff --git a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx index 39caec253a5..78f6e30606d 100644 --- a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx +++ b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx @@ -3,8 +3,12 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import { WorkflowAuthorPicker } from "./WorkflowAuthorPicker"; import { WorkflowEmojiField } from "./WorkflowEmojiField"; +import { WorkflowMessagePicker } from "./WorkflowMessagePicker"; +import { useWorkflowTriggerPresentation } from "./useWorkflowTriggerPresentation"; import { FieldLabel } from "./workflowFormPrimitives"; import { buildConditionExpressions, @@ -45,12 +49,81 @@ function compact(value: string): string { : `${trimmed.slice(0, 11)}…${trimmed.slice(-6)}`; } +function fieldUsesFullHeightPicker(field: string): boolean { + return field === "trigger_author" || field === "trigger_message_id"; +} + function fieldPlaceholder(field: string): string { if (field === "trigger_author") return "64-character hex pubkey"; if (field === "trigger_message_id") return "64-character hex event ID"; return "e.g. deploy"; } +function ExclusionStrike() { + return ( +
) : ( -
+
{fields.map((field) => { const existing = conditions.find( (condition) => condition.field === field.value, @@ -179,8 +279,32 @@ export function WorkflowTriggerConditions({ const summary = existing ? `${OPERATOR_LABELS[condition.operator]}${condition.value ? ` ${compact(condition.value)}` : ""}` : "Any"; + const authorSummary = + existing && + field.value === "trigger_author" && + triggerPresentation.pubkey && + triggerPresentation.label + ? triggerPresentation.label + : null; + const messageSummary = + existing && + field.value === "trigger_message_id" && + triggerPresentation.messageId && + triggerPresentation.messageLabel + ? triggerPresentation.messageLabel + .trim() + .replaceAll(/\s+/g, " ") + : null; return ( -
+
{expanded ? ( -
+
Match
@@ -258,6 +417,51 @@ export function WorkflowTriggerConditions({ } value={condition.value} /> + ) : field.value === "trigger_author" ? ( + collapsePicker(field.value)} + onChange={(pubkey) => + updateConditions( + pubkey + ? [ + ...conditions.filter( + (item) => item.field !== field.value, + ), + { ...condition, value: pubkey }, + ] + : conditions.filter( + (item) => item.field !== field.value, + ), + ) + } + value={condition.value} + /> + ) : field.value === "trigger_message_id" ? ( + collapsePicker(field.value)} + onChange={(messageId) => + updateConditions( + messageId + ? [ + ...conditions.filter( + (item) => item.field !== field.value, + ), + { ...condition, value: messageId }, + ] + : conditions.filter( + (item) => item.field !== field.value, + ), + ) + } + value={condition.value} + /> ) : (
) ) : null} - {existing ? ( + {existing && !fieldUsesFullHeightPicker(field.value) ? (
{menu && canActOnCurrentImage ? ( diff --git a/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx b/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx new file mode 100644 index 00000000000..f92f4afc89a --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx @@ -0,0 +1,91 @@ +import type { CSSProperties } from "react"; +import { ZoomIn, ZoomOut } from "lucide-react"; + +import { + IMAGE_LIGHTBOX_MAX_ZOOM, + IMAGE_LIGHTBOX_MIN_ZOOM, + IMAGE_LIGHTBOX_ZOOM_STEP, +} from "./imageLightbox"; + +type ImageLightboxZoomControlsProps = { + markControlGesture: () => void; + setClampedZoom: (nextZoom: number) => void; + setIsAdjustingZoom: (isAdjusting: boolean) => void; + updateZoom: (updater: (currentZoom: number) => number) => void; + zoom: number; +}; + +const ZOOM_BUTTON_CLASS_NAME = + "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-muted-foreground/10 hover:text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring/70 disabled:pointer-events-none disabled:opacity-45"; + +/** Interactive zoom controls for the image lightbox toolbar. */ +export function ImageLightboxZoomControls({ + markControlGesture, + setClampedZoom, + setIsAdjustingZoom, + updateZoom, + zoom, +}: ImageLightboxZoomControlsProps) { + const zoomFillPercent = + ((zoom - IMAGE_LIGHTBOX_MIN_ZOOM) / + (IMAGE_LIGHTBOX_MAX_ZOOM - IMAGE_LIGHTBOX_MIN_ZOOM)) * + 100; + + return ( + <> + + setIsAdjustingZoom(false)} + onChange={(event) => { + markControlGesture(); + setClampedZoom(Number(event.target.value)); + }} + onPointerCancel={() => setIsAdjustingZoom(false)} + onPointerDown={() => { + markControlGesture(); + setIsAdjustingZoom(true); + }} + onPointerUp={() => { + markControlGesture(); + setIsAdjustingZoom(false); + }} + /> + + + {Math.round(zoom * 100)}% + + + ); +} diff --git a/desktop/src/shared/ui/markdown/imageLightbox.ts b/desktop/src/shared/ui/markdown/imageLightbox.ts index 4c0c01f93e5..da29fc4971b 100644 --- a/desktop/src/shared/ui/markdown/imageLightbox.ts +++ b/desktop/src/shared/ui/markdown/imageLightbox.ts @@ -38,6 +38,16 @@ export type ImageGalleryItem = { thumbnailCornerRadii?: ImageLightboxCornerRadii; }; +export type ImageLightboxZoomAnchor = { + x: number; + y: number; +}; + +export type ImageLightboxZoomState = { + zoom: number; + zoomOffset: ImageLightboxZoomAnchor; +}; + export const IMAGE_LIGHTBOX_ENTER_MS = 260; export const IMAGE_LIGHTBOX_EXIT_MS = 170; export const IMAGE_LIGHTBOX_FADE_ENTER_MS = 180; @@ -54,7 +64,8 @@ export const IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED = 0.002; export const IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA = 0.2; export const IMAGE_LIGHTBOX_MIN_ZOOM = 1; export const IMAGE_LIGHTBOX_MAX_ZOOM = 3; -export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.05; +export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.15; +export const IMAGE_LIGHTBOX_CLICK_ZOOM = 1.75; export const IMAGE_LIGHTBOX_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; export const IMAGE_LIGHTBOX_EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)"; export const IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS = "1rem"; @@ -151,18 +162,91 @@ export function imageLightboxTransform( export function imageLightboxZoomBox( targetBox: ImageLightboxBox, zoom: number, + offset: ImageLightboxZoomAnchor = { x: 0, y: 0 }, ): ImageLightboxBox { const width = targetBox.width * zoom; const height = targetBox.height * zoom; return { height, - left: targetBox.left + (targetBox.width - width) / 2, - top: targetBox.top + (targetBox.height - height) / 2, + left: targetBox.left + (targetBox.width - width) / 2 + offset.x, + top: targetBox.top + (targetBox.height - height) / 2 + offset.y, width, }; } +export function imageLightboxZoomBoxAtPoint( + targetBox: ImageLightboxBox, + currentBox: ImageLightboxBox, + nextZoom: number, + point: ImageLightboxZoomAnchor, +): ImageLightboxBox { + const relativeX = (point.x - currentBox.left) / Math.max(1, currentBox.width); + const relativeY = (point.y - currentBox.top) / Math.max(1, currentBox.height); + const nextBox = imageLightboxZoomBox(targetBox, nextZoom); + + return { + ...nextBox, + left: point.x - relativeX * nextBox.width, + top: point.y - relativeY * nextBox.height, + }; +} + +export function imageLightboxZoomStateAtZoom( + currentState: ImageLightboxZoomState, + nextZoom: number, +): ImageLightboxZoomState { + const zoom = clampImageLightboxZoom(nextZoom); + if (zoom === IMAGE_LIGHTBOX_MIN_ZOOM) { + return { zoom, zoomOffset: { x: 0, y: 0 } }; + } + + // Scale the stored offset by the zoom ratio so the image point currently + // at the frame center stays anchored there as the slider/wheel changes zoom. + const offsetScale = zoom / currentState.zoom; + return { + zoom, + zoomOffset: { + x: currentState.zoomOffset.x * offsetScale, + y: currentState.zoomOffset.y * offsetScale, + }, + }; +} + +export function imageLightboxZoomStateAtPoint( + targetBox: ImageLightboxBox, + currentState: ImageLightboxZoomState, + point: ImageLightboxZoomAnchor, +): ImageLightboxZoomState { + const nextZoom = + currentState.zoom === IMAGE_LIGHTBOX_MIN_ZOOM + ? IMAGE_LIGHTBOX_CLICK_ZOOM + : IMAGE_LIGHTBOX_MIN_ZOOM; + if (nextZoom === IMAGE_LIGHTBOX_MIN_ZOOM) { + return imageLightboxZoomStateAtZoom(currentState, nextZoom); + } + + const currentBox = imageLightboxZoomBox( + targetBox, + currentState.zoom, + currentState.zoomOffset, + ); + const nextBox = imageLightboxZoomBoxAtPoint( + targetBox, + currentBox, + nextZoom, + point, + ); + const centeredNextBox = imageLightboxZoomBox(targetBox, nextZoom); + return { + zoom: nextZoom, + zoomOffset: { + x: nextBox.left - centeredNextBox.left, + y: nextBox.top - centeredNextBox.top, + }, + }; +} + export function imageLightboxBasisBoxForItem( item: ImageGalleryItem, fallbackBox: ImageLightboxBox, diff --git a/desktop/tests/e2e/image-attachment-gallery.spec.ts b/desktop/tests/e2e/image-attachment-gallery.spec.ts index 8c360236550..38975b4a3ab 100644 --- a/desktop/tests/e2e/image-attachment-gallery.spec.ts +++ b/desktop/tests/e2e/image-attachment-gallery.spec.ts @@ -175,6 +175,68 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { .first(); await expectCornerRadiusPx(lightboxSurface, 16); await expectSmoothCorners(lightboxSurface); + await expect(page.getByRole("button", { name: "Zoom out" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Zoom in" })).toBeEnabled(); + + // Clicking the image zooms to the secondary level instead of dismissing the + // dialog, and the clicked image point remains under the cursor. + await waitForAnimations(page); + const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`); + const initialImageBox = await lightboxImage.boundingBox(); + if (!initialImageBox) { + throw new Error("Expected lightbox image to have a layout box"); + } + const clickPoint = { + x: initialImageBox.x + initialImageBox.width * 0.25, + y: initialImageBox.y + initialImageBox.height * 0.35, + }; + await page.mouse.click(clickPoint.x, clickPoint.y); + await expect(dialog).toBeVisible(); + await expect(page.getByText("175%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1.75", + ); + await waitForAnimations(page); + const zoomedImageBox = await lightboxImage.boundingBox(); + if (!zoomedImageBox) { + throw new Error("Expected zoomed lightbox image to have a layout box"); + } + expect(zoomedImageBox.width).toBeCloseTo(initialImageBox.width * 1.75, 0); + expect(zoomedImageBox.height).toBeCloseTo(initialImageBox.height * 1.75, 0); + expect( + Math.abs( + zoomedImageBox.x + initialImageBox.width * 0.25 * 1.75 - clickPoint.x, + ), + ).toBeLessThan(2); + expect( + Math.abs( + zoomedImageBox.y + initialImageBox.height * 0.35 * 1.75 - clickPoint.y, + ), + ).toBeLessThan(2); + + // A second image click toggles back to the centered 1× view. + await page.mouse.click(clickPoint.x, clickPoint.y); + await expect(dialog).toBeVisible(); + await expect(page.getByText("100%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1", + ); + + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByText("115%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1.15", + ); + await expect( + page.getByRole("slider", { name: "Image zoom" }), + ).toHaveAttribute("step", "0.15"); + await page.getByRole("button", { name: "Zoom out" }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByText("100%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1", + ); await expect( page.getByRole("button", { name: "Previous image" }), ).toHaveCount(0); From 29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 12:22:12 -0400 Subject: [PATCH 031/101] highlight search terms in results and messages (#6702) **Category:** fix **User Impact:** Search terms are now highlighted in yellow in Cmd+F results and in the message opened from a result. **Problem:** Search returned relevant messages, but users still had to reread each preview and destination message to discover where the query appeared. Search navigation could also lose or misapply highlighting during rapid typing, repeated navigation, thread opening, and forum navigation. **Solution:** Render match-focused previews with a shared yellow treatment, carry the result query through navigation, and apply it only to the clicked destination. The implementation binds highlights to the debounced result set, supports token and prefix matching, and handles channels, threads, forums, diffs, and wave messages consistently.
File changes **desktop/src/app/AppShell.tsx** Carries the active result query into search-hit navigation so the destination can preserve the user's context. **desktop/src/app/navigation/searchHitEventCache.ts** Adds a bounded, one-shot cache for result queries and navigation IDs alongside cached search-hit events. **desktop/src/app/navigation/searchHitNavigation.test.mjs** Covers query retention, one-shot consumption, repeated same-route activations, forum posts, forced routing, and cancellation. **desktop/src/app/navigation/searchHitNavigation.ts** Associates each search-result activation with a unique navigation ID and forwards it to channel or forum destinations. **desktop/src/app/navigation/useAppNavigation.ts** Extends channel and forum navigation to carry search navigation state without changing normal navigation behavior. **desktop/src/app/routes/ChannelRouteScreen.tsx** Consumes search highlight state after navigation and retains it for the selected message while clearing it on community context changes. **desktop/src/app/routes/channels.$channelId.posts.$postId.tsx** Validates and forwards forum search-navigation IDs. **desktop/src/app/routes/channels.$channelId.tsx** Validates and forwards channel search-navigation IDs. **desktop/src/features/channels/ui/ChannelPane.tsx** Passes the selected result ID and query into the main timeline and open thread panel. **desktop/src/features/channels/ui/ChannelPane.types.ts** Defines the destination-highlight contract for channel panes. **desktop/src/features/channels/ui/ChannelScreen.tsx** Routes destination highlighting to either forum content or the channel timeline. **desktop/src/features/channels/ui/ChannelScreen.types.ts** Defines route-level search-highlight inputs. **desktop/src/features/channels/ui/ForumChannelContent.tsx** Forwards search context into expanded forum threads. **desktop/src/features/forum/ui/ForumThreadPanel.tsx** Highlights the matching text in the selected forum post or reply. **desktop/src/features/forum/ui/ForumView.tsx** Carries selected-result context from forum routing into the thread panel. **desktop/src/features/messages/ui/DiffMessage.tsx** Forwards the query into diff descriptions and rendered diff content. **desktop/src/features/messages/ui/DiffViewer.tsx** Provides a highlighted match excerpt for structured diffs and highlights fallback raw diff text. **desktop/src/features/messages/ui/MessageRow.tsx** Passes destination queries into normal Markdown, diff, and wave message renderers. **desktop/src/features/messages/ui/MessageThreadPanel.tsx** Applies highlighting to the selected thread head or reply without tinting unrelated messages. **desktop/src/features/messages/ui/WaveMessageAttachment.tsx** Highlights matches in wave-message fallback text. **desktop/src/features/search/lib/searchMatch.test.mjs** Covers case-insensitive, literal, multi-term, prefix, one-character, and late-preview matches. **desktop/src/features/search/lib/searchMatch.ts** Centralizes token extraction, case-insensitive match splitting, and match-focused preview generation. **desktop/src/features/search/ui/HighlightedSearchText.tsx** Adds the reusable accessible mark renderer used by result and specialized message surfaces. **desktop/src/features/search/ui/TopbarSearch.tsx** Highlights result previews, centers excerpts around matches, strips search operators, and hides stale results during debounce transitions. **desktop/src/features/sidebar/ui/AppSidebar.types.ts** Updates the sidebar search callback contract to include the result query. **desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx** Passes query-aware result selection through the pinned search entry point. **desktop/src/shared/lib/rehypeSearchHighlight.ts** Uses the shared matcher to mark every matching Markdown text segment while leaving code blocks untouched. **desktop/src/shared/lib/searchHighlightStyle.ts** Defines one yellow highlight treatment for light and dark themes. **desktop/src/shared/ui/markdown/nodeCache.test.mjs** Verifies all Markdown matches, one-character scoped searches, code exclusion, and transient cache behavior. **desktop/src/shared/ui/markdown/nodeCache.ts** Enables transient highlighting for one-character scoped searches without polluting the Markdown cache. **desktop/tests/e2e/smoke.spec.ts** Exercises result and destination highlighting, same-route forum activation, and stale-query suppression end to end.
## Reproduction steps 1. Open a channel containing a longer message and press Cmd+F. 2. Search for a word that appears later in the message, such as `mentions`. 3. Confirm each matching result shows the word in yellow and keeps the match visible in its preview. 4. Open a result and confirm the same term is highlighted in yellow in the destination message. 5. Repeat with a thread reply or forum post, and quickly change the query to confirm stale results are not selectable. ## Demo - **Before:** Search results returned the correct message but offered no visual indication of where the query matched; opening the message also showed no match treatment. - **After:** Every matching term is marked in yellow in the result preview and in the exact channel, thread, or forum message opened from that result. --------- Signed-off-by: tulsi --- desktop/src/app/AppShell.tsx | 4 +- .../searchHighlightNavigation.test.mjs | 39 ++ .../navigation/searchHighlightNavigation.ts | 47 +++ .../navigation/searchHitNavigation.test.mjs | 40 ++ .../src/app/navigation/searchHitNavigation.ts | 19 +- .../src/app/navigation/useAppNavigation.ts | 39 +- desktop/src/app/routes/ChannelRouteScreen.tsx | 77 +++- .../channels.$channelId.posts.$postId.tsx | 7 +- .../src/app/routes/channels.$channelId.tsx | 8 +- .../routes/searchHighlightRouteState.test.mjs | 39 ++ .../app/routes/searchHighlightRouteState.ts | 17 + .../src/features/channels/ui/ChannelPane.tsx | 22 +- .../features/channels/ui/ChannelPane.types.ts | 4 + .../features/channels/ui/ChannelScreen.tsx | 375 +++++++++--------- .../channels/ui/ChannelScreen.types.ts | 4 + .../channels/ui/ForumChannelContent.tsx | 6 + .../channels/ui/searchTargetForwarding.tsx | 20 + .../channels/ui/useSearchHighlightProps.ts | 18 + .../features/forum/ui/ForumThreadPanel.tsx | 17 + desktop/src/features/forum/ui/ForumView.tsx | 6 + .../src/features/messages/ui/DiffMessage.tsx | 6 +- .../src/features/messages/ui/DiffViewer.tsx | 20 +- .../src/features/messages/ui/MessageRow.tsx | 2 + .../messages/ui/MessageThreadPanel.tsx | 12 + .../messages/ui/WaveMessageAttachment.tsx | 10 +- .../features/search/lib/searchMatch.test.mjs | 113 ++++++ .../src/features/search/lib/searchMatch.ts | 201 ++++++++++ .../search/ui/HighlightedSearchText.tsx | 26 ++ .../src/features/search/ui/TopbarSearch.tsx | 48 +-- .../features/sidebar/ui/AppSidebar.types.ts | 2 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 2 +- .../src/shared/lib/rehypeSearchHighlight.ts | 32 +- .../src/shared/lib/searchHighlightStyle.ts | 3 + .../src/shared/ui/markdown/nodeCache.test.mjs | 24 +- desktop/src/shared/ui/markdown/nodeCache.ts | 4 +- desktop/src/testing/e2eBridge.ts | 9 + desktop/tests/e2e/smoke.spec.ts | 165 ++++++++ 37 files changed, 1205 insertions(+), 282 deletions(-) create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.test.mjs create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.ts create mode 100644 desktop/src/app/routes/searchHighlightRouteState.test.mjs create mode 100644 desktop/src/app/routes/searchHighlightRouteState.ts create mode 100644 desktop/src/features/channels/ui/searchTargetForwarding.tsx create mode 100644 desktop/src/features/channels/ui/useSearchHighlightProps.ts create mode 100644 desktop/src/features/search/lib/searchMatch.test.mjs create mode 100644 desktop/src/features/search/lib/searchMatch.ts create mode 100644 desktop/src/features/search/ui/HighlightedSearchText.tsx create mode 100644 desktop/src/shared/lib/searchHighlightStyle.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0e..468435e15ec 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -648,8 +648,8 @@ export function AppShell() { ); const handleOpenSearchResult = React.useCallback( - (hit: SearchHit) => { - void openSearchHit(hit); + (hit: SearchHit, query: string) => { + void openSearchHit(hit, { query }); }, [openSearchHit], ); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.test.mjs b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs new file mode 100644 index 00000000000..1e05a62669f --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { createSearchHighlightNavigation, parseSearchHighlightNavigation } = + await import("./searchHighlightNavigation.ts"); + +test("creates trimmed transient state with a unique activation id", () => { + const first = createSearchHighlightNavigation("message", " Mentions "); + const second = createSearchHighlightNavigation("message", "Mentions"); + + assert.deepEqual( + { messageId: first.messageId, query: first.query }, + { messageId: "message", query: "Mentions" }, + ); + assert.notEqual(first.activationId, second.activationId); +}); + +test("does not create highlight state for an empty query", () => { + assert.equal(createSearchHighlightNavigation("message", " "), undefined); + assert.equal( + createSearchHighlightNavigation("message", undefined), + undefined, + ); +}); + +test("parses only complete highlight navigation state", () => { + const state = { + activationId: "activation", + messageId: "message", + query: "mentions", + }; + + assert.deepEqual(parseSearchHighlightNavigation(state), state); + assert.equal( + parseSearchHighlightNavigation({ messageId: "message", query: "mentions" }), + null, + ); + assert.equal(parseSearchHighlightNavigation(null), null); +}); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.ts b/desktop/src/app/navigation/searchHighlightNavigation.ts new file mode 100644 index 00000000000..43d3506ec8c --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.ts @@ -0,0 +1,47 @@ +export type SearchHighlightNavigation = { + activationId: string; + messageId: string; + query: string; +}; + +export function createSearchHighlightNavigation( + messageId: string, + query: string | undefined, +): SearchHighlightNavigation | undefined { + const trimmedQuery = query?.trim(); + if (!trimmedQuery) { + return undefined; + } + + return { + activationId: crypto.randomUUID(), + messageId, + query: trimmedQuery, + }; +} + +export function parseSearchHighlightNavigation( + value: unknown, +): SearchHighlightNavigation | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + if ( + typeof candidate.activationId !== "string" || + candidate.activationId.length === 0 || + typeof candidate.messageId !== "string" || + candidate.messageId.length === 0 || + typeof candidate.query !== "string" || + candidate.query.length === 0 + ) { + return null; + } + + return { + activationId: candidate.activationId, + messageId: candidate.messageId, + query: candidate.query, + }; +} diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 74e5f108af6..02276d9eb34 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn options: { force: true, messageId: "message", + searchHighlight: undefined, threadRootId: "thread-root", }, }, @@ -58,6 +59,45 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); +test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => { + clearSearchHitEventCache(); + const calls = []; + + await openSearchHitWithNavigation(plainMessage, { + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, + goForumPost: async () => false, + query: " Mentions ", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "message"); + assert.equal(calls[0].options.searchHighlight.query, "Mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + +test("forum-post search navigation carries transient same-route activation state", async () => { + clearSearchHitEventCache(); + const forumPost = { ...forumComment, eventId: "post", kind: 45001 }; + const calls = []; + + await openSearchHitWithNavigation(forumPost, { + goChannel: async () => false, + goForumPost: async (channelId, postId, options) => { + calls.push({ channelId, postId, options }); + return true; + }, + query: "mentions", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "post"); + assert.equal(calls[0].options.searchHighlight.query, "mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + test("cancelled search-hit navigation cannot repopulate cache or route", async () => { clearSearchHitEventCache(); let resolveLookup; diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 8523340d481..6b180c33f00 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -1,21 +1,28 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { createSearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import type { SearchHit } from "@/shared/api/types"; type SearchHitNavigationActions = { force?: boolean; + query?: string; goChannel: ( channelId: string, options?: { force?: boolean; messageId?: string; + searchHighlight?: ReturnType; threadRootId?: string | null; }, ) => Promise; goForumPost: ( channelId: string, postId: string, - options?: { force?: boolean; replyId?: string }, + options?: { + force?: boolean; + replyId?: string; + searchHighlight?: ReturnType; + }, ) => Promise; signal?: AbortSignal; }; @@ -30,6 +37,10 @@ export async function openSearchHitWithNavigation( } const isLifecycleBound = Boolean(actions.signal); + const searchHighlight = createSearchHighlightNavigation( + hit.eventId, + actions.query, + ); if (!isLifecycleBound) { cacheSearchHitEvent(hit); } @@ -47,14 +58,16 @@ export async function openSearchHitWithNavigation( if (destination.kind === "forum-post") { return actions.goForumPost(destination.channelId, destination.postId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), replyId: destination.replyId, + searchHighlight, }); } return actions.goChannel(destination.channelId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), messageId: destination.messageId, + searchHighlight, threadRootId: destination.threadRootId, }); } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 7a21f0dfbe1..c82c4d96eea 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,6 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { allowNavigation, @@ -32,14 +33,23 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; - state?: Record; + state?: + | Record + | (( + previousState: Record, + ) => Record); }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); + const hasStateUpdate = next.state !== undefined; - if (location.href === nextLocation.href && !behavior.force) { + if ( + location.href === nextLocation.href && + !behavior.force && + !hasStateUpdate + ) { return false; } @@ -265,6 +275,9 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -290,6 +303,12 @@ export function useAppNavigation() { ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -329,6 +348,9 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; }, ) => { return commitNavigation( @@ -338,7 +360,15 @@ export function useAppNavigation() { channelId, postId, }, - search: options?.replyId ? { replyId: options.replyId } : {}, + search: { + ...(options?.replyId ? { replyId: options.replyId } : {}), + }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -406,6 +436,8 @@ export function useAppNavigation() { * Used by desktop-notification activation so a click is never * silently swallowed (block/buzz#3509). */ force?: boolean; + /** Search text to highlight after opening this result. */ + query?: string; /** Stop notification-driven routing when its owning lifecycle ends. */ signal?: AbortSignal; }, @@ -414,6 +446,7 @@ export function useAppNavigation() { force: behavior?.force, goChannel, goForumPost, + query: behavior?.query, signal: behavior?.signal, }), [goChannel, goForumPost], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6fa..03b08196afb 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; @@ -20,6 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; + searchHighlight: SearchHighlightNavigation | null | undefined; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -100,6 +102,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ autoSendDraftKey, channelId, + searchHighlight, selectedPostId, targetMessageId, targetReplyId, @@ -132,23 +135,67 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); - - // Reset spliced target events when the channel context changes (channel - // switch or entering/leaving a forum post). Tied to channel identity rather - // than the route target so clearing the `messageId` param mid-channel keeps - // the deep-linked row in view. Seeded with the mount key so the initial - // cache-seeded events survive first commit; only a genuine channel change - // clears them. Declared before the fetch effect so a channel switch clears - // stale events before the new target is fetched. - const previousResetKeyRef = React.useRef( - `${channelId}::${selectedPostId ?? ""}`, + const [activeSearchHighlight, setActiveSearchHighlight] = + React.useState(searchHighlight ?? null); + const appliedSearchActivationIdRef = React.useRef( + searchHighlight?.activationId ?? null, ); + + // Router state is transient and can be cleared by the target URL cleanup. + // Retain the applied activation locally until an ordinary route transition + // explicitly arrives without search state. + React.useEffect(() => { + if (searchHighlight === null) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + return; + } + if (!searchHighlight) { + const ordinaryTargetIds = [ + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ].filter((targetId): targetId is string => targetId !== null); + if ( + ordinaryTargetIds.length > 0 && + activeSearchHighlight && + !ordinaryTargetIds.includes(activeSearchHighlight.messageId) + ) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + } + return; + } + if (appliedSearchActivationIdRef.current === searchHighlight.activationId) { + return; + } + + appliedSearchActivationIdRef.current = searchHighlight.activationId; + setActiveSearchHighlight(searchHighlight); + }, [ + activeSearchHighlight, + searchHighlight, + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ]); + + // Reset spliced target events when the channel changes. Tied to channel + // identity rather than the route target so clearing the `messageId` param + // mid-channel keeps the deep-linked row in view. Seeded with the mount key so + // the initial cache-seeded events survive first commit; only a genuine + // channel change clears them. Declared before the fetch effect so a channel + // switch clears stale events before the new target is fetched. + const previousResetKeyRef = React.useRef(channelId); React.useEffect(() => { - const resetKey = `${channelId}::${selectedPostId ?? ""}`; - if (previousResetKeyRef.current === resetKey) return; - previousResetKeyRef.current = resetKey; + if (previousResetKeyRef.current === channelId) return; + previousResetKeyRef.current = channelId; + appliedSearchActivationIdRef.current = null; setTargetMessageEvents([]); - }, [channelId, selectedPostId]); + setActiveSearchHighlight(null); + }, [channelId]); React.useEffect(() => { let isCancelled = false; @@ -234,6 +281,8 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetSearchMessageId={activeSearchHighlight?.messageId} + targetSearchQuery={activeSearchHighlight?.query} /> ); } diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 1025cc1e89a..8fcab41817d 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -33,6 +34,9 @@ function ForumPostRouteComponent() { usePreviewFeatureWarning("forum"); const { channelId, postId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); const isHuddleTranscript = huddleWindowChannelId() !== null; return ( @@ -74,6 +79,7 @@ function ChannelRouteComponent() { { + assert.deepEqual( + selectSearchHighlightRouteState({ state: { searchHighlight } }), + searchHighlight, + ); +}); + +test("target cleanup without highlight state preserves the selection", () => { + assert.equal(selectSearchHighlightRouteState({ state: {} }), undefined); +}); + +test("ordinary navigation explicitly clears the selection", () => { + assert.equal( + selectSearchHighlightRouteState({ state: { searchHighlight: null } }), + null, + ); +}); + +test("ignores malformed router state", () => { + assert.equal( + selectSearchHighlightRouteState({ + state: { searchHighlight: { messageId: "message", query: "mentions" } }, + }), + undefined, + ); +}); diff --git a/desktop/src/app/routes/searchHighlightRouteState.ts b/desktop/src/app/routes/searchHighlightRouteState.ts new file mode 100644 index 00000000000..4fcc01c8d39 --- /dev/null +++ b/desktop/src/app/routes/searchHighlightRouteState.ts @@ -0,0 +1,17 @@ +import { + parseSearchHighlightNavigation, + type SearchHighlightNavigation, +} from "@/app/navigation/searchHighlightNavigation"; + +export function selectSearchHighlightRouteState(location: { + state: unknown; +}): SearchHighlightNavigation | null | undefined { + const state = location.state as { searchHighlight?: unknown } | undefined; + if (!(state && "searchHighlight" in state)) { + return undefined; + } + if (state.searchHighlight === null) { + return null; + } + return parseSearchHighlightNavigation(state.searchHighlight) ?? undefined; +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4086a4e1c1a..19bb21b62fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -49,6 +49,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { useSearchHighlightProps } from "@/features/channels/ui/useSearchHighlightProps"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -152,6 +153,8 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + targetSearchMessageId, + targetSearchQuery, threadAllMessages, threadHeadMessage, threadMessages, @@ -176,6 +179,10 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, ); const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const searchHighlightProps = useSearchHighlightProps( + targetSearchMessageId, + targetSearchQuery, + ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); const isNonMemberView = @@ -194,8 +201,6 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear only the auto-send key so thread state survives deferred submission; - // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -252,9 +257,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; - // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → - // ordinary DM, composer enabled. const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); const isModerationDmChannel = isModerationDm( activeChannel ?? null, @@ -339,10 +341,6 @@ export const ChannelPane = React.memo(function ChannelPane({ !isMainDeferredEditPending && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; - // Unified working set for the composer bar: observer-derived turns primary, - // bot typing fallback (both folded together by agentWorkingSignal). This is - // what makes the bar show for an agent whose observer stream is live but - // whose typing signal never arrives — and vice versa. const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( activeChannel?.id ?? null, ); @@ -696,6 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} + {...searchHighlightProps.timeline} targetMessageId={targetMessageId} splitThreadPanelOpen={ useSplitAuxiliaryPane && @@ -894,6 +893,7 @@ export const ChannelPane = React.memo(function ChannelPane({ replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} + {...searchHighlightProps.thread} threadHead={threadHeadMessage} videoReviewPresentation={threadVideoReviewPresentation} widthPx={threadPanelWidthPx} @@ -940,10 +940,6 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : activeChannel && selectedAgent ? ( (() => { - // When the panel was opened from a different channel than the - // currently active one, re-scope it to the active channel so - // that both the content/header AND channel-backed actions (e.g. - // Stop current turn) operate on the same channel object. const effectiveAgentSessionChannelId = openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 02968221680..d1b88ae3a6b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -181,6 +181,10 @@ export type ChannelPaneProps = { threadReplyUnreadCounts?: ReadonlyMap; threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; + /** Exact clicked result id, including a reply routed into the thread panel. */ + targetSearchMessageId?: string | null; + /** Search text to highlight within the clicked result. */ + targetSearchQuery?: string; typingPubkeys: string[]; isFollowingThread?: boolean; onFollowThread?: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 240a9ad70c1..fe0c4d23b7d 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -7,24 +7,14 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import { - useChannelMembersQuery, - useJoinChannelMutation, -} from "@/features/channels/hooks"; -import { - MSG_PREFIX, - THREAD_PREFIX, -} from "@/features/channels/readState/readStateFormat"; +import * as channelHooks from "@/features/channels/hooks"; +import * as readStateFormat from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import { - useManagedAgentsQuery, - usePersonasQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; +import * as agentHooks from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -44,10 +34,7 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { - getThreadReference, - isThreadReply, -} from "@/features/messages/lib/threading"; +import * as threading from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -62,10 +49,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import { - useHuddleChannelMessages, - useIsHuddleTranscript, -} from "@/features/channels/ui/useHuddleChannelMessages"; +import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -89,6 +73,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; +import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -101,6 +86,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, + ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -171,7 +157,8 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = + huddleMessages.useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -214,7 +201,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) + if (threading.getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -233,7 +220,8 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) + contextId.startsWith(readStateFormat.THREAD_PREFIX) || + contextId.startsWith(readStateFormat.MSG_PREFIX) ? activeChannelId : null, ); @@ -253,13 +241,14 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = useJoinChannelMutation(activeChannelId); + const joinChannelMutation = + channelHooks.useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = useHuddleChannelMessages({ + } = huddleMessages.useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -305,9 +294,11 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); + const channelMembersQuery = channelHooks.useChannelMembersQuery( + activeChannel?.id ?? null, + ); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -318,7 +309,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -385,7 +376,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = usePersonasQuery(); + const personasQuery = agentHooks.usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -502,7 +493,8 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && + threading.isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -632,9 +624,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -825,170 +814,176 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - - ) : ( - - } - > - - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} + onClosePost={onCloseForumPost} + onCloseProfilePanel={handleCloseProfilePanel} onOpenDm={handleOpenDm} onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} + onPanelResizeStart={handleThreadPanelResizeStart} onProfilePanelTabChange={setProfilePanelTab} + onProfilePanelViewChange={setProfilePanelView} + onResetPanelWidth={handleThreadPanelWidthReset} + onSelectPost={onSelectForumPost} + panelWidthPx={threadPanelWidthPx} profilePanelPubkey={profilePanelPubkey} profilePanelTab={profilePanelTab} profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} - typingPubkeys={humanTypingPubkeys} - /> + selectedPostId={selectedForumPostId} + targetReplyId={targetForumReplyId} + />, + searchTarget, + ) + ) : ( + + } + > + {searchForwarding.renderSearchAwareChannel( + + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} + typingPubkeys={humanTypingPubkeys} + />, + searchTarget, + )} ) ) : ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5d..a64937a74b1 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -22,4 +22,8 @@ export type ChannelScreenProps = { targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; + /** Exact clicked result id, retained after route target cleanup. */ + targetSearchMessageId?: string; + /** Search text to highlight within the opened result message. */ + targetSearchQuery?: string; }; diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 35655026161..78269386653 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -42,6 +42,8 @@ type ForumChannelContentProps = { profilePanelView: ProfilePanelView; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; /** @@ -71,6 +73,8 @@ export function ForumChannelContent({ profilePanelView, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumChannelContentProps) { return ( <> @@ -88,6 +92,8 @@ export function ForumChannelContent({ onSelectPost={onSelectPost} selectedPostId={selectedPostId} targetReplyId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} /> diff --git a/desktop/src/features/channels/ui/searchTargetForwarding.tsx b/desktop/src/features/channels/ui/searchTargetForwarding.tsx new file mode 100644 index 00000000000..925156258c9 --- /dev/null +++ b/desktop/src/features/channels/ui/searchTargetForwarding.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import type { ForumChannelContent } from "./ForumChannelContent"; +import type { GuardedChannelPane } from "./GuardedChannelPane"; +import type { ChannelScreenProps } from "./ChannelScreen.types"; + +type SearchTarget = Pick< + ChannelScreenProps, + "targetSearchMessageId" | "targetSearchQuery" +>; + +export const renderSearchAwareForum = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); + +export const renderSearchAwareChannel = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); diff --git a/desktop/src/features/channels/ui/useSearchHighlightProps.ts b/desktop/src/features/channels/ui/useSearchHighlightProps.ts new file mode 100644 index 00000000000..b506fee182f --- /dev/null +++ b/desktop/src/features/channels/ui/useSearchHighlightProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; + +export function useSearchHighlightProps( + messageId: string | null | undefined, + query: string | undefined, +) { + const searchMatchingMessageIds = React.useMemo( + () => (messageId ? new Set([messageId]) : undefined), + [messageId], + ); + return React.useMemo( + () => ({ + thread: { searchMessageId: messageId, searchQuery: query }, + timeline: { searchMatchingMessageIds, searchQuery: query }, + }), + [messageId, query, searchMatchingMessageIds], + ); +} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 7bf40fbffe3..348fa157000 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -41,6 +41,8 @@ type ForumThreadPanelProps = { canDeletePost?: boolean; isDeletingPost?: boolean; targetEventId?: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDeleteReply( @@ -57,12 +59,14 @@ function ReplyRow({ profiles, channelNames, onDelete, + searchQuery, }: { reply: ThreadReply; currentPubkey?: string; profiles?: UserProfileLookup; channelNames?: string[]; onDelete?: (eventId: string) => void; + searchQuery?: string; }) { const replyAuthorLabel = resolveUserLabel({ pubkey: reply.pubkey, @@ -122,6 +126,7 @@ function ReplyRow({ imetaByUrl={parseImetaTags(reply.tags)} mentionNames={replyMentionNames} mentionPubkeysByName={replyMentionPubkeysByName} + searchQuery={searchQuery} />
@@ -143,6 +148,8 @@ export function ForumThreadPanel({ canDeletePost, isDeletingPost, targetEventId, + targetSearchMessageId, + targetSearchQuery, }: ForumThreadPanelProps) { const scrollRef = React.useRef(null); const { channels } = useChannelNavigation(); @@ -268,6 +275,11 @@ export function ForumThreadPanel({ imetaByUrl={parseImetaTags(post.tags)} mentionNames={postMentionNames} mentionPubkeysByName={postMentionPubkeysByName} + searchQuery={ + targetSearchMessageId === post.eventId + ? targetSearchQuery + : undefined + } />
@@ -286,6 +298,11 @@ export function ForumThreadPanel({ onDelete={onDeleteReply} profiles={profiles} reply={reply} + searchQuery={ + targetSearchMessageId === reply.eventId + ? targetSearchQuery + : undefined + } /> ))} diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 9efada55492..670ee4e6dfb 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -30,6 +30,8 @@ type ForumViewProps = { onTargetReached?: (messageId: string) => void; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDelete(postPubkey: string, currentPubkey?: string): boolean { @@ -47,6 +49,8 @@ export function ForumView({ onTargetReached, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumViewProps) { const [isComposerOpen, setIsComposerOpen] = React.useState(false); const postsScrollRef = React.useRef(null); @@ -156,6 +160,8 @@ export function ForumView({ onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} thread={threadQuery.data} /> ); diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx index 71a3460ce86..f1ff265339a 100644 --- a/desktop/src/features/messages/ui/DiffMessage.tsx +++ b/desktop/src/features/messages/ui/DiffMessage.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { FileDiff, Maximize2 } from "lucide-react"; import { getDiffTitleBadge } from "@/features/messages/lib/parseDiff"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { isSafeUrl } from "@/shared/lib/url"; import { Button } from "@/shared/ui/button"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; @@ -14,6 +15,7 @@ type DiffMessageProps = { filePath?: string; commitSha?: string; description?: string; + searchQuery?: string; truncated?: boolean; onExpand?: () => void; }; @@ -32,6 +34,7 @@ export default function DiffMessage({ filePath, commitSha, description, + searchQuery, truncated, onExpand, }: DiffMessageProps) { @@ -116,7 +119,7 @@ export default function DiffMessage({ {description && (
- {description} +
)} @@ -126,6 +129,7 @@ export default function DiffMessage({ className="p-3" content={content} fallbackFilePath={filePath} + searchQuery={searchQuery} viewType="unified" />
diff --git a/desktop/src/features/messages/ui/DiffViewer.tsx b/desktop/src/features/messages/ui/DiffViewer.tsx index 1444cf99777..ed6703f66cb 100644 --- a/desktop/src/features/messages/ui/DiffViewer.tsx +++ b/desktop/src/features/messages/ui/DiffViewer.tsx @@ -2,6 +2,8 @@ import { Diff, Hunk, type ViewType } from "react-diff-view"; import "react-diff-view/style/index.css"; import { useMemo } from "react"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { countDiffFileChanges, DIFF_TYPE_LABELS, @@ -18,6 +20,7 @@ type DiffViewerProps = { fallbackFilePath?: string; viewType?: ViewType; className?: string; + searchQuery?: string; }; function FileChangeBadge({ @@ -46,16 +49,20 @@ export function DiffViewer({ fallbackFilePath, viewType = "unified", className, + searchQuery, }: DiffViewerProps) { const { files, parseError } = useMemo( () => parseUnifiedDiff(content), [content], ); + const searchPreview = searchQuery + ? buildSearchResultPreview(content, searchQuery, 160) + : null; if (parseError) { return (
-        {content}
+        
       
); } @@ -70,6 +77,17 @@ export function DiffViewer({ return (
+ {searchPreview ? ( +
+          
+        
+ ) : null}
{files.map((file) => { const label = getDiffFileLabel(file, fallbackFilePath); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 536631b02d3..0573a3186b8 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -396,6 +396,7 @@ export const MessageRow = React.memo( setExpandedDiffId(message.id); }} repoUrl={getTag("repo")} + searchQuery={searchQuery} truncated={getTag("truncated") === "true"} /> @@ -417,6 +418,7 @@ export const MessageRow = React.memo( fallbackText={waveMessage.fallbackText} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} + searchQuery={searchQuery} /> ); } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 01fbcda7ffb..d3b1394829b 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -81,6 +81,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { onScrollTargetResolved: () => void; onScrollTargetSettled?: (messageId: string) => void; scrollTargetHighlights?: boolean; + searchMessageId?: string | null; + searchQuery?: string; onSelectReplyTarget: (message: TimelineMessage) => void; onSend: ( content: string, @@ -185,6 +187,8 @@ export function MessageThreadPanel({ replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, + searchMessageId, + searchQuery, threadHead, videoReviewPresentation, threadReplies, @@ -562,6 +566,9 @@ export function MessageThreadPanel({ onUnfollowThread ? (_msg) => onUnfollowThread() : undefined } profiles={profiles} + searchQuery={ + searchMessageId === threadHead.id ? searchQuery : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( threadHead.id, @@ -725,6 +732,11 @@ export function MessageThreadPanel({ onSendToChannel={stableSendToChannel} onToggleReaction={onToggleReaction} profiles={profiles} + searchQuery={ + searchMessageId === entry.message.id + ? searchQuery + : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( entry.message.id, diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index f782326295a..9c1a6feeeea 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useHuddle } from "@/features/huddle"; import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { @@ -20,6 +21,7 @@ type WaveMessageAttachmentProps = { fallbackText: string; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; + searchQuery?: string; }; export function WaveMessageAttachment({ @@ -27,6 +29,7 @@ export function WaveMessageAttachment({ fallbackText, huddleMemberPubkeys = [], huddleMemberPubkeysPending = false, + searchQuery, }: WaveMessageAttachmentProps) { const queryClient = useQueryClient(); const { isStarting, startHuddle } = useHuddle(); @@ -68,7 +71,12 @@ export function WaveMessageAttachment({ 👋 - {fallbackText} + + + Start a huddle to talk to them. diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs new file mode 100644 index 00000000000..4f9651a75da --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts"; + +test("splitSearchMatches highlights every case-insensitive lexeme match", () => { + assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [ + { isMatch: true, key: "0-8", text: "Mentions" }, + { isMatch: false, key: "8-5", text: " and " }, + { isMatch: true, key: "13-8", text: "mentions" }, + ]); +}); + +test("splitSearchMatches normalizes punctuation into search lexemes", () => { + assert.deepEqual(splitSearchMatches("foo bar release", "foo-bar"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "bar" }, + { isMatch: false, key: "7-8", text: " release" }, + ]); +}); + +test("splitSearchMatches keeps completed tokens on lexeme boundaries", () => { + assert.deepEqual( + splitSearchMatches("projectile notes about project planning", "project pl"), + [ + { + isMatch: false, + key: "0-23", + text: "projectile notes about ", + }, + { isMatch: true, key: "23-7", text: "project" }, + { isMatch: false, key: "30-1", text: " " }, + { isMatch: true, key: "31-2", text: "pl" }, + { isMatch: false, key: "33-6", text: "anning" }, + ], + ); +}); + +test("splitSearchMatches preserves exact and prefix modes for a repeated term", () => { + assert.deepEqual(splitSearchMatches("foo foobar", "foo foo"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "foo" }, + { isMatch: false, key: "7-3", text: "bar" }, + ]); +}); + +test("splitSearchMatches highlights non-adjacent prefix-search terms", () => { + assert.deepEqual(splitSearchMatches("agent status mentions", "agent ment"), [ + { isMatch: true, key: "0-5", text: "agent" }, + { isMatch: false, key: "5-8", text: " status " }, + { isMatch: true, key: "13-4", text: "ment" }, + { isMatch: false, key: "17-4", text: "ions" }, + ]); +}); + +test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () => { + assert.deepEqual(splitSearchMatches("A plan", "a"), [ + { isMatch: true, key: "0-1", text: "A" }, + { isMatch: false, key: "1-5", text: " plan" }, + ]); +}); + +test("splitSearchMatches maps expanding lowercase prefixes to original spans", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "İs"), [ + { isMatch: true, key: "0-2", text: "İs" }, + { isMatch: false, key: "2-14", text: "tanbul release" }, + ]); + assert.deepEqual(splitSearchMatches("İstanbul release", "İst"), [ + { isMatch: true, key: "0-3", text: "İst" }, + { isMatch: false, key: "3-13", text: "anbul release" }, + ]); +}); + +test("splitSearchMatches does not split a character whose lowercase form expands", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "i"), [ + { isMatch: true, key: "0-1", text: "İ" }, + { isMatch: false, key: "1-15", text: "stanbul release" }, + ]); +}); + +test("splitSearchMatches preserves UTF-16 boundaries for supplementary letters", () => { + assert.deepEqual(splitSearchMatches("𐐀İstanbul release", "𐐨İs"), [ + { isMatch: true, key: "0-4", text: "𐐀İs" }, + { isMatch: false, key: "4-14", text: "tanbul release" }, + ]); +}); + +test("buildSearchResultPreview keeps a late match visible", () => { + const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; + const preview = buildSearchResultPreview(content, "mentions", 96); + + assert.equal(preview.length <= 96, true); + assert.match(preview, /mentions/i); + assert.match(preview, /^\.\.\./); + assert.match(preview, /\.\.\.$/); +}); + +test("buildSearchResultPreview ignores an invalid completed-token substring", () => { + const content = `${"projectile filler ".repeat(20)}project planning release notes`; + const preview = buildSearchResultPreview(content, "project pl", 80); + + assert.match(preview, /project planning/); + assert.match(preview, /^\.\.\./); +}); + +test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => { + assert.equal( + buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10), + "abcdefg...", + ); +}); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts new file mode 100644 index 00000000000..f74d1a26784 --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -0,0 +1,201 @@ +export type SearchMatchPart = { + isMatch: boolean; + key: string; + text: string; +}; + +type SearchHighlightTerm = { + isPrefix: boolean; + value: string; +}; + +type TextLexeme = { + end: number; + normalized: string; + start: number; +}; + +// PostgreSQL's `simple` text-search configuration breaks ordinary punctuation +// into lexemes (for example, `foo-bar` contributes `foo` and `bar`). Keep the +// desktop highlighter on those lexical boundaries rather than treating raw +// whitespace tokens as unrestricted substrings. +const LEXEME_PATTERN = /[\p{L}\p{N}]+/gu; + +function extractLexemes(value: string): string[] { + return Array.from(value.matchAll(LEXEME_PATTERN), (match) => + match[0].toLowerCase(), + ); +} + +function getSearchHighlightMatchers(query: string): SearchHighlightTerm[] { + const rawTokens = query.trim().split(/\s+/).filter(Boolean); + const matchers: SearchHighlightTerm[] = []; + + rawTokens.forEach((rawToken, tokenIndex) => { + const isPrefix = tokenIndex === rawTokens.length - 1; + for (const value of extractLexemes(rawToken)) { + matchers.push({ isPrefix, value }); + } + }); + + // Deduplicate repeated constraints without collapsing exact and prefix modes: + // `foo foo` asks Postgres for both an exact `foo` and a `foo:*` lexeme. + const deduped = new Map(); + for (const matcher of matchers) { + deduped.set( + `${matcher.isPrefix ? "prefix" : "exact"}:${matcher.value}`, + matcher, + ); + } + return [...deduped.values()].sort( + (left, right) => right.value.length - left.value.length, + ); +} + +/** + * Lexemes used by desktop prefix search after punctuation normalization. + * Completed whitespace-delimited tokens match exactly; only lexemes from the + * trailing token match prefixes. + */ +export function getSearchHighlightTerms(query: string): string[] { + return getSearchHighlightMatchers(query).map((matcher) => matcher.value); +} + +function getTextLexemes(text: string): TextLexeme[] { + return Array.from(text.matchAll(LEXEME_PATTERN), (match) => ({ + end: (match.index ?? 0) + match[0].length, + normalized: match[0].toLowerCase(), + start: match.index ?? 0, + })); +} + +function getOriginalPrefixLength( + original: string, + normalizedLength: number, +): number { + let normalizedOffset = 0; + let originalOffset = 0; + + for (const character of original) { + normalizedOffset += character.toLowerCase().length; + originalOffset += character.length; + if (normalizedOffset >= normalizedLength) { + return originalOffset; + } + } + + return original.length; +} + +function getMatchSpans( + text: string, + query: string, +): Array<{ end: number; start: number }> { + const matchers = getSearchHighlightMatchers(query); + if (matchers.length === 0) { + return []; + } + + const spans: Array<{ end: number; start: number }> = []; + for (const lexeme of getTextLexemes(text)) { + const exactMatch = matchers.find( + (matcher) => !matcher.isPrefix && matcher.value === lexeme.normalized, + ); + if (exactMatch) { + spans.push({ start: lexeme.start, end: lexeme.end }); + continue; + } + + const prefixMatch = matchers.find( + (matcher) => + matcher.isPrefix && lexeme.normalized.startsWith(matcher.value), + ); + if (prefixMatch) { + const originalLexeme = text.slice(lexeme.start, lexeme.end); + spans.push({ + start: lexeme.start, + end: + lexeme.start + + getOriginalPrefixLength(originalLexeme, prefixMatch.value.length), + }); + } + } + + return spans; +} + +/** Split text around case-insensitive lexeme/prefix matches of the query. */ +export function splitSearchMatches( + text: string, + query: string, +): SearchMatchPart[] { + const spans = getMatchSpans(text, query); + if (spans.length === 0) { + return [{ isMatch: false, key: "0", text }]; + } + + const parts: SearchMatchPart[] = []; + let offset = 0; + for (const span of spans) { + if (span.start > offset) { + parts.push({ + isMatch: false, + key: `${offset}-${span.start - offset}`, + text: text.slice(offset, span.start), + }); + } + parts.push({ + isMatch: true, + key: `${span.start}-${span.end - span.start}`, + text: text.slice(span.start, span.end), + }); + offset = span.end; + } + if (offset < text.length) { + parts.push({ + isMatch: false, + key: `${offset}-${text.length - offset}`, + text: text.slice(offset), + }); + } + return parts; +} + +/** + * Build a compact result excerpt that keeps the first matching search term + * visible. Context is biased before the match so the excerpt still reads like + * a sentence while avoiding a match that is clipped offscreen. + */ +export function buildSearchResultPreview( + content: string, + query: string, + maxLength = 96, +): string { + const text = content.trim(); + if (!text) { + return "No message body."; + } + if (text.length <= maxLength) { + return text; + } + + const matchIndex = getMatchSpans(text, query)[0]?.start ?? -1; + if (matchIndex < 0) { + return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; + } + + const contextBefore = Math.min(32, Math.floor(maxLength / 3)); + let start = Math.max(0, matchIndex - contextBefore); + const end = Math.min(text.length, start + maxLength); + + if (end === text.length) { + start = Math.max(0, end - maxLength); + } + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + const available = Math.max(0, maxLength - prefix.length - suffix.length); + const excerpt = text.slice(start, start + available).trim(); + + return `${prefix}${excerpt}${suffix}`; +} diff --git a/desktop/src/features/search/ui/HighlightedSearchText.tsx b/desktop/src/features/search/ui/HighlightedSearchText.tsx new file mode 100644 index 00000000000..51745f88737 --- /dev/null +++ b/desktop/src/features/search/ui/HighlightedSearchText.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + +export function HighlightedSearchText({ + query, + text, +}: { + query: string; + text: string; +}) { + return splitSearchMatches(text, query).map((part) => + part.isMatch ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42e..70365497bcc 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; +import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; import { useSearchResults } from "@/features/search/useSearchResults"; import { resultIcon, @@ -15,6 +17,7 @@ import { getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -36,7 +39,7 @@ type TopbarSearchProps = { currentChannelId?: string | null; focusRequest?: number; onOpenChannel: (channelId: string) => void; - onOpenResult: (hit: SearchHit) => void; + onOpenResult: (hit: SearchHit, query: string) => void; onOpenUser?: (user: UserSearchResult) => void | Promise; onBrowseChannels?: () => void | Promise; onCreateAgent?: () => void | Promise; @@ -72,19 +75,6 @@ type SearchHitContextLabel = { text: string; }; -function truncateResultText(content: string, maxLength = 96) { - const trimmed = content.trim(); - if (trimmed.length === 0) { - return "No message body."; - } - - if (trimmed.length <= maxLength) { - return trimmed; - } - - return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; -} - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; @@ -436,6 +426,10 @@ export function TopbarSearch({ scopeChannelId, }); const trimmedQuery = query.trim(); + // Bind highlights to the debounced result source so stale results can never + // pair with newly typed text during the debounce window. + const resultQuery = parseSearchOperators(debouncedQuery).text; + const resultsAreCurrent = debouncedQuery === trimmedQuery; const isIconVariant = variant === "icon"; const currentChannel = currentChannelId ? (channelLookup.get(currentChannelId) ?? null) @@ -504,9 +498,10 @@ export function TopbarSearch({ ), [currentPubkeyNormalized, results], ); + const visibleSearchableResults = resultsAreCurrent ? searchableResults : []; const searchResultSections = React.useMemo( - () => groupSearchResults(searchableResults), - [searchableResults], + () => groupSearchResults(visibleSearchableResults), + [visibleSearchableResults], ); const groupedSearchResults = React.useMemo( () => searchResultSections.flatMap((section) => section.results), @@ -516,8 +511,11 @@ export function TopbarSearch({ ? scopeChannel ? [] : suggestionResults - : groupedSearchResults; + : resultsAreCurrent + ? groupedSearchResults + : []; const isSearchLoading = + (!isShowingSuggestions && !resultsAreCurrent) || isWaitingOnFromResolution || searchQuery.isLoading || fuzzyUserCandidatesQuery.isLoading || @@ -581,7 +579,7 @@ export function TopbarSearch({ return; } - onOpenResult(result.hit); + onOpenResult(result.hit, resultQuery); }, [ onBrowseChannels, @@ -592,6 +590,7 @@ export function TopbarSearch({ onOpenUser, openAfterExit, setQuery, + resultQuery, ], ); @@ -697,7 +696,7 @@ export function TopbarSearch({ ? result.action.description : result.kind === "user" ? getUserSecondaryLabel(result.user) - : truncateResultText(result.hit.content); + : buildSearchResultPreview(result.hit.content, resultQuery); const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -771,7 +770,7 @@ export function TopbarSearch({ ) : null} {preview ? ( - {preview} + ) : null} @@ -873,12 +872,13 @@ export function TopbarSearch({
) - ) : isSearchLoading && searchableResults.length === 0 ? ( + ) : isSearchLoading && visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}
- ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( + ) : searchQuery.error instanceof Error && + visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

- ) : searchableResults.length === 0 ? ( + ) : visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

{ diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 8d626c45938..ab35b8b598c 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -88,7 +88,7 @@ export type AppSidebarProps = { onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; /** Full channel set for global search, including channels outside the joined sidebar list. */ searchChannels: Channel[]; searchFocusRequests: readonly [global: number, channel: number]; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 1e0db29cac1..a9bf7058eb6 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = { onCreateAgent: () => void; onCreateChannel: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; onSelectChannel: (channelId: string) => void; searchChannels: Channel[]; searchFocusRequest: number; diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts index df7f1de2923..e3de49a6649 100644 --- a/desktop/src/shared/lib/rehypeSearchHighlight.ts +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -6,6 +6,9 @@ * ReactMarkdown's architecture — no post-render tree walking needed. */ +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + // Minimal HAST types — matches the pattern in rehypeImageGallery.ts. interface HastText { type: "text"; @@ -34,45 +37,32 @@ function isText(node: HastNode): node is HastText { return node.type === "text"; } -function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - export default function rehypeSearchHighlight({ query }: { query: string }) { return (tree: HastRoot) => { - const trimmed = query.trim(); - if (trimmed.length < 2) return; - - const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i"); - function walk(nodes: HastNode[]): HastNode[] { const result: HastNode[] = []; for (const node of nodes) { if (isText(node)) { - const parts = node.value.split(pattern); - if (parts.length === 1) { + const parts = splitSearchMatches(node.value, query); + if (!parts.some((part) => part.isMatch)) { result.push(node); continue; } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - - if (i % 2 === 1) { - // Odd indices from split-with-capture are always the match. + for (const part of parts) { + if (part.isMatch) { result.push({ type: "element", tagName: "mark", properties: { - className: - "rounded-xs bg-primary/20 text-foreground dark:bg-primary/30", + className: SEARCH_MATCH_HIGHLIGHT_CLASS, + "data-search-match": "true", }, - children: [{ type: "text", value: part }], + children: [{ type: "text", value: part.text }], }); } else { - result.push({ type: "text", value: part }); + result.push({ type: "text", value: part.text }); } } } else if (isElement(node)) { diff --git a/desktop/src/shared/lib/searchHighlightStyle.ts b/desktop/src/shared/lib/searchHighlightStyle.ts new file mode 100644 index 00000000000..0ede60986c9 --- /dev/null +++ b/desktop/src/shared/lib/searchHighlightStyle.ts @@ -0,0 +1,3 @@ +/** Shared visual treatment for literal search matches. */ +export const SEARCH_MATCH_HIGHLIGHT_CLASS = + "rounded-xs bg-yellow-300/80 text-yellow-950 dark:bg-yellow-300/70 dark:text-yellow-950"; diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index e29abe1ee1e..becca46499f 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -227,9 +227,27 @@ test("hardLineBreaks changes the parse and the cache key", () => { assert.equal(withoutBreaks, withoutBreaksAgain); }); -test("active search queries bypass the cache", () => { +test("single-character scoped search stays on lexeme boundaries", () => { + const html = renderToStaticMarkup( + renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }), + ); + + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 1); +}); + +test("active search queries bypass the cache and highlight every match", () => { clearMarkdownNodeCache(); - const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); + const input = { + ...BASE, + content: "Bold and bold, but not code `bold`.", + searchQuery: "bold", + }; + const first = renderCachedMarkdown(input); + const second = renderCachedMarkdown(input); + const html = renderToStaticMarkup(first); + assert.notEqual(first, second); + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2); + assert.match(html, /bg-yellow-300/); + assert.match(html, /bold<\/code>/); }); diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 549ca892eab..9e012f0549d 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -94,7 +94,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { if (input.leadingInlineContent) { rehypePlugins.push(rehypeLeadingInlineContent); } - if (input.searchQuery && input.searchQuery.trim().length >= 2) { + if (input.searchQuery && input.searchQuery.trim().length >= 1) { rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]); } // Called as a plain function rather than rendered as : @@ -131,7 +131,7 @@ export function renderCachedMarkdown( // than churn the cache with per-query variants. Oversized content parses // fresh too — see MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH. if ( - (input.searchQuery && input.searchQuery.trim().length >= 2) || + (input.searchQuery && input.searchQuery.trim().length >= 1) || input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH ) { return buildMarkdownElement(input); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5bbc160752e..0bd7bc6eecc 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4418,6 +4418,15 @@ function getMockMessageStore(channelId: string): RelayEvent[] { content: "Release checklist: async feedback thread.", sig: "mocksig".repeat(20).slice(0, 128), }, + { + id: "mock-forum-offsite-thread", + pubkey: ALICE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 85 * 60, + kind: 45001, + tags: [["h", channelId]], + content: "Team offsite planning and travel notes.", + sig: "mocksig".repeat(20).slice(0, 128), + }, { id: "mock-forum-release-reply", pubkey: ALICE_PUBKEY, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 14fc299e9b7..7902b3eace2 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -346,6 +346,171 @@ test("opens sidebar search with the shortcut and loads the exact result", async ); }); +test("highlights the query in search results and the opened message", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("SHIPPED"); + + const result = page.getByTestId("search-result-mock-engineering-shipped"); + await expect(result).toBeVisible(); + await expect(result.locator("mark")).toHaveText("shipped"); + await expect(result.locator("mark")).toHaveClass(/bg-yellow-300/); + + await result.click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message).toBeVisible(); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); +}); + +test("highlights the clicked forum post when its route is already open", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await expect( + page.locator('[data-forum-event-id="mock-forum-release-thread"]'), + ).toBeVisible(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("checklist"); + + const result = page.getByTestId("search-result-mock-forum-release-thread"); + await expect(result).toBeVisible(); + await result.click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); +}); + +test("ordinary same-channel activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("shipped"); + await page.getByTestId("search-result-mock-engineering-shipped").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); + await expect(page).toHaveURL( + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9(?:\?thread=mock-engineering-shipped)?$/, + ); + + await page.getByTestId("channel-engineering").click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary rendered channel link clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("welcome"); + await page.getByTestId("search-result-mock-general-welcome").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-general-welcome"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "Welcome", + ); + + await message.locator('[data-channel-link=""]').click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary same-forum activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(post.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary forum navigation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const releasePost = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Team offsite planning and travel notes.").click(); + await expect( + page.locator('[data-forum-event-id="mock-forum-offsite-thread"]'), + ).toBeVisible(); + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(releasePost).toBeVisible(); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveCount( + 0, + ); +}); + +test("does not expose stale search results with a newly typed query", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + const input = page.getByTestId("search-dialog-input"); + await input.fill("shipped"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toBeVisible(); + + await input.fill("mentions"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toHaveCount(0); +}); + test("opens channel matches from search", async ({ page }) => { await page.goto("/"); From 12f3fea26e4c638a5fae20dce1ec0876e3bbca41 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 12:49:43 -0400 Subject: [PATCH 032/101] revert fixed mention highlight (#6716) **Category:** fix **User Impact:** Mention chips now follow the active theme without a fixed yellow fill and remain readable across every supported theme. **Problem:** PR #6696 added an opaque yellow treatment to every human and agent mention. Removing that fixed color exposed another issue: some theme accent colors do not provide WCAG AA contrast when also used as normal-size mention text. **Solution:** Remove the fixed yellow treatment and keep the active theme accent as a subtle translucent chip background. Mention labels use a light/dark semantic foreground selected for readable contrast, including hover states.

File changes **desktop/src/shared/styles/globals/markdown.css** Removes the fixed-yellow treatment, retains theme-derived accent backgrounds, and applies an AA-safe semantic foreground to human and agent mention labels in resting and hover states. **desktop/src/shared/styles/globals/theme.css** Removes the obsolete fixed-yellow tokens and defines light/dark mention foreground values. **desktop/tests/e2e/mentions.spec.ts** Adds rendered contrast coverage for resting and hover states across every supported syntax theme, compositing translucent chip and ancestor surfaces before checking the 4.5:1 threshold.
## Reproduction steps 1. Open a message containing a human or agent mention. 2. Confirm its subtle background follows the active theme accent rather than using yellow. 3. Hover the mention and confirm the accent background strengthens while the label remains readable. 4. Switch between default, light, dark, and custom syntax themes and confirm mention labels remain legible. This is a focused visual rollback of the mention treatment introduced by #6696. The channel notification badge changes from that PR remain intact. --------- Signed-off-by: tulsi --- .../src/shared/styles/globals/markdown.css | 15 ------ desktop/src/shared/styles/globals/theme.css | 4 -- desktop/tests/e2e/mentions.spec.ts | 48 ------------------- 3 files changed, 67 deletions(-) diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 135c036e74c..251fdb525c2 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -303,21 +303,6 @@ color: hsl(var(--primary) / 0.9); } -/* @mentions use an opaque highlighter-yellow treatment so they scan - differently from channel links while retaining AA contrast on every theme. - Shared by human and agent chips in both timeline and composer. */ -.message-markdown .mention-chip.inline-chip-icon-human, -.message-markdown .mention-chip.inline-chip-icon-agent { - background: hsl(var(--mention-highlight)); - color: hsl(var(--buzz-content-dark)); -} - -.message-markdown .mention-chip-hover.inline-chip-icon-human:hover, -.message-markdown .mention-chip-hover.inline-chip-icon-agent:hover { - background: hsl(var(--mention-highlight-hover)); - color: hsl(var(--buzz-content-dark)); -} - .message-markdown .mention-prefix-hidden { display: inline-block; width: 0; diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 875a17aedfc..9b55da2703f 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -32,10 +32,6 @@ destructive, these must stay recognizably red with AA label contrast. */ --notification: 348 65% 48%; --notification-foreground: 350 100% 98%; - /* Opaque highlighter colors keep near-black mention text readable on every - message surface instead of depending on the active theme underneath. */ - --mention-highlight: 48 96% 70%; - --mention-highlight-hover: 48 96% 62%; --border: 225 13.56% 76.86%; --input: 225 13.56% 76.86%; --ring: 234 16.02% 35.49%; diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e90281a52fa..6726a224024 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -39,37 +39,6 @@ const DM_THREAD_AGENT_MENTION_ERROR_TEXT = const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT = "Checking conversation members. Try again in a moment."; -async function expectTextContrast( - locator: import("@playwright/test").Locator, - minimum = 4.5, -) { - const contrastRatio = await locator.evaluate((element) => { - const parseRgb = (value: string) => - (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); - const luminance = (color: number[]) => - color - .map((channel) => { - const value = channel / 255; - return value <= 0.04045 - ? value / 12.92 - : ((value + 0.055) / 1.055) ** 2.4; - }) - .reduce( - (sum, channel, index) => - sum + channel * [0.2126, 0.7152, 0.0722][index], - 0, - ); - const style = getComputedStyle(element); - const foreground = luminance(parseRgb(style.color)); - const background = luminance(parseRgb(style.backgroundColor)); - return ( - (Math.max(foreground, background) + 0.05) / - (Math.min(foreground, background) + 0.05) - ); - }); - expect(contrastRatio).toBeGreaterThanOrEqual(minimum); -} - /** Locator scoped to the mention autocomplete dropdown inside the composer. */ function autocomplete(page: import("@playwright/test").Page) { return page @@ -2749,9 +2718,6 @@ test("sent non-member person mention uses the normal mention style", async ({ test("sent managed non-member agent mention uses the agent mention style", async ({ page, }) => { - await page.addInitScript(() => { - window.localStorage.setItem("buzz-theme", "buzz-dark"); - }); await installMockBridge(page, { managedAgents: [ { @@ -2781,13 +2747,6 @@ test("sent managed non-member agent mention uses the agent mention style", async await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("charlie"); await expect(mentionChip).toHaveClass(/agent-mention-highlight/); - await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); - await mentionChip.hover(); - await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); }); test("mention button opens autocomplete and inserts a selected member", async ({ @@ -2893,13 +2852,6 @@ test("mention text is highlighted in sent messages", async ({ page }) => { await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); - await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); - await mentionChip.hover(); - await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); }); test("clicking author name opens user profile panel", async ({ page }) => { From 113a33b7e49b7173ee1767c49ef2f49c63803034 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 25 Aug 2026 13:00:22 -0400 Subject: [PATCH 033/101] Add database pressure observability (#6700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Database pressure currently collapses several distinct delays into one symptom. This adds the evidence layer needed to distinguish pool acquisition wait, logical database operation time, advisory-lock wait, and selected transaction duration before changing timeout or retry policy. This is the phase 2 Lane A observability bundle for [#26](https://github.com/TheSentinel454/buzz/issues/26), [#28](https://github.com/TheSentinel454/buzz/issues/28), and [#33](https://github.com/TheSentinel454/buzz/issues/33). It is stacked on #6668. ## What - Record explicit reader/writer checkout wait and acquisition outcomes with `buzz_db_pool_acquire_wait_seconds` and `buzz_db_pool_acquisitions_total`. - Extend the compile-time `#[datastore_span(name = "...")]` seam with `buzz_db_operation_duration_seconds`, so operation labels remain static source literals instead of request data. - Route correctness-critical replacement, membership, push-gate, deletion, and migration/schema-safety advisory locks through one observer without changing their SQL, order, scope, or blocking behavior. - Measure six internally owned transaction lifetimes with `buzz_db_transaction_duration_seconds`, starting after `BEGIN` succeeds and ending after explicit commit/rollback or scope exit. - Emit root slow-operation warnings at 500 ms, logging the first slow completion and then 1/100 per call site with only `operation`, `outcome`, and `elapsed_ms`. - Document names, units, fixed label vocabularies, measurement boundaries, and blind spots in this PR description. Fixed labels are deliberately small: - `pool_role`: `writer`, `reader` - `lock_type`: `replacement`, `membership`, `push_gate`, `deletion`, `migration_schema_safety` - `outcome`: `success`, `error`, `timeout` where SQLx/PostgreSQL can distinguish it accurately - `operation`: compile-time datastore names plus the six closed transaction operation names documented in the runbook No metric or slow warning contains community IDs, event IDs, event kinds, coordinates, d-tags, SQL/query text, query IDs, returned errors, or event content. ## Coverage boundaries - Operation duration is the complete annotated logical function body, not pure SQL execution; it may include implicit checkout, lock wait, nested operations, and application work. Cancelled futures do not reach its completion hook. - Pool timing covers explicit helper checkouts, including proved-reader routing and selected writer-owned transactions. Implicit SQLx checkout through `&PgPool` remains folded into operation duration. - Lock timing covers application-side blocking locks in the five named families. Trigger/stored-procedure locks, channel-TTL locking, the usage try-lock, and the audit service session lock remain outside this slice. - Transaction timing covers only the six wholly owned boundaries documented in the runbook. It excludes pool wait, `BEGIN`, asynchronous rollback cleanup after an early return, and caller-owned `Db::begin_transaction` lifetime. ## Relationship to #6229 #6229 is the incident-driven timeout precursor. This PR does not add or change `statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout`, retries, audit durability, or client-visible conflicts. It provides the missing distributions needed to evaluate those policies later and intentionally leaves #6229's open audit retry/durability finding untouched. The branches overlap in `crates/buzz-db/src/lib.rs` and `crates/buzz-db/src/migration.rs`, so a later rebase may need textual conflict resolution, but the behavior is complementary rather than duplicated. ## Risk assessment Moderate-low. The primary risk is instrumentation overhead and added static series. Cardinality is source-bounded, slow logs are sampled/redacted root events, and the lock/transaction changes wrap existing awaits without changing policy or ordering. ## Verification Author workstation: `buzz-tornquist-db-pressure-observability` (`2010927`), exact head `d7cf833e26c528adfcde3917ded80daf6f4ddac9`, parent `6f50e6b2b2a996349149af61d35bdd6a355f77fd`. - `cargo fmt --all --check` — passed - `cargo clippy -p buzz-datastore-tracing -p buzz-db -p buzz-audit -p buzz-search -p buzz-relay --all-targets -- -D warnings` — passed - `cargo test -p buzz-datastore-tracing --quiet` — 4 passed - `cargo test -p buzz-db --quiet` — 109 passed, 200 ignored - `cargo test -p buzz-audit -p buzz-search --quiet` — 16 passed, 25 ignored - `cargo test -p buzz-relay --lib --quiet -- --test-threads=1` — 906 passed, 48 ignored - Native PostgreSQL focused tests for pool success/timeout/error, lock success/contention/timeout/error, replacement, membership serialization, push ordering, deletion fencing, migration/schema exclusion, and reader fallback — 8 passed The default-parallel relay run passed once; subsequent runs exposed the existing load-sensitive `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` 504 at the end of the suite. That test passes in isolation and the full relay suite passes serially. Independent exact-head review workstation: `buzz-tornquist-db-pressure-observability-review` (`2013067`). Formatting, the same all-target clippy command, datastore instrumentation tests, DB unit tests, source privacy guards, and diff/non-goal audits passed; no review findings. Generated with Codex --------- Signed-off-by: tornquist --- .github/workflows/ci.yml | 12 + Cargo.lock | 4 + crates/buzz-audit/Cargo.toml | 1 + crates/buzz-datastore-tracing/Cargo.toml | 2 + crates/buzz-datastore-tracing/src/lib.rs | 40 ++ .../buzz-datastore-tracing/tests/runtime.rs | 129 ++++ crates/buzz-db/src/channel.rs | 30 +- crates/buzz-db/src/community.rs | 11 +- crates/buzz-db/src/deletion.rs | 96 ++- crates/buzz-db/src/lib.rs | 78 ++- crates/buzz-db/src/migration.rs | 15 +- crates/buzz-db/src/observability.rs | 636 ++++++++++++++++++ crates/buzz-db/src/push.rs | 45 +- crates/buzz-db/src/relay_members.rs | 13 +- crates/buzz-db/src/replaceable.rs | 56 +- crates/buzz-db/tests/observability_source.rs | 40 ++ crates/buzz-search/Cargo.toml | 1 + 17 files changed, 1086 insertions(+), 123 deletions(-) create mode 100644 crates/buzz-db/src/observability.rs create mode 100644 crates/buzz-db/tests/observability_source.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80167668121..1fa6fb94ad6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,6 +692,18 @@ jobs: env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Database pressure observability PostgreSQL tests + # Explicit pool acquisition and advisory-lock metrics require real + # Postgres and are ignored by the infrastructure-free unit-test job. + run: | + filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E "${filter}" \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay diff --git a/Cargo.lock b/Cargo.lock index 9ff8ccf6185..5e9c5aade3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,7 @@ dependencies = [ "chrono", "futures-util", "hex", + "metrics", "serde", "serde_json", "sha2 0.11.0", @@ -1035,6 +1036,8 @@ dependencies = [ name = "buzz-datastore-tracing" version = "0.1.0" dependencies = [ + "metrics", + "metrics-util", "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", "proc-macro2", @@ -1361,6 +1364,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-datastore-tracing", + "metrics", "sqlx", "thiserror 2.0.18", "tokio", diff --git a/crates/buzz-audit/Cargo.toml b/crates/buzz-audit/Cargo.toml index dfa73353ded..766ade65050 100644 --- a/crates/buzz-audit/Cargo.toml +++ b/crates/buzz-audit/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +metrics = { workspace = true } thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } diff --git a/crates/buzz-datastore-tracing/Cargo.toml b/crates/buzz-datastore-tracing/Cargo.toml index e93900c54ce..fb7ba6f37d8 100644 --- a/crates/buzz-datastore-tracing/Cargo.toml +++ b/crates/buzz-datastore-tracing/Cargo.toml @@ -16,6 +16,8 @@ quote = "1" syn = { version = "2", features = ["full"] } [dev-dependencies] +metrics = { workspace = true } +metrics-util = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["testing"] } tokio = { workspace = true } diff --git a/crates/buzz-datastore-tracing/src/lib.rs b/crates/buzz-datastore-tracing/src/lib.rs index f2645cb8f37..217f2335dee 100644 --- a/crates/buzz-datastore-tracing/src/lib.rs +++ b/crates/buzz-datastore-tracing/src/lib.rs @@ -70,6 +70,9 @@ impl Parse for DatastoreArgs { /// PostgreSQL spans always omit function arguments, use the `buzz_datastore` /// target, and expose only canonical semantic fields plus explicitly supplied /// safe fields. An `Err` sets `otel.status_code` without inspecting the error. +/// The literal `name` also labels a logical-operation duration histogram. Slow +/// completions are sampled and logged with only that name, outcome, and elapsed +/// time; arguments, error values, and return values are never formatted. #[proc_macro_attribute] pub fn datastore_span(args: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(args as DatastoreArgs); @@ -129,9 +132,46 @@ pub fn datastore_span(args: TokenStream, item: TokenStream) -> TokenStream { } } }); + let outcome = if returns_result { + quote! { + if #result.is_err() { "error" } else { "success" } + } + } else { + quote!("success") + }; function.block = Box::new(syn::parse_quote!({ + let __buzz_datastore_started_7f3a9c = ::std::time::Instant::now(); let #result: #return_type = (async #original_body).await; #record_error + let __buzz_datastore_outcome_7f3a9c = #outcome; + let __buzz_datastore_elapsed_7f3a9c = __buzz_datastore_started_7f3a9c.elapsed(); + ::metrics::histogram!( + "buzz_db_operation_duration_seconds", + "operation" => #name, + "outcome" => __buzz_datastore_outcome_7f3a9c, + ) + .record(__buzz_datastore_elapsed_7f3a9c.as_secs_f64()); + if __buzz_datastore_elapsed_7f3a9c >= ::std::time::Duration::from_millis(500) { + static __BUZZ_DATASTORE_SLOW_SAMPLE_7F3A9C: + ::std::sync::atomic::AtomicU64 = ::std::sync::atomic::AtomicU64::new(0); + if __BUZZ_DATASTORE_SLOW_SAMPLE_7F3A9C.fetch_add( + 1, + ::std::sync::atomic::Ordering::Relaxed, + ) % 100 == 0 { + let __buzz_datastore_elapsed_ms_7f3a9c = + __buzz_datastore_elapsed_7f3a9c + .as_millis() + .min(::std::primitive::u64::MAX as u128) as u64; + ::tracing::warn!( + target: "buzz_datastore", + parent: None, + operation = #name, + outcome = __buzz_datastore_outcome_7f3a9c, + elapsed_ms = __buzz_datastore_elapsed_ms_7f3a9c, + "slow datastore operation" + ); + } + } #result })); diff --git a/crates/buzz-datastore-tracing/tests/runtime.rs b/crates/buzz-datastore-tracing/tests/runtime.rs index b58dca8715f..3355190956f 100644 --- a/crates/buzz-datastore-tracing/tests/runtime.rs +++ b/crates/buzz-datastore-tracing/tests/runtime.rs @@ -1,6 +1,12 @@ use buzz_datastore_tracing::datastore_span; +use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use opentelemetry::trace::{SpanKind, Status, TracerProvider as _}; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; use tracing_subscriber::prelude::*; const DIRECT_ERROR: &str = "raw-secret-direct-error"; @@ -27,8 +33,48 @@ async fn operation( Ok(limit) } +#[datastore_span(name = "slow_test_operation", system = "postgresql")] +async fn slow_operation(delay: std::time::Duration) -> Result<(), &'static str> { + tokio::time::sleep(delay).await; + Err(DIRECT_ERROR) +} + +#[derive(Default)] +struct EventFields(BTreeMap); + +impl Visit for EventFields { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().to_owned(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.0.insert(field.name().to_owned(), value.to_owned()); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.0.insert(field.name().to_owned(), value.to_string()); + } +} + +#[derive(Clone, Default)] +struct EventCapture(Arc>>); + +impl Layer for EventCapture +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _context: Context<'_, S>) { + let mut fields = EventFields::default(); + event.record(&mut fields); + self.0.lock().expect("capture lock").push(fields); + } +} + #[tokio::test(flavor = "current_thread")] async fn exports_policy_fields_without_error_or_argument_data() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _metrics_guard = metrics::set_default_local_recorder(&recorder); let exporter = InMemorySpanExporter::default(); let provider = SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) @@ -41,6 +87,37 @@ async fn exports_policy_fields_without_error_or_argument_data() { assert_eq!(operation(8, true, false).await, Err(DIRECT_ERROR)); assert_eq!(operation(9, false, true).await, Err(QUESTION_ERROR)); + let operation_samples = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_operation_duration_seconds") + .map(|(key, _, _, value)| { + let DebugValue::Histogram(samples) = value else { + panic!("operation duration must be a histogram"); + }; + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (labels, samples) + }) + .collect::>(); + assert_eq!(operation_samples.len(), 2); + for (labels, samples) in operation_samples { + assert_eq!( + labels.get("operation").map(String::as_str), + Some("test_operation") + ); + assert!(matches!( + labels.get("outcome").map(String::as_str), + Some("success" | "error") + )); + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0)); + } + provider.force_flush().expect("spans flush"); let spans = exporter.get_finished_spans().expect("exported spans"); assert_eq!(spans.len(), 3); @@ -78,3 +155,55 @@ async fn exports_policy_fields_without_error_or_argument_data() { } } } + +#[tokio::test(flavor = "current_thread")] +async fn slow_operation_logging_is_guarded_sampled_and_redacted() { + let capture = EventCapture::default(); + let subscriber = tracing_subscriber::registry().with(capture.clone()); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + + assert_eq!( + slow_operation(std::time::Duration::from_millis(1)).await, + Err(DIRECT_ERROR) + ); + assert_eq!( + slow_operation(std::time::Duration::from_millis(510)).await, + Err(DIRECT_ERROR) + ); + assert_eq!( + slow_operation(std::time::Duration::from_millis(510)).await, + Err(DIRECT_ERROR) + ); + + let events = capture.0.lock().expect("capture lock"); + let slow = events + .iter() + .filter(|event| { + event + .0 + .get("message") + .is_some_and(|message| message.contains("slow datastore operation")) + }) + .collect::>(); + assert_eq!( + slow.len(), + 1, + "first slow call is logged, next 99 are sampled out" + ); + let fields = &slow[0].0; + assert_eq!( + fields.get("operation").map(String::as_str), + Some("slow_test_operation") + ); + assert_eq!(fields.get("outcome").map(String::as_str), Some("error")); + assert!(fields + .get("elapsed_ms") + .and_then(|value| value.parse::().ok()) + .is_some_and(|elapsed| elapsed >= 500)); + assert_eq!( + fields.len(), + 4, + "only message and fixed safe fields are logged" + ); + assert!(!format!("{fields:?}").contains(DIRECT_ERROR)); +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index a1890adb56e..98790e3d623 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -478,14 +478,17 @@ async fn acquire_channel_membership_lock( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") - .bind(format!( - "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", - community_id.as_uuid(), - channel_id - )) - .execute(&mut **tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx), + ) + .await?; Ok(()) } @@ -631,10 +634,13 @@ pub async fn lock_member_snapshot( relay_pubkey, Some(channel_id.as_bytes()), ); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(replacement_lock) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(replacement_lock) + .execute(&mut *tx), + ) + .await?; acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; let rows = sqlx::query( r#" diff --git a/crates/buzz-db/src/community.rs b/crates/buzz-db/src/community.rs index 64c116a5200..5df896a3500 100644 --- a/crates/buzz-db/src/community.rs +++ b/crates/buzz-db/src/community.rs @@ -325,10 +325,13 @@ impl Db { // Serialize on the owner pubkey so concurrent creates to the same // owner cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx), + ) + .await?; let row = sqlx::query( r#" diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index 7db0df8ee37..98a039d62f3 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -1210,12 +1210,15 @@ impl DeletionStore { /// Already-acquired leases remain renewable, verifiable, and releasable so /// admitted remote effects retain their exclusion proof until completion. pub async fn begin_quiescing(&self, token: &LeaseToken) -> Result<()> { - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + &self.pool, + crate::observability::TransactionOperation::BeginCommunityDeletionQuiescing, + ) + .await?; + transaction_timer + .observe(async { verify_lease(&mut tx, token, DeletionStage::Approved).await?; - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(token.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, token.community_id).await?; verify_lease(&mut tx, token, DeletionStage::Approved).await?; let (generation, archived_at): (i64, Option>) = sqlx::query_as( "SELECT deletion_fence_generation, archived_at FROM communities WHERE id = $1 FOR UPDATE", @@ -1259,16 +1262,21 @@ impl DeletionStore { .await?; tx.commit().await?; Ok(()) + }) + .await } /// Acquire the universal durable fence after all pre-quiesce serving leases drain. pub async fn fence(&self, token: &LeaseToken) -> Result { - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + &self.pool, + crate::observability::TransactionOperation::FenceCommunityDeletion, + ) + .await?; + transaction_timer + .observe(async { verify_lease(&mut tx, token, DeletionStage::Approved).await?; - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(token.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, token.community_id).await?; verify_lease(&mut tx, token, DeletionStage::Approved).await?; let active_serving_writes = sqlx::query( "SELECT count(*)::BIGINT AS active_count, \ @@ -1331,6 +1339,8 @@ impl DeletionStore { .await?; tx.commit().await?; Ok(generation) + }) + .await } /// Freeze the exact post-fence storage binding manifest. @@ -1967,10 +1977,7 @@ impl DeletionStore { .ok_or_else(|| DbError::NotFound(format!("community deletion {request_id}")))?; // Every lifecycle transition takes the community lock before any row lock. // Inverting this order lets abort and the executor deadlock each other. - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, community_id).await?; let row = sqlx::query("SELECT * FROM community_deletion_requests WHERE id = $1 FOR UPDATE") .bind(request_id) .fetch_optional(&mut *tx) @@ -2254,10 +2261,7 @@ impl DeletionStore { tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(community.as_uuid()) - .execute(&mut **tx) - .await?; + lock_community_deletion_shared(tx, community).await?; let state: Option = sqlx::query_scalar( "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", ) @@ -2286,10 +2290,7 @@ impl DeletionStore { tx: &mut Transaction<'_, Postgres>, lease: &ServingWriteLease, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut **tx) - .await?; + lock_community_deletion_shared(tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -2407,10 +2408,7 @@ impl DeletionStore { ) -> Result<()> { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); let mut tx = self.pool.begin().await?; - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion_shared(&mut tx, lease.community_id).await?; let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ SET lease_until = now() + make_interval(secs => $6), heartbeat_at = now() \ @@ -2465,10 +2463,7 @@ impl DeletionStore { /// admitted remote effect. pub async fn verify_serving_write_lease(&self, lease: &ServingWriteLease) -> Result<()> { let mut tx = self.pool.begin().await?; - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion_shared(&mut tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -2572,6 +2567,34 @@ impl DeletionStore { } } +async fn lock_community_deletion( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + crate::observability::observe_advisory_lock( + crate::observability::LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut **tx), + ) + .await?; + Ok(()) +} + +async fn lock_community_deletion_shared( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + crate::observability::observe_advisory_lock( + crate::observability::LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut **tx), + ) + .await?; + Ok(()) +} + /// Take the shared schema/destruction advisory lock for the current /// transaction. /// @@ -2580,10 +2603,13 @@ impl DeletionStore { /// whole run (see [`crate::migration::run_migrations`]); shared holders do /// not block each other, so concurrent deletion executors are unaffected. async fn lock_schema_destruction_shared(conn: &mut PgConnection) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared($1)") - .bind(SCHEMA_DESTRUCTION_LOCK_KEY) - .execute(conn) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_xact_lock_shared($1)") + .bind(SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(conn), + ) + .await?; Ok(()) } @@ -3302,7 +3328,7 @@ mod postgres_tests { async fn store() -> (Db, DeletionStore) { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials let db = Db::new(&DbConfig { database_url, max_connections: 5, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index a5c03a256ed..a6cd77c9796 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -50,6 +50,7 @@ pub mod git_repo; pub mod migration; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +mod observability; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. @@ -712,7 +713,7 @@ impl Db { }; let aurora_identity = self.reader_aurora_identity.clone(); tokio::spawn(async move { - match read_pool.acquire().await { + match observability::acquire(&read_pool, observability::PoolRole::Reader).await { Ok(mut conn) => { tracing::info!("read replica reachable at boot"); match replica_fence::reader_supports_aurora_identity(&mut conn).await { @@ -854,7 +855,7 @@ impl Db { // `read_pool` separately would spend a second budget whenever the // capability is uncached — i.e. after a failed boot ping, which is // precisely the reader-unavailable case the bound must hold for. - let conn = match read_pool.acquire().await { + let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { Ok(conn) => conn, Err(sqlx::Error::PoolTimedOut) => { tracing::warn!("reader pool acquire timed out; routing to writer"); @@ -1030,7 +1031,8 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = self.pool.acquire().await?; + let mut connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -1177,7 +1179,11 @@ impl Db { /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. /// The transaction holds an owned pool handle, not a borrow. pub async fn begin_transaction(&self) -> Result> { - self.pool.begin().await.map_err(Into::into) + let connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + sqlx::Transaction::begin(connection, None) + .await + .map_err(Into::into) } /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. @@ -4349,14 +4355,23 @@ impl Db { channel_id.as_ref().map(|id| id.as_bytes().as_slice()), ); - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::ReplaceAddressableEvent, + ) + .await?; + transaction_timer + .observe(async { // Serialize all writers for the same (kind, pubkey, channel_id) tuple. // Advisory lock is transaction-scoped — released on commit/rollback. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; + observability::observe_advisory_lock( + observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; // Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against // historical data where prior bugs may have left multiple live rows. @@ -4452,6 +4467,8 @@ impl Db { StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), true, )) + }) + .await } /// Returns whether the relay-authored NIP-43 snapshot is absent or differs @@ -4532,16 +4549,25 @@ impl Db { None, ); - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::PublishNip43MembershipLocked, + ) + .await?; + let (event, received_at, was_inserted, member_count) = transaction_timer + .observe(async { // Acquire the per-community snapshot lock BEFORE reading members. // This serializes the entire read-build-write cycle: a concurrent // publication will block here until our transaction commits, then // read the updated membership state. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; + observability::observe_advisory_lock( + observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; // Read current members inside the locked transaction. let rows = sqlx::query( @@ -4616,24 +4642,24 @@ impl Db { .await?; let was_inserted = insert_result.rows_affected() > 0; - if !was_inserted { + if was_inserted { + tx.commit().await?; + } else { tx.rollback().await?; - return Ok(( - StoredEvent::with_received_at(event, received_at, None, false), - false, - member_count, - )); } + Ok::<_, DbError>((event, received_at, was_inserted, member_count)) + }) + .await?; - tx.commit().await?; - - if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + if was_inserted { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } } Ok(( - StoredEvent::with_received_at(event, received_at, None, true), - true, + StoredEvent::with_received_at(event, received_at, None, was_inserted), + was_inserted, member_count, )) } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 94c7aea2faf..9df02c8abf9 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -81,11 +81,16 @@ where F: FnOnce(PgConnection) -> Fut, Fut: Future)>, { - let mut lock_conn = pool.acquire().await?.detach(); - sqlx::query("SELECT pg_advisory_lock($1)") - .bind(SCHEMA_DESTRUCTION_LOCK_KEY) - .execute(&mut lock_conn) - .await?; + let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) + .await? + .detach(); + crate::observability::observe_advisory_lock( + crate::observability::LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut lock_conn), + ) + .await?; let (mut lock_conn, outcome) = op(lock_conn).await; let unlock = sqlx::query("SELECT pg_advisory_unlock($1)") .bind(SCHEMA_DESTRUCTION_LOCK_KEY) diff --git a/crates/buzz-db/src/observability.rs b/crates/buzz-db/src/observability.rs new file mode 100644 index 00000000000..afe1d20b305 --- /dev/null +++ b/crates/buzz-db/src/observability.rs @@ -0,0 +1,636 @@ +//! Bounded-cardinality database pressure instrumentation primitives. +//! +//! Label values come only from the closed enums in this module. Callers must +//! never derive labels from tenant data, events, SQL text, or query identifiers. + +use std::future::Future; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PoolRole { + Writer, + Reader, +} + +impl PoolRole { + #[cfg(test)] + pub(crate) const ALL: [Self; 2] = [Self::Writer, Self::Reader]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Writer => "writer", + Self::Reader => "reader", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LockType { + Replacement, + Membership, + PushGate, + Deletion, + MigrationSchemaSafety, +} + +impl LockType { + #[cfg(test)] + pub(crate) const ALL: [Self; 5] = [ + Self::Replacement, + Self::Membership, + Self::PushGate, + Self::Deletion, + Self::MigrationSchemaSafety, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Replacement => "replacement", + Self::Membership => "membership", + Self::PushGate => "push_gate", + Self::Deletion => "deletion", + Self::MigrationSchemaSafety => "migration_schema_safety", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Outcome { + Success, + Error, + Timeout, +} + +impl Outcome { + #[cfg(test)] + pub(crate) const ALL: [Self; 3] = [Self::Success, Self::Error, Self::Timeout]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::Error => "error", + Self::Timeout => "timeout", + } + } + + fn from_sqlx_error(error: &sqlx::Error) -> Self { + match error { + sqlx::Error::PoolTimedOut => Self::Timeout, + sqlx::Error::Database(database) if database.code().as_deref() == Some("55P03") => { + Self::Timeout + } + _ => Self::Error, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TransactionOperation { + ReplaceParameterizedEvent, + ReplaceAddressableEvent, + PublishNip43MembershipLocked, + AcceptPushLeaseEvent, + BeginCommunityDeletionQuiescing, + FenceCommunityDeletion, +} + +impl TransactionOperation { + #[cfg(test)] + pub(crate) const ALL: [Self; 6] = [ + Self::ReplaceParameterizedEvent, + Self::ReplaceAddressableEvent, + Self::PublishNip43MembershipLocked, + Self::AcceptPushLeaseEvent, + Self::BeginCommunityDeletionQuiescing, + Self::FenceCommunityDeletion, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::ReplaceParameterizedEvent => "replace_parameterized_event", + Self::ReplaceAddressableEvent => "replace_addressable_event", + Self::PublishNip43MembershipLocked => "publish_nip43_membership_locked", + Self::AcceptPushLeaseEvent => "accept_push_lease_event", + Self::BeginCommunityDeletionQuiescing => "begin_community_deletion_quiescing", + Self::FenceCommunityDeletion => "fence_community_deletion", + } + } +} + +pub(crate) fn record_pool_acquire(role: PoolRole, outcome: Outcome, elapsed: Duration) { + metrics::histogram!( + "buzz_db_pool_acquire_wait_seconds", + "pool_role" => role.as_str(), + "outcome" => outcome.as_str(), + ) + .record(elapsed.as_secs_f64()); + metrics::counter!( + "buzz_db_pool_acquisitions_total", + "pool_role" => role.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); +} + +pub(crate) async fn acquire( + pool: &sqlx::PgPool, + role: PoolRole, +) -> sqlx::Result> { + let started = Instant::now(); + let result = pool.acquire().await; + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + record_pool_acquire(role, outcome, started.elapsed()); + result +} + +pub(crate) async fn begin_transaction( + pool: &sqlx::PgPool, + operation: TransactionOperation, +) -> sqlx::Result<(sqlx::Transaction<'static, sqlx::Postgres>, TransactionTimer)> { + let connection = acquire(pool, PoolRole::Writer).await?; + let transaction = sqlx::Transaction::begin(connection, None).await?; + Ok((transaction, TransactionTimer::start(operation))) +} + +pub(crate) async fn observe_advisory_lock(lock_type: LockType, future: F) -> sqlx::Result +where + F: Future>, +{ + let started = Instant::now(); + let result = future.await; + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + metrics::histogram!( + "buzz_db_advisory_lock_wait_seconds", + "lock_type" => lock_type.as_str(), + "outcome" => outcome.as_str(), + ) + .record(started.elapsed().as_secs_f64()); + metrics::counter!( + "buzz_db_advisory_lock_acquisitions_total", + "lock_type" => lock_type.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); + result +} + +pub(crate) struct TransactionTimer { + operation: TransactionOperation, + started: Instant, + outcome: Outcome, +} + +impl TransactionTimer { + pub(crate) fn start(operation: TransactionOperation) -> Self { + Self { + operation, + started: Instant::now(), + outcome: Outcome::Error, + } + } + + pub(crate) async fn observe(mut self, future: F) -> Result + where + F: Future>, + { + let result = future.await; + if result.is_ok() { + self.outcome = Outcome::Success; + } + result + } +} + +impl Drop for TransactionTimer { + fn drop(&mut self) { + metrics::histogram!( + "buzz_db_transaction_duration_seconds", + "operation" => self.operation.as_str(), + "outcome" => self.outcome.as_str(), + ) + .record(self.started.elapsed().as_secs_f64()); + } +} + +#[cfg(test)] +mod tests { + use super::{ + acquire, observe_advisory_lock, record_pool_acquire, LockType, Outcome, PoolRole, + TransactionOperation, TransactionTimer, + }; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use std::collections::{BTreeMap, BTreeSet}; + use std::time::Duration; + + #[test] + fn label_vocabularies_are_closed_and_documented() { + assert_eq!(PoolRole::ALL.map(PoolRole::as_str), ["writer", "reader"]); + assert_eq!( + LockType::ALL.map(LockType::as_str), + [ + "replacement", + "membership", + "push_gate", + "deletion", + "migration_schema_safety", + ] + ); + assert_eq!( + Outcome::ALL.map(Outcome::as_str), + ["success", "error", "timeout"] + ); + assert_eq!( + TransactionOperation::ALL.map(TransactionOperation::as_str), + [ + "replace_parameterized_event", + "replace_addressable_event", + "publish_nip43_membership_locked", + "accept_push_lease_event", + "begin_community_deletion_quiescing", + "fence_community_deletion", + ] + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn transaction_timer_observe_classifies_result_outcomes() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let success = TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent) + .observe(async { Ok::<_, &str>("committed") }) + .await; + assert_eq!(success, Ok("committed")); + + let error = TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent) + .observe(async { Err::<(), _>("rollback") }) + .await; + assert_eq!(error, Err("rollback")); + + let keys = snapshotter + .snapshot() + .into_vec() + .into_iter() + .map(|(key, ..)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (key.key().name().to_owned(), labels) + }) + .collect::>(); + + for (operation, outcome) in [ + ("replace_parameterized_event", "success"), + ("accept_push_lease_event", "error"), + ] { + assert!(keys.contains(&( + "buzz_db_transaction_duration_seconds".to_owned(), + [ + ("operation".to_owned(), operation.to_owned()), + ("outcome".to_owned(), outcome.to_owned()), + ] + .into_iter() + .collect(), + ))); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn primitives_record_fixed_success_error_and_timeout_labels() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + record_pool_acquire( + PoolRole::Writer, + Outcome::Success, + Duration::from_millis(12), + ); + record_pool_acquire( + PoolRole::Reader, + Outcome::Timeout, + Duration::from_millis(34), + ); + let lock_ok: sqlx::Result<()> = + observe_advisory_lock(LockType::Replacement, async { Ok(()) }).await; + assert!(lock_ok.is_ok()); + let lock_error: sqlx::Result<()> = + observe_advisory_lock(LockType::Membership, async { Err(sqlx::Error::PoolClosed) }) + .await; + assert!(lock_error.is_err()); + + let committed: Result<(), ()> = + TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent) + .observe(async { Ok(()) }) + .await; + assert!(committed.is_ok()); + let rolled_back: Result<(), ()> = + TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent) + .observe(async { Err(()) }) + .await; + assert!(rolled_back.is_err()); + + let snapshot = snapshotter.snapshot().into_vec(); + let keys = snapshot + .iter() + .map(|(key, ..)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (key.key().name().to_owned(), labels) + }) + .collect::>(); + + for expected in [ + ( + "buzz_db_pool_acquire_wait_seconds", + [("outcome", "success"), ("pool_role", "writer")], + ), + ( + "buzz_db_pool_acquire_wait_seconds", + [("outcome", "timeout"), ("pool_role", "reader")], + ), + ( + "buzz_db_pool_acquisitions_total", + [("outcome", "success"), ("pool_role", "writer")], + ), + ( + "buzz_db_pool_acquisitions_total", + [("outcome", "timeout"), ("pool_role", "reader")], + ), + ( + "buzz_db_advisory_lock_wait_seconds", + [("lock_type", "replacement"), ("outcome", "success")], + ), + ( + "buzz_db_advisory_lock_wait_seconds", + [("lock_type", "membership"), ("outcome", "error")], + ), + ( + "buzz_db_advisory_lock_acquisitions_total", + [("lock_type", "replacement"), ("outcome", "success")], + ), + ( + "buzz_db_advisory_lock_acquisitions_total", + [("lock_type", "membership"), ("outcome", "error")], + ), + ( + "buzz_db_transaction_duration_seconds", + [ + ("operation", "replace_parameterized_event"), + ("outcome", "success"), + ], + ), + ( + "buzz_db_transaction_duration_seconds", + [ + ("operation", "accept_push_lease_event"), + ("outcome", "error"), + ], + ), + ] { + let expected_labels = expected + .1 + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + assert!( + keys.contains(&(expected.0.to_owned(), expected_labels)), + "missing metric series {expected:?}; got {keys:?}" + ); + } + + for (key, _, _, value) in snapshot { + if key.key().name().ends_with("_seconds") { + let DebugValue::Histogram(samples) = value else { + panic!("seconds metrics must be histograms"); + }; + assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0)); + } else if key.key().name().ends_with("_total") { + let DebugValue::Counter(value) = value else { + panic!("total metrics must be counters"); + }; + assert_eq!(value, 1); + } + } + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(75)) + .connect(&database_url) + .await + .expect("connect size-one test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let held = acquire(&pool, PoolRole::Writer) + .await + .expect("writer acquire succeeds"); + let timeout = acquire(&pool, PoolRole::Reader) + .await + .expect_err("reader-labeled checkout times out while pool is saturated"); + assert!(matches!(timeout, sqlx::Error::PoolTimedOut)); + drop(held); + pool.close().await; + let closed = acquire(&pool, PoolRole::Writer) + .await + .expect_err("closed pool acquire errors"); + assert!(matches!(closed, sqlx::Error::PoolClosed)); + + let mut outcomes = BTreeMap::<(String, String), Vec>::new(); + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + if key.key().name() != "buzz_db_pool_acquire_wait_seconds" { + continue; + } + let DebugValue::Histogram(samples) = value else { + panic!("pool wait must be a histogram"); + }; + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value().to_owned()) + .unwrap_or_default() + }; + outcomes.insert( + (label("pool_role"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } + assert!(outcomes.contains_key(&("writer".to_owned(), "success".to_owned()))); + assert!(outcomes.contains_key(&("writer".to_owned(), "error".to_owned()))); + let timeout_samples = outcomes + .get(&("reader".to_owned(), "timeout".to_owned())) + .expect("reader timeout series"); + assert!( + timeout_samples.iter().any(|sample| *sample >= 0.05), + "timeout wait must include the saturated checkout delay: {timeout_samples:?}" + ); + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn advisory_lock_records_success_contention_timeout_and_error() { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect(&database_url) + .await + .expect("connect advisory-lock test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let mut success_tx = pool.begin().await.expect("begin success transaction"); + observe_advisory_lock( + LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(0x62757a7a6f627331_i64) + .execute(&mut *success_tx), + ) + .await + .expect("uncontended lock succeeds"); + success_tx + .rollback() + .await + .expect("rollback success transaction"); + + let contention_key = 0x62757a7a6f627332_i64; + let mut holder = pool.begin().await.expect("begin lock holder"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(contention_key) + .execute(&mut *holder) + .await + .expect("holder acquires contention key"); + let mut waiter = pool.begin().await.expect("begin lock waiter"); + let waiter_task = tokio::spawn(async move { + let result = observe_advisory_lock( + LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(contention_key) + .execute(&mut *waiter), + ) + .await; + (waiter, result) + }); + tokio::time::sleep(Duration::from_millis(60)).await; + assert!( + !waiter_task.is_finished(), + "waiter must be blocked by holder" + ); + holder.commit().await.expect("release contention key"); + let (waiter, waited) = waiter_task.await.expect("join lock waiter"); + waited.expect("contended lock succeeds after release"); + waiter.rollback().await.expect("rollback waiter"); + + let timeout_key = 0x62757a7a6f627333_i64; + let mut timeout_holder = pool.begin().await.expect("begin timeout holder"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(timeout_key) + .execute(&mut *timeout_holder) + .await + .expect("holder acquires timeout key"); + let mut timeout_waiter = pool.begin().await.expect("begin timeout waiter"); + sqlx::query("SET LOCAL lock_timeout = '30ms'") + .execute(&mut *timeout_waiter) + .await + .expect("set test-only lock timeout"); + let timed_out = observe_advisory_lock( + LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(timeout_key) + .execute(&mut *timeout_waiter), + ) + .await + .expect_err("lock wait times out"); + assert_eq!( + timed_out + .as_database_error() + .and_then(|error| error.code()) + .as_deref(), + Some("55P03") + ); + timeout_holder + .rollback() + .await + .expect("release timeout key"); + + let mut aborted = pool.begin().await.expect("begin error transaction"); + sqlx::query("SELECT 1 / 0") + .execute(&mut *aborted) + .await + .expect_err("abort transaction before lock"); + observe_advisory_lock( + LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(0x62757a7a6f627334_i64) + .execute(&mut *aborted), + ) + .await + .expect_err("lock statement fails in aborted transaction"); + aborted + .rollback() + .await + .expect("rollback aborted transaction"); + + let mut outcomes = BTreeMap::<(String, String), Vec>::new(); + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + if key.key().name() != "buzz_db_advisory_lock_wait_seconds" { + continue; + } + let DebugValue::Histogram(samples) = value else { + panic!("lock wait must be a histogram"); + }; + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value().to_owned()) + .unwrap_or_default() + }; + outcomes.insert( + (label("lock_type"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } + assert!(outcomes.contains_key(&("replacement".to_owned(), "success".to_owned()))); + assert!(outcomes.contains_key(&("membership".to_owned(), "error".to_owned()))); + assert!( + outcomes.contains_key(&("migration_schema_safety".to_owned(), "timeout".to_owned())) + ); + let contention = outcomes + .get(&("deletion".to_owned(), "success".to_owned())) + .expect("deletion contention series"); + assert!( + contention.iter().any(|sample| *sample >= 0.04), + "lock timer must include the holder wait: {contention:?}" + ); + } +} diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 0b3245ffcc2..3aa6cd9b3fe 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -25,10 +25,13 @@ async fn acquire_push_gate_lock( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, community: CommunityId, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") - .bind(format!("{PUSH_GATE_LOCK_NAMESPACE}{}", community.as_uuid())) - .execute(&mut **tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!("{PUSH_GATE_LOCK_NAMESPACE}{}", community.as_uuid())) + .execute(&mut **tx), + ) + .await?; Ok(()) } @@ -220,7 +223,13 @@ pub async fn accept_lease_event( max_active_leases: i64, ) -> Result { let author = event.pubkey.as_bytes(); - let mut tx = pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + pool, + crate::observability::TransactionOperation::AcceptPushLeaseEvent, + ) + .await?; + transaction_timer + .observe(async { let mut address_lock = Vec::with_capacity(16 + author.len() + installation_id.len()); address_lock.extend_from_slice(community.as_uuid().as_bytes()); address_lock.extend_from_slice(author); @@ -230,14 +239,20 @@ pub async fn accept_lease_event( author_lock.extend_from_slice(community.as_uuid().as_bytes()); author_lock.extend_from_slice(author); let author_lock = i64::from_le_bytes(Sha256::digest(&author_lock)[..8].try_into().unwrap()); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(address_lock) - .execute(&mut *tx) - .await?; - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(author_lock) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(address_lock) + .execute(&mut *tx), + ) + .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(author_lock) + .execute(&mut *tx), + ) + .await?; // T1b: an activation can flip the community from "no eligible lease" to // "eligible", so it must serialize against the trigger's shared gate lock. // Acquired after the address/author locks to keep one global lock order. @@ -392,6 +407,8 @@ pub async fn accept_lease_event( } tx.commit().await?; Ok(AcceptLeaseOutcome::Accepted) + }) + .await } fn constraint_acceptance_outcome(error: &sqlx::Error) -> Option { @@ -1271,7 +1288,7 @@ mod tests { async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 402229cdec5..3cb86e8a437 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -473,10 +473,13 @@ pub async fn transfer_ownership( // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(owner_count_advisory_lock_key(&pubkey)) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(owner_count_advisory_lock_key(&pubkey)) + .execute(&mut *tx), + ) + .await?; // 2. Lock the current owner row FOR UPDATE and verify the expected owner. // FOR UPDATE prevents the stale-owner race: a concurrent transfer that @@ -637,7 +640,7 @@ mod tests { use super::*; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/replaceable.rs b/crates/buzz-db/src/replaceable.rs index aa57a51666d..4904dfe209d 100644 --- a/crates/buzz-db/src/replaceable.rs +++ b/crates/buzz-db/src/replaceable.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use sqlx::{Acquire, Postgres, Transaction}; use uuid::Uuid; +use crate::observability::{self, LockType, TransactionOperation}; use crate::{Db, DbError, Result}; /// Result category for a parameterized-replaceable event write. @@ -122,10 +123,13 @@ async fn replace_parameterized_event_in_transaction_impl( pubkey_bytes.as_slice(), Some(d_tag.as_bytes()), ); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut **tx) - .await?; + observability::observe_advisory_lock( + LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx), + ) + .await?; let d_tag_count = event .tags @@ -395,23 +399,31 @@ impl Db { d_tag: &str, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let mut tx = self.pool.begin().await?; - let result = self - .replace_parameterized_event_in_transaction( - &mut tx, - community_id, - event, - d_tag, - channel_id, - ParameterizedReplacePrecondition::Unconditional, - ) - .await?; - let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; - if was_inserted { - tx.commit().await?; - } else { - tx.rollback().await?; - } - Ok((result.event, was_inserted)) + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + TransactionOperation::ReplaceParameterizedEvent, + ) + .await?; + transaction_timer + .observe(async { + let result = self + .replace_parameterized_event_in_transaction( + &mut tx, + community_id, + event, + d_tag, + channel_id, + ParameterizedReplacePrecondition::Unconditional, + ) + .await?; + let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; + if was_inserted { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok((result.event, was_inserted)) + }) + .await } } diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs new file mode 100644 index 00000000000..724a9b47ed8 --- /dev/null +++ b/crates/buzz-db/tests/observability_source.rs @@ -0,0 +1,40 @@ +#[test] +fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { + let implementation = include_str!("../src/observability.rs"); + let datastore_macro = include_str!("../../buzz-datastore-tracing/src/lib.rs"); + let instrumentation = format!("{implementation}\n{datastore_macro}"); + + for forbidden in [ + "\"community\" =>", + "\"event_id\" =>", + "\"event_kind\" =>", + "\"kind\" =>", + "\"sql\" =>", + "\"query\" =>", + "\"query_id\" =>", + "\"d_tag\" =>", + "\"coordinate\" =>", + "community =", + "event_id =", + "event_kind =", + "sql =", + "query_id =", + "d_tag =", + "coordinate =", + ] { + assert!( + !instrumentation.contains(forbidden), + "database instrumentation must not expose {forbidden}" + ); + } + + assert!(datastore_macro.contains("name: LitStr")); + assert!(datastore_macro.contains("\"operation\" => #name")); + assert!(datastore_macro.contains("elapsed_ms =")); + assert!( + datastore_macro.contains("parent: None"), + "slow warnings must not inherit dynamic datastore span fields" + ); + // The runtime tracing-layer assertion covers field names because a source + // search would also match ordinary local variables such as `record_error`. +} diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e28c5b68409..6c6b9ada221 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -14,6 +14,7 @@ sqlx = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +metrics = { workspace = true } [dev-dependencies] tokio = { workspace = true } From a526dca9bcaa08dfb5db77999cc1f584a17a9d64 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 25 Aug 2026 11:34:16 -0700 Subject: [PATCH 034/101] feat: navigate images across message threads (#6705) ## Summary - navigate across image occurrences that are currently rendered inside the nearest explicit open-thread DOM scope - preserve rendered DOM order, duplicate occurrence identity, hidden-spoiler exclusion, preview-image entry parity, and the exact thumbnail return target - keep adjacent timeline messages and unrelated surfaces as separate galleries - make Copy and Download follow the current gallery item, including when a link-preview image opens the gallery first and navigation reaches Markdown media ## Scope contract This is intentionally a **rendered-media gallery only**. Gallery membership comes from mounted image triggers under the nearest explicit thread scope. Collapsed or unloaded descendants are excluded until the thread UI renders them. There is no canonical thread fetch, descendant projection, depth override, or synchronous message reparse in this change. `ChannelPane.tsx` remains unchanged from `main`; `useSearchHighlightProps` and both timeline/thread prop spreads are preserved. ## Verification - rebased onto `origin/main` at `113a33b7e49b7173ee1767c49ef2f49c63803034` - `git diff --check` - differential file-size check - full Desktop check (Biome plus text/pubkey policy checks; only pre-existing informational diagnostics) - Desktop TypeScript typecheck - Desktop production build and rebuilt E2E build - full Desktop JS units: `5,476/5,476` passed - full Desktop Tauri workspace tests passed (one documented native performance test ignored) - rebuilt image gallery Playwright spec: `14/14` passed - search-highlight smoke cases: `5/5` passed - reduced diff: 6 files, `+417/-41`; production `+78/-41`, E2E `+339/-0` The four added E2E cases cover cross-message thread navigation and return targeting, rendered-only exclusion of collapsed descendants, preview-first current-item actions, and adjacent-message isolation. --------- Signed-off-by: Kalvin Chau Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .../messages/ui/MessageThreadPanel.tsx | 4 + desktop/src/shared/ui/markdown.tsx | 62 ++-- .../shared/ui/markdown/ImageGalleryStatus.tsx | 31 ++ .../ui/markdown/LinkPreviewImageLightbox.tsx | 10 +- .../src/shared/ui/markdown/imageLightbox.ts | 12 +- .../e2e/image-attachment-gallery.spec.ts | 339 ++++++++++++++++++ 6 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d3b1394829b..8797bec7a0e 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -510,8 +510,12 @@ export function MessageThreadPanel({ tabIndex={-1} ref={threadBodyRef} > + {/* The gallery is intentionally DOM-scoped: only media currently rendered + in this open thread participates. Collapsed or unloaded descendants + join only after the thread UI renders them. */}
{ + toast.success("Copied to clipboard"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Copy failed"; + toast.error(msg); + }); +} + +function downloadImage(src: string | undefined) { + if (!src) return; + invokeTauri("download_image", { url: src }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Download failed"; + toast.error(msg); + }); +} + function ImageZoomOverlay({ alt, galleryIndex = 0, galleryItems, - onCopy, - onDownload, onClose, resolvedSrc, sourceBox, @@ -161,8 +180,6 @@ function ImageZoomOverlay({ alt: string | undefined; galleryIndex?: number; galleryItems?: ImageGalleryItem[]; - onCopy: (src: string | undefined) => void; - onDownload: (src: string | undefined) => void; onClose: () => void; resolvedSrc: string; sourceBox: ImageLightboxBox; @@ -718,13 +735,13 @@ function ImageZoomOverlay({ const handleMenuCopy = React.useCallback(() => { setMenu(null); markControlGesture(); - onCopy(currentItem.src); - }, [currentItem.src, markControlGesture, onCopy]); + copyImageToClipboard(currentItem.src); + }, [currentItem.src, markControlGesture]); const handleMenuDownload = React.useCallback(() => { setMenu(null); markControlGesture(); - onDownload(currentItem.src); - }, [currentItem.src, markControlGesture, onDownload]); + downloadImage(currentItem.src); + }, [currentItem.src, markControlGesture]); return createPortal(
{ event.stopPropagation(); - onDownload(currentItem.src); + downloadImage(currentItem.src); }} > @@ -952,6 +969,7 @@ function ImageZoomOverlay({ updateZoom={updateZoom} zoom={zoom} /> +
{menu && canActOnCurrentImage ? ( @@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const triggerRef = React.useRef(null); useSmoothCorners(inlineImageRef); useSmoothCorners(thumbnailImageRef); - const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{ height: number; src: string; @@ -1078,7 +1095,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { return () => observer.disconnect(); }, []); - const closeMenu = React.useCallback(() => setMenu(null), []); useDismissMediaContextMenu(Boolean(menu), closeMenu); @@ -1089,7 +1105,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { e.nativeEvent.stopImmediatePropagation(); setMenu({ x: e.clientX, y: e.clientY }); }; - const openLightbox = React.useCallback( (image: HTMLImageElement) => { if (!resolvedSrc || isInsideHiddenSpoiler(image)) { @@ -1113,6 +1128,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { { alt, dim, + trigger: triggerRef.current, resolvedSrc, src, thumbnailBox: sourceBox, @@ -1140,27 +1156,13 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const handleCopyImage = React.useCallback((copySrc: string | undefined) => { setMenu(null); - if (!copySrc) return; - invokeTauri("copy_image_to_clipboard", { url: copySrc }) - .then(() => { - toast.success("Copied to clipboard"); - }) - .catch((err: unknown) => { - const msg = err instanceof Error ? err.message : "Copy failed"; - toast.error(msg); - }); + copyImageToClipboard(copySrc); }, []); const handleDownload = React.useCallback( (downloadSrc: string | undefined) => { setMenu(null); - if (!downloadSrc) return; - invokeTauri("download_image", { url: downloadSrc }).catch( - (err: unknown) => { - const msg = err instanceof Error ? err.message : "Download failed"; - toast.error(msg); - }, - ); + downloadImage(downloadSrc); }, [], ); @@ -1215,8 +1217,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { alt={alt} galleryIndex={lightboxState.galleryIndex} galleryItems={lightboxState.galleryItems} - onCopy={handleCopyImage} - onDownload={handleDownload} onClose={() => setLightboxState(null)} resolvedSrc={resolvedSrc} sourceBox={lightboxState.sourceBox} diff --git a/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx new file mode 100644 index 00000000000..66e7b68b8bb --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx @@ -0,0 +1,31 @@ +type ImageGalleryStatusProps = { + currentIndex: number; + itemCount: number; +}; + +export function ImageGalleryStatus({ + currentIndex, + itemCount, +}: ImageGalleryStatusProps) { + if (itemCount <= 1) { + return null; + } + + const position = currentIndex + 1; + return ( + <> + Promise; placeholder?: string; profiles?: UserProfileLookup; + /** Explicit mention pubkeys from the loaded channel window, newest first. */ + recentMentionPubkeys?: readonly string[]; replyTarget?: { author: string; body: string; diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index d4e2284b85f..5d5a1f5876e 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -26,6 +26,7 @@ const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ addressedAgents = NO_ADDRESSED_AGENTS, + autoPinConfirmationTitle, composerDisabled, editor, extraActions, @@ -35,6 +36,8 @@ export const MessageComposerToolbar = React.memo( isSending, isUploading, onCaptureSelection, + onAutoPinConfirmationDismiss, + onAutoPinConfirmationTurnOff, onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, @@ -47,6 +50,7 @@ export const MessageComposerToolbar = React.memo( shakeVersionByPubkey, }: { addressedAgents?: readonly ComposerAddressAgent[]; + autoPinConfirmationTitle?: string | null; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; @@ -56,6 +60,8 @@ export const MessageComposerToolbar = React.memo( isSending: boolean; isUploading: boolean; onCaptureSelection: () => void; + onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; @@ -175,7 +181,10 @@ export const MessageComposerToolbar = React.memo( > import("./DiffMessage")); @@ -280,10 +280,12 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); const addressedAgentPubkeys = React.useMemo(() => { - return getAgentAddressMentionPubkeys(message.tags).filter( - isKnownAgentPubkey, + return getVisibleAgentAddressPubkeys( + message.body, + getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey), + mentionPubkeysByName, ); - }, [isKnownAgentPubkey, message.tags]); + }, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]); const agentAddressPrefix = addressedAgentPubkeys.length > 0 ? ( Promise; profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; @@ -184,6 +185,7 @@ export function MessageThreadPanel({ onToggleReaction, onUnfollowThread, profiles, + recentMentionPubkeys, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, @@ -855,6 +857,7 @@ export function MessageThreadPanel({ : `Reply in thread to ${threadHead.author}` } profiles={profiles} + recentMentionPubkeys={recentMentionPubkeys} replyTarget={composerReplyTarget} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index e98763a4a51..b95528dd939 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,13 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+Enter opens the picker or toggles in place", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const opened = []; + const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -59,8 +59,9 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { }; const createEvent = () => ({ altKey: false, + code: "KeyM", ctrlKey: !isMacPlatform(), - key: "Enter", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -71,24 +72,123 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { useAlwaysAddressShortcut({ enabled: true, mentions: { + getDefaultAgentSuggestion: () => suggestion, isMentionOpen, mentionSelectedIndex: 0, suggestions: [suggestion], }, - onOpenPicker: (insertTrigger) => opened.push(insertTrigger), + onOpenPicker: () => {}, + onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, ); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(opened, [false]); - assert.deepEqual(toggled, []); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion, suggestion]); +}); + +test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + const lockedAgent = { + avatarUrl: null, + displayName: "Agent Ada", + pubkey: "agent-a", + }; + const defaultAgent = { + displayName: "Agent Bea", + isAgent: true, + pubkey: "agent-b", + }; + const toggled = []; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + lockedAgent, + mentions: { + getDefaultAgentSuggestion: () => defaultAgent, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => {}, + onSelect: () => {}, + onToggle: (value) => toggled.push(value), + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.deepEqual(toggled, [{ ...lockedAgent, isAgent: true }]); +}); + +test("primary+Shift+M opens the picker when no default agent is ready", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + let opened = 0; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + mentions: { + getDefaultAgentSuggestion: () => null, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => { + opened += 1; + }, + onSelect: () => {}, + onToggle: () => {}, + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 4acd89cf0a5..95e0af9e06b 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -23,7 +23,7 @@ afterEach(async () => { after(() => dom.window.close()); -test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => { +test("always addressing an agent keeps autocomplete open, inserts the chip, adds the lock, and pulses", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -32,7 +32,7 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul const addedPubkeys = []; const pulsedPubkeys = []; let cancelCount = 0; - const text = "Ask @Agent Ada later @"; + const text = "@"; const mentions = { cancelMentionAutocomplete: () => { cancelCount += 1; @@ -45,6 +45,9 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }, ], getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: true, + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -73,7 +76,14 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + preserveSelection: true, + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); @@ -83,6 +93,49 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul ); }); +test("always addressing a new agent delegates the first add for immediate confirmation", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const addressedSuggestions = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAddressAgentMention: (value) => addressedSuggestions.push(value), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText: { + getPlainTextAndCursor: () => ({ text: "@Agent Ada ", cursor: 11 }), + }, + }), + ); + + act(() => result.current.toggleAlwaysAddressAgent(suggestion)); + + assert.deepEqual(addressedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); +}); + test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -105,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -136,7 +190,13 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 4, + replaceToOffset: 15, + insertText: "", + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); @@ -158,10 +218,13 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("an already addressed agent must not be inserted"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -190,16 +253,23 @@ test("selecting an already addressed agent from the explicit picker pulses its b }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); }); -test("selecting an agent from a typed query leaves the inline mention for send", async () => { +test("selecting an agent from a typed query immediately auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const autoPinnedSuggestions = []; const appliedEdits = []; const addedPubkeys = []; const pulsedPubkeys = []; @@ -207,6 +277,7 @@ test("selecting an agent from a typed query leaves the inline mention for send", cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 5, @@ -230,18 +301,19 @@ test("selecting an agent from a typed query leaves the inline mention for send", audience, audienceScope: "channel-scope", mentions, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), ); - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + act(() => result.current.selectMentionSuggestion(suggestion)); assert.deepEqual(appliedEdits, [ { @@ -250,11 +322,151 @@ test("selecting an agent from a typed query leaves the inline mention for send", insertText: "@Agent Ada ", }, ]); + assert.deepEqual(autoPinnedSuggestions, [suggestion]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, []); assert.equal(result.current.announcement, ""); }); +test("selecting a human mention never changes automatic addressing", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const autoPinnedSuggestions = []; + const appliedEdits = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getMentionDisplayName: () => "Alice", + insertMention: () => ({ + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }), + }, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text: "@Al", cursor: 3 }), + }, + }), + ); + + act(() => + result.current.selectMentionSuggestion({ + pubkey: "human-pubkey", + displayName: "Alice", + isAgent: false, + }), + ); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, []); +}); + +test("removing the last agent chip clears its automatic address", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const mentionRefsByText = { + "@Agent Ada first @Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "@Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "": [], + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["agent-pubkey", "existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => mentionRefsByText[text] ?? [], + getMentionDisplayName: () => "Agent Ada", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => result.current.trackMentionAddressedAgent("agent-pubkey")); + act(() => + result.current.syncAddressedAgentsFromText( + "@Agent Ada first @Agent Ada second", + ), + ); + act(() => result.current.syncAddressedAgentsFromText("@Agent Ada second")); + assert.deepEqual(removedPubkeys, []); + + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); +}); + +test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => { + if (text === "@Alice @Existing Agent") { + return [ + { displayName: "Alice", pubkey: "human-pubkey", isAgent: false }, + { + displayName: "Existing Agent", + pubkey: "existing-lock", + isAgent: true, + }, + ]; + } + return text + ? [{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false }] + : []; + }, + getMentionDisplayName: () => "Existing Agent", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => + result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), + ); + act(() => result.current.syncAddressedAgentsFromText("@Alice")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); +}); + test("selecting an agent from the explicit picker auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -267,10 +479,13 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("explicit picker selections must become addressing"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -299,7 +514,13 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -319,8 +540,11 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn const pulsedPubkeys = []; const mentions = { cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], + getDraftMentionRefs: () => [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ replaceFromOffset: 0, @@ -330,7 +554,10 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn mentionStartIndex: 0, }; const richText = { - getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + getPlainTextAndCursor: () => ({ + text: "@Agent Ada keep this authored text", + cursor: 35, + }), }; const { result, rerender } = renderHook( ({ pubkeys }) => @@ -350,6 +577,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); + assert.deepEqual(appliedEdits, []); rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 8d5a87b9b8f..17883900ecb 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -16,8 +16,7 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; function buildMentionRemovalEdits( text: string, displayNames: readonly string[], - queryStart: number, - cursor: number, + queryRange?: { start: number; end: number }, ): AutocompleteEdit[] { const ranges = displayNames.flatMap((displayName) => getMentionOffsets(text, displayName).map((start) => { @@ -26,10 +25,12 @@ function buildMentionRemovalEdits( return { start, end }; }), ); - ranges.push({ - start: Math.max(0, Math.min(queryStart, text.length)), - end: Math.max(0, Math.min(cursor, text.length)), - }); + if (queryRange) { + ranges.push({ + start: Math.max(0, Math.min(queryRange.start, text.length)), + end: Math.max(0, Math.min(queryRange.end, text.length)), + }); + } const merged = ranges .filter(({ start, end }) => start < end) @@ -56,6 +57,8 @@ export function useAgentAddressLockPicker({ audience, audienceScope, mentions, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, profiles, richText, @@ -64,6 +67,8 @@ export function useAgentAddressLockPicker({ audience: ReturnType; audienceScope: string | null; mentions: UseMentionsResult; + onAddressAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -79,6 +84,12 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.clear(); } const lockedAgentNamesRef = React.useRef(new Map()); + const visibleAgentMentionPubkeysRef = React.useRef(new Set()); + const mentionSyncScopeRef = React.useRef(audienceScope); + if (mentionSyncScopeRef.current !== audienceScope) { + mentionSyncScopeRef.current = audienceScope; + visibleAgentMentionPubkeysRef.current.clear(); + } const [announcement, setAnnouncement] = React.useState(""); const lockedAgents = React.useMemo( () => @@ -104,41 +115,42 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); - const consumeAddressSuggestion = React.useCallback( - ( - suggestion: MentionSuggestion, - { removeInlineMentions }: { removeInlineMentions: boolean }, - ): string | null => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return null; - - const { text, cursor } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = removeInlineMentions - ? mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === pubkey) - .map((ref) => ref.displayName) - : []; - mentions.cancelMentionAutocomplete(); - for (const edit of buildMentionRemovalEdits( - text, - matchingDisplayNames, - mentions.mentionStartIndex, - cursor, - )) { - applyAutocompleteEdit(edit); + const trackMentionAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (audienceScope && normalized) { + visibleAgentMentionPubkeysRef.current.add(normalized); + } + }, + [audienceScope], + ); + const syncAddressedAgentsFromText = React.useCallback( + (text: string) => { + if (!audienceScope) return; + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); + for (const pubkey of visibleAgentMentionPubkeysRef.current) { + if ( + !presentAgentPubkeys.has(pubkey) && + lockedAgentPubkeys.has(pubkey) + ) { + audience.removePubkey(pubkey); + } } - return pubkey; + visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ - applyAutocompleteEdit, + audience.removePubkey, audienceScope, - mentions.cancelMentionAutocomplete, + lockedAgentPubkeys, mentions.getDraftMentionRefs, - mentions.mentionStartIndex, - richText.getPlainTextAndCursor, ], ); + const removeAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -148,24 +160,63 @@ export function useAgentAddressLockPicker({ }, [audience.removePubkey, audienceScope], ); + const removeAddressedAgentMentions = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + const { text } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === normalized) + .map((ref) => ref.displayName); + for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { + applyAutocompleteEdit(edit); + } + removeAddressedAgent(normalized); + }, + [ + applyAutocompleteEdit, + audienceScope, + mentions.getDraftMentionRefs, + removeAddressedAgent, + richText.getPlainTextAndCursor, + ], + ); const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgent(pubkey); + removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); } else { unpinnedAgentPubkeysRef.current.delete(pubkey); - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); + mentions.registerMentionPubkey(suggestion.displayName, pubkey, { + isAgent: true, + }); + const { text } = richText.getPlainTextAndCursor(); + if (getMentionOffsets(text, suggestion.displayName).length === 0) { + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: `@${suggestion.displayName} `, + preserveSelection: true, + }); + } + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen) { + if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { const { text, cursor } = richText.getPlainTextAndCursor(); const activeMention = detectPrefixQuery("@", text, cursor, [ suggestion.displayName.toLowerCase(), @@ -190,12 +241,16 @@ export function useAgentAddressLockPicker({ audience.addPubkey, audienceScope, lockedAgentPubkeys, + mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, + mentions.registerMentionPubkey, + onAddressAgentMention, onPulseAddressLock, - removeAddressedAgent, + removeAddressedAgentMentions, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, ], ); @@ -209,15 +264,25 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); + trackMentionAddressedAgent(pubkey); + onAutoPinAgentMention?.(suggestion); return; } - consumeAddressSuggestion(suggestion, { removeInlineMentions: false }); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); if (!lockedAgentPubkeys.has(pubkey)) { - audience.addPubkey(pubkey); + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } else { + onPulseAddressLock(pubkey); } - onPulseAddressLock(pubkey); return; } @@ -228,12 +293,84 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience.addPubkey, audienceScope, - consumeAddressSuggestion, lockedAgentPubkeys, mentions.isInlineMentionSelection, mentions.insertMention, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, + ], + ); + + const restoreAddressedAgentMentions = React.useCallback( + ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys: readonly string[] = [], + ) => { + const restorePubkeys = pubkeys + ? new Set(pubkeys.map(normalizePubkey)) + : null; + const allowedUnpinned = new Set( + allowedUnpinnedPubkeys.map(normalizePubkey), + ); + const currentAudiencePubkeys = new Set( + audience.pubkeys.map(normalizePubkey), + ); + const targetAgents = [...(restorePubkeys ?? currentAudiencePubkeys)] + .filter( + (pubkey) => + currentAudiencePubkeys.has(pubkey) || allowedUnpinned.has(pubkey), + ) + .map((pubkey) => { + const profile = profiles?.[pubkey]; + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + mentions.getMentionDisplayName(pubkey)?.trim() || + lockedAgentNamesRef.current.get(pubkey) || + truncatePubkey(pubkey); + return { pubkey, displayName }; + }); + const { text } = richText.getPlainTextAndCursor(); + for (const agent of targetAgents) { + if (getMentionOffsets(text, agent.displayName).length > 0) { + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + } + const missingAgents = targetAgents.filter( + (agent) => + (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || + allowedUnpinned.has(agent.pubkey)) && + getMentionOffsets(text, agent.displayName).length === 0, + ); + if (missingAgents.length === 0) return text; + for (const agent of missingAgents) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + const insertedText = `${missingAgents + .map((agent) => `@${agent.displayName}`) + .join(" ")} `; + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: insertedText, + preserveSelection: true, + }); + return `${insertedText}${text}`; + }, + [ + applyAutocompleteEdit, + audience.pubkeys, + mentions.getMentionDisplayName, + mentions.registerMentionPubkey, + profiles, + richText.getPlainTextAndCursor, ], ); @@ -242,7 +379,10 @@ export function useAgentAddressLockPicker({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, + restoreAddressedAgentMentions, selectMentionSuggestion, + syncAddressedAgentsFromText, toggleAlwaysAddressAgent, + trackMentionAddressedAgent, }; } diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index 8fd253df222..ed92e7b3f0f 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; export function useAlwaysAddressShortcut({ enabled, + lockedAgent, mentions, onOpenPicker, + onSelect, onToggle, }: { enabled: boolean; + lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; + onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { - const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions; + const { + getDefaultAgentSuggestion, + isMentionOpen, + mentionSelectedIndex, + suggestions, + } = mentions; return React.useCallback( (event: React.KeyboardEvent): boolean => { if ( !enabled || - event.key !== "Enter" || + event.code !== "KeyM" || !hasPrimaryShortcutModifier(event) || event.altKey || !event.shiftKey @@ -30,21 +39,30 @@ export function useAlwaysAddressShortcut({ event.preventDefault(); if (event.repeat) return true; - if (!isMentionOpen) { - onOpenPicker(false); + const suggestion = isMentionOpen + ? suggestions[mentionSelectedIndex] + : lockedAgent + ? { ...lockedAgent, isAgent: true } + : getDefaultAgentSuggestion(); + if (!suggestion?.isAgent || !suggestion.pubkey) { + if (!isMentionOpen) onOpenPicker(false); return true; } - - const suggestion = suggestions[mentionSelectedIndex]; - if (!suggestion?.isAgent || !suggestion.pubkey) return true; - onToggle(suggestion); + if (isMentionOpen) { + onSelect(suggestion); + } else { + onToggle(suggestion); + } return true; }, [ enabled, + getDefaultAgentSuggestion, isMentionOpen, + lockedAgent, mentionSelectedIndex, onOpenPicker, + onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index 44b87c9f1d0..d0557567fdd 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -1,36 +1,62 @@ import * as React from "react"; -import { toast } from "sonner"; import { + getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; +const CONFIRMATION_DURATION_MS = 4_000; + +type Confirmation = { + expectedRevision: number; + pubkeys: readonly string[]; + scope: string; + title: string; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; - onOpenOptions: () => void; onPulse: (pubkey: string) => void; + onTurnOff: () => void; }; export function useAutoPinMentionedAgents({ audienceScope, enabled, getDisplayName, - onOpenOptions, onPulse, + onTurnOff, }: Options) { - return React.useCallback( + const [confirmation, setConfirmation] = React.useState( + null, + ); + + React.useEffect(() => { + if (!confirmation) return; + const timeout = window.setTimeout( + () => setConfirmation(null), + CONFIRMATION_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [confirmation]); + + const promoteAgents = React.useCallback( ({ - expectedRevision, + expectedRevision = audienceScope + ? getPersistentAgentAudienceRevision(audienceScope) + : 0, pubkeys, + requirePreference, }: { - expectedRevision: number; + expectedRevision?: number; pubkeys: readonly string[]; + requirePreference: boolean; }) => { - if (!audienceScope || !enabled) return; + if (!audienceScope || (requirePreference && !enabled)) return; const normalizedPubkeys = [ ...new Set(pubkeys.map(normalizePubkey)), ].filter(Boolean); @@ -52,23 +78,47 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; - toast.success(title, { - action: { - label: "Undo", - onClick: () => { - if ( - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: revision, - pubkeys: promotedPubkeys, - scope: audienceScope, - }) - ) { - onOpenOptions(); - } - }, - }, + setConfirmation({ + expectedRevision: revision, + pubkeys: promotedPubkeys, + scope: audienceScope, + title, }); }, - [audienceScope, enabled, getDisplayName, onOpenOptions, onPulse], + [audienceScope, enabled, getDisplayName, onPulse], + ); + const promoteMentionedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: true }), + [promoteAgents], + ); + const promoteExplicitlyAddressedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: false }), + [promoteAgents], ); + + const dismissConfirmation = React.useCallback( + () => setConfirmation(null), + [], + ); + const turnOffConfirmation = React.useCallback(() => { + if (!confirmation) return; + setConfirmation(null); + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: confirmation.expectedRevision, + pubkeys: confirmation.pubkeys, + scope: confirmation.scope, + }); + onTurnOff(); + }, [confirmation, onTurnOff]); + + return { + confirmationTitle: + confirmation?.scope === audienceScope ? confirmation.title : null, + dismissConfirmation, + promoteExplicitlyAddressedAgents, + promoteMentionedAgents, + turnOffConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts new file mode 100644 index 00000000000..8e56da071a9 --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerPasteHandler.ts @@ -0,0 +1,73 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardHtml, +} from "@/features/messages/lib/normalizeMentionClipboard"; +import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; + +export function useComposerPasteHandler(options: { + editor: Editor | null; + scrollToBottom: () => void; + setPendingImeta: ( + update: (current: BlobDescriptor[]) => BlobDescriptor[], + ) => void; + uploadFile: (file: File) => Promise; +}) { + const uploadFileRef = React.useRef(options.uploadFile); + uploadFileRef.current = options.uploadFile; + React.useEffect(() => { + const editor = options.editor; + if (!editor) return; + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + handlePaste: (view, event) => { + const mediaItem = Array.from(event.clipboardData?.items ?? []).find( + (item) => item.kind === "file", + ); + if (mediaItem) { + const file = mediaItem.getAsFile(); + if (file) void uploadFileRef.current(file); + return true; + } + const codeBlockText = getBuzzCodeBlockClipboardText( + event.clipboardData, + ); + if (codeBlockText !== null) { + event.preventDefault(); + editor + .chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + options.scrollToBottom(); + return true; + } + if (handleAgentSnapshotPaste(event, options.setPendingImeta)) + return true; + const html = event.clipboardData?.getData("text/html"); + if (html && hasMentionClipboardHtml(html)) { + event.preventDefault(); + view.pasteHTML(normalizeMentionClipboardHtml(html)); + return true; + } + if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n")) + options.scrollToBottom(); + return false; + }, + }, + }); + }, [options.editor, options.scrollToBottom, options.setPendingImeta]); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 8176bb13bbd..ab7d8c2f4d6 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -13,7 +13,6 @@ export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; - audienceRevision: number; inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { @@ -38,7 +37,6 @@ export type PendingNonMemberMentionSend = { export type SendMessageWithMentionFlowInput = { addressedAgentPubkeys?: readonly string[]; - audienceRevision?: number; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 601c3b41135..10ca0332728 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -17,21 +17,14 @@ import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgent import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -47,46 +40,8 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; - onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; - onInlineAgentMentionsSent?: (promotion: { - expectedRevision: number; - pubkeys: readonly string[]; - }) => void; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; + export function useMentionSendFlow({ channelId, channelLinks, @@ -98,8 +53,9 @@ export function useMentionSendFlow({ mentions, onPrepareSendChannel, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onSendRef, richText, setContent, @@ -406,6 +362,7 @@ export function useMentionSendFlow({ ); }; let composerCleared = false; + let optimisticComposerContent = ""; const restoreComposerAfterFailure = () => { if (!composerCleared) return; composerCleared = false; @@ -422,7 +379,7 @@ export function useMentionSendFlow({ } const canRestoreCurrentComposer = canAnimateCurrentComposer && - contentRef.current.trim().length === 0 && + contentRef.current.trim() === optimisticComposerContent.trim() && !hasUnsavedMedia(); if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { saveQueuedAttachmentsForDraft( @@ -451,6 +408,12 @@ export function useMentionSendFlow({ onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys); } clearComposer(); + if (draft.addressedAgentPubkeys.length > 0) { + optimisticComposerContent = + onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? + ""; + contentRef.current = optimisticComposerContent; + } composerCleared = true; } let uploadStarted = false; @@ -587,12 +550,23 @@ export function useMentionSendFlow({ const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), ); - onInlineAgentMentionsSent?.({ - expectedRevision: draft.audienceRevision, - pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) => - sentMentionPubkeys.has(normalizePubkey(pubkey)), - ), - }); + const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( + (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), + ); + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + onAddressedAgentsSendSucceeded?.( + [ + ...new Set([ + ...draft.addressedAgentPubkeys, + ...newlyPinnedPubkeys, + ]), + ], + newlyPinnedPubkeys, + ); + } if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -604,12 +578,18 @@ export function useMentionSendFlow({ } }; if (preparedUpload) { + let settleUpload!: () => void; + const uploadSettled = new Promise((resolve) => { + settleUpload = resolve; + }); uploadStarted = preparedUpload.start({ onComplete: async (uploaded, signal) => { try { await finishSend(uploaded, signal); } catch { restoreComposerAfterFailure(); + } finally { + settleUpload(); } }, onError: (error) => { @@ -617,14 +597,18 @@ export function useMentionSendFlow({ toast.error( `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); + settleUpload(); }, onCancel: () => { restoreComposerAfterFailure(); + settleUpload(); }, }); if (!uploadStarted) { + settleUpload(); return restoreComposerAfterFailure(); } + await uploadSettled; } if (!preparedUpload) { try { @@ -657,8 +641,9 @@ export function useMentionSendFlow({ mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText.setContent, @@ -674,7 +659,6 @@ export function useMentionSendFlow({ const sendMessageWithMentionFlow = React.useCallback( async ({ addressedAgentPubkeys = [], - audienceRevision = 0, capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -783,7 +767,6 @@ export function useMentionSendFlow({ const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); const pendingDraft: PendingNonMemberMentionSend = { addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), - audienceRevision, inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( savedMentionRefs .filter((ref) => ref.isAgent) diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 00000000000..496a73a94a7 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,52 @@ +import type * as React from "react"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import type { ChannelType } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; + onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string; + onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onAddressedAgentsSendSucceeded?: ( + pubkeys: readonly string[], + newlyPinnedPubkeys: readonly string[], + ) => void; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + ) => Promise + >; + richText: Pick; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; +}; diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index e8b388d0dcb..d8e1550549f 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,9 +166,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Open the agent picker, or toggle the highlighted agent", - keys: "⇧⌘↵", - keysWindows: "Ctrl+Shift+Enter", + description: "Address the default agent, or select the highlighted agent", + keys: "⇧⌘M", + keysWindows: "Ctrl+Shift+M", category: "Messages", }, { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6726a224024..2136e2e12f5 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -430,7 +430,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect .poll(() => readOutgoingMentionPubkeys(page, "local")) .toEqual([managedPubkey]); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@carl "); await page.getByTestId(`composer-address-lock-${managedPubkey}`).click(); await input.fill("@carl"); @@ -720,7 +720,7 @@ test("defers agent mentions until DM members finish loading", async ({ expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( commandCount(baselineCommands, "add_channel_members"), ); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@alice "); await expect(threadPanel).toContainText("before members resolve"); }); @@ -1290,7 +1290,8 @@ test("managed relay-profile agents with member roles use the agent address tray" await expect(dropdown.getByText("agent")).toBeVisible(); await input.press("Enter"); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); @@ -2642,8 +2643,8 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await expect(input.locator(".mention-chip")).toHaveCount(0); await input.press("Enter"); - await expect(input).toBeEmpty(); - await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index be1dbc86c3f..f9481c91f05 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -28,6 +28,9 @@ async function automaticallyMention( .getByTestId("mention-autocomplete") .getByRole("button", { name: `Automatically mention ${displayName}` }) .click(); + await expect(composer.getByTestId("message-input")).toContainText( + `@${displayName}`, + ); await composer.locator("[data-mention-picker-trigger]").click(); } @@ -54,7 +57,20 @@ function threadComposer(page: Page) { return page.getByTestId("thread-composer-overlay"); } -async function pressPrimaryShift(page: Page, key: "Enter" | "M") { +async function readComposerCaret(input: Locator) { + return input.evaluate((element) => { + const selection = window.getSelection(); + if (!selection?.anchorNode || !element.contains(selection.anchorNode)) { + return null; + } + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selection.anchorNode, selection.anchorOffset); + return range.toString().length; + }); +} + +async function pressPrimaryShift(page: Page, key: "M") { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); @@ -106,11 +122,38 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) { }, content); } +async function emitMockMessage( + page: Page, + content: string, + mentionPubkeys: string[], +) { + await page.evaluate( + ({ body, mentions }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + mentionPubkeys: mentions, + }); + }, + { body: content, mentions: mentionPubkeys }, + ); +} + async function installAudienceFixtures( page: Page, options: { + deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; + uploadDelayMs?: number; + uploadDescriptors?: Array<{ + filename: string; + sha256: string; + size: number; + type: string; + uploaded: number; + url: string; + }>; usersBatchDelayMs?: number; } = {}, ) { @@ -133,6 +176,80 @@ async function installAudienceFixtures( }); } +test("keeps a queued-attachment send locked through upload and send settlement", async ({ + page, +}) => { + await installAudienceFixtures(page, { + deferredComposerUploads: true, + uploadDelayMs: 2_000, + sendMessageDelayMs: 2_000, + uploadDescriptors: [ + { + filename: "delayed-video.mp4", + sha256: "d".repeat(64), + size: 16, + type: "video/mp4", + uploaded: 1, + url: `https://mock.relay/media/${"d".repeat(64)}.mp4`, + }, + ], + }); + await openGeneral(page); + + const composer = channelComposer(page); + const composerForm = composer.getByTestId("message-composer"); + const input = composer.getByTestId("message-input"); + await input.fill("delayed upload"); + + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + composer.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("delayed video"), + mimeType: "video/mp4", + name: "delayed-video.mp4", + }); + + const sendAttempts = () => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.filter( + (entry) => entry.command === "send_channel_message", + ).length ?? 0, + ); + const sendAttemptsBefore = await sendAttempts(); + await input.press("Enter"); + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + + // Observe the synchronous lock itself after upload has started. The exact + // premature-release mutation (`void uploadSettled; await Promise.resolve()`) + // has already cleared this attribute by this boundary, before any retryable + // downstream command or restored-audience timing can obscure the defect. + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1); + + // Command entry marks the independently delayed finishSend() window. Keep + // the synchronous lock held and fence repeated submits until it settles. + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect( + page + .getByTestId("message-row") + .filter({ hasText: "delayed upload" }) + .last(), + ).toBeVisible({ timeout: 5_000 }); + await expect(composerForm).toHaveAttribute("data-submit-locked", "false"); +}); + test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { @@ -154,9 +271,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab keeps a manually selected agent as an inline mention", async ({ - page, -}) => { +test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -169,12 +284,21 @@ test("Tab keeps a manually selected agent as an inline mention", async ({ await expect(input).toHaveText("@Morgarita "); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + await expect(input).toHaveText(""); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); }); -test("primary+Shift+Enter opens the picker, then pins the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { await installAudienceFixtures(page); @@ -183,23 +307,65 @@ test("primary+Shift+Enter opens the picker, then pins the highlighted agent", as const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - const menu = composer.getByTestId("mention-autocomplete"); - await expect(menu).toBeVisible(); + await expect(input).toHaveText("@alice draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveText("alice"); + await expect( + page.getByTestId("composer-auto-pin-confirmation"), + ).toContainText("alice will be mentioned automatically"); + await pressPrimaryShift(page, "M"); await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); - await input.fill("@Mor"); - await expect(menu.getByTestId(`mention-suggestion-${AGENT_A}`)).toHaveClass( + await input.fill("@Vog"); + const menu = composer.getByTestId("mention-autocomplete"); + await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - await expect(menu).toBeVisible(); - await expect(input).toHaveText(""); + await expect(menu).toHaveCount(0); + await expect(input).toHaveText("@Vogue "); await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), + composer.getByTestId(`composer-address-lock-${AGENT_B}`), ).toBeVisible(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); +}); + +test("primary+Shift+M favors the most recently mentioned eligible agent", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); + + const input = channelComposer(page).getByTestId("message-input"); + await input.fill("draft text"); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await expect.poll(() => readComposerCaret(input)).toBe(8); + await pressPrimaryShift(page, "M"); + + await expect(input).toHaveText("@Vogue draft text"); + await expect.poll(() => readComposerCaret(input)).toBe(15); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@Vogue draft text"); }); test("the mention button opens settings and can undo an address", async ({ @@ -215,11 +381,11 @@ test("the mention button opens settings and can undo an address", async ({ name: "Manage automatic agent mentions", }); - await input.fill("draft text"); + await input.type("draft text"); await ingress.click(); const menu = composer.getByTestId("mention-autocomplete"); await expect(menu).toBeVisible(); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await page.getByTestId("mention-options-trigger").click(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), @@ -235,7 +401,7 @@ test("the mention button opens settings and can undo an address", async ({ if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out"); await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2); await expect(menu).toHaveCount(0); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute( @@ -247,7 +413,7 @@ test("the mention button opens settings and can undo an address", async ({ ).toHaveCount(0); await ingress.click(); await expect(menu).toHaveCount(0); - await input.fill(""); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); @@ -260,10 +426,11 @@ test("the mention button opens settings and can undo an address", async ({ await menu .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) .click(); - await expect(input).toHaveText(""); + await expect(input).toHaveText("draft text"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); + await input.fill(""); await menu .getByRole("button", { name: "Mention Morgarita", exact: true }) @@ -271,11 +438,11 @@ test("the mention button opens settings and can undo an address", async ({ await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText(""); + await expect(input).toHaveText("@Morgarita "); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); @@ -296,15 +463,41 @@ test("always-mentioned agents remain in the mention button while Enter-send reso const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); - const send = composer.getByTestId("send-message"); + await input.evaluate((element) => { + const snapshots = [element.textContent ?? ""]; + new MutationObserver(() => + snapshots.push(element.textContent ?? ""), + ).observe(element, { childList: true, characterData: true, subtree: true }); + ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots; + }); const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`); const initialPulseVersion = Number( await avatar.getAttribute("data-pulse-version"), ); - await input.fill("hello"); + await input.type("hello"); + await input.evaluate((element) => { + const snapshots = ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__; + snapshots?.splice(0, snapshots.length, element.textContent ?? ""); + }); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); + await expect(input).toHaveText("@Morgarita ", { timeout: 500 }); + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[]; + } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [], + ), + ) + .not.toContain(""); await expect(avatar).toHaveAttribute( "data-pulse-version", String(initialPulseVersion + 1), @@ -316,10 +509,10 @@ test("always-mentioned agents remain in the mention button while Enter-send reso await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await expect(send).toBeDisabled(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "hello")) + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); + await expect(input).toHaveText("@Morgarita "); const sentRow = page .getByTestId("message-row") @@ -331,6 +524,18 @@ test("always-mentioned agents remain in the mention button while Enter-send reso ); await expect(addressPrefix).toBeVisible(); await expect(addressPrefix).toHaveText("Morgarita"); + + await input.type("follow up"); + await input.press("Enter"); + const inlineMentionRow = page + .getByTestId("message-row") + .filter({ hasText: "follow up" }) + .last(); + await expect( + inlineMentionRow.locator("[data-mention].agent-mention-highlight", { + hasText: "Morgarita", + }), + ).toHaveCount(1); }); test("a failed always-mentioned send shakes the composer avatar", async ({ @@ -351,7 +556,7 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ ); await expect(avatar).toHaveAttribute("data-shake-version", "0"); - await input.fill("please retry"); + await input.type("please retry"); await input.press("Enter"); await expect(avatar).toHaveAttribute( @@ -359,11 +564,11 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ String(initialPulseVersion + 1), { timeout: 500 }, ); - await expect(input).toHaveText("please retry"); + await expect(input).toHaveText("@Morgarita please retry"); await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected after the message sends", async ({ +test("a manually mentioned agent becomes selected immediately", async ({ page, }) => { await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); @@ -377,42 +582,74 @@ test("a manually mentioned agent becomes selected after the message sends", asyn await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await expect(autoPinConfirmation).not.toContainText( + "Future messages in this channel will include this agent.", + ); + await expect(autoPinConfirmation).toHaveAttribute("data-side", "right"); + await expect(autoPinConfirmation.locator("span")).toHaveCSS( + "white-space", + "nowrap", + ); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Morgarita will be mentioned automatically" }), ).toHaveCount(0); + const addressControlBox = await composer + .getByTestId("composer-address-locks") + .locator("..") + .boundingBox(); + const confirmationBox = await autoPinConfirmation.boundingBox(); + expect(addressControlBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressControlBox || !confirmationBox) { + throw new Error("Automatic mention confirmation is not laid out"); + } + expect(confirmationBox.x).toBeGreaterThan( + addressControlBox.x + addressControlBox.width, + ); + const turnOffAction = autoPinConfirmation.getByRole("button", { + name: "Turn off", + }); + await expect(turnOffAction).toHaveRole("button"); + await expect(turnOffAction).toHaveText("Turn off"); + + await input.press("Escape"); + await expect(autoPinConfirmation).toHaveCount(0); await input.type("hello"); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); - await expect(input.locator("[data-placeholder]").first()).toHaveAttribute( - "data-placeholder", - "Message #general", - { timeout: 500 }, - ); + await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 }); + await expect(input.locator("[data-placeholder]")).toHaveCount(0); await expect(input).toBeFocused(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible({ timeout: 2_500 }); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await expect(autoPinToast).not.toContainText( - "Future messages in this channel will include this agent.", - ); - const undoAction = autoPinToast.locator("[data-action]"); - await expect(undoAction).toHaveRole("button"); - await expect(undoAction).toHaveText("Undo"); + await expect(autoPinConfirmation).toHaveCount(0); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); await input.fill("follow up"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); await input.press("Enter"); await expect .poll(() => readOutgoingMentionPubkeys(page, "follow up")) - .toContain(AGENT_A); + .not.toContain(AGENT_A); }); -test("the auto-pin toast can undo and the picker can restore the agent", async ({ +test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { await installAudienceFixtures(page); @@ -424,41 +661,32 @@ test("the auto-pin toast can undo and the picker can restore the agent", async ( await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); - await input.press("Space"); - await input.type("undo me"); - await input.press("Enter"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await autoPinToast.locator("[data-action]").click(); + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await expect(autoPinConfirmation).toHaveCount(0); + + await composer.getByTestId("message-insert-mention").click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "true", - ); + await composer.getByTestId("mention-options-trigger").click(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toBeVisible(); + ).toHaveAttribute("data-state", "unchecked"); await input.press("Escape"); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await composer.locator("[data-mention-picker-trigger]").click(); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: "Mention Morgarita", exact: true }) - .click(); - await expect(input).toHaveText(""); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); }); test("channel automatic mentions carry into threads and stay synchronized", async ({ @@ -479,12 +707,36 @@ test("channel automatic mentions carry into threads and stay synchronized", asyn await expect(threadAutomaticMention).toBeVisible(); await threadComposer(page) - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByTestId(`composer-address-lock-remove-${AGENT_A}`) .click(); await expect(threadAutomaticMention).toHaveCount(0); await expect(channelAutomaticMention).toHaveCount(0); }); +test("reduced motion removes addressed agents without spatial animation", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + const removeButton = composer.getByTestId( + `composer-address-lock-remove-${AGENT_A}`, + ); + await expect(removeButton).toBeVisible(); + await expect(removeButton).toHaveAttribute("style", /opacity: 1/); + await expect(removeButton).toHaveCSS("transform", "none"); + + await removeButton.click(); + await expect(input).toHaveText("@Morgarita "); + await expect(removeButton).toHaveCount(0); +}); + for (const theme of ["buzz", "buzz-dark"]) { test(`captures the mention-button placement in ${theme}`, async ({ page, @@ -519,3 +771,45 @@ test("the mention-button placement fits the narrow composer", async ({ await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); }); + +test("captures the lightweight auto-pin popover", async ({ page }) => { + await seedTheme(page, "buzz-dark"); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + + const addressControl = composer + .getByTestId("composer-address-locks") + .locator(".."); + const confirmation = page.getByTestId("composer-auto-pin-confirmation"); + await expect(confirmation).toContainText( + "alice will be mentioned automatically", + ); + await waitForAnimations(page); + + const addressBox = await addressControl.boundingBox(); + const confirmationBox = await confirmation.boundingBox(); + expect(addressBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressBox || !confirmationBox) { + throw new Error("Popover is not laid out"); + } + + const left = addressBox.x - 14; + const top = Math.min(addressBox.y, confirmationBox.y) - 14; + const right = confirmationBox.x + confirmationBox.width + 14; + const bottom = + Math.max( + addressBox.y + addressBox.height, + confirmationBox.y + confirmationBox.height, + ) + 14; + await page.screenshot({ + path: `${SHOTS}/auto-pin-popover-dark.png`, + clip: { x: left, y: top, width: right - left, height: bottom - top }, + }); +}); diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index b0edbf66302..3a7fdf7613a 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -84,8 +84,9 @@ test("message with agent mention lands in compose-time channel despite mid-send await input.press("Enter"); await page.keyboard.type(` ${MESSAGE_TEXT}`); - // Verify the agent is addressed before submitting. - await expect(input).toHaveText(` ${MESSAGE_TEXT}`); + // Verify the inline mention and persistent address are present before submitting. + await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`); + await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA"); await expect( page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`), ).toBeVisible(); From f24971033178926153b49d320bd876d15d9cb2bf Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 25 Aug 2026 14:44:49 -0400 Subject: [PATCH 036/101] Qualify canonical relay images for staged delivery (#6781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Phase 1 of progressive delivery needs an explicit eligibility boundary before deployment automation can safely consume relay artifacts. Today the canonical Docker workflow can publish independently of the same-SHA CI result, the relay does not expose its source/build identity, and the Helm chart only renders mutable tags. The existing `ghcr.io/block/buzz-staging-dev` workflow in #6709 remains a manual pre-merge preview lane. It is deliberately not a canonical promotion input. ## What changed - Compile the full source SHA and GitHub Actions build identity into canonical relay binaries and expose it at `/_status`. - Gate creation of tagged multi-architecture manifests on the latest exact-SHA `CI` push run succeeding on `main` or `release`. - Preserve the signed SLSA provenance and add a signed, release-only deployment-eligibility predicate with source, build run, qualifying CI run, and exact compatible Buzz chart version. - Keep failed or pending builds untagged and without an eligibility predicate; preview-package artifacts are excluded by package and signer contract. - Add optional `image.digest` support to Buzz chart `0.1.8`, retaining the existing tag fallback. - Document verification and the intentional boundary between image/chart compatibility and backwards-compatible database migrations. ## Risk and rollout Canonical tag publication now waits for same-SHA CI and fails closed when the newest matching run fails. Per-architecture blobs may remain untagged after a failed qualification; they are not promotion inputs. Debug manifests are also CI-gated but do not receive the release eligibility predicate. There are no migration or schema-orchestration changes. The companion BuilderBot infrastructure PR will pin a separately verified canonical main digest and expose a read-only running-deployment inspection command. Signed eligibility is available only for artifacts published after this workflow lands. ## Verification - Blox: workflow contract fixtures, including stale-green rejection and predicate structure - Blox: `actionlint` for `ci.yml` and `docker.yml` - Blox: 46 Helm unit tests, chart lint, and production/quickstart render fixtures - Blox: relay status unit test, workspace Rust formatting, and workspace all-target clippy - Live evidence: exact-SHA CI selector chose successful run `32857345153` for source `9d1e4b257657f382d3111ce748f3da8d063b7671` - Existing canonical digest provenance verified with `gh attestation verify` ## References - #6709 — developer-preview precursor; intentionally separate from canonical eligibility - `docs/deployment-identity.md` *Generated with Codex* --------- Signed-off-by: Luke Tornquist --- .github/workflows/ci.yml | 2 + .github/workflows/docker.yml | 151 +++++++++++++++++- Dockerfile | 8 + crates/buzz-relay/src/build_info.rs | 16 ++ crates/buzz-relay/src/lib.rs | 1 + crates/buzz-relay/src/router.rs | 35 +++- deploy/charts/buzz/Chart.yaml | 4 +- deploy/charts/buzz/README.md | 11 +- deploy/charts/buzz/templates/_helpers.tpl | 4 + deploy/charts/buzz/tests/render_test.yaml | 18 +++ deploy/charts/buzz/tests/validation_test.yaml | 10 ++ deploy/charts/buzz/values.schema.json | 5 + deploy/charts/buzz/values.yaml | 1 + docs/deployment-identity.md | 68 ++++++++ ...create-deployment-eligibility-predicate.jq | 26 +++ scripts/select-qualified-ci-run.jq | 9 ++ .../test-relay-image-eligibility-workflow.sh | 108 +++++++++++++ 17 files changed, 467 insertions(+), 10 deletions(-) create mode 100644 crates/buzz-relay/src/build_info.rs create mode 100644 docs/deployment-identity.md create mode 100644 scripts/create-deployment-eligibility-predicate.jq create mode 100644 scripts/select-qualified-ci-run.jq create mode 100755 scripts/test-relay-image-eligibility-workflow.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fa6fb94ad6..f2bc8db65e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Relay image eligibility contract + run: scripts/test-relay-image-eligibility-workflow.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: OSS desktop promotion contract diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9dd..ddb55f85420 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,6 +49,10 @@ on: - "Dockerfile.push-gateway" - ".dockerignore" - ".github/workflows/docker.yml" + - "deploy/charts/buzz/Chart.yaml" + - "scripts/create-deployment-eligibility-predicate.jq" + - "scripts/select-qualified-ci-run.jq" + - "scripts/test-relay-image-eligibility-workflow.sh" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -172,6 +176,10 @@ jobs: target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} # Push by digest, not by tag — the merge job assembles the tags # into one multi-arch manifest. This is what makes the native-arm # matrix possible. @@ -190,6 +198,10 @@ jobs: target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} @@ -222,11 +234,86 @@ jobs: if-no-files-found: error retention-days: 1 + qualify: + name: Qualify relay image source + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 70 + permissions: + actions: read + contents: read + outputs: + source_sha: ${{ steps.qualify.outputs.source_sha }} + ci_run_id: ${{ steps.qualify.outputs.ci_run_id }} + ci_run_attempt: ${{ steps.qualify.outputs.ci_run_attempt }} + ci_run_url: ${{ steps.qualify.outputs.ci_run_url }} + chart_version: ${{ steps.qualify.outputs.chart_version }} + steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Require successful same-SHA CI run + id: qualify + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid source SHA: $SOURCE_SHA" + exit 1 + fi + + deadline=$((SECONDS + 3900)) + while (( SECONDS < deadline )); do + payload=$(gh api \ + "/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SOURCE_SHA}&event=push&per_page=100") + successful_run=$(jq -c --arg source_sha "$SOURCE_SHA" \ + -f "$GITHUB_WORKSPACE/scripts/select-qualified-ci-run.jq" <<<"$payload") + + if [[ -n "$successful_run" ]]; then + ci_run_id=$(jq -r '.id' <<<"$successful_run") + ci_run_attempt=$(jq -r '.run_attempt' <<<"$successful_run") + ci_run_url=$(jq -r '.html_url' <<<"$successful_run") + chart_version=$(awk '/^version:/ { print $2; exit }' deploy/charts/buzz/Chart.yaml) + if [[ ! "$chart_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid Buzz chart version: $chart_version" + exit 1 + fi + { + echo "source_sha=$SOURCE_SHA" + echo "ci_run_id=$ci_run_id" + echo "ci_run_attempt=$ci_run_attempt" + echo "ci_run_url=$ci_run_url" + echo "chart_version=$chart_version" + } >>"$GITHUB_OUTPUT" + echo "Qualified source $SOURCE_SHA with CI run $ci_run_id (attempt $ci_run_attempt)." + exit 0 + fi + + matching=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release")] | length' <<<"$payload") + pending=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release") | select(.status != "completed")] | length' <<<"$payload") + if (( matching > 0 && pending == 0 )); then + echo "::error::No successful CI run exists for source $SOURCE_SHA" + jq -r --arg source_sha "$SOURCE_SHA" '.workflow_runs[] | select(.head_sha == $source_sha) | "run=\(.id) attempt=\(.run_attempt) status=\(.status) conclusion=\(.conclusion)"' <<<"$payload" + exit 1 + fi + + echo "Waiting for same-SHA CI qualification ($matching matching, $pending pending)..." + sleep 30 + done + + echo "::error::Timed out waiting for successful CI qualification of $SOURCE_SHA" + exit 1 + merge: name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 - needs: build + needs: [build, qualify] timeout-minutes: 15 permissions: contents: read @@ -286,6 +373,10 @@ jobs: env: IMAGE_NAME: ${{ env.IMAGE_NAME }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | set -euo pipefail # Build -t flags from the metadata-action output. @@ -300,7 +391,15 @@ jobs: digests+=("${IMAGE_NAME}@sha256:${digest}") done - docker buildx imagetools create "${tags[@]}" "${digests[@]}" + annotations=( + --annotation "index:org.opencontainers.image.revision=${SOURCE_SHA}" + --annotation "index:xyz.block.buzz.build.id=github-actions:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-run-id=${CI_RUN_ID}" + --annotation "index:xyz.block.buzz.qualification.ci-run-attempt=${CI_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-conclusion=success" + --annotation "index:xyz.block.buzz.helm-chart.version=${CHART_VERSION}" + ) + docker buildx imagetools create "${tags[@]}" "${annotations[@]}" "${digests[@]}" # Capture the merged manifest digest for the attestation step. first_tag=$(echo "$META_TAGS" | head -n1) @@ -317,17 +416,58 @@ jobs: subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true + - name: Create deployment eligibility predicate + if: matrix.variant == 'release' + env: + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} + run: | + jq -n \ + --arg source_repository "$GITHUB_REPOSITORY" \ + --arg source_ref "$GITHUB_REF" \ + --arg source_sha "$SOURCE_SHA" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id "$GITHUB_RUN_ID" \ + --argjson build_run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg build_run_url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id "$CI_RUN_ID" \ + --argjson qualification_run_attempt "$CI_RUN_ATTEMPT" \ + --arg qualification_run_url "$CI_RUN_URL" \ + --arg chart_version "$CHART_VERSION" \ + -f "$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq" \ + >/tmp/deployment-eligibility.json + + - name: Attest deployment eligibility + if: matrix.variant == 'release' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + predicate-type: https://buzz.block.xyz/attestations/deployment-eligibility/v1 + predicate-path: /tmp/deployment-eligibility.json + push-to-registry: true + - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | { echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" + echo "**Source:** \`${SOURCE_SHA}\`" + echo "**Compatible Buzz chart:** \`${CHART_VERSION}\`" + echo "**Qualifying CI:** ${CI_RUN_URL} (success)" echo echo "**Tags:**" echo '```' @@ -338,6 +478,13 @@ jobs: echo '```' echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" echo '```' + if [[ "$VARIANT" == "release" ]]; then + echo + echo "Verify deployment eligibility:" + echo '```' + echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --repo block/buzz --signer-workflow block/buzz/.github/workflows/docker.yml --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 --source-digest ${SOURCE_SHA}" + echo '```' + fi } >> "$GITHUB_STEP_SUMMARY" push-gateway-build: diff --git a/Dockerfile b/Dockerfile index d883ac6b015..2c1e8adbb5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,6 +67,14 @@ COPY --from=planner /build/recipe.json recipe.json # scoping to -p buzz-relay misses transitive deps and re-builds them later. RUN cargo chef cook --release --recipe-path recipe.json COPY . . +# Compile immutable artifact identity into the relay. Defaults preserve local +# and third-party builds that do not run in provenance-aware CI. +ARG BUZZ_SOURCE_SHA=unknown +ARG BUZZ_BUILD_ID=local +ARG BUZZ_BUILD_URL=unknown +ENV BUZZ_SOURCE_SHA=${BUZZ_SOURCE_SHA} \ + BUZZ_BUILD_ID=${BUZZ_BUILD_ID} \ + BUZZ_BUILD_URL=${BUZZ_BUILD_URL} RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ -p buzz-pair-relay --bin buzz-pair-relay diff --git a/crates/buzz-relay/src/build_info.rs b/crates/buzz-relay/src/build_info.rs new file mode 100644 index 00000000000..c7073505b3d --- /dev/null +++ b/crates/buzz-relay/src/build_info.rs @@ -0,0 +1,16 @@ +//! Build-time identity compiled into the relay binary. + +/// Full source commit SHA, or `unknown` outside a provenance-aware build. +pub(crate) fn source_sha() -> &'static str { + option_env!("BUZZ_SOURCE_SHA").unwrap_or("unknown") +} + +/// Stable build identifier, or `local` outside CI. +pub(crate) fn build_id() -> &'static str { + option_env!("BUZZ_BUILD_ID").unwrap_or("local") +} + +/// Build details URL, or `unknown` outside CI. +pub(crate) fn build_url() -> &'static str { + option_env!("BUZZ_BUILD_URL").unwrap_or("unknown") +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e0..800433a8498 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -3,6 +3,7 @@ //! NIP-01 WebSocket relay for Buzz private team communication. mod admission; +mod build_info; /// REST API route handlers. pub mod api; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e4..653e21f0936 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -413,14 +413,22 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } } -/// Status endpoint — service name, version, uptime. -async fn status_handler(State(state): State>) -> impl IntoResponse { - let uptime_secs = state.started_at.elapsed().as_secs(); - Json(json!({ +fn status_payload(uptime_secs: u64) -> serde_json::Value { + json!({ "service": "buzz-relay", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": uptime_secs, - })) + "build": { + "source_sha": crate::build_info::source_sha(), + "id": crate::build_info::build_id(), + "url": crate::build_info::build_url(), + }, + }) +} + +/// Status endpoint — service name, version, uptime, and intrinsic build identity. +async fn status_handler(State(state): State>) -> impl IntoResponse { + Json(status_payload(state.started_at.elapsed().as_secs())) } /// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer @@ -506,6 +514,23 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[test] + fn status_payload_exposes_source_and_build_identity() { + let payload = status_payload(42); + + assert_eq!(payload["service"], "buzz-relay"); + assert_eq!(payload["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(payload["uptime_seconds"], 42); + for field in ["source_sha", "id", "url"] { + assert!( + payload["build"][field] + .as_str() + .is_some_and(|value| !value.is_empty()), + "build.{field} must be a non-empty string" + ); + } + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default(); diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 9309074895b..49a6fafd192 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.7 +version: 0.1.8 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. + description: Optional immutable relay image digest pinning with backwards-compatible tag fallback. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 30cee4f4063..e0645ef6fb9 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -12,7 +12,7 @@ This chart has two operating profiles selected by values: ## Quickstart (eval only) ```sh -helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.8 \ --create-namespace --namespace buzz \ --set quickstart=true \ --set postgresql.enabled=true \ @@ -29,6 +29,15 @@ intent marker surfaced in NOTES.txt; the bundled services are opted in via the four `*.enabled` flags above (see `ci/quickstart-values.yaml` for the exact set CI installs). Eval-only: every bundled service is a single replica with no HA. +For immutable delivery, pin the OCI digest instead of a tag. `image.digest` +overrides `image.tag` when both are present: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + ## Production (GitOps) The chart is designed for ArgoCD and Flux. Both render charts with `helm template`, in which mode Helm's `lookup` function returns empty — any chart-side `randAlphaNum` call would regenerate secrets on every sync. The chart-managed Secret path is **only** safe for `helm install` / `helm upgrade`. diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index ff070379ebc..13efe0d1fd6 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -53,9 +53,13 @@ app.kubernetes.io/component: relay {{- end -}} {{- define "buzz.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- $tag := default .Chart.AppVersion .Values.image.tag -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- end -}} {{/* Name of the chart-managed Secret holding relay-identity material and any diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 10a1a34d1fd..f288f8df2d5 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -195,6 +195,24 @@ tests: path: spec.template.spec.containers[0].args template: templates/deployment.yaml + - it: renders an immutable digest instead of a configured tag + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + image.repository: ghcr.io/block/buzz + image.tag: sha-deadbee + image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/block/buzz@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + template: templates/deployment.yaml + - it: appends generic Pod extensions and overrides the relay command set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index a5a0050a866..3498189c8aa 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -2,6 +2,16 @@ suite: validation templates: - templates/deployment.yaml tests: + - it: rejects a malformed image digest + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + image.digest: sha256:not-a-digest + asserts: + - failedTemplate: + errorPattern: "image.digest: Does not match pattern" + - it: fails when relayUrl is missing set: relayUrl: "" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 94d369c8903..aaab848fd33 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -15,6 +15,11 @@ "properties": { "repository": { "type": "string", "minLength": 1 }, "tag": { "type": "string" }, + "digest": { + "type": "string", + "pattern": "^$|^sha256:[0-9a-f]{64}$", + "description": "Optional immutable OCI image digest. When set, the chart renders repository@digest and ignores tag." + }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, "pullSecrets": { "type": "array", diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 448e15ca976..738c50eec31 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -25,6 +25,7 @@ quickstart: false image: repository: ghcr.io/block/buzz tag: "" # empty → .Chart.AppVersion + digest: "" # optional sha256:...; when set, overrides tag pullPolicy: IfNotPresent pullSecrets: [] diff --git a/docs/deployment-identity.md b/docs/deployment-identity.md new file mode 100644 index 00000000000..32d8c426e76 --- /dev/null +++ b/docs/deployment-identity.md @@ -0,0 +1,68 @@ +# Relay deployment identity + +Canonical relay images from `ghcr.io/block/buzz` carry two signed +attestations: + +- SLSA build provenance maps the immutable image digest to the source commit + and Docker workflow run. +- The Buzz deployment-eligibility predicate records the successful same-SHA + CI run and the exact Buzz Helm chart version from that source commit. + +The Docker workflow creates tagged multi-architecture manifests only after the +same full source SHA has a successful `CI` push run on `main` or `release`. +Architecture-specific build manifests may exist without tags while CI is +running or after it fails; they do not receive the deployment-eligibility +predicate and are not promotion inputs. + +Verify a canonical eligible digest with: + +```bash +gh attestation verify \ + oci://ghcr.io/block/buzz@sha256: \ + --repo block/buzz \ + --signer-workflow block/buzz/.github/workflows/docker.yml \ + --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 \ + --source-ref refs/heads/main +``` + +The predicate's `helm_chart.compatible_version` is image-to-chart metadata. It +does not describe database schema compatibility and does not relax Buzz's rule +that migrations remain backwards compatible. + +The manual pre-merge workflow publishes only to +`ghcr.io/block/buzz-staging-dev`. Those preview images are intentionally +ineligible: they use a different package, may name non-main source, and do not +receive the canonical deployment-eligibility predicate. + +## Runtime inspection + +The relay health listener exposes intrinsic build identity at `/_status`: + +```json +{ + "service": "buzz-relay", + "version": "0.2.1", + "uptime_seconds": 123, + "build": { + "source_sha": "<40-character-source-sha>", + "id": "github-actions::", + "url": "https://github.com/block/buzz/actions/runs//attempts/" + } +} +``` + +Non-CI builds report stable `unknown` or `local` fallback values instead of +claiming provenance they do not have. + +## Helm digest pinning + +Buzz chart `0.1.8` and newer accept an immutable image digest: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + +When `image.digest` is set, the chart renders `repository@digest` and ignores +`image.tag`. Existing tag-only values remain backwards compatible. diff --git a/scripts/create-deployment-eligibility-predicate.jq b/scripts/create-deployment-eligibility-predicate.jq new file mode 100644 index 00000000000..3d984b4e6e9 --- /dev/null +++ b/scripts/create-deployment-eligibility-predicate.jq @@ -0,0 +1,26 @@ +{ + predicate_version: 1, + eligible: true, + source: { + repository: $source_repository, + ref: $source_ref, + sha: $source_sha + }, + build: { + workflow: $build_workflow, + run_id: $build_run_id, + run_attempt: $build_run_attempt, + run_url: $build_run_url + }, + qualification: { + workflow: $qualification_workflow, + run_id: $qualification_run_id, + run_attempt: $qualification_run_attempt, + run_url: $qualification_run_url, + conclusion: "success" + }, + helm_chart: { + name: "buzz", + compatible_version: $chart_version + } +} diff --git a/scripts/select-qualified-ci-run.jq b/scripts/select-qualified-ci-run.jq new file mode 100644 index 00000000000..4d99a9e60f8 --- /dev/null +++ b/scripts/select-qualified-ci-run.jq @@ -0,0 +1,9 @@ +[ + .workflow_runs[] + | select(.head_sha == $source_sha) + | select(.event == "push") + | select(.head_branch == "main" or .head_branch == "release") +] +| sort_by([.id, .run_attempt]) +| last // empty +| select(.conclusion == "success") diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh new file mode 100755 index 00000000000..168f9d36892 --- /dev/null +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +workflow="$repo_root/.github/workflows/docker.yml" +selector="$repo_root/scripts/select-qualified-ci-run.jq" +predicate_builder="$repo_root/scripts/create-deployment-eligibility-predicate.jq" + +require_literal() { + local needle=$1 + grep -Fq -- "$needle" "$workflow" || { + echo "relay image workflow is missing required delivery contract: $needle" >&2 + exit 1 + } +} + +require_literal " qualify:" +require_literal "actions: read" +require_literal "actions/workflows/ci.yml/runs" +require_literal 'select-qualified-ci-run.jq' +require_literal "needs: [build, qualify]" +require_literal "https://buzz.block.xyz/attestations/deployment-eligibility/v1" +require_literal "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" +require_literal "if: matrix.variant == 'release'" +require_literal "BUZZ_SOURCE_SHA" +require_literal "BUZZ_BUILD_ID" +require_literal "BUZZ_BUILD_URL" +require_literal '- "deploy/charts/buzz/Chart.yaml"' +require_literal '- "scripts/create-deployment-eligibility-predicate.jq"' +require_literal '- "scripts/select-qualified-ci-run.jq"' +require_literal '- "scripts/test-relay-image-eligibility-workflow.sh"' + +if grep -Fq "buzz-staging-dev" "$workflow"; then + echo "canonical relay image workflow references the preview-only image package" >&2 + exit 1 +fi + +select_run() { + jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":100,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"failure"}, + {"id":101,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"success"} +]} +JSON +) +[[ "$selected" == "101" ]] || { + echo "latest successful same-SHA main run was not selected" >&2 + exit 1 +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":100,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"success"}, + {"id":101,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"failure"} +]} +JSON +) +[[ -z "$selected" ]] || { + echo "stale successful run remained eligible after a newer failure" >&2 + exit 1 +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":102,"run_attempt":1,"head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","head_branch":"main","event":"push","status":"completed","conclusion":"success"}, + {"id":103,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"feature","event":"pull_request","status":"completed","conclusion":"success"} +]} +JSON +) +[[ -z "$selected" ]] || { + echo "wrong-SHA or pull-request CI run was accepted" >&2 + exit 1 +} + +predicate=$(jq -n \ + --arg source_repository "block/buzz" \ + --arg source_ref "refs/heads/main" \ + --arg source_sha "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id 200 \ + --argjson build_run_attempt 2 \ + --arg build_run_url "https://github.com/block/buzz/actions/runs/200/attempts/2" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id 201 \ + --argjson qualification_run_attempt 1 \ + --arg qualification_run_url "https://github.com/block/buzz/actions/runs/201" \ + --arg chart_version "0.1.8" \ + -f "$predicate_builder") + +jq -e ' + .predicate_version == 1 and + .eligible == true and + .source.sha == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" and + .build.run_id == 200 and + .qualification.run_id == 201 and + .qualification.conclusion == "success" and + .helm_chart == {"name":"buzz","compatible_version":"0.1.8"} and + (has("schema") | not) and + (.helm_chart | has("schema_compatibility") | not) +' <<<"$predicate" >/dev/null || { + echo "deployment eligibility predicate has an invalid contract" >&2 + exit 1 +} + +echo "relay image eligibility workflow contract passed" From b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 13:32:09 -0700 Subject: [PATCH 037/101] fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Users can place `@Agent` mentions inline anywhere in a message—even when that agent is also persistently addressed—so separate agents can receive separate instructions in the same message. **Problem:** Selecting an agent from the composer mention picker could turn the selection into persistent addressing instead of leaving an inline `@Agent` at the cursor. That made persistent addressing and inline composition mutually exclusive: once the persistent behavior took over, users lost the clear `@Agent A do X, @Agent B do Y` message structure they previously had. **Solution:** Make inline mentioning and persistent addressing independent behaviors: - Ordinary mention selections always insert `@Agent` inline at the current cursor position. - This remains true when the agent is already persistently addressed; the persistent audience never blocks or replaces an inline mention. - If **Automatically mention agents** is enabled, a successfully sent inline mention may additionally make that agent persistent for later messages. Existing saved preferences remain respected. - The dedicated automatic-mention control and primary+Shift+Enter shortcut continue to add or remove persistent addressing directly. - For users without a saved preference, **Automatically mention agents** now defaults off.
File changes **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs** Updates preference coverage for the default-off behavior while preserving explicit saved choices. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts** Defaults automatic post-send persistence off when no valid preference exists. **desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs** Verifies ordinary picker selections insert inline mentions without changing or pulsing the persistent audience, including when the selected agent is already persistently addressed. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts** Keeps ordinary mention selection on the existing inline insertion path while preserving the separate persistent-address controls and shortcut. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Covers separately targeted inline instructions to two agents, signed outgoing recipients, default-off persistence, and explicit opt-in persistence scenarios.
## Reproduction steps ### Inline mentions without persistence 1. Open a channel with at least two available agents and leave **Automatically mention agents** disabled. 2. Use the composer mention picker to insert the first agent, type an instruction, then insert a second agent and type a different instruction. 3. Confirm the draft reads like `@Agent A review this, @Agent B test that` and neither agent appears in the persistent addressed-agent controls. 4. Send the message and confirm both agents are recipients while neither remains persistently addressed for the next draft. ### Inline mentions with persistence 1. Enable **Automatically mention agents**, then mention an agent inline and send successfully. 2. Confirm that agent becomes persistently addressed for later messages. 3. In a new draft, select the same agent from the mention picker again. 4. Confirm a new inline `@Agent` is inserted at the cursor while the agent remains persistently addressed. 5. Confirm the dedicated automatic-mention control and primary+Shift+Enter shortcut can still add or remove persistent addressing directly. ## Demo - **Before:** Persistent addressing could consume an ordinary picker selection, preventing users from placing that agent inline in the message. - **After:** Ordinary selection always produces an inline `@Agent`; persistence is a separate optional behavior that can coexist with inline mentions. ### Related issue N/A — reported through the Buzz feature room. ### Testing At `a4579c0a666f3644abe44271614186d7b9356bf5`: - Post-push hooks passed desktop check, desktop typecheck, and the full desktop unit suite. - The two persistence-focused Playwright smoke scenarios passed after explicitly opting into **Automatically mention agents**. - The one-time multi-agent targeting and persistent-address shortcut Playwright scenarios passed on the feature changes before the final rebase; CI is validating the rebased branch. --------- Signed-off-by: Taylor Ho Co-authored-by: Carl --- .../autoPinMentionedAgentsPreference.test.mjs | 18 +- .../lib/autoPinMentionedAgentsPreference.ts | 2 +- .../src/features/messages/lib/useDrafts.ts | 4 + .../features/messages/ui/MessageComposer.tsx | 55 ++--- .../MessageComposerDraftImagePersist.test.mjs | 219 ++++++++++++++++++ .../messages/ui/useDraftPersistSnapshot.ts | 73 +++++- desktop/tests/e2e/mentions.spec.ts | 30 ++- .../e2e/persistent-agent-audience.spec.ts | 26 ++- 8 files changed, 372 insertions(+), 55 deletions(-) diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index d7516fe6912..b30008109c1 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -9,25 +9,25 @@ globalThis.localStorage = { const preference = await import("./autoPinMentionedAgentsPreference.ts"); -test("defaults missing and invalid values to keeping mentioned agents pinned", () => { - assert.equal(preference.parseKeepMentionedAgentsPinned(null), true); - assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true); +test("defaults missing and invalid values to one-time agent mentions", () => { + assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); + assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false); assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true); assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false); }); test("persists changes to the post-mention pinning preference", () => { - preference.setKeepMentionedAgentsPinned(false); - assert.equal(preference.getKeepMentionedAgentsPinned(), false); + preference.setKeepMentionedAgentsPinned(true); + assert.equal(preference.getKeepMentionedAgentsPinned(), true); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "false", + "true", ); - preference.setKeepMentionedAgentsPinned(true); - assert.equal(preference.getKeepMentionedAgentsPinned(), true); + preference.setKeepMentionedAgentsPinned(false); + assert.equal(preference.getKeepMentionedAgentsPinned(), false); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "true", + "false", ); }); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 0a3821b4e37..8f8e0b12d65 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -2,7 +2,7 @@ import * as React from "react"; export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; -export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true; +export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; const listeners = new Set<() => void>(); let keepMentionedAgentsPinned = readStoredPreference(); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 2a78e881320..a3e0fcf9197 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -114,6 +114,10 @@ function storageKey(): string { : legacyStorageKey(); } +export function getDraftStoreScope(): string { + return storageKey(); +} + function legacyStorageKey(): string { return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 98c600e87f1..d4db6f5d173 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -170,32 +170,33 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - useDraftPersistLifecycle({ - effectiveDraftKey, - channelId, - loadDraft: drafts.loadDraft, - persistDraft: drafts.persistDraft, - getMentionRefs: mentions.getDraftMentionRefs, - restoreMentionRefs: mentions.restoreDraftMentionRefs, - livePendingImeta: media.pendingImeta, - setPendingImeta: media.setPendingImeta, - getQueuedAttachments: () => media.queuedAttachmentsRef.current, - saveQueuedAttachmentsForDraft, - clearQueuedAttachments: media.clearQueuedAttachments, - restoreQueuedAttachments: media.restoreQueuedAttachments, - takeQueuedAttachmentsForDraft, - setContent: (content) => { - setComposerContent(content); - richText.setContent(content); - }, - clearContent: () => { - setComposerContent(""); - richText.clearContent(); - }, - setSpoileredAttachmentUrls, - spoileredAttachmentUrlsRef, - syncComposerContentFromEditor, - }); + const { trackAuthoredContent: trackDraftAuthoredContent } = + useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft: drafts.loadDraft, + persistDraft: drafts.persistDraft, + getMentionRefs: mentions.getDraftMentionRefs, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + livePendingImeta: media.pendingImeta, + setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + setComposerContent(content); + richText.setContent(content); + }, + clearContent: () => { + setComposerContent(""); + richText.clearContent(); + }, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { media.setUploadState({ status: "idle" }); @@ -272,6 +273,8 @@ function MessageComposerImpl({ onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, linkPreviewContent, text }) => { + trackDraftAuthoredContent(text); + contentRef.current = text; setComposerContentFromText(text); setPreviewContent(linkPreviewContent); if (!isSubmitLockedRef.current && !editTargetRef.current) { diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b2..e5f08b6ebdc 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); +test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => { + const DRAFT_KEY = "chan-clear-race"; + setupStore("pubkey-clear-race"); + persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "draft text"); + + trackAuthoredContent(""); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "the authoritative update removes the stale body even while editor reads lag", + ); + + await handle.unmount(); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "async settlement must not repersist the deleted body", + ); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "the deleted body must not be restored"); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-image"; + setupStore("pubkey-clear-caption-image"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]); + + let editorContent = ""; + let pendingImeta = []; + let spoileredUrls = new Set(); + let trackAuthoredContent; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredUrls = urls; + }, + spoileredAttachmentUrlsRef: { + get current() { + return spoileredUrls; + }, + }, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(pendingImeta[0]?.url, IMG_A.url); + assert.deepEqual([...spoileredUrls], [IMG_A.url]); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, ""); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-file"; + setupStore("pubkey-clear-caption-file"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 9, + spoilered: true, + }; + + let editorContent = ""; + let queuedAttachments = [FILE_A]; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + assert.equal(queuedAttachments[0]?.spoilered, true); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => { + const DRAFT_KEY = "shared-key"; + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore("pubkey-a", "wss://relay-a.example"); + persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const workspaceA = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await workspaceA.unmount(); + + initDraftStore("pubkey-b", "wss://relay-b.example"); + persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []); + const workspaceB = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "workspace B"); + await workspaceB.unmount(); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B"); + + initDraftStore("pubkey-a", "wss://relay-a.example"); + const workspaceARemount = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "workspace A stale text must stay cleared"); + await workspaceARemount.unmount(); +}); + test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { setupStore("pubkey-switch-files"); const FILE_A = { diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adbc..694bf6a2a55 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -2,9 +2,10 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { - DraftMentionRef, - DraftState, +import { + getDraftStoreScope, + type DraftMentionRef, + type DraftState, } from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { @@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = { syncComposerContentFromEditor: () => string; }; +type UseDraftPersistLifecycleResult = { + /** + * Record the latest authored editor content. Empty content is persisted + * immediately and remains authoritative across composer remounts until a + * later non-empty editor update supersedes it. + */ + trackAuthoredContent: (content: string) => void; +}; + +const authoritativelyClearedDraftKeys = new Set(); + +function scopedDraftKey(draftKey: string): string { + return `${getDraftStoreScope()}:${draftKey}`; +} + /** * Owns the draft-persist lifecycle for `MessageComposer`. * @@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, -}: UseDraftPersistLifecycleParams): void { +}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { const pendingImetaForPersistRef = React.useRef([]); + const emptyContentIsAuthoritativeRef = React.useRef(false); + const isRestoringContentRef = React.useRef(false); const restoredQueuedAttachmentsRef = React.useRef( [], ); @@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({ pendingImetaForPersistRef.current = livePendingImeta; // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger - React.useEffect(() => { + React.useLayoutEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before // this body on key changes and has the correct outgoing channelId in its // closure. Do NOT re-persist prevKey here: channelId in this render @@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({ : []; } restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); + const authoritativeDraftKey = effectiveDraftKey + ? scopedDraftKey(effectiveDraftKey) + : null; + const wasAuthoritativelyCleared = authoritativeDraftKey + ? authoritativelyClearedDraftKeys.has(authoritativeDraftKey) + : false; const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; + emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; + isRestoringContentRef.current = true; if (saved) { - setContent(saved.content); - restoreMentionRefs(saved.mentionRefs ?? []); + const restoredContent = wasAuthoritativelyCleared ? "" : saved.content; + setContent(restoredContent); + restoreMentionRefs( + wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []), + ); // Set the persist-snapshot ref SYNCHRONOUSLY before calling the async // state setter, so the cleanup closure (which may fire before the state // update commits in React StrictMode's simulate-unmount pass) reads the @@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({ setPendingImeta([]); setSpoileredAttachmentUrls(new Set()); } + isRestoringContentRef.current = false; return () => { if (effectiveDraftKey) { @@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({ if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); } - const content = syncComposerContentFromEditor(); + const content = emptyContentIsAuthoritativeRef.current + ? "" + : syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, content, @@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + const trackAuthoredContent = React.useCallback( + (content: string) => { + if (!effectiveDraftKey || isRestoringContentRef.current) return; + const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); + if (content.length > 0) { + authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = false; + return; + } + authoritativelyClearedDraftKeys.add(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = true; + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + [], + ); + }, + [channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef], + ); + + return { trackAuthoredContent }; } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 2136e2e12f5..edcf523e36c 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1260,7 +1260,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({ await expect(mentionChip).toHaveText("Fizz"); }); -test("managed relay-profile agents with member roles use the agent address tray", async ({ +test("managed relay-profile agents with member roles can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -1286,9 +1286,17 @@ test("managed relay-profile agents with member roles use the agent address tray" await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); - await input.press("Enter"); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); + await expect(charlieRow.getByText("agent")).toBeVisible(); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); @@ -2618,7 +2626,7 @@ test("system member-joined rows render the joined person as a plain profile name await expect(joinedPersonName).not.toHaveAttribute("data-mention"); }); -test("selecting a managed non-member agent from a DM addresses it", async ({ +test("a managed non-member agent from a DM can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -2638,10 +2646,18 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); await expect(autocomplete(page)).toHaveCount(1); await expect(input.locator(".mention-chip")).toHaveCount(0); - await input.press("Enter"); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index f9481c91f05..9a02e2e1a87 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -8,6 +8,20 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; +const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = + "buzz.messages.keepMentionedAgentsPinned"; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((storageKey) => { + window.localStorage.removeItem(storageKey); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +}); + +async function keepMentionedAgentsPinned(page: Page) { + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, "true"); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +} async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") { await page.addInitScript( @@ -271,7 +285,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab immediately selects a manually mentioned agent", async ({ page }) => { +test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -286,7 +300,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); const selectAllShortcut = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); @@ -301,6 +315,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -448,7 +463,7 @@ test("the mention button opens settings and can undo an address", async ({ .toContain(AGENT_A); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(1); + ).toHaveCount(0); }); test("always-mentioned agents remain in the mention button while Enter-send resolves", async ({ @@ -568,9 +583,10 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected immediately", async ({ +test("a manual mention persists when automatic mentions are enabled", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); await openGeneral(page); @@ -652,6 +668,7 @@ test("a manually mentioned agent becomes selected immediately", async ({ test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -717,6 +734,7 @@ test("reduced motion removes addressed agents without spatial animation", async page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); From 22f32c99e2e983b7e015e8c058cac47438d05b2f Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 25 Aug 2026 14:35:53 -0600 Subject: [PATCH 038/101] docs(nest): make commit attribution policy-neutral (#6707) ## Summary - replace the generated Nest's unconditional human author/sign-off rules with portable guidance that separates authorship, material co-authorship, DCO certification, and cryptographic signing - defer attribution to repository-local policy, forbid inferred or guessed identities, and require inspection of every outgoing commit - bump the Nest template version so existing installations refresh, with regression coverage for fresh generation and upgrade preservation ### Related issue None found. Related runtime identity work exists in #6177, but this PR is intentionally limited to the generated Nest guidance and its refresh behavior. ### Testing - `bin/just desktop-tauri-clippy` - `bin/just desktop-tauri-test` - `bin/just file-size-check` - `git diff --check` - pre-push hooks: `push-head-scope`, `branch-skew`, `file-size-check`, and `desktop-tauri-checks` Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest/tests.rs | 43 +++++++++++++++++++ .../src/managed_agents/nest_agents.md | 17 +++++--- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 72cf4664272..3d191926a39 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -48,7 +48,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); /// Template content version for AGENTS.md static content (above managed markers). /// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. /// Version 1 is implicitly "before this mechanism existed" (no version file). -const NEST_AGENTS_VERSION: u32 = 4; +const NEST_AGENTS_VERSION: u32 = 5; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index bc67a5b69eb..9aa1eeb0985 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,21 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[test] +fn nest_agents_template_separates_commit_attribution_claims() { + assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1); + assert!(AGENTS_MD.contains( + "Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims" + )); + assert!(AGENTS_MD + .contains("Request, approval, review, or accountability alone is not co-authorship")); + assert!(AGENTS_MD.contains("A sign-off is not an approval marker")); + assert!(AGENTS_MD.contains("Never use another person's signing key")); + assert!(AGENTS_MD.contains("inspect every outgoing commit against the actual upstream or base")); + assert!(AGENTS_MD.contains("An agent-owned repository may use the agent as author")); + assert!(!AGENTS_MD.contains("every commit MUST include a `Signed-off-by`")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); @@ -431,6 +446,34 @@ fn refresh_agents_md_writes_version_file() { assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string()); } +#[test] +fn refresh_agents_md_upgrades_attribution_and_preserves_owned_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + let agents_md = root.join("AGENTS.md"); + fs::write( + &agents_md, + "# Buzz Nest\n\n## Git Commit Identity\n\n\ + - **Human sign-off (required):** every commit MUST include a `Signed-off-by`.\n\n\ + \n\ + ## Active Agents\n\n| Name | Persona | How to address |\n\ + |------|---------|----------------|\n| Kit | Builder | @Kit |\n\ + \n\n## Local Notes\n\nKeep me.\n", + ) + .unwrap(); + fs::write(root.join(".nest-agents-version"), "4\n").unwrap(); + + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!(content.matches("## Git Commit Attribution").count(), 1); + assert!(!content.contains("**Human sign-off (required):**")); + assert!(content.contains("| Kit | Builder | @Kit |")); + assert!(content.contains("## Local Notes\n\nKeep me.")); +} + #[test] fn refresh_skill_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 7cb7489b852..dbba2624db7 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -44,15 +44,18 @@ created: 2026-01-15 - **`.scratch/` is disposable** — don't rely on it across sessions - **Stay on task** — only stage files relevant to your current work -## Git Commit Identity +## Git Commit Attribution -The human operator signs off for accountability. +Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims. Follow repository-local rules and the authorizing human's explicit directions; do not infer attribution from repository ownership or from who requested, approved, or reviewed the work. -- **Human sign-off (required):** every commit MUST include a `Signed-off-by` trailer for the human operator who is responsible for the agent's work. Add via `git commit --trailer "Signed-off-by: Human Name "`. One blank line must separate trailers from the commit body. -- **Human credit (`Co-authored-by`):** every commit MUST also include a `Co-authored-by` trailer for the same human operator, with identical name and email to the `Signed-off-by` line. GitHub parses `Co-authored-by` for contribution-graph credit; `Signed-off-by` alone does not grant it. Add via `git commit --trailer "Co-authored-by: Human Name "`. Place `Co-authored-by` before `Signed-off-by` in the trailer block. -- **Discovering the human's identity:** read `git config user.name` and `git config user.email` from the working repository. These reflect the human operator's configured identity for that repo (which may differ from their global config). Use these exact values for both trailers. Do NOT hardcode, guess, or prompt for the email — the repo config is the source of truth. If `git config user.email` returns empty, STOP and ask the human operator for their name and email before committing. -- **Signing:** if the agent has a registered signing key, sign commits. If not, commits will land unverified — this is acceptable until agent SSH keys are provisioned. Do NOT use the human's signing key. -- **Verify before pushing:** `git log -1` should show the human's `Signed-off-by` trailer. +- **Author:** use the person or agent required by the applicable policy. If no policy specifies an author, use the identity that actually authored the change. +- **Co-authors:** add `Co-authored-by` only for other people or agents who materially authored the change. Request, approval, review, or accountability alone is not co-authorship. +- **DCO:** add `Signed-off-by` only when repository policy requires that identity's DCO certification. A sign-off is not an approval marker. +- **Identity:** resolve required identities from trusted local configuration or explicit verified direction; never hard-code or guess them. A managed runtime may make effective `git config user.*` values identify the agent. Stop and ask if a required identity cannot be established. +- **Signing:** use only the signing key configured for the committing identity. Never use another person's signing key. +- **Verify before pushing:** inspect every outgoing commit against the actual upstream or base and confirm its attribution matches the applicable policy. + +A repository may require an accountable human as author and the implementing agent as co-author. An agent-owned repository may use the agent as author and require no human trailer. In both cases, repository-local policy controls. ## Active Agents From ee6ca5fa28bce04dfecb6717de65b08a57f2ac47 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Tue, 25 Aug 2026 13:57:23 -0700 Subject: [PATCH 039/101] Remove public relay signing key fallback (#6729) Require `BUZZ_RELAY_PRIVATE_KEY` on every relay startup and remove the shared public fallback key completely. For local development, `just bootstrap` now generates a random key once in the gitignored `.env` file. Re-running bootstrap preserves that key, so relay-authored events retain the same identity across restarts. The local relay recipes reload `.env`, while isolated CI and E2E launchers pass explicit per-run keys. Startup fails immediately when the key is missing or invalid, before connecting to Postgres or Redis. Deployed relays continue to receive their stable key from the existing chart-managed secret. Validated with: - `./scripts/test-ensure-local-relay-key.sh` - `cargo test -p buzz-relay --bin buzz-relay` - `cargo clippy -p buzz-relay --bin buzz-relay -- -D warnings` - `cargo fmt --all -- --check` - `shellcheck scripts/ensure-local-relay-key.sh scripts/test-ensure-local-relay-key.sh` Signed-off-by: Jordan Mecom --- .env.example | 4 +- .github/workflows/ci.yml | 2 + Justfile | 21 +++++++- TESTING.md | 11 +++-- crates/buzz-relay/src/main.rs | 55 +++++++++++---------- scripts/e2e-git-perms.sh | 1 + scripts/ensure-local-relay-key.sh | 66 ++++++++++++++++++++++++++ scripts/run-desktop-release-smoke.sh | 2 + scripts/start-isolated-test-relay.sh | 2 + scripts/start-relay-for-tests.sh | 5 +- scripts/test-ensure-local-relay-key.sh | 33 +++++++++++++ 11 files changed, 169 insertions(+), 33 deletions(-) create mode 100755 scripts/ensure-local-relay-key.sh create mode 100755 scripts/test-ensure-local-relay-key.sh diff --git a/.env.example b/.env.example index 0f7bbba6f13..cb503392b30 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,8 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 -# Stable relay signing key. Set this in dev if you want REST-created forum posts -# to keep resolving to the original author across relay restarts. +# Stable relay signing key (required). `just bootstrap` generates a random key in +# the gitignored .env file. Preserve that value across restarts and backups. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2bc8db65e7..fd2949492f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,6 +529,7 @@ jobs: REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ @@ -714,6 +715,7 @@ jobs: REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ diff --git a/Justfile b/Justfile index fe5d7bf2858..6c7740bc7ac 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,7 @@ bootstrap: cp .env.example .env echo "Created .env from .env.example — review it before running just dev." fi + ./scripts/ensure-local-relay-key.sh .env # Start Docker services, run migrations, install desktop deps setup: bootstrap @@ -307,6 +308,7 @@ test: test-unit: #!/usr/bin/env bash set -euo pipefail + ./scripts/test-ensure-local-relay-key.sh if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib @@ -423,6 +425,9 @@ relay: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay # Start the relay with the built web UI served from it @@ -430,6 +435,9 @@ relay-web: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C web build BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay @@ -439,6 +447,9 @@ admin: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" @@ -459,7 +470,12 @@ admin-check: fmt-check pnpm -C admin-web exec playwright test # Start the relay server in release mode -relay-release: _ensure-migrations +relay-release: bootstrap _ensure-migrations + #!/usr/bin/env bash + set -euo pipefail + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay --release @@ -468,6 +484,9 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 health_port="${BUZZ_HEALTH_PORT:-8080}" diff --git a/TESTING.md b/TESTING.md index 29d07a80de0..0e64b740665 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,7 +29,7 @@ CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled ```bash . ./bin/activate-hermit # activate pinned toolchain -cp .env.example .env # one-time +just bootstrap # create .env and its stable relay key once just setup # start Docker services, run migrations ``` @@ -70,6 +70,9 @@ Rebuild after any code change — the steps below use the release binaries. In a separate terminal (it runs in the foreground): ```bash +set -o allexport +source .env # includes the key generated by just bootstrap +set +o allexport buzz-relay # release binary from step 2, serves ws://localhost:3000 # alternatives: # cargo run --release -p buzz-relay # rebuild + run in release @@ -89,9 +92,9 @@ curl -s http://localhost:8080/_readiness # → {"status":"ready"} > `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app > port also exposes `/health` for convenience. -The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup -log emits a WARN about this — that's expected for local testing. See the env -vars table at the bottom if you need to lock it down. +The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`) with the stable +relay identity generated in `.env`. See the env vars table at the bottom if +you need to lock it down. > **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` / > `:9102`?** Buzz binds three ports — main, health, metrics — and any of diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 566b684f830..3e39b22f8b2 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -35,6 +35,16 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { + let hex = relay_private_key.ok_or_else(|| { + anyhow::anyhow!( + "BUZZ_RELAY_PRIVATE_KEY must be set. Run `just bootstrap` for local \ + development or configure a stable 32-byte hex private key." + ) + })?; + nostr::Keys::parse(hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}")) +} + /// Controls how many per-community gauge series the usage poller emits. /// /// Datadog cost is proportional to the number of unique time-series. With ~25 @@ -143,6 +153,7 @@ async fn main() -> anyhow::Result<()> { error!("Invalid configuration: {e}"); anyhow::anyhow!("Configuration error: {e}") })?; + let relay_keypair = relay_keypair_from_config(config.relay_private_key.as_deref())?; info!( bind_addr = %config.bind_addr, relay_url = %config.relay_url, @@ -422,29 +433,6 @@ async fn main() -> anyhow::Result<()> { let workflow_config = buzz_workflow::WorkflowConfig::default(); let workflow_engine = Arc::new(WorkflowEngine::new(db.clone(), workflow_config)); - let relay_keypair = if let Some(hex) = &config.relay_private_key { - nostr::Keys::parse(hex) - .map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? - } else if !config.require_auth_token { - // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) - // replace correctly across restarts. Without this, each restart generates a new pubkey - // and replace_addressable_event inserts duplicates instead of replacing. - const DEV_RELAY_PRIVKEY: &str = - "0000000000000000000000000000000000000000000000000000000000000001"; - let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); - tracing::warn!( - pubkey = %keys.public_key().to_hex(), - "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ - Set BUZZ_RELAY_PRIVATE_KEY for production." - ); - keys - } else { - panic!( - "BUZZ_RELAY_PRIVATE_KEY must be set when BUZZ_REQUIRE_AUTH_TOKEN=true. \ - A stable relay identity is required for production." - ); - }; - config .media .validate() @@ -2037,8 +2025,8 @@ mod tests { use super::{ buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, - refresh_legacy_active_gauge_recency, run_periodic_until_cancelled, EmissionScope, - InMemoryMetricKey, + refresh_legacy_active_gauge_recency, relay_keypair_from_config, + run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; use metrics::GaugeFn; use metrics_util::{ @@ -2086,6 +2074,23 @@ mod tests { assert!(buzz_auto_migrate_enabled(Some("on"))); } + #[test] + fn configured_relay_identity_is_preserved() { + let configured = nostr::Keys::generate(); + let secret = configured.secret_key().to_secret_hex(); + + let selected = relay_keypair_from_config(Some(&secret)).expect("configured key"); + + assert_eq!(selected.public_key(), configured.public_key()); + } + + #[test] + fn missing_relay_identity_is_rejected() { + let result = relay_keypair_from_config(None); + + assert!(result.is_err()); + } + #[test] fn test_emission_scope_off_disallows_every_community() { assert!(EmissionScope::All.allows(&Uuid::new_v4())); diff --git a/scripts/e2e-git-perms.sh b/scripts/e2e-git-perms.sh index f6a6a93063e..bc507a7ff31 100755 --- a/scripts/e2e-git-perms.sh +++ b/scripts/e2e-git-perms.sh @@ -336,6 +336,7 @@ export BUZZ_GIT_HOOK_HMAC_SECRET="${HMAC_SECRET}" export BUZZ_BIND_ADDR="${RELAY_HOST}:${RELAY_PORT}" export RELAY_URL="${RELAY_WS}" export RUST_LOG="buzz_relay=warn" +export BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:-$(openssl rand -hex 32)}" export BUZZ_REQUIRE_AUTH_TOKEN=false # Clean repos dir (isolated test state) diff --git a/scripts/ensure-local-relay-key.sh b/scripts/ensure-local-relay-key.sh new file mode 100755 index 00000000000..8cf926a3d74 --- /dev/null +++ b/scripts/ensure-local-relay-key.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "error: ${ENV_FILE} does not exist" >&2 + exit 1 +fi + +existing_key="$({ + unset BUZZ_RELAY_PRIVATE_KEY + set +u + # shellcheck disable=SC1090 + source "${ENV_FILE}" || exit 1 + printf '%s' "${BUZZ_RELAY_PRIVATE_KEY:-}" +})" + +if [[ -n "${existing_key}" ]]; then + chmod 600 "${ENV_FILE}" + exit 0 +fi + +relay_key="$(node <<'NODE' +const { randomBytes } = require("node:crypto"); +const curveOrder = BigInt( + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", +); + +let bytes; +let scalar; +do { + bytes = randomBytes(32); + scalar = BigInt(`0x${bytes.toString("hex")}`); +} while (scalar === 0n || scalar >= curveOrder); + +process.stdout.write(bytes.toString("hex")); +NODE +)" + +temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" +trap 'rm -f "${temp_file}"' EXIT + +awk -v key="${relay_key}" ' + BEGIN { replaced = 0 } + /^[[:space:]]*(export[[:space:]]+)?BUZZ_RELAY_PRIVATE_KEY=/ { + if (!replaced) { + print "BUZZ_RELAY_PRIVATE_KEY=" key + replaced = 1 + } + next + } + { print } + END { + if (!replaced) { + if (NR > 0) print "" + print "BUZZ_RELAY_PRIVATE_KEY=" key + } + } +' "${ENV_FILE}" > "${temp_file}" + +chmod 600 "${temp_file}" +mv "${temp_file}" "${ENV_FILE}" +trap - EXIT + +echo "Generated BUZZ_RELAY_PRIVATE_KEY in ${ENV_FILE}." diff --git a/scripts/run-desktop-release-smoke.sh b/scripts/run-desktop-release-smoke.sh index 33f7013ab3e..85aee2e05cc 100755 --- a/scripts/run-desktop-release-smoke.sh +++ b/scripts/run-desktop-release-smoke.sh @@ -103,6 +103,7 @@ else RELAY_BIN="${ROOT}/target/ci/buzz-relay" fi log "starting relay at ${RELAY_HTTP_URL}" +RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" env \ DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/${DB_NAME}" \ REDIS_URL="redis://localhost:6379/${REDIS_DB}" \ @@ -110,6 +111,7 @@ env \ BUZZ_BIND_ADDR="127.0.0.1:${RELAY_PORT}" \ BUZZ_HEALTH_PORT="${HEALTH_PORT}" \ BUZZ_METRICS_PORT="${METRICS_PORT}" \ + BUZZ_RELAY_PRIVATE_KEY="${RELAY_PRIVATE_KEY}" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=1000000 \ diff --git a/scripts/start-isolated-test-relay.sh b/scripts/start-isolated-test-relay.sh index 1e2047e99c9..1f6b6620c13 100755 --- a/scripts/start-isolated-test-relay.sh +++ b/scripts/start-isolated-test-relay.sh @@ -133,6 +133,7 @@ ok "Relay built" # survives (same pattern the perf stack uses). Logs to ${RELAY_LOG}. RELAY_LOG="${RELAY_LOG:-/tmp/dawn-relay-run.log}" TMUX_SESSION="${TMUX_SESSION:-dawn-relay}" +RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" tmux kill-session -t "${TMUX_SESSION}" 2>/dev/null || true if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${RELAY_MAIN}" -sTCP:LISTEN >/dev/null 2>&1; then err "Port ${RELAY_MAIN} is already in use; refusing to report a stale relay as this harness." @@ -151,6 +152,7 @@ tmux new-session -d -s "${TMUX_SESSION}" "cd '${REPO_ROOT}' && env \ BUZZ_S3_ACCESS_KEY=buzz_dev \ BUZZ_S3_SECRET_KEY=buzz_dev_secret \ BUZZ_S3_BUCKET=buzz-media \ + BUZZ_RELAY_PRIVATE_KEY=${RELAY_PRIVATE_KEY} \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ './target/${CARGO_TARGET_PROFILE}/buzz-relay' > '${RELAY_LOG}' 2>&1" diff --git a/scripts/start-relay-for-tests.sh b/scripts/start-relay-for-tests.sh index b9d93935c08..4bd1a274084 100755 --- a/scripts/start-relay-for-tests.sh +++ b/scripts/start-relay-for-tests.sh @@ -152,6 +152,8 @@ fi log "Starting relay..." +TEST_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:-$(openssl rand -hex 32)}" + # Optional NIP-43 membership gating: exported by callers that need a # membership-gated relay (e.g. the mesh lifecycle smoke). All three must be # set together — the relay fails fast otherwise. @@ -160,8 +162,8 @@ if [[ "${BUZZ_REQUIRE_RELAY_MEMBERSHIP:-}" == "true" ]]; then MEMBERSHIP_ENV+=( BUZZ_REQUIRE_RELAY_MEMBERSHIP=true RELAY_OWNER_PUBKEY="${RELAY_OWNER_PUBKEY:?RELAY_OWNER_PUBKEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" - BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" ) + : "${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" log "Membership gating enabled (NIP-43)" fi @@ -170,6 +172,7 @@ nohup env \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="${TEST_RELAY_PRIVATE_KEY}" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ diff --git a/scripts/test-ensure-local-relay-key.sh b/scripts/test-ensure-local-relay-key.sh new file mode 100755 index 00000000000..be87100557e --- /dev/null +++ b/scripts/test-ensure-local-relay-key.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/buzz-relay-key-test.XXXXXX")" +trap 'rm -rf "${TEST_DIR}"' EXIT + +ENV_FILE="${TEST_DIR}/.env" +cp "${REPO_ROOT}/.env.example" "${ENV_FILE}" + +"${SCRIPT_DIR}/ensure-local-relay-key.sh" "${ENV_FILE}" >/dev/null +first_key="$(sed -n 's/^BUZZ_RELAY_PRIVATE_KEY=//p' "${ENV_FILE}")" + +if [[ ! "${first_key}" =~ ^[0-9a-f]{64}$ ]]; then + echo "FAIL: bootstrap did not generate a valid 32-byte hex relay key" >&2 + exit 1 +fi + +"${SCRIPT_DIR}/ensure-local-relay-key.sh" "${ENV_FILE}" >/dev/null +second_key="$(sed -n 's/^BUZZ_RELAY_PRIVATE_KEY=//p' "${ENV_FILE}")" + +if [[ "${first_key}" != "${second_key}" ]]; then + echo "FAIL: bootstrap replaced the existing relay key" >&2 + exit 1 +fi + +if [[ "$(grep -c '^BUZZ_RELAY_PRIVATE_KEY=' "${ENV_FILE}")" -ne 1 ]]; then + echo "FAIL: bootstrap wrote more than one relay key" >&2 + exit 1 +fi + +echo "PASS: bootstrap generates one relay key and reuses it" From 7a1b7d8e09f96c10b6617a66bb8589e9984ffb3c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 17:36:57 -0400 Subject: [PATCH 040/101] chore(release): release Buzz Desktop version 0.5.19 (#6828) ## Buzz Desktop release v0.5.19 - **Frozen main:** `ee6ca5fa28bce04dfecb6717de65b08a57f2ac47` - **Reviewed candidate:** `ab691bcdaeccaa6698a2199beaba3f6e93daae81` - **Previous desktop release:** `desktop-v0.5.18` - **Proposed immutable tag:** `desktop-v0.5.19` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++--- CHANGELOG.md | 79 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 784725b2e6f..e2711c63b35 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.18", - "base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", - "previous_tag": "desktop-v0.5.17", - "previous_base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", - "previous_merge_sha": "8232299cbe6d90692fac3de46cde0ec123edd6c1", - "tag": "desktop-v0.5.18", - "commit_count": 66 + "version": "0.5.19", + "base_sha": "ee6ca5fa28bce04dfecb6717de65b08a57f2ac47", + "previous_tag": "desktop-v0.5.18", + "previous_base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", + "previous_merge_sha": "1e6f1a2584d1620fcc16baf8df962bda8d7c7ae9", + "tag": "desktop-v0.5.19", + "commit_count": 69 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fc482515e..f721ba905b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## v0.5.19 + +### Desktop and shared changes + +- docs(nest): make commit attribution policy-neutral ([#6707](https://github.com/block/buzz/pull/6707)) ([`22f32c99e2e983b7e015e8c058cac47438d05b2f`](https://github.com/block/buzz/commit/22f32c99e2e983b7e015e8c058cac47438d05b2f)) +- fix(desktop-messages): preserve inline agent mentions with persistent addressing ([#6793](https://github.com/block/buzz/pull/6793)) ([`b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0`](https://github.com/block/buzz/commit/b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0)) +- feat(desktop): persist agent addressing across composer messages ([#6714](https://github.com/block/buzz/pull/6714)) ([`7ba1197aa6b01616b5920487dd585a37d2e7f74d`](https://github.com/block/buzz/commit/7ba1197aa6b01616b5920487dd585a37d2e7f74d)) +- feat: navigate images across message threads ([#6705](https://github.com/block/buzz/pull/6705)) ([`a526dca9bcaa08dfb5db77999cc1f584a17a9d64`](https://github.com/block/buzz/commit/a526dca9bcaa08dfb5db77999cc1f584a17a9d64)) +- revert fixed mention highlight ([#6716](https://github.com/block/buzz/pull/6716)) ([`12f3fea26e4c638a5fae20dce1ec0876e3bbca41`](https://github.com/block/buzz/commit/12f3fea26e4c638a5fae20dce1ec0876e3bbca41)) +- highlight search terms in results and messages ([#6702](https://github.com/block/buzz/pull/6702)) ([`29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1`](https://github.com/block/buzz/commit/29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1)) +- fix(desktop): make lightbox zoom controls interactive ([#6710](https://github.com/block/buzz/pull/6710)) ([`9b6a637d014607760d116b90c57062f82ab27cf3`](https://github.com/block/buzz/commit/9b6a637d014607760d116b90c57062f82ab27cf3)) +- Support community deletion in versioned media buckets ([#6738](https://github.com/block/buzz/pull/6738)) ([`d12dea4e67c5224a626d9c00f45e68d1def72d4c`](https://github.com/block/buzz/commit/d12dea4e67c5224a626d9c00f45e68d1def72d4c)) +- Fix TipTap editor mount race ([#6779](https://github.com/block/buzz/pull/6779)) ([`bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9`](https://github.com/block/buzz/commit/bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9)) +- feat(buzz-agent): gate LLM tool calls on session/request_permission ([#5712](https://github.com/block/buzz/pull/5712)) ([`a1219070fa6c3263c8a29637c70b7a317d4ecd9d`](https://github.com/block/buzz/commit/a1219070fa6c3263c8a29637c70b7a317d4ecd9d)) +- Fix mobile Huddle agent voice turn states ([#6611](https://github.com/block/buzz/pull/6611)) ([`8b812017a79c9279594330fb2b04c1eaf0e6e6c1`](https://github.com/block/buzz/commit/8b812017a79c9279594330fb2b04c1eaf0e6e6c1)) +- fix(desktop): polish inline chip states ([#6718](https://github.com/block/buzz/pull/6718)) ([`a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21`](https://github.com/block/buzz/commit/a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21)) +- feat(workflows): discover trigger filter values ([#6712](https://github.com/block/buzz/pull/6712)) ([`e760c51820b2103d965c22b44254678e10fb689a`](https://github.com/block/buzz/commit/e760c51820b2103d965c22b44254678e10fb689a)) +- feat(desktop): simplify the message action rail ([#6529](https://github.com/block/buzz/pull/6529)) ([`c5166f2164035ca96787daee6528d5dc04c4a02e`](https://github.com/block/buzz/commit/c5166f2164035ca96787daee6528d5dc04c4a02e)) +- fix(desktop): restore icon-only remote marker ([#6491](https://github.com/block/buzz/pull/6491)) ([`30d2fc52f96138311f2006627ffc1a6d5ff1865b`](https://github.com/block/buzz/commit/30d2fc52f96138311f2006627ffc1a6d5ff1865b)) +- fix(composer): wrap Buzz chip labels without orphaning icons ([#6581](https://github.com/block/buzz/pull/6581)) ([`f79d346a178408661fcad85122364ac2ad7e9cb2`](https://github.com/block/buzz/commit/f79d346a178408661fcad85122364ac2ad7e9cb2)) +- fix(desktop): bound thread /query and surface load errors, not false-empty ([#6447](https://github.com/block/buzz/pull/6447)) ([`f6e6617a9dcc2308d5039f8afaab974b49fb9577`](https://github.com/block/buzz/commit/f6e6617a9dcc2308d5039f8afaab974b49fb9577)) +- fix(messages): route edits to the owning composer ([#6575](https://github.com/block/buzz/pull/6575)) ([`4bf80978f52981f0035e6c0b86bdf1108bbf64c8`](https://github.com/block/buzz/commit/4bf80978f52981f0035e6c0b86bdf1108bbf64c8)) +- fix(desktop): align jump-to-latest pill with composer height ([#6606](https://github.com/block/buzz/pull/6606)) ([`9f55bf67456be10ff7c8238bf0d9e12e582848f6`](https://github.com/block/buzz/commit/9f55bf67456be10ff7c8238bf0d9e12e582848f6)) +- fix(desktop): emit singular `mention` feed category so alerts route correctly ([#6665](https://github.com/block/buzz/pull/6665)) ([`db5617dd1541aeab7bacaf039b6ca98f856776d0`](https://github.com/block/buzz/commit/db5617dd1541aeab7bacaf039b6ca98f856776d0)) +- show mention counts in channel notifications ([#6696](https://github.com/block/buzz/pull/6696)) ([`0e69b3fd7c44c09da62e2c4e89fdb4a26e666869`](https://github.com/block/buzz/commit/0e69b3fd7c44c09da62e2c4e89fdb4a26e666869)) +- fix(desktop): hide selection formatting tray on composer right-click ([#6683](https://github.com/block/buzz/pull/6683)) ([`2f13e30e88e84851e7ad336364dd3cfd547b8c16`](https://github.com/block/buzz/commit/2f13e30e88e84851e7ad336364dd3cfd547b8c16)) +- fix(desktop): stabilize members dialog scrolling ([#6670](https://github.com/block/buzz/pull/6670)) ([`72ba987c365abb98939153c4d43dde73257c1264`](https://github.com/block/buzz/commit/72ba987c365abb98939153c4d43dde73257c1264)) +- fix(desktop): keep member runtime status off the UI thread ([#6445](https://github.com/block/buzz/pull/6445)) ([`17af15effac63e6bc5338448326ce52ba4426e5f`](https://github.com/block/buzz/commit/17af15effac63e6bc5338448326ce52ba4426e5f)) +- perf(desktop): persist channel heads, collapse thread reads and reply sends ([#6572](https://github.com/block/buzz/pull/6572)) ([`2d280376ad36134cec1f23bead6d866d30bed147`](https://github.com/block/buzz/commit/2d280376ad36134cec1f23bead6d866d30bed147)) +- Downgrade desktop Huddles to audio protocol v2 ([#6610](https://github.com/block/buzz/pull/6610)) ([`0720f5380ce8a6c050afac159f8462c06cd51ab5`](https://github.com/block/buzz/commit/0720f5380ce8a6c050afac159f8462c06cd51ab5)) +- perf(desktop): make the Projects surface render-cheap ([#6460](https://github.com/block/buzz/pull/6460)) ([`040b203f73576e15ef749b0ff0ee6243f06a5c48`](https://github.com/block/buzz/commit/040b203f73576e15ef749b0ff0ee6243f06a5c48)) +- refactor(acp): clarify agent prompt sections ([#6501](https://github.com/block/buzz/pull/6501)) ([`f99532585a0715bac73b4a6361a9b4966bdb5095`](https://github.com/block/buzz/commit/f99532585a0715bac73b4a6361a9b4966bdb5095)) +- Add mobile Huddles voice MVP ([#6056](https://github.com/block/buzz/pull/6056)) ([`8c0f42e187ca82d701251fc849217530178ebace`](https://github.com/block/buzz/commit/8c0f42e187ca82d701251fc849217530178ebace)) +- feat(desktop-messages): keep agents addressed across messages ([#6315](https://github.com/block/buzz/pull/6315)) ([`a2d8be5efa126221c7676f7797555dfb2bf5b0e0`](https://github.com/block/buzz/commit/a2d8be5efa126221c7676f7797555dfb2bf5b0e0)) +- fix(desktop): remove Buzz entity link previews ([#6512](https://github.com/block/buzz/pull/6512)) ([`723affe5d1340896209bf3aca94c7b791bdcf38b`](https://github.com/block/buzz/commit/723affe5d1340896209bf3aca94c7b791bdcf38b)) +- fix(composer): preserve caret when inserting mentions mid-message ([#6531](https://github.com/block/buzz/pull/6531)) ([`074561233eef71df9690ec22c2a9c5e798c297a7`](https://github.com/block/buzz/commit/074561233eef71df9690ec22c2a9c5e798c297a7)) +- chore(deps): update rust crate futures-util to v0.3.33 ([#5448](https://github.com/block/buzz/pull/5448)) ([`d874d67c117e4582cc9549ebe85c942e5e49faf7`](https://github.com/block/buzz/commit/d874d67c117e4582cc9549ebe85c942e5e49faf7)) +- fix(desktop): restore true zoom by scaling the root rem ([#6514](https://github.com/block/buzz/pull/6514)) ([`97b1fee5c3d9ad574196e974b520061fccc47d07`](https://github.com/block/buzz/commit/97b1fee5c3d9ad574196e974b520061fccc47d07)) +- chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec ([#6517](https://github.com/block/buzz/pull/6517)) ([`eb484387d5816b6f9155ad3a0be79ede3c3d7ad5`](https://github.com/block/buzz/commit/eb484387d5816b6f9155ad3a0be79ede3c3d7ad5)) +- feat(workflows): clarify workflow setup and activation ([#6470](https://github.com/block/buzz/pull/6470)) ([`0e48ff26915aa32d5f05208847b9aba75f4f19cd`](https://github.com/block/buzz/commit/0e48ff26915aa32d5f05208847b9aba75f4f19cd)) +- perf(desktop): stop the Projects fan refetching on re-entry and running after leave ([#6458](https://github.com/block/buzz/pull/6458)) ([`b85d680fb1e3cc7eef6d31d47598741b35836a2e`](https://github.com/block/buzz/commit/b85d680fb1e3cc7eef6d31d47598741b35836a2e)) +- perf(desktop): keep the member roster off the channel-switch path ([#6456](https://github.com/block/buzz/pull/6456)) ([`b0466ac465336cb773fbf7355ec05f7d61f4a3aa`](https://github.com/block/buzz/commit/b0466ac465336cb773fbf7355ec05f7d61f4a3aa)) +- Clarify huddle message destination ([#6496](https://github.com/block/buzz/pull/6496)) ([`7da8f9abf3245d7ab31ba6e4ad72598b03471f2b`](https://github.com/block/buzz/commit/7da8f9abf3245d7ab31ba6e4ad72598b03471f2b)) +- feat(archive): add observer-frame retention schema and gated DB adapter ([#5719](https://github.com/block/buzz/pull/5719)) ([`fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce`](https://github.com/block/buzz/commit/fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce)) +- fix(desktop): restore human barge-in over agent TTS in huddles ([#6431](https://github.com/block/buzz/pull/6431)) ([`6039fed565fd73a07cdddc3143c86733cad91709`](https://github.com/block/buzz/commit/6039fed565fd73a07cdddc3143c86733cad91709)) + +### Other repository changes + +- Remove public relay signing key fallback ([#6729](https://github.com/block/buzz/pull/6729)) ([`ee6ca5fa28bce04dfecb6717de65b08a57f2ac47`](https://github.com/block/buzz/commit/ee6ca5fa28bce04dfecb6717de65b08a57f2ac47)) +- Qualify canonical relay images for staged delivery ([#6781](https://github.com/block/buzz/pull/6781)) ([`f24971033178926153b49d320bd876d15d9cb2bf`](https://github.com/block/buzz/commit/f24971033178926153b49d320bd876d15d9cb2bf)) +- Add database pressure observability ([#6700](https://github.com/block/buzz/pull/6700)) ([`113a33b7e49b7173ee1767c49ef2f49c63803034`](https://github.com/block/buzz/commit/113a33b7e49b7173ee1767c49ef2f49c63803034)) +- Add staging dev relay image workflow ([#6709](https://github.com/block/buzz/pull/6709)) ([`931747c9c42df14d5c23c87fe57e30b995321ae3`](https://github.com/block/buzz/commit/931747c9c42df14d5c23c87fe57e30b995321ae3)) +- Extract community persistence ([#6668](https://github.com/block/buzz/pull/6668)) ([`9d1e4b257657f382d3111ce748f3da8d063b7671`](https://github.com/block/buzz/commit/9d1e4b257657f382d3111ce748f3da8d063b7671)) +- Add inline profile camera capture ([#6680](https://github.com/block/buzz/pull/6680)) ([`9aa332af03b4fb416dd8648b320447617ffd6fa5`](https://github.com/block/buzz/commit/9aa332af03b4fb416dd8648b320447617ffd6fa5)) +- Hide Huddles in mobile agent DMs ([#6676](https://github.com/block/buzz/pull/6676)) ([`822c5ab231bc253d809d2d13da4b381f723dcd25`](https://github.com/block/buzz/commit/822c5ab231bc253d809d2d13da4b381f723dcd25)) +- Centralize replaceable event persistence ([#6660](https://github.com/block/buzz/pull/6660)) ([`8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8`](https://github.com/block/buzz/commit/8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8)) +- fix(ci): prevent poisoned Rust caches ([#6618](https://github.com/block/buzz/pull/6618)) ([`69b1225923c9bd98784e86a6976bbc34a3dc8630`](https://github.com/block/buzz/commit/69b1225923c9bd98784e86a6976bbc34a3dc8630)) +- docs(security): route reports through private advisories ([#6728](https://github.com/block/buzz/pull/6728)) ([`02dc49f0e60f75027e926e33a2d0021f7b4e0cd3`](https://github.com/block/buzz/commit/02dc49f0e60f75027e926e33a2d0021f7b4e0cd3)) +- fix(mobile): join starter channels after accepting invite ([#5915](https://github.com/block/buzz/pull/5915)) ([`6eff84d1271eb1b90e07c5a0673343a76a0753fc`](https://github.com/block/buzz/commit/6eff84d1271eb1b90e07c5a0673343a76a0753fc)) +- Add mobile profile editing ([#6583](https://github.com/block/buzz/pull/6583)) ([`a0298539f7043cd0f2d961030e60cc0fd82970b1`](https://github.com/block/buzz/commit/a0298539f7043cd0f2d961030e60cc0fd82970b1)) +- fix(mobile): recover stale and shuffled messages ([#6691](https://github.com/block/buzz/pull/6691)) ([`01091c15a15d6057d80463dfd828e6e1e4b60743`](https://github.com/block/buzz/commit/01091c15a15d6057d80463dfd828e6e1e4b60743)) +- feat(mobile): browse and join open channels ([#6243](https://github.com/block/buzz/pull/6243)) ([`26f4c3ed304db2c273f0bd4d2746aa9598f38366`](https://github.com/block/buzz/commit/26f4c3ed304db2c273f0bd4d2746aa9598f38366)) +- Polish Huddle participant interactions ([#6312](https://github.com/block/buzz/pull/6312)) ([`e23632941331502c0330e51d407e667bea26ef57`](https://github.com/block/buzz/commit/e23632941331502c0330e51d407e667bea26ef57)) +- Downgrade mobile Huddles to audio protocol v2 ([#6558](https://github.com/block/buzz/pull/6558)) ([`4baccd5394d6166bb68ff03b24e376e322281a59`](https://github.com/block/buzz/commit/4baccd5394d6166bb68ff03b24e376e322281a59)) +- chore(deps): update rust crate async-trait to v0.1.92 ([#6094](https://github.com/block/buzz/pull/6094)) ([`f7942167372501576c9f0f589cf2c166882668bb`](https://github.com/block/buzz/commit/f7942167372501576c9f0f589cf2c166882668bb)) +- chore(deps): update dependency sonner to v2.0.8 ([#6093](https://github.com/block/buzz/pull/6093)) ([`2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67`](https://github.com/block/buzz/commit/2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67)) +- chore(deps): update rust crate http-body-util to v0.1.4 ([#5452](https://github.com/block/buzz/pull/5452)) ([`9390e11c9babeef221aeb0a22cc61a52700d168f`](https://github.com/block/buzz/commit/9390e11c9babeef221aeb0a22cc61a52700d168f)) +- chore(deps): update rust crate http to v1.4.2 ([#5451](https://github.com/block/buzz/pull/5451)) ([`1a0a27d3586b14fb79ec8e162441e610f8548188`](https://github.com/block/buzz/commit/1a0a27d3586b14fb79ec8e162441e610f8548188)) +- chore(deps): update rust crate futures to v0.3.33 ([#5445](https://github.com/block/buzz/pull/5445)) ([`2cef92df676490654f13ebe1de56923b2636123f`](https://github.com/block/buzz/commit/2cef92df676490654f13ebe1de56923b2636123f)) +- chore(deps): update dependency @tauri-apps/api to v2.11.1 ([#5444](https://github.com/block/buzz/pull/5444)) ([`f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8`](https://github.com/block/buzz/commit/f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8)) +- chore(deps): update ubuntu:24.04 docker digest to 561618e ([#5442](https://github.com/block/buzz/pull/5442)) ([`9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb`](https://github.com/block/buzz/commit/9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb)) +- chore(deps): update swatinem/rust-cache digest to 6323deb ([#5441](https://github.com/block/buzz/pull/5441)) ([`694d804b24cd0b97403b3caf159a55ffd1fc3b08`](https://github.com/block/buzz/commit/694d804b24cd0b97403b3caf159a55ffd1fc3b08)) +- fix(deletion): allow IRSA S3 credentials ([#6495](https://github.com/block/buzz/pull/6495)) ([`47526784d5c1967de6b2c5d1ee642bccfefbfab9`](https://github.com/block/buzz/commit/47526784d5c1967de6b2c5d1ee642bccfefbfab9)) +- docs(nips): comprehensive NIP-FI — core + claimable profiles (EDGE/LIFECYCLE/DELEG/CONF) ([#5946](https://github.com/block/buzz/pull/5946)) ([`d97780b4777f2fe3430b4e30a7d47fc6837ee059`](https://github.com/block/buzz/commit/d97780b4777f2fe3430b4e30a7d47fc6837ee059)) +- fix(benchmarks): wait for scripted event delivery ([#6487](https://github.com/block/buzz/pull/6487)) ([`025425591ed67518a63870316f1473ffd02dd520`](https://github.com/block/buzz/commit/025425591ed67518a63870316f1473ffd02dd520)) +- Polish mobile channel navigation and message sends ([#6488](https://github.com/block/buzz/pull/6488)) ([`aeb741fd31044ec560d953b0986dec2e7e93e2c6`](https://github.com/block/buzz/commit/aeb741fd31044ec560d953b0986dec2e7e93e2c6)) + +[Compare desktop-v0.5.18...desktop-v0.5.19](https://github.com/block/buzz/compare/desktop-v0.5.18...desktop-v0.5.19) + ## v0.5.18 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 4a6bdcd7f56..1064769148d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.18", + "version": "0.5.19", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0933149f5b5..82758c1d88a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.19" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3f7189deea1..97791da13fe 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.19" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4a73c780641..c29a9b8dcef 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.18", + "version": "0.5.19", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From cae7f826b39b38e7d49a90d342499234dec007bf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 18:50:47 -0400 Subject: [PATCH 041/101] fix(ci): check out source in docker.yml merge job (#6833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every `docker.yml` run on `main` has failed since [#6781](https://github.com/block/buzz/pull/6781) merged. That PR added a "Create deployment eligibility predicate" step to the `merge` job, which runs `jq` against `$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq`. But the `merge` job has no `actions/checkout` step — the workspace is empty, so `jq` can't open the file and exits `2` (`jq: Could not open ... No such file or directory`). The `build` and `qualify` jobs each check out the source; `merge` never needed one until this step was added. ## Fix - `.github/workflows/docker.yml`: add `actions/checkout` (same pinned SHA as the other jobs, `df4cb1c` / v6.0.3) as the first step of the `merge` job. - `scripts/test-relay-image-eligibility-workflow.sh`: guard the regression by asserting the `merge` job checks out the source before building the predicate. ## Verification CI cannot exercise the `merge` job on a pull request — the job is gated `if: github.event_name != 'pull_request'`, so it only runs on push to `main`. The proof is the root cause (missing checkout for a step that reads a repo file) plus the eligibility workflow test: `scripts/test-relay-image-eligibility-workflow.sh` passes with the fix and fails with the new guard's specific message (`merge job must check out the source before building the eligibility predicate`) when the checkout is removed. Signed-off-by: Will Pfleger --- .github/workflows/docker.yml | 5 +++++ scripts/test-relay-image-eligibility-workflow.sh | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ddb55f85420..f48f1bc92fb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -330,6 +330,11 @@ jobs: tag_prefix: debug- steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh index 168f9d36892..40745a8e887 100755 --- a/scripts/test-relay-image-eligibility-workflow.sh +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -35,6 +35,15 @@ if grep -Fq "buzz-staging-dev" "$workflow"; then exit 1 fi +# The merge job reads scripts/create-deployment-eligibility-predicate.jq from the +# workspace, so it must check out the source first. Guard against the checkout being +# dropped from that job (the workspace is otherwise empty and jq exits non-zero). +merge_job=$(awk '/^ merge:/{f=1} f&&/^ [a-z][a-z_-]*:$/&&!/^ merge:/{exit} f' "$workflow") +grep -Fq "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" <<<"$merge_job" || { + echo "merge job must check out the source before building the eligibility predicate" >&2 + exit 1 +} + select_run() { jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' } From 820a8589971df49bb9285a236ce1e2955a301abd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 18:50:58 -0400 Subject: [PATCH 042/101] fix(release): attribute desktop candidates to the operator (#6831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop release tooling hardcoded one contributor's personal identity into every release candidate commit. `scripts/prepare-desktop-release.sh` committed the candidate with a `git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com'` override, and `scripts/desktop_release.py` `validate` required the candidate author to be exactly `Wes ` plus a matching `Signed-off-by` trailer. That leaked from Wes's working setup into the validation contract in #3568, so a release cut by any other operator was falsely attributed to and signed off by Wes (as happened on #6828). ## Change - `prepare-desktop-release.sh`: drop the `-c` identity overrides so `git commit -s` uses the operator's own configured identity to author and sign off the candidate. The automation `Co-authored-by` trailer is unchanged. - `desktop_release.py` `validate`: replace the exact-Wes checks with structural ones — the commit author must be non-empty, the body must contain a `Signed-off-by` trailer whose name and email match the commit author (honest DCO), and the existing automation `Co-authored-by` regex check stays. Failure messages remain specific. - `test-desktop-release-candidate.sh`: the fixture candidate now commits under the harness's own identity, and a new negative case rewrites the author to a mismatched identity and asserts the validator rejects it. Release authorization is bound to the merged PR via the GitHub API in `scripts/verify-desktop-release-merge.sh`, never the commit author field, so this does not weaken the trust model. `RELEASING.md` and `.github/workflows/desktop-release-candidate.yml` reference no author identity and need no change. Verified locally: `scripts/test-desktop-release-candidate.sh` passes, including the new sign-off/author-mismatch rejection. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- scripts/desktop_release.py | 13 ++++---- scripts/prepare-desktop-release.sh | 3 +- scripts/test-desktop-release-candidate.sh | 36 ++++++++++++++++++++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index ce9ceaab932..48e7ce86ced 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -257,12 +257,15 @@ def validate(args: argparse.Namespace) -> None: bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] if bad: raise SystemExit(f"version mismatch in: {', '.join(bad)}") - author = git("show", "-s", "--format=%an <%ae>", candidate) + name = git("show", "-s", "--format=%an", candidate) + email = git("show", "-s", "--format=%ae", candidate) + if not name or not email: + raise SystemExit("candidate has no author identity") + author = f"{name} <{email}>" body = git("show", "-s", "--format=%B", candidate) - if author != "Wes ": - raise SystemExit(f"unexpected candidate author: {author}") - if "Signed-off-by: Wes " not in body: - raise SystemExit("candidate is missing Wes Signed-off-by trailer") + signoffs = re.findall(rf"(?m)^Signed-off-by: {re.escape(author)}$", body) + if len(signoffs) != 1: + raise SystemExit(f"candidate must carry exactly one Signed-off-by trailer matching its author {author}") if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): raise SystemExit("candidate is missing automation Co-authored-by trailer") print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index 43785075a15..eec08ac8c56 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -42,8 +42,7 @@ chore(release): release Buzz Desktop version $version Co-authored-by: $agent_name <$agent_email> EOF -git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ - commit -s -F "$msg" +git commit -s -F "$msg" scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz candidate_sha="$(git rev-parse HEAD)" diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index c63a157c006..503c64eb37d 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -64,8 +64,42 @@ for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): open('desktop/src-tauri/Cargo.toml','w').write('[package]\nversion = "1.0.1"\n') PY git add . - git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation ' + git commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation ' PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + good_candidate=$(git rev-parse HEAD) + + # A candidate whose Signed-off-by does not match its author is a dishonest + # DCO sign-off and must be rejected. Rewrite the author while keeping the + # original trailer body, then restore the honest candidate. + git -c user.name=Impostor -c user.email=impostor@example.com commit -q --amend --no-edit --reset-author + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a candidate whose sign-off does not match its author" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + + # The trailer must be a complete anchored line, not substring-matched. A prose + # line that merely contains the sign-off text, or a real trailer with trailing + # garbage, must be rejected. Both were accepted before the anchored parse. + for bogus in \ + 'not-a-trailer Signed-off-by: test ' \ + 'Signed-off-by: test trailing-garbage'; do + git commit -q --amend -m 'chore(release): release Buzz Desktop version 1.0.1' \ + -m 'Co-authored-by: Test Automation ' -m "$bogus" + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a malformed sign-off: $bogus" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + done + + # Two matching sign-offs are also invalid: the contract is exactly one. + git commit -q --amend -m 'chore(release): release Buzz Desktop version 1.0.1' \ + -m 'Co-authored-by: Test Automation ' \ + -m 'Signed-off-by: test ' -m 'Signed-off-by: test ' + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted duplicate Signed-off-by trailers" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + grep -Fq "$unrelated_before" CHANGELOG.md grep -Fq "$unrelated_after" CHANGELOG.md ! grep -Fq "$prior_merge" CHANGELOG.md From e8cd7516e6df62c2a9025d7a821bf98e0e8f83b4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 15:59:38 -0700 Subject: [PATCH 043/101] fix(desktop): respect automatic mention preference after send (#6837) **Category:** fix **User Impact:** Disabling automatic agent mentions now keeps one-time agent mentions out of the next message draft. **Problem:** A successfully sent inline agent mention was treated as eligible for post-send restoration even when **Automatically mention agents** was disabled, so the agent immediately reappeared in the composer. **Solution:** Gate the send-success restoration path on the live preference while leaving explicitly pinned agents and enabled automatic mentions unchanged.
File changes **desktop/src/features/messages/ui/MessageComposer.tsx** Skips the automatic post-send mention restoration path when the preference is disabled. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Reproduces disabling the toggle before sending an inline agent mention and verifies the next composer is empty while the outgoing mention still reaches the agent.
## Reproduction steps 1. Enable **Automatically mention agents** in the composer mention options. 2. Disable it again. 3. Compose and send `@Agent test` using the inline mention picker. 4. Confirm the message still mentions the agent, but the cleared composer does not repopulate `@Agent`. 5. Re-enable the preference and repeat; confirm the mention is restored for the next message. ## Testing At `a179a2d00a6ac5cf0bc4a1c7c1ef3d5dfa9d0875`: - Full desktop unit suite: 5,501 passed. - Desktop check and typecheck passed in the pre-push hooks. - Desktop file-size ratchet passed in the pre-push hooks. - Full `persistent-agent-audience.spec.ts` Playwright file: 17 passed, including preference off (empty composer), preference on (restored mention), and the existing address-undo journey. ## Visual validation | Automatic mentions off | Automatic mentions on | | --- | --- | | After sending a one-time mention, the outgoing message is preserved and the next composer stays empty. | After sending, the agent remains addressed and is restored in the next composer. | | ![Automatic mentions disabled: empty composer after send](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/disabled-after-send.png) | ![Automatic mentions enabled: agent restored after send](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/enabled-after-send.png) |
Original regression With automatic mentions disabled, sending `@Alia test` still repopulated `@Alia` in the cleared composer. ![Original regression: the sent agent reappears in the composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/auto-mention-regression-before.png)
Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageComposer.tsx | 2 +- .../e2e/persistent-agent-audience.spec.ts | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index d4db6f5d173..c255f4a8cab 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -347,7 +347,7 @@ function MessageComposerImpl({ restoreAddressedAgentMentionsRef.current(pubkeys), onAddressedAgentsSendFailed: addressPulse.shakeMany, onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (newlyPinnedPubkeys.length === 0) return; + if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; const sentChannelId = channelId; if (restoreAddressedAgentMentionsFrameRef.current !== null) { cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 9a02e2e1a87..12a6f07819f 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -312,6 +312,40 @@ test("Tab inserts a one-time agent mention by default", async ({ page }) => { ).toHaveCount(0); }); +test("disabling automatic mentions leaves the composer empty after send", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await composer.getByTestId("message-insert-mention").click(); + await composer.getByTestId("mention-options-trigger").click(); + const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); + await expect(preference).toHaveAttribute("data-state", "checked"); + await preference.click(); + await expect(preference).toHaveAttribute("data-state", "unchecked"); + await input.press("Escape"); + + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.type("test"); + await expect(input).toHaveText("@Morgarita test"); + await input.press("Enter"); + + await expect(input).toHaveText(""); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita test")) + .toContain(AGENT_A); +}); + test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { @@ -457,7 +491,7 @@ test("the mention button opens settings and can undo an address", async ({ await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText("@Morgarita "); + await expect(input).toHaveText(""); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); From 8471049c430073474939336dfc6aa98272bc8762 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 26 Aug 2026 00:09:55 +0100 Subject: [PATCH 044/101] feat(desktop): add KLIPY GIF search to composers (#5554) ## Summary - Supersedes #1913 with a KLIPY-hosted URL implementation. - Adds KLIPY GIF search and trending results to desktop message and forum composers. - Keeps selected GIFs hosted by KLIPY; Buzz stores only the external URL and media metadata (no imeta tag, since relays only accept hash-backed local `/media/` entries). - Aligns the Emoji/GIF picker with Buzz's standard segmented control, theme surfaces, and motion behavior. ## Relay-to-provider boundary - The relay proxies KLIPY search/share so the `BUZZ_KLIPY_API_KEY` never reaches the desktop; the key stays server-side behind a redacted `Debug` impl. - The dedicated GIF `reqwest` client sets `redirect::Policy::none()`. Because the API key rides in the request path, following a provider `3xx` could replay a key-bearing URL to an attacker-chosen host (an SSRF/key-disclosure primitive). With redirects disabled, a `3xx` returns as a non-success status that the handlers map to a generic `502`; the `Location` target is never read or forwarded. - Admission reuses the established NIP-98, membership, replay, and per-pubkey rate-limit gates, with an upstream response-size cap and allowlisting so KLIPY error bodies never cross the relay boundary. ## Accessibility - Under `prefers-reduced-motion: reduce`, the picker grid renders a static provider poster (a normalized `jpg` asset) instead of the animated preview, or a named static placeholder when no poster is available. It reacts to preference changes while mounted. `no-preference` keeps the animated preview. - Selected GIFs carry their title through `ImetaMedia`'s `displayLabel`, so the composer thumbnail, preview dialog, editor, lightbox, and remove control all derive one non-empty accessible name instead of an empty `Attachment ` label. Ordinary hashed uploads keep their existing hash-derived names. --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Will Pfleger Signed-off-by: Duncan Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Duncan Co-authored-by: Will Pfleger Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- .env.example | 6 + crates/buzz-auth/src/rate_limit.rs | 22 + crates/buzz-relay/src/api/gifs.rs | 613 ++++++++++++++++++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/config.rs | 53 ++ crates/buzz-relay/src/handlers/req.rs | 1 + crates/buzz-relay/src/nip11.rs | 86 ++- crates/buzz-relay/src/router.rs | 3 + crates/buzz-relay/src/state.rs | 5 + deploy/charts/buzz/README.md | 6 + .../charts/buzz/examples/secret-sample.yaml | 2 + deploy/charts/buzz/templates/deployment.yaml | 6 + deploy/charts/buzz/tests/secrets_test.yaml | 21 + deploy/charts/buzz/values.yaml | 1 + .../features/custom-emoji/ui/EmojiPicker.tsx | 5 +- .../src/features/forum/ui/ForumComposer.tsx | 1 + desktop/src/features/gifs/api.test.mjs | 179 +++++ desktop/src/features/gifs/api.ts | 185 ++++++ desktop/src/features/gifs/relay.ts | 142 ++++ .../src/features/gifs/ui/KlipyGifPicker.tsx | 161 +++++ .../messages/lib/imetaMediaMarkdown.test.mjs | 48 +- .../messages/lib/imetaMediaMarkdown.ts | 48 +- .../messages/ui/ComposerAttachments.tsx | 96 +-- .../messages/ui/ComposerEmojiPicker.tsx | 124 +++- .../features/messages/ui/MessageComposer.tsx | 56 +- .../messages/ui/MessageComposerToolbar.tsx | 4 + desktop/tests/e2e/channels.spec.ts | 9 + desktop/tests/e2e/composer-image-draw.spec.ts | 24 +- .../e2e/composer-tooltip-dismiss.spec.ts | 2 +- desktop/tests/e2e/messaging.spec.ts | 388 +++++++++++ desktop/tests/e2e/spoiler.spec.ts | 8 +- desktop/tests/e2e/video-attachment.spec.ts | 12 +- docs/gif-search.md | 41 ++ docs/multi-tenant-conformance.md | 2 +- 34 files changed, 2215 insertions(+), 146 deletions(-) create mode 100644 crates/buzz-relay/src/api/gifs.rs create mode 100644 desktop/src/features/gifs/api.test.mjs create mode 100644 desktop/src/features/gifs/api.ts create mode 100644 desktop/src/features/gifs/relay.ts create mode 100644 desktop/src/features/gifs/ui/KlipyGifPicker.tsx create mode 100644 docs/gif-search.md diff --git a/.env.example b/.env.example index cb503392b30..a6740f7a7d8 100644 --- a/.env.example +++ b/.env.example @@ -59,9 +59,15 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and +# authenticated desktop clients use this relay as the metadata/search proxy. +# Keep the real value in your deployment's secret manager; never commit it. +# BUZZ_KLIPY_API_KEY= + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 +# BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN=30 # BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300 # BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10 # BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120 diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index 8fd42c50fb9..9e64627404c 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -60,6 +60,8 @@ pub enum LimitType { Messages, /// HTTP REST API calls. ApiCalls, + /// Relay-proxied GIF metadata searches. + GifSearches, /// All WebSocket events (broader than `Messages`). WsEvents, /// Concurrent WebSocket connections from a single IP address. @@ -72,6 +74,7 @@ impl LimitType { match self { Self::Messages => "msg", Self::ApiCalls => "api", + Self::GifSearches => "gif", Self::WsEvents => "ws", Self::IpConnections => "conn", } @@ -87,6 +90,10 @@ pub struct RateLimitConfig { /// Maximum messages per minute for human users. Default: 60. #[serde(default = "default_human_msg")] pub human_messages_per_min: u64, + /// Maximum relay-proxied GIF searches per minute for each pubkey. + /// Default: 30. + #[serde(default = "default_gif_searches")] + pub gif_searches_per_min: u64, /// Maximum HTTP API calls per minute for human users. Default: 300. #[serde(default = "default_human_api")] pub human_api_calls_per_min: u64, @@ -110,6 +117,9 @@ pub struct RateLimitConfig { fn default_human_msg() -> u64 { 60 } +fn default_gif_searches() -> u64 { + 30 +} fn default_human_api() -> u64 { 300 } @@ -133,6 +143,7 @@ impl Default for RateLimitConfig { fn default() -> Self { Self { human_messages_per_min: default_human_msg(), + gif_searches_per_min: default_gif_searches(), human_api_calls_per_min: default_human_api(), human_ws_events_per_sec: default_human_ws(), agent_standard_messages_per_min: default_agent_std_msg(), @@ -272,6 +283,17 @@ mod tests { assert!(key.ends_with(":msg")); } + #[test] + fn gif_searches_have_an_independent_quota_key() { + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let gif_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::GifSearches); + let api_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::ApiCalls); + + assert!(gif_key.ends_with(":gif")); + assert_ne!(gif_key, api_key); + } + #[test] fn rate_limit_key_isolates_communities_for_same_pubkey() { // The S1 cross-community isolation fence at the rate-limit key layer: diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs new file mode 100644 index 00000000000..a8848af295a --- /dev/null +++ b/crates/buzz-relay/src/api/gifs.rs @@ -0,0 +1,613 @@ +//! Relay-owned KLIPY GIF metadata/search proxy. +//! +//! KLIPY requires a provider credential, but desktop applications cannot keep +//! build-time credentials secret. These narrow endpoints keep the key on the +//! operator's relay while returning only KLIPY-hosted media URLs and metadata; +//! GIF bytes are never downloaded, cached, or stored by Buzz. +//! +//! Search and share reporting are the only relay endpoints. Sending a selected +//! GIF is a normal message containing its CDN URL, and clients render that URL +//! through the existing image path. No GIF bytes transit the relay. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::Json, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; + +use crate::state::AppState; + +use buzz_auth::LimitType; + +use super::{api_error, bridge, internal_error, relay_members}; + +const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/"; +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +pub(crate) const SHARE_PATH: &str = "/gifs/share"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_UPSTREAM_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +/// Build the dedicated KLIPY client. Redirects are disabled: the API key rides +/// in the request path, so following a provider 3xx could replay a key-bearing +/// URL to an attacker-chosen host. With no redirect policy, a 3xx comes back as +/// a non-success status that the handlers map to a generic `502`, and the +/// `Location` target is never read or forwarded. +pub fn build_gif_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("static GIF HTTP client configuration") +} + +#[derive(Debug, Deserialize)] +/// Client-owned search context forwarded to KLIPY by the relay. +pub struct SearchRequest { + /// Empty means trending; otherwise this is the user's search text. + query: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, + /// Desktop locale used to localize provider results. + locale: String, +} + +#[derive(Debug, Deserialize)] +/// Client-owned share context forwarded to KLIPY by the relay. +pub struct ShareRequest { + /// Provider slug for the selected GIF. + slug: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, +} + +fn validate_text( + name: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), (StatusCode, Json)> { + let count = value.chars().count(); + if (!allow_empty && value.trim().is_empty()) || count > max_chars { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "{name} must be {} through {max_chars} characters", + if allow_empty { 0 } else { 1 } + ), + )); + } + Ok(()) +} + +fn klipy_url( + api_key: &str, + path: &[&str], + query: &[(&str, &str)], +) -> Result)> { + let mut url = url::Url::parse(KLIPY_API_ROOT) + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + segments.pop_if_empty().push(api_key); + for segment in path { + segments.push(segment); + } + } + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + Ok(url) +} + +fn klipy_share_request( + client: &reqwest::Client, + api_key: &str, + request: &ShareRequest, +) -> Result)> { + let url = klipy_url(api_key, &["gifs", "share", request.slug.trim()], &[])?; + Ok(client + .post(url) + .json(&serde_json::json!({ "customer_id": request.customer_id }))) +} + +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &expected_url, + Some(body), + true, + true, + )?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey.to_bytes(), + headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()), + ) + .await?; + + Ok((tenant, pubkey)) +} + +async fn send_upstream( + request: reqwest::RequestBuilder, +) -> Result)> { + request + .timeout(UPSTREAM_TIMEOUT) + .send() + .await + .map_err(|error| { + tracing::warn!( + timeout = error.is_timeout(), + "KLIPY upstream request failed" + ); + api_error(StatusCode::BAD_GATEWAY, "GIF provider is unavailable") + }) +} + +async fn enforce_search_admission( + state: &AppState, + tenant: &buzz_core::TenantContext, + pubkey: &nostr::PublicKey, +) -> Result<(), (StatusCode, Json)> { + let limit = state.auth.config().rate_limits.gif_searches_per_min; + match crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + tenant, + pubkey, + LimitType::GifSearches, + 60, + limit, + ) + .await + { + Ok(()) => Ok(()), + Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_gif_search_rejections_total", "reason" => "quota").increment(1); + Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + &format!("rate-limited: GIF search quota exceeded; retry in {reset_in_secs}s"), + )) + } + Err(crate::admission::AdmissionError::Unavailable) => Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "rate-limited: GIF search admission unavailable", + )), + } +} + +async fn limited_json(response: reqwest::Response) -> Result)> { + if response + .content_length() + .is_some_and(|length| length > MAX_UPSTREAM_RESPONSE_BYTES as u64) + { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response could not be read", + ) + })?; + if body.len().saturating_add(chunk.len()) > MAX_UPSTREAM_RESPONSE_BYTES { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider returned an invalid response", + ) + }) +} + +fn successful_search_payload(upstream: &Value) -> Result)> { + if upstream.get("result").and_then(Value::as_bool) != Some(true) { + tracing::warn!("KLIPY search returned an unsuccessful result"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + let data = upstream.get("data").cloned().unwrap_or(Value::Null); + Ok(serde_json::json!({ "result": true, "data": data })) +} + +/// Search or browse trending KLIPY GIF metadata for an authenticated member. +pub async fn search( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let request: SearchRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; + validate_text("query", &request.query, 200, true)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + validate_text("locale", &request.locale, 32, false)?; + enforce_search_admission(&state, &tenant, &pubkey).await?; + + let endpoint = if request.query.trim().is_empty() { + "trending" + } else { + "search" + }; + let mut query = vec![ + ("page", "1"), + ("per_page", "24"), + ("customer_id", request.customer_id.as_str()), + ("locale", request.locale.as_str()), + ]; + if !request.query.trim().is_empty() { + query.push(("q", request.query.trim())); + } + let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; + let response = send_upstream(state.gif_http_client.get(url)).await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + + // Never forward the provider response wholesale. KLIPY may report an + // application-level failure with HTTP 200 and include request details in + // its error fields. Allowlist only successful result data so credentials + // and provider diagnostics cannot cross the relay boundary. + let upstream = limited_json(response).await?; + Ok(Json(successful_search_payload(&upstream)?)) +} + +/// Report a selected GIF to KLIPY so the provider can update Recents. +pub async fn share( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + authenticate(&state, &headers, SHARE_PATH, &body).await?; + let request: ShareRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; + validate_text("slug", &request.slug, 200, false)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + + let response = send_upstream(klipy_share_request( + &state.gif_http_client, + config.api_key(), + &request, + )?) + .await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the share request", + )); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + async fn unconfigured_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("test config"); + config.klipy = None; + config.redis_url = "redis://127.0.0.1:1".to_string(); + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://buzz:buzz_dev@127.0.0.1:1/buzz") // sadscan:disable np.postgres.1 + .expect("lazy test database pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("lazy test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("test pubsub"), + ); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("test media storage config"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + None::, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn search_route_returns_not_found_before_auth_when_unconfigured() { + let state = unconfigured_test_state().await; + let response = Router::new() + .route(SEARCH_PATH, axum::routing::post(search)) + .with_state(state) + .oneshot( + Request::post(SEARCH_PATH) + .body(Body::from("{}")) + .expect("search request"), + ) + .await + .expect("search response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn limited_json_rejects_oversized_streamed_bodies() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/oversized", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + "x".repeat(MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + }), + ), + ) + .await + .expect("serve oversized response"); + }); + let response = reqwest::get(format!("http://{address}/oversized")) + .await + .expect("test upstream response"); + let (status, _) = limited_json(response) + .await + .expect_err("oversized body must be rejected"); + + server.abort(); + let _ = server.await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + } + + #[test] + fn klipy_url_encodes_credentials_as_a_path_segment() { + let url = klipy_url( + "key/with spaces", + &["gifs", "search"], + &[("customer_id", "customer")], + ) + .expect("static URL is valid"); + + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/key%2Fwith%20spaces/gifs/search?customer_id=customer" + ); + } + + #[test] + fn klipy_share_request_uses_slug_path_and_customer_body() { + let request = ShareRequest { + slug: " ship/it ".to_string(), + customer_id: "customer-123".to_string(), + }; + let built = klipy_share_request(&reqwest::Client::new(), "secret-key", &request) + .expect("share request builds") + .build() + .expect("share request is valid"); + + assert_eq!(built.method(), reqwest::Method::POST); + assert_eq!( + built.url().as_str(), + "https://api.klipy.com/api/v1/secret-key/gifs/share/ship%2Fit" + ); + assert_eq!( + built.body().and_then(reqwest::Body::as_bytes), + Some(br#"{"customer_id":"customer-123"}"#.as_slice()) + ); + } + + #[test] + fn validation_bounds_provider_control_fields() { + assert!(validate_text("query", "", 200, true).is_ok()); + assert!(validate_text("customer_id", "", 128, false).is_err()); + assert!(validate_text("query", &"x".repeat(201), 200, true).is_err()); + } + + #[test] + fn successful_payload_strips_provider_errors_and_unknown_fields() { + let payload = successful_search_payload(&serde_json::json!({ + "result": true, + "data": { "data": [] }, + "errors": { "message": ["request used secret-key"] }, + "debug": "secret-key" + })) + .expect("successful payload"); + + assert_eq!( + payload, + serde_json::json!({ "result": true, "data": { "data": [] } }) + ); + } + + #[test] + fn unsuccessful_payload_is_rejected_without_provider_details() { + let (status, body) = successful_search_payload(&serde_json::json!({ + "result": false, + "errors": { "message": ["request used secret-key"] } + })) + .expect_err("unsuccessful provider payload must be rejected"); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert!(!serialized.contains("secret-key")); + } + + /// A provider 3xx must never cause a second connection, and the error + /// surfaced past the shared send/reject path must leak neither the API key + /// (carried in the request path) nor the redirect target. + /// + /// Mutation check: swapping `build_gif_http_client`'s redirect policy back + /// to the default makes the client follow the 302, the redirect listener + /// records a request, and this test fails on the `redirect_hits` assertion. + #[tokio::test] + async fn gif_client_refuses_provider_redirects_without_leaking_secrets() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SECRET_KEY: &str = "super-secret-klipy-key"; + + // Second listener: the redirect target. It must never be reached. + let redirect_hits = Arc::new(AtomicUsize::new(0)); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect target"); + let redirect_addr = redirect_listener.local_addr().expect("redirect address"); + let redirect_hits_server = redirect_hits.clone(); + let redirect_server = tokio::spawn(async move { + axum::serve( + redirect_listener, + Router::new().route( + "/leaked", + get(move || { + redirect_hits_server.fetch_add(1, Ordering::SeqCst); + async { "reached the redirect target" } + }), + ), + ) + .await + .expect("serve redirect target"); + }); + + // Fake upstream: answers the key-bearing path with a 302 whose Location + // points at the second listener, exactly the disclosure vector. + let redirect_location = format!("http://{redirect_addr}/leaked"); + let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake upstream"); + let upstream_addr = upstream_listener.local_addr().expect("upstream address"); + let location_header = redirect_location.clone(); + let upstream_server = tokio::spawn(async move { + axum::serve( + upstream_listener, + Router::new().route( + &format!("/{SECRET_KEY}/gifs/search"), + get(move || { + let location = location_header.clone(); + async move { + ( + StatusCode::FOUND, + [(header::LOCATION, location)], + "provider body naming the secret-key", + ) + } + }), + ), + ) + .await + .expect("serve fake upstream"); + }); + + let client = build_gif_http_client(); + let response = + send_upstream(client.get(format!("http://{upstream_addr}/{SECRET_KEY}/gifs/search"))) + .await + .expect("request completes without following the redirect"); + + // The redirect was not followed: the client surfaces the 3xx itself. + assert!(response.status().is_redirection()); + assert!(!response.status().is_success()); + assert_eq!(redirect_hits.load(Ordering::SeqCst), 0); + + // The shared reject path (both handlers gate on `!is_success`) returns a + // static generic error carrying no key and no redirect target. + let (status, body) = api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + ); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(!serialized.contains(SECRET_KEY)); + assert!(!serialized.contains(&redirect_location)); + + upstream_server.abort(); + redirect_server.abort(); + let _ = upstream_server.await; + let _ = redirect_server.await; + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc8039..204ec360c3f 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod bridge; pub mod events; +pub mod gifs; pub mod git; pub mod invites; pub mod media; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3d..9fb96b7a198 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -46,6 +46,30 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Optional KLIPY GIF-search integration owned by the relay operator. +/// +/// The API key deliberately stays private and its [`Debug`] implementation is +/// redacted so dumping [`Config`] cannot disclose it. +#[derive(Clone)] +pub struct KlipyConfig { + api_key: String, +} + +impl KlipyConfig { + /// Return the key only to the outbound KLIPY client. + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } +} + +impl std::fmt::Debug for KlipyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KlipyConfig") + .field("api_key", &"[REDACTED]") + .finish() + } +} + /// Maximum configured jitter, leaving ten seconds of the hard-drain budget for /// WebSocket close-frame delivery after the final delayed cancellation. pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; @@ -218,6 +242,10 @@ pub struct Config { /// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`. pub allow_nip_oa_auth: bool, + /// Relay-owned KLIPY integration. Unset means GIF search is not advertised + /// and its proxy routes return 404. + pub klipy: Option, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -319,6 +347,10 @@ fn rate_limit_config_from_env() -> Result, + /// Relay-owned GIF search integration. The descriptor is public and + /// provider-agnostic; provider credentials remain server-side. + #[serde(skip_serializing_if = "Option::is_none")] + pub gif: Option, /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, } +/// Public capability descriptor for relay-proxied GIF search. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GifDescriptor { + /// Provider identifier understood by Buzz clients. + pub provider: String, + /// Relay-relative authenticated metadata search endpoint. + pub search: String, + /// Relay-relative authenticated share-reporting endpoint. + pub share: String, +} + /// Protocol and resource limits advertised in the NIP-11 document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayLimitation { @@ -138,12 +153,18 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. + /// + /// `gif_provider` is a config-derived provider identifier. When present, + /// `build` advertises the provider-agnostic `buzz-gif` extension and the + /// relay-relative metadata search endpoint. It must never contain a + /// provider credential. pub fn build( relay_self: Option<&str>, icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, pairing_relay_url: Option<&str>, + gif_provider: Option<&str>, ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), @@ -155,6 +176,16 @@ impl RelayInfo { supported_nips.push(NIP_RELAY_MEMBERSHIP); } + let mut supported_extensions = vec!["nip-er".to_string()]; + let gif = gif_provider.map(|provider| { + supported_extensions.push("buzz-gif".to_string()); + GifDescriptor { + provider: provider.to_string(), + search: crate::api::gifs::SEARCH_PATH.to_string(), + share: crate::api::gifs::SHARE_PATH.to_string(), + } + }); + Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), @@ -162,12 +193,13 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(supported_extensions), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), + gif, relay_self: relay_self.map(|s| s.to_string()), } } @@ -236,7 +268,8 @@ fn push_descriptor( /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. Every input to `RelayInfo::build` /// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the -/// host-scoped workspace icon. +/// host-scoped workspace icon. Optional provider capabilities are passed as +/// config-derived scalar identifiers; no provider credential enters NIP-11. pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; @@ -246,6 +279,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st advertise_nip43, state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), + state.config.klipy.as_ref().map(|_| "klipy"), ); let tenant_host = if state.config.push_gateway_delivery_url.is_some() { crate::tenant::bind_community(&state.db, raw_host) @@ -337,6 +371,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( bool, usize, Option<&str>, + Option<&str>, ) -> RelayInfo = RelayInfo::build; #[cfg(test)] @@ -391,7 +426,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -403,6 +438,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, Some("wss://pairing.buzz.xyz"), + None, ); let json = serde_json::to_value(&info).expect("serialize"); assert_eq!( @@ -411,11 +447,40 @@ mod tests { Some("wss://pairing.buzz.xyz") ); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); let json = serde_json::to_value(&info).expect("serialize"); assert!(json.get("pairing_relay_url").is_none()); } + #[test] + fn gif_descriptor_and_extension_are_config_gated_and_credential_free() { + let info = RelayInfo::build( + None, + None, + false, + DEFAULT_MAX_FRAME_BYTES, + None, + Some("klipy"), + ); + + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!(json["gif"]["provider"], "klipy"); + assert_eq!(json["gif"]["search"], "/gifs/search"); + assert_eq!(json["gif"]["share"], "/gifs/share"); + assert!(json["supported_extensions"] + .as_array() + .expect("extensions") + .contains(&serde_json::json!("buzz-gif"))); + assert!(!json.to_string().contains("api_key")); + + let unconfigured = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); + assert!(unconfigured.gif.is_none()); + assert!(!unconfigured + .supported_extensions + .expect("extensions") + .contains(&"buzz-gif".to_string())); + } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard /// `icon` field; no icon (or a cleared, empty icon) omits the field /// entirely so the JSON matches pre-icon documents byte-for-byte. @@ -427,6 +492,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, None, + None, ); assert_eq!( info.icon.as_deref(), @@ -439,7 +505,7 @@ mod tests { ); for icon in [None, Some("")] { - let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -459,7 +525,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144, None); + let info = RelayInfo::build(None, None, false, 262_144, None, None); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -490,7 +556,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -503,7 +569,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -512,7 +578,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -523,6 +589,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 653e21f0936..fc5396407c1 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,9 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + // Relay-owned third-party GIF metadata proxy (NIP-98 auth). + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::gifs::SHARE_PATH, post(api::gifs::share)) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c0..ab3f1d8c7eb 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -722,6 +722,9 @@ pub struct AppState { /// replace this with process-local caching; replay freshness must survive /// cross-pod routing. pub nip98_replay: Arc, + /// Shared HTTP client for relay-proxied GIF provider requests. Reusing the + /// connection pool avoids a fresh TLS handshake for every search/share. + pub gif_http_client: reqwest::Client, /// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work. pub admission_rate_limiter: Arc, @@ -852,6 +855,7 @@ impl AppState { ); let nip98_replay: Arc = Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone())); + let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); let state = Self { @@ -912,6 +916,7 @@ impl AppState { shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), nip98_replay, + gif_http_client, admission_rate_limiter, observer_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index e0645ef6fb9..8a4b0c6d665 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -44,6 +44,12 @@ The chart is designed for ArgoCD and Flux. Both render charts with `helm templat Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. +To enable relay-proxied KLIPY search, add `BUZZ_KLIPY_API_KEY` to that Secret. +The key stays in the relay pod; clients discover the public `buzz-gif` +extension and `gif` descriptor in NIP-11, then receive KLIPY-hosted media URLs. +See [`docs/gif-search.md`](../../../docs/gif-search.md) for the protocol and +security boundaries. + See: - [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml index 42d3254d486..c615a0de316 100644 --- a/deploy/charts/buzz/examples/secret-sample.yaml +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -12,6 +12,7 @@ # REDIS_URL — redis://... (required when replicaCount > 1) # BUZZ_S3_ACCESS_KEY # BUZZ_S3_SECRET_KEY +# BUZZ_KLIPY_API_KEY — omit to disable relay-proxied GIF search apiVersion: v1 kind: Secret metadata: @@ -25,3 +26,4 @@ stringData: REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" BUZZ_S3_ACCESS_KEY: "REPLACE" BUZZ_S3_SECRET_KEY: "REPLACE" + BUZZ_KLIPY_API_KEY: "REPLACE" diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 451ebb1cded..319ec7f1594 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -215,6 +215,12 @@ spec: name: {{ include "buzz.envSecretName" . }} key: BUZZ_S3_SECRET_KEY optional: true + - name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_KLIPY_API_KEY + optional: true - name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: {{ include "buzz.huddleAudioAvailable" . | quote }} diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml index dca83ce27ff..d313caf4d4a 100644 --- a/deploy/charts/buzz/tests/secrets_test.yaml +++ b/deploy/charts/buzz/tests/secrets_test.yaml @@ -80,6 +80,27 @@ tests: optional: true template: templates/deployment.yaml + - it: Deployment env points BUZZ_KLIPY_API_KEY at existingSecret as optional + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + secrets.existingSecret: "buzz-secrets" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: buzz-secrets + key: BUZZ_KLIPY_API_KEY + optional: true + template: templates/deployment.yaml + - it: READ_DATABASE_URL stays optional against the chart-managed Secret set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 738c50eec31..6c57a5c8ac9 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -94,6 +94,7 @@ ownerPubkey: "" # REDIS_URL — full Redis URL with auth # BUZZ_S3_ACCESS_KEY — S3 access key # BUZZ_S3_SECRET_KEY — S3 secret key +# BUZZ_KLIPY_API_KEY — KLIPY GIF search key; omit to disable GIF search secrets: existingSecret: "" # Inline overrides (NOT recommended for production; they land in values). diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 92d640afa9f..ea7136db427 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -82,11 +82,14 @@ type EmojiPickerProps = { autoFocus?: boolean; /** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */ onSelect: (emoji: string) => void; + /** Number of emoji columns. Defaults to the compact picker used elsewhere. */ + perLine?: number; }; export const EmojiPicker = React.memo(function EmojiPicker({ autoFocus = false, onSelect, + perLine = 8, }: EmojiPickerProps) { const customEmoji = useCustomEmoji(); const custom = React.useMemo( @@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ onSelect(value); } }} - perLine={8} + perLine={perLine} previewPosition="bottom" set="native" skinTonePosition="search" diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d79cce9c35a..961716aaf48 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -610,6 +610,7 @@ export function ForumComposer({ ) : undefined } formattingDisabled={Boolean(disabled || isSubmissionPending)} + gifMediaController={media} isEmojiPickerOpen={isEmojiPickerOpen} isFormattingOpen={isFormattingOpen} isSending={Boolean(isSending || isSubmissionPending)} diff --git a/desktop/src/features/gifs/api.test.mjs b/desktop/src/features/gifs/api.test.mjs new file mode 100644 index 00000000000..60d44b29de0 --- /dev/null +++ b/desktop/src/features/gifs/api.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + klipyGifAttachment, + klipyGifFilename, + normalizeKlipyGifs, + relayKlipyCapability, +} from "./api.ts"; + +const GIF_ASSET = { + url: "https://static.klipy.com/example.gif", + width: 640, + height: 360, + size: 42, +}; + +test("normalizeKlipyGifs selects a compact preview and medium GIF", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 7, + title: " Ship it ", + slug: "ship-it", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + webp: { + url: "https://static.klipy.com/preview.webp", + width: 220, + height: 124, + size: 12, + }, + }, + }, + }, + ]); + + assert.equal(gif.title, "Ship it"); + assert.equal(gif.original.url, GIF_ASSET.url); + assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp"); + assert.equal(gif.poster, null); +}); + +test("normalizeKlipyGifs carries a static jpg poster when present", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 8, + title: "Static poster", + slug: "static-poster", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + jpg: { + url: "https://static.klipy.com/poster.jpg", + width: 220, + height: 124, + size: 8, + }, + }, + }, + }, + ]); + + assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg"); +}); + +test("normalizeKlipyGifs omits ads and malformed file records", () => { + const gifs = normalizeKlipyGifs([ + { id: 1, slug: "ad", type: "ad" }, + { id: 2, slug: "missing", type: "gif", file: {} }, + ]); + + assert.deepEqual(gifs, []); +}); + +test("relayKlipyCapability requires safe search and share endpoints", () => { + assert.deepEqual( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["nip-er", "buzz-gif"], + }), + { searchPath: "/gifs/search", sharePath: "/gifs/share" }, + ); + assert.equal(relayKlipyCapability({}), null); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "another", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { provider: "klipy", search: "/gifs/search" }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + for (const path of [ + "https://attacker.example/search", + "//attacker.example/search", + "/\\attacker.example/search", + "/%5c%5cattacker.example/search", + "/gifs/../admin", + "/gifs/%2e%2e/admin", + "/gifs/search?redirect=https://attacker.example", + "/gifs/search#fragment", + ]) { + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: path, + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: path, + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + } +}); + +test("klipyGifFilename sanitizes provider slugs", () => { + const gif = { + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: " That's a wrap! ", + title: "That's a wrap", + }; + + const filename = klipyGifFilename(gif); + + assert.equal(filename, "that-s-a-wrap.gif"); +}); + +test("klipyGifAttachment references KLIPY media without an uploaded copy", () => { + const attachment = klipyGifAttachment({ + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: "ship-it", + title: "Ship it", + }); + + assert.deepEqual(attachment, { + dim: "640x360", + displayLabel: "Ship it", + filename: "ship-it.gif", + sha256: "", + size: 42, + type: "image/gif", + uploaded: 0, + url: GIF_ASSET.url, + }); +}); diff --git a/desktop/src/features/gifs/api.ts b/desktop/src/features/gifs/api.ts new file mode 100644 index 00000000000..1408eaeb3cf --- /dev/null +++ b/desktop/src/features/gifs/api.ts @@ -0,0 +1,185 @@ +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; + +type KlipyAsset = { + height?: number; + size?: number; + url?: string; + width?: number; +}; + +type KlipyFileSet = { + gif?: KlipyAsset; + jpg?: KlipyAsset; + webp?: KlipyAsset; +}; + +type KlipyRawGif = { + file?: { + hd?: KlipyFileSet; + md?: KlipyFileSet; + sm?: KlipyFileSet; + xs?: KlipyFileSet; + }; + id?: number; + slug?: string; + title?: string; + type?: string; +}; + +export type KlipyResponse = { + data?: { + data?: KlipyRawGif[]; + }; + result?: boolean; +}; + +export type KlipyGif = { + id: number | null; + original: Required; + /** + * Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg` + * asset. Rendered in place of the animated preview under + * `prefers-reduced-motion: reduce`. + */ + poster: Required | null; + preview: Required; + slug: string; + title: string; +}; + +export type RelayGifSearchInfo = { + gif?: { + provider?: string; + search?: string; + share?: string; + }; + supported_extensions?: string[]; +}; + +export type RelayKlipyCapability = { + searchPath: string; + sharePath: string; +}; + +function safeRelayPath(path: unknown): path is string { + return ( + typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") && + !path.includes("\\") && + !path.includes("%") && + !path.includes("?") && + !path.includes("#") && + !path.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */ +export function relayKlipyCapability( + info: RelayGifSearchInfo, +): RelayKlipyCapability | null { + const searchPath = info.gif?.search; + const sharePath = info.gif?.share; + if ( + info.supported_extensions?.includes("buzz-gif") === true && + info.gif?.provider === "klipy" && + safeRelayPath(searchPath) && + safeRelayPath(sharePath) + ) { + return { searchPath, sharePath }; + } + return null; +} + +function isCompleteAsset( + asset: KlipyAsset | undefined, +): asset is Required { + return ( + typeof asset?.url === "string" && + asset.url.length > 0 && + typeof asset.width === "number" && + typeof asset.height === "number" && + typeof asset.size === "number" + ); +} + +function firstCompleteAsset( + ...assets: Array +): Required | null { + return assets.find(isCompleteAsset) ?? null; +} + +/** + * Normalize KLIPY's mixed media response to GIF-only results. The API can + * interleave ad/content records without a file payload; those are intentionally + * omitted until Buzz has an explicit third-party ad surface. + */ +export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] { + const gifs: KlipyGif[] = []; + + for (const item of items) { + if (item.type !== "gif" || !item.file || !item.slug) continue; + + const original = firstCompleteAsset( + item.file.md?.gif, + item.file.hd?.gif, + item.file.sm?.gif, + item.file.xs?.gif, + ); + const preview = firstCompleteAsset( + item.file.sm?.webp, + item.file.sm?.gif, + item.file.xs?.webp, + item.file.xs?.gif, + item.file.md?.webp, + original ?? undefined, + ); + if (!original || !preview) continue; + + const poster = firstCompleteAsset( + item.file.sm?.jpg, + item.file.xs?.jpg, + item.file.md?.jpg, + item.file.hd?.jpg, + ); + + gifs.push({ + id: item.id ?? null, + original, + poster, + preview, + slug: item.slug, + title: item.title?.trim() || "GIF", + }); + } + + return gifs; +} + +export function klipyGifFilename(gif: KlipyGif): string { + const safeSlug = gif.slug + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${safeSlug || "klipy-gif"}.gif`; +} + +/** + * Represent a selected KLIPY GIF as externally hosted media. The empty hash + * marks it as content-only media: the outgoing builder appends the image URL + * to the message body but deliberately omits an imeta tag, since Buzz relays + * only accept verified local `/media/` entries in imeta. + */ +export function klipyGifAttachment(gif: KlipyGif): ImetaMedia { + return { + dim: `${gif.original.width}x${gif.original.height}`, + displayLabel: gif.title, + filename: klipyGifFilename(gif), + sha256: "", + size: gif.original.size, + type: "image/gif", + uploaded: 0, + url: gif.original.url, + }; +} diff --git a/desktop/src/features/gifs/relay.ts b/desktop/src/features/gifs/relay.ts new file mode 100644 index 00000000000..bdcffd4f9f6 --- /dev/null +++ b/desktop/src/features/gifs/relay.ts @@ -0,0 +1,142 @@ +import { + type KlipyGif, + type KlipyResponse, + normalizeKlipyGifs, + relayKlipyCapability, + type RelayKlipyCapability, + type RelayGifSearchInfo, +} from "@/features/gifs/api"; +import { relayHttpFromWs } from "@/shared/api/inviteHelpers"; +import { signRelayEvent } from "@/shared/api/tauri"; + +const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:"; +const NIP98_KIND = 27235; + +function customerId(relayUrl: string): string { + if (typeof window === "undefined") return globalThis.crypto.randomUUID(); + + try { + const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`; + const existing = window.localStorage.getItem(storageKey); + if (existing) return existing; + + const created = globalThis.crypto.randomUUID(); + window.localStorage.setItem(storageKey, created); + return created; + } catch { + // Storage can be unavailable in hardened webviews. Prefer an ephemeral ID + // over a process-wide fallback that would correlate unrelated relays. + return globalThis.crypto.randomUUID(); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function nip98PostHeader(url: string, body: string): Promise { + const authEvent = await signRelayEvent({ + kind: NIP98_KIND, + content: "", + tags: [ + ["u", url], + ["method", "POST"], + ["payload", await sha256Hex(body)], + ["nonce", crypto.randomUUID()], + ], + }); + return `Nostr ${btoa(JSON.stringify(authEvent))}`; +} + +const FRIENDLY_GIF_ERRORS: Record = { + relay_membership_required: "Join this community to search GIFs.", +}; + +function gifErrorMessage(error: string | undefined, status: number): string { + if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error]; + return error || `GIF request failed (${status})`; +} + +async function relayPost( + relayUrl: string, + path: string, + payload: Record, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`; + const body = JSON.stringify(payload); + const response = await fetch(url, { + body, + headers: { + Authorization: await nip98PostHeader(url, body), + "Content-Type": "application/json", + }, + method: "POST", + signal, + }); + if (!response.ok) { + const json = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(gifErrorMessage(json.error, response.status)); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +/** The selected relay's advertised KLIPY endpoints, when supported. */ +export async function relayKlipyEndpoints( + relayUrl: string, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`; + const response = await fetch(url, { + headers: { Accept: "application/nostr+json" }, + signal, + }); + if (!response.ok) + throw new Error(`Could not read relay capabilities (${response.status})`); + const info = (await response.json()) as RelayGifSearchInfo; + return relayKlipyCapability(info); +} + +/** Search KLIPY through the selected relay without exposing its provider key. */ +export async function fetchKlipyGifs( + relayUrl: string, + searchPath: string, + query: string, + signal?: AbortSignal, +): Promise { + const response = await relayPost( + relayUrl, + searchPath, + { + customer_id: customerId(relayUrl), + locale: navigator.language || "en-US", + query: query.trim(), + }, + signal, + ); + if (response.result === false) { + throw new Error("GIF search failed"); + } + return normalizeKlipyGifs(response.data?.data ?? []); +} + +/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */ +export async function reportKlipyShare( + relayUrl: string, + sharePath: string, + slug: string, +): Promise { + await relayPost(relayUrl, sharePath, { + customer_id: customerId(relayUrl), + slug, + }); +} diff --git a/desktop/src/features/gifs/ui/KlipyGifPicker.tsx b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx new file mode 100644 index 00000000000..6558cb24b8c --- /dev/null +++ b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoaderCircle, Search } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { KlipyGif } from "@/features/gifs/api"; +import { fetchKlipyGifs } from "@/features/gifs/relay"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type KlipyGifPickerProps = { + onSelect: (gif: KlipyGif) => void; + relayUrl: string; + searchPath: string; +}; + +const LOADING_SKELETONS = [ + "tall-a", + "short-a", + "short-b", + "tall-b", + "short-c", + "short-d", + "tall-c", + "short-e", + "short-f", + "tall-d", +] as const; + +export const KlipyGifPicker = React.memo(function KlipyGifPicker({ + onSelect, + relayUrl, + searchPath, +}: KlipyGifPickerProps) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const prefersReducedMotion = useReducedMotion() ?? false; + + React.useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search.trim()), + 500, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const gifsQuery = useQuery({ + queryFn: ({ signal }) => + fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal), + queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch], + retry: false, + staleTime: 5 * 60 * 1_000, + }); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search KLIPY" + type="search" + value={search} + /> + {gifsQuery.isFetching ? ( + + ) : null} +
+
+ +
+ {gifsQuery.isPending ? ( +
+ Loading GIFs + {LOADING_SKELETONS.map((id) => ( + + ))} +
+ ) : gifsQuery.isError ? ( +
+

+ {gifsQuery.error.message} +

+ +
+ ) : gifsQuery.data.length === 0 ? ( +
+ No GIFs found. +
+ ) : ( +
+ {gifsQuery.data.map((gif) => { + const staticPoster = prefersReducedMotion ? gif.poster : null; + const showAnimated = !prefersReducedMotion; + return ( + + ); + })} +
+ )} +
+ +
+ Powered by KLIPY +
+
+ ); +}); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 79c193fb19b..e1af5be6bcd 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => { assert.equal(out[0].size, 0); }); -test("buildImetaTags: omits x line when sha256 is empty", () => { +test("buildImetaTags: omits hashless external media entirely", () => { const tags = buildImetaTags([ { - url: "https://b/a.png", - type: "image/png", + url: "https://static.klipy.com/a.gif", + type: "image/gif", sha256: "", size: 1, uploaded: 0, }, ]); - assert.equal(tags.length, 1); - // No element starts with "x " or "x\t" — no empty x line emitted. - assert.ok( - !tags[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(tags[0])}`, - ); + assert.deepEqual(tags, []); +}); + +test("buildOutgoingMessage: hashless external media is content-only", () => { + const out = buildOutgoingMessage("", [ + { + url: "https://static.klipy.com/a.gif", + type: "image/gif", + sha256: "", + size: 1, + uploaded: 0, + }, + ]); + assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)"); + assert.equal(out.mediaTags, undefined); }); test("buildImetaTags: omits size line when size is 0", () => { @@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => { ); }); -test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => { - // Legacy / cross-client entry: only url + m. No x, no size. +test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => { const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]]; const projected = imetaMediaFromTags(legacyTags); assert.equal(projected.length, 1); assert.equal(projected[0].sha256, ""); assert.equal(projected[0].size, 0); - const rebuilt = buildImetaTags(projected); - assert.equal(rebuilt.length, 1); - // Neither "x " nor "size 0" leaked into the rebuilt tag. - assert.ok( - !rebuilt[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(rebuilt[0])}`, - ); - assert.ok( - !rebuilt[0].some((part) => /^size[\s\t]/.test(part)), - `expected no size line, got ${JSON.stringify(rebuilt[0])}`, - ); - // url and m survived. - assert.deepEqual(rebuilt[0], [ - "imeta", - "url https://b/legacy.png", - "m image/png", - ]); + assert.deepEqual(buildImetaTags(projected), []); }); const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index fde84922897..f02b9906ca2 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -79,29 +79,31 @@ export function imetaMediaFromTags( * Shared by the send path (initial post) and the edit path (full new tag set * on the edit event), so the two stay perfectly symmetric. * - * `url` and `m` are always emitted (NIP-92's only de-facto required fields; - * `m` carries a fallback in `imetaMediaFromTags`). All other fields are - * conditional — including `x` and `size` — because legacy and cross-client - * imeta entries can land without a sha256 or size, and our relay validator - * rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every - * field except `url` as optional, so dropping them is spec-clean. + * `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries + * without a hash represent external content (for example KLIPY GIFs); their + * markdown URL remains in the message body, but they are omitted from imeta + * because Buzz's relay validator requires a hash-backed local `/media/` path. + * Other fields remain conditional so legacy entries do not emit invalid + * literal `"size 0"` values. */ export function buildImetaTags( imetaMedia: ReadonlyArray, ): string[][] { - return imetaMedia.map((d) => [ - "imeta", - `url ${d.url}`, - `m ${d.type}`, - ...(d.sha256 ? [`x ${d.sha256}`] : []), - ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), - ...(d.dim ? [`dim ${d.dim}`] : []), - ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), - ...(d.thumb ? [`thumb ${d.thumb}`] : []), - ...(d.duration != null ? [`duration ${d.duration}`] : []), - ...(d.image ? [`image ${d.image}`] : []), - ...(d.filename ? [`filename ${d.filename}`] : []), - ]); + return imetaMedia + .filter((d) => d.sha256.length > 0) + .map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + `x ${d.sha256}`, + ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ...(d.duration != null ? [`duration ${d.duration}`] : []), + ...(d.image ? [`image ${d.image}`] : []), + ...(d.filename ? [`filename ${d.filename}`] : []), + ]); } const MEDIA_LINE_RE = @@ -329,9 +331,11 @@ export function buildOutgoingMessage( spoiler: spoileredMediaUrls.has(d.url), }); } - const mediaTags = - pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined; - return { content, mediaTags }; + const mediaTags = buildImetaTags(pendingImeta); + return { + content, + mediaTags: mediaTags.length > 0 ? mediaTags : undefined, + }; } /** diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index f46336b3e90..be0db496496 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -13,7 +13,6 @@ import { X, } from "lucide-react"; -import type { BlobDescriptor } from "@/shared/api/tauri"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { @@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties { } type MediaAttachmentItemProps = { - attachment: BlobDescriptor; + attachment: ImetaMedia; isSpoilered: boolean; onEditSave?: (url: string, bytes: Uint8Array) => Promise; onRemove: (url: string) => void; @@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef< const hash = shortHash(attachment.sha256); const isVideo = attachment.type.startsWith("video/"); + // One accessible name for every control/label in this item. Provider media + // (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary + // uploads keep their historical type-aware `Attachment ` / + // `Video attachment ` name; only genuinely hashless non-provider media + // falls back to a filename. + const mediaLabel = + attachment.displayLabel?.trim() || + (attachment.sha256 + ? isVideo + ? `Video attachment ${hash}` + : `Attachment ${hash}` + : attachment.filename?.trim() || `Attachment ${hash}`); const thumbUrl = attachment.thumb ? rewriteRelayUrl(attachment.thumb) : rewriteRelayUrl(attachment.url); @@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef< ? rewriteRelayUrl(attachment.thumb) : undefined; - const canEdit = !isVideo && onEditSave !== undefined; + // Only Buzz-hosted uploads have a content hash. URL-only provider media + // must remain externally hosted instead of being copied into storage by the + // image editor's save path. + const canEdit = + !isVideo && onEditSave !== undefined && attachment.sha256.length === 64; const canRevert = !isVideo && onRevert !== undefined && originalUrl !== undefined; @@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef< style={composerMediaStyle()} > - -
- {isVideo ? ( -
- {videoPosterUrl ? ( - {`Video - ) : ( -
- )} -
-
- -
-
- ) : ( - {`Attachment - )} - {isSpoilered ? ( -
- + + {isVideo ? ( +
+ {videoPosterUrl ? ( + + ) : ( +
+ )} +
+
+
- ) : null} -
+
+ ) : ( + + )} + {isSpoilered ? ( +
+ +
+ ) : null} - Attachment {hash} preview + {mediaLabel} preview Full-size attachment preview. Press Escape or click outside to @@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef< ) : null} {mode === "edit" && !isVideo ? ( ) : ( {`Attachment + + {displayName} + + ); +} + +function isCustomEmojiShortcode(emoji: string) { + return emoji.startsWith(":") && emoji.endsWith(":"); +} + export const MessageActionBar = React.memo(function MessageActionBar({ channelId, message, @@ -372,6 +424,20 @@ export const MessageActionBar = React.memo(function MessageActionBar({ }) { const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false); const [isDropdownOpen, setIsDropdownOpen] = React.useState(false); + const customEmoji = useCustomEmoji(); + const quickReactionEmojis = useQuickReactionEmojis(3, customEmoji); + const quickReactionItems = React.useMemo( + () => + quickReactionEmojis + .map((emoji) => ({ + customEmojiUrl: reactionEmojiUrl(emoji, customEmoji), + emoji, + })) + .filter( + (item) => !isCustomEmojiShortcode(item.emoji) || item.customEmojiUrl, + ), + [customEmoji, quickReactionEmojis], + ); const hasReplyAction = Boolean(onReply); const hasReactionAction = Boolean(onReactionSelect); @@ -436,6 +502,19 @@ export const MessageActionBar = React.memo(function MessageActionBar({ >
+ {hasReactionAction && quickReactionItems.length > 0 ? ( +
+ {quickReactionItems.map(({ customEmojiUrl, emoji }) => ( + + ))} +
+ ) : null} + {hasReactionAction ? ( ) : null} + {hasReactionAction && quickReactionItems.length > 0 ? ( + ); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index d1b88ae3a6b..5bce6a71ff7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -13,6 +13,7 @@ import type { } from "@/features/profile/ui/UserProfilePanel"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import type { Channel } from "@/shared/api/types"; +import type { IdleAuxiliaryHeaderControls } from "./IdleAuxiliaryPanel"; export type ChannelPaneProps = { activeChannel: Channel | null; activityAgents?: BotActivityAgent[]; @@ -39,6 +40,16 @@ export type ChannelPaneProps = { editTarget?: MessageComposerEditTarget | null; fetchOlder?: () => Promise; header?: React.ReactNode; + /** + * Idle-state body for the right auxiliary pane (project extras, etc.). + * Uses the same slot as thread, profile, agent-session, and management panels. + * By default it yields to those surfaces; callers may opt into thread override. + */ + idleAuxiliaryPanel?: React.ReactNode; + idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + /** Show the idle auxiliary surface ahead of an already-open thread. */ + idleAuxiliaryOverridesThread?: boolean; + idleAuxiliaryTitle?: string; hasOlderMessages?: boolean; /** True when the loaded window provably starts at the channel's beginning. */ historyExhausted?: boolean; @@ -80,8 +91,10 @@ export type ChannelPaneProps = { onCloseAgentSession: () => void; onCloseChannelManagement?: () => void; onChannelManagementDeleted?: () => void; + onCloseIdleAuxiliaryPanel?: () => void; onCloseProfilePanel: () => void; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onCloseThread: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index fe0c4d23b7d..9c2d25dd7cf 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,3 +1,4 @@ +// biome-ignore-all format: line-count ratchet requires compact forwarding in this legacy component import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAppShell } from "@/app/AppShellContext"; @@ -7,14 +8,24 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import * as channelHooks from "@/features/channels/hooks"; -import * as readStateFormat from "@/features/channels/readState/readStateFormat"; +import { + useChannelMembersQuery, + useJoinChannelMutation, +} from "@/features/channels/hooks"; +import { + MSG_PREFIX, + THREAD_PREFIX, +} from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import * as agentHooks from "@/features/agents/hooks"; +import { + useManagedAgentsQuery, + usePersonasQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -34,7 +45,10 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import * as threading from "@/features/messages/lib/threading"; +import { + getThreadReference, + isThreadReply, +} from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -49,7 +63,10 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; +import { + useHuddleChannelMessages, + useIsHuddleTranscript, +} from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -71,21 +88,20 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -import { GuardedChannelPane } from "./GuardedChannelPane"; -import { useNavigationGuard } from "./useNavigationGuard"; -import * as searchForwarding from "./searchTargetForwarding"; +import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, currentIdentity, currentProfile, - onCloseForumPost, - onSelectForumPost, - selectedForumPostId, - targetForumReplyId, - targetMessageEvents, - targetMessageId, + headerEndActions, idleAuxiliaryPanel, + idleAuxiliaryHeaderActions, idleAuxiliaryOverridesThread, + idleAuxiliaryTitle, + onAddFiles, onCloseIdleAuxiliaryPanel, + onCloseForumPost, onSelectForumPost, + selectedForumPostId, targetForumReplyId, + targetMessageEvents, targetMessageId, ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); @@ -157,8 +173,7 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = - huddleMessages.useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -201,7 +216,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (threading.getThreadReference(messages[index].tags).parentId === null) + if (getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -220,8 +235,7 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(readStateFormat.THREAD_PREFIX) || - contextId.startsWith(readStateFormat.MSG_PREFIX) + contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) ? activeChannelId : null, ); @@ -241,14 +255,13 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = - channelHooks.useJoinChannelMutation(activeChannelId); + const joinChannelMutation = useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = huddleMessages.useHuddleChannelMessages({ + } = useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -294,11 +307,9 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = channelHooks.useChannelMembersQuery( - activeChannel?.id ?? null, - ); + const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -309,7 +320,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -376,7 +387,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = agentHooks.usePersonasQuery(); + const personasQuery = usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -493,8 +504,7 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && - threading.isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -556,14 +566,8 @@ export function ChannelScreen({ welcomeAgentCreate.openAddAgent(() => setIsAddBotOpen(true), options), [welcomeAgentCreate], ); - const handleOpenMembersSidebar = React.useCallback( - () => setIsMembersSidebarOpen(true), - [], - ); - const handleCloseChannelManagement = React.useCallback( - () => setChannelManagementOpen(false), - [setChannelManagementOpen], - ); + const handleOpenMembersSidebar = () => setIsMembersSidebarOpen(true); + const handleCloseChannelManagement = () => setChannelManagementOpen(false); const handleChannelManagementDeleted = React.useCallback(() => { setChannelManagementOpen(false); void goHome({ replace: true }); @@ -753,7 +757,7 @@ export function ChannelScreen({ activeDmHeaderParticipants={activeDmHeaderParticipants} activeDmPresenceStatus={activeDmPresenceStatus} chromeWrapperRef={channelHeaderChromeRef} - currentPubkey={currentPubkey} + {...{ currentPubkey, headerEndActions }} isAddBotOpen={isAddBotOpen} isJoining={joinChannelMutation.isPending} onAddBotOpenChange={setIsAddBotOpen} @@ -774,6 +778,7 @@ export function ChannelScreen({ activeDmPresenceStatus, channelHeaderChromeRef, currentPubkey, + headerEndActions, isAddBotOpen, joinChannelMutation.isPending, joinChannelMutation.mutateAsync, @@ -816,170 +821,165 @@ export function ChannelScreen({ activeChannel.channelType === "forum" ? ( searchForwarding.renderSearchAwareForum( , searchTarget, ) ) : ( - } + fallback={} > {searchForwarding.renderSearchAwareChannel( - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} - onOpenDm={handleOpenDm} - onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} - onProfilePanelTabChange={setProfilePanelTab} - profilePanelPubkey={profilePanelPubkey} - profilePanelTab={profilePanelTab} - profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} + activeChannel={activeChannel} + activityAgents={channelAgentSessionAgents} + agentPubkeys={agentPubkeys} + agentPubkeysPending={agentPubkeysPending} + agentSessionAgents={agentSessionAgents} + autoSendDraftKey={autoSendDraftKey} + onAutoSendComplete={clearAutoSend} + botTypingEntries={botTypingEntries} + channelManagementOpen={channelManagementOpen} + currentPubkey={currentPubkey} + canResetThreadPanelWidth={canResetThreadPanelWidth} + fetchOlder={fetchOlder} + header={channelHeader} + {...{ idleAuxiliaryHeaderActions, idleAuxiliaryOverridesThread, idleAuxiliaryPanel, idleAuxiliaryTitle, hasOlderMessages, historyExhausted }} + {...{ onAddFiles }} + onAddAgent={handleOpenAddBot} + onBrowseChannels={openBrowseChannels} + onCreateChannel={openCreateChannel} + onOpenMembers={handleOpenMembersSidebar} + isFetchingOlder={isFetchingOlder} + isHuddleTranscript={isHuddleTranscript} + entranceMessageId={welcomeEntranceMessageId} + onEntranceMessageComplete={handleWelcomeEntranceComplete} + welcomeKickoffStage={welcomeKickoffStage} + welcomeKickoffSettingUp={welcomeKickoffSettingUp} + editTarget={ + editTargetMessage + ? buildMessageComposerEditTarget( + editTargetMessage, + messageProfiles, + (pubkey) => + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + {...{ onCloseIdleAuxiliaryPanel }} + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} typingPubkeys={humanTypingPubkeys} />, searchTarget, diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index a64937a74b1..0a465331399 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -1,9 +1,12 @@ +import type { ReactNode } from "react"; + import type { Channel, Identity, Profile, RelayEvent, } from "@/shared/api/types"; +import type { IdleAuxiliaryHeaderControls } from "./IdleAuxiliaryPanel"; export type ChannelScreenProps = { activeChannel: Channel | null; @@ -16,6 +19,13 @@ export type ChannelScreenProps = { autoSendDraftKey: string | null; currentIdentity?: Identity; currentProfile?: Profile; + idleAuxiliaryPanel?: ReactNode; + idleAuxiliaryHeaderActions?: IdleAuxiliaryHeaderControls; + idleAuxiliaryOverridesThread?: boolean; + idleAuxiliaryTitle?: string; + headerEndActions?: ReactNode; + onAddFiles?: () => void; + onCloseIdleAuxiliaryPanel?: () => void; onCloseForumPost: () => void; onSelectForumPost: (postId: string) => void; selectedForumPostId: string | null; diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 358a0e637bb..44e4d891dc1 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -6,6 +6,7 @@ import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralC import type { ActiveDmHeaderParticipant } from "@/features/channels/useActiveChannelHeader"; import { getChannelDescription } from "@/features/channels/lib/channelDescription"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { @@ -38,6 +39,7 @@ type ChannelScreenHeaderProps = { activeDmPresenceStatus: PresenceStatus | null; chromeWrapperRef?: React.Ref; currentPubkey?: string; + headerEndActions?: React.ReactNode; isAddBotOpen?: boolean; isJoining?: boolean; showHeaderContent?: boolean; @@ -58,6 +60,7 @@ export function ChannelScreenHeader({ activeDmPresenceStatus, chromeWrapperRef, currentPubkey, + headerEndActions, isAddBotOpen, isJoining = false, onAddBotOpenChange, @@ -95,19 +98,23 @@ export function ChannelScreenHeader({ ) : null; const channelActions = activeChannel ? ( showJoinButton ? ( - +
+ + {headerEndActions} +
) : ( ) - ) : null; - const actions = activeChannel ? ( -
- {terminalButton} - {channelActions} -
- ) : null; + ) : ( + headerEndActions + ); + const actions = + terminalButton || channelActions ? ( +
+ {terminalButton} + {channelActions} +
+ ) : null; if (!showHeaderContent) { return null; @@ -173,6 +183,11 @@ export function ChannelScreenHeader({ testId="chat-header-dm-avatar" /> ) + ) : activeChannel ? ( + ) : undefined } statusBadge={ diff --git a/desktop/src/features/channels/ui/ChannelTypePicker.tsx b/desktop/src/features/channels/ui/ChannelTypePicker.tsx index 78e2c1080bb..b69e382bbb2 100644 --- a/desktop/src/features/channels/ui/ChannelTypePicker.tsx +++ b/desktop/src/features/channels/ui/ChannelTypePicker.tsx @@ -1,6 +1,8 @@ import { ChevronDown, ClockFading, Hash } from "lucide-react"; import * as React from "react"; +import type { ChannelLifecycle } from "@/features/channels/lib/channelLifecycle"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { @@ -11,37 +13,57 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +const LIFECYCLE_LABEL: Record = { + ongoing: "Ongoing", + project: "Project", + temporary: "Temporary", +}; + +const LIFECYCLE_ICON = { + ongoing: Hash, + temporary: ClockFading, +} as const; + export function ChannelTypePicker({ align = "start", + allowProject = false, ariaLabel, className, disabled, + lifecycle, + onLifecycleChange, onOpenChange, - onTemporaryChange, open, - temporary, temporaryOptionAriaLabel = "Temporary channel", testId, }: { align?: React.ComponentProps["align"]; + allowProject?: boolean; ariaLabel?: string; className?: string; disabled?: boolean; + lifecycle: ChannelLifecycle; + onLifecycleChange: (lifecycle: Exclude) => void; onOpenChange?: (open: boolean) => void; - onTemporaryChange: (temporary: boolean) => void; open?: boolean; - temporary: boolean; temporaryOptionAriaLabel?: string; testId?: string; }) { const [internalOpen, setInternalOpen] = React.useState(false); const pickerOpen = open ?? internalOpen; const setPickerOpen = onOpenChange ?? setInternalOpen; - const label = temporary ? "Temporary" : "Ongoing"; - const Icon = temporary ? ClockFading : Hash; + const label = LIFECYCLE_LABEL[lifecycle]; + const Icon = lifecycle === "project" ? null : LIFECYCLE_ICON[lifecycle]; + const projectLocked = lifecycle === "project"; function selectType(nextType: string) { - onTemporaryChange(nextType === "temporary"); + if (nextType === "project" || projectLocked) { + setPickerOpen(false); + return; + } + if (nextType === "temporary" || nextType === "ongoing") { + onLifecycleChange(nextType); + } setPickerOpen(false); } @@ -59,7 +81,11 @@ export function ChannelTypePicker({ type="button" variant="ghost" > - + {Icon ? ( + + ) : ( + + )} {label} @@ -71,15 +97,22 @@ export function ChannelTypePicker({ minWidth: "var(--radix-dropdown-menu-trigger-width)", }} > - - + + {allowProject ? ( + + Project + + ) : null} + Ongoing Temporary diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 6883f4cad1a..82fd1c9f7a1 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,10 +1,16 @@ import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + channelLifecycle, + channelLifecycleLabel, +} from "@/features/channels/lib/channelLifecycle"; import { DEFAULT_EPHEMERAL_TTL_SECONDS, formatTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import type { Channel } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -13,6 +19,7 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; const EPHEMERAL_TIMEOUT_OPTIONS = [ @@ -32,7 +39,34 @@ const CHANNEL_TYPE_RESIZE_TRANSITION = { ease: [0.23, 1, 0.32, 1], } as const; +export function ChannelTypeDetailRow({ + canEdit, + channel, + onEdit, +}: { + canEdit: boolean; + channel: Channel; + onEdit?: () => void; +}) { + const projectHome = useIsProjectHomeChannel(channel.id); + const lifecycle = channelLifecycle({ + projectHome, + temporary: channel.ttlSeconds !== null, + }); + + return ( + + ); +} + export function ChannelTypeSettings({ + channelId, disabled, label = "Channel type", onOpenChange, @@ -43,6 +77,7 @@ export function ChannelTypeSettings({ testIdPrefix, ttlSeconds, }: { + channelId?: string | null; disabled?: boolean; label?: string; onOpenChange?: (open: boolean) => void; @@ -53,6 +88,8 @@ export function ChannelTypeSettings({ testIdPrefix: string; ttlSeconds: number; }) { + const projectHome = useIsProjectHomeChannel(channelId); + const lifecycle = channelLifecycle({ projectHome, temporary }); const shouldReduceMotion = useReducedMotion(); const channelTypeResizeTransition = shouldReduceMotion ? { duration: 0 } @@ -82,17 +119,18 @@ export function ChannelTypeSettings({ {label} onTemporaryChange(next === "temporary")} onOpenChange={onOpenChange} - onTemporaryChange={onTemporaryChange} open={open} - temporary={temporary} testId={`${testIdPrefix}-channel-type`} />
- {temporary ? ( + {temporary && !projectHome ? ( void; }; @@ -140,7 +142,8 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const; export function FocusThreadDrawer({ channelName, children, - hasActiveEdit, + label = "Thread", + hasActiveEdit = false, onClose, }: FocusThreadDrawerProps) { const prefersReducedMotion = useReducedMotion(); @@ -228,9 +231,9 @@ export function FocusThreadDrawer({ // share a radius — a smaller one here would put two radii on one // element. `shadow-panel-left` draws the left edge and its corners; // see the token for why a `border-l` cannot. - "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left", + "absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left outline-hidden", )} - aria-label="Thread" + aria-label={label} data-testid="focus-thread-drawer" ref={drawerRef} role="complementary" diff --git a/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx new file mode 100644 index 00000000000..7a9c454554e --- /dev/null +++ b/desktop/src/features/channels/ui/IdleAuxiliaryPanel.tsx @@ -0,0 +1,82 @@ +import type * as React from "react"; + +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, +} from "@/shared/layout/AuxiliaryPanel"; + +export type IdleAuxiliaryHeaderControls = { + actions?: React.ReactNode; + backLabel?: string; + onBack?: () => void; +}; + +export function IdleAuxiliaryPanel({ + canResetWidth, + children, + headerControls, + isFocusDrawer = false, + isSinglePanelView, + onClose, + onResetWidth, + onResizeStart, + title, + useSplitAuxiliaryPane, + widthPx, +}: { + canResetWidth: boolean; + children: React.ReactNode; + headerControls?: IdleAuxiliaryHeaderControls; + isFocusDrawer?: boolean; + isSinglePanelView: boolean; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: React.PointerEventHandler; + title: string; + useSplitAuxiliaryPane: boolean; + widthPx: number; +}) { + const split = useSplitAuxiliaryPane && !isFocusDrawer; + return ( + + + {title} + + {headerControls?.actions ? ( + + {headerControls.actions} + + ) : null} + + } + > + + {children} + + + ); +} diff --git a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx index 43e4d80e6ba..68f68890bbf 100644 --- a/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx +++ b/desktop/src/features/channels/ui/RightAuxiliaryPane.tsx @@ -6,10 +6,12 @@ import { cn } from "@/shared/lib/cn"; type RightAuxiliaryPaneProps = { canResetWidth: boolean; children: React.ReactNode; + className?: string; constrainToAvailableSpace?: boolean; detached?: boolean; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; + showResizeIndicator?: boolean; testId?: string; widthPx: number; }; @@ -17,10 +19,12 @@ type RightAuxiliaryPaneProps = { export function RightAuxiliaryPane({ canResetWidth, children, + className, constrainToAvailableSpace = true, detached = false, onResetWidth, onResizeStart, + showResizeIndicator = true, testId, widthPx, }: RightAuxiliaryPaneProps) { @@ -31,6 +35,7 @@ export function RightAuxiliaryPane({ detached ? "bg-transparent" : "before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-50 before:w-px before:bg-border/80 before:content-['']", + className, )} data-testid={testId} style={{ @@ -53,7 +58,12 @@ export function RightAuxiliaryPane({ } type="button" > - + {showResizeIndicator ? ( + + ) : null}
{children} diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx index 19f2da0edbc..3374fb7a654 100644 --- a/desktop/src/features/channels/ui/useChannelIntro.tsx +++ b/desktop/src/features/channels/ui/useChannelIntro.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Bot, Plus, Sparkles, UserPlus } from "lucide-react"; +import { Bot, FolderPlus, Plus, Sparkles, UserPlus } from "lucide-react"; import { getChannelIntroDescription, @@ -9,6 +9,8 @@ import { isWelcomeChannel, isWelcomeExperienceChannel, } from "@/features/onboarding/welcome"; +import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; import type { Channel } from "@/shared/api/types"; import { HashSearch } from "@/shared/ui/icons"; @@ -29,6 +31,7 @@ type ChannelIntroAction = { export function useChannelIntro({ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, @@ -36,11 +39,14 @@ export function useChannelIntro({ }: { activeChannel: Channel | null; onAddAgent?: (options?: { beforeSend?: () => void }) => void; + onAddFiles?: () => void; onBrowseChannels?: () => void; onCreateChannel?: () => void; onOpenMembers?: () => void; onWelcomeAddAgent?: () => void; }) { + const projectHome = useIsProjectHomeChannel(activeChannel?.id); + return React.useMemo(() => { if (!activeChannel || activeChannel.channelType === "dm") { return null; @@ -79,7 +85,7 @@ export function useChannelIntro({ actions, channelKindLabel: isWelcomeChannel(activeChannel) ? "private welcome channel" - : getChannelIntroKind(activeChannel), + : getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: isWelcomeChannel(activeChannel) ? null @@ -89,11 +95,21 @@ export function useChannelIntro({ } if (!activeChannel.archivedAt && activeChannel.isMember) { + if (onAddFiles) { + actions.push({ + description: "Add a repo.", + icon: , + label: "Add files", + onClick: onAddFiles, + testId: "channel-intro-action-add-files", + }); + } + if (onAddAgent) { actions.push({ - description: "Bring them in.", - icon: , - label: "Add agents", + description: "Add an agent here.", + icon: , + label: "Add agent", onClick: onAddAgent, testId: "channel-intro-action-create-agent", }); @@ -102,7 +118,7 @@ export function useChannelIntro({ if (onOpenMembers) { actions.push({ description: "Invite members.", - icon: , + icon: , label: "Add people", onClick: onOpenMembers, testId: "channel-intro-action-add-people", @@ -112,16 +128,22 @@ export function useChannelIntro({ return { actions, - channelKindLabel: getChannelIntroKind(activeChannel), + channelKindLabel: getChannelIntroKind(activeChannel, projectHome), channelName: activeChannel.name, description: getChannelIntroDescription(activeChannel), + hideBeginning: projectHome, + icon: projectHome ? ( + + ) : undefined, }; }, [ activeChannel, onAddAgent, + onAddFiles, onBrowseChannels, onCreateChannel, onOpenMembers, onWelcomeAddAgent, + projectHome, ]); } diff --git a/desktop/src/features/channels/ui/useRoutedMessageEdit.ts b/desktop/src/features/channels/ui/useRoutedMessageEdit.ts new file mode 100644 index 00000000000..426226a9fd2 --- /dev/null +++ b/desktop/src/features/channels/ui/useRoutedMessageEdit.ts @@ -0,0 +1,123 @@ +import * as React from "react"; +import { toast } from "sonner"; +import { isThreadReply } from "@/features/messages/lib/threading"; +import type { TimelineMessage } from "@/features/messages/types"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; + +type Input = { + activeChannelId: string | null; + channelIsCovered: boolean; + currentPubkey?: string | null; + editTarget: { id: string; isThreadReply: boolean } | null; + isSinglePanelView: boolean; + mainMessages: TimelineMessage[]; + onCloseThread: () => void; + onEdit?: (message: TimelineMessage) => void; + threadHeadMessage: TimelineMessage | null; + threadMessages: TimelineMessage[]; + useFocusThreadDrawer: boolean; +}; + +export function useRoutedMessageEdit({ + activeChannelId, + channelIsCovered, + currentPubkey, + editTarget, + isSinglePanelView, + mainMessages, + onCloseThread, + onEdit, + threadHeadMessage, + threadMessages, + useFocusThreadDrawer, +}: Input) { + const pendingMainEditRef = React.useRef(null); + const editTargetRef = React.useRef(editTarget); + editTargetRef.current = editTarget; + const contextRef = React.useRef({ + channelId: activeChannelId, + threadId: threadHeadMessage?.id, + }); + const context = { + channelId: activeChannelId, + threadId: threadHeadMessage?.id, + }; + if ( + contextRef.current.channelId !== context.channelId || + (contextRef.current.threadId && + context.threadId && + contextRef.current.threadId !== context.threadId) + ) + pendingMainEditRef.current = null; + contextRef.current = context; + + const findLastOwnEditable = React.useCallback( + (messages: TimelineMessage[]) => { + if (!onEdit || !currentPubkey) return null; + return messages.reduce( + (best, message) => + message.kind === KIND_SYSTEM_MESSAGE || + message.pubkey !== currentPubkey || + message.pending || + (best && message.createdAt < best.createdAt) + ? best + : message, + null, + ); + }, + [currentPubkey, onEdit], + ); + const routeEdit = React.useCallback( + (message: TimelineMessage) => { + const current = editTargetRef.current; + if ( + current && + current.id !== message.id && + current.isThreadReply !== isThreadReply(message.tags ?? []) + ) { + pendingMainEditRef.current = null; + toast.info("Finish or cancel your edit first."); + return false; + } + if (current?.id === message.id) { + pendingMainEditRef.current = null; + onEdit?.(message); + return true; + } + if ( + !isThreadReply(message.tags ?? []) && + (isSinglePanelView || useFocusThreadDrawer) + ) { + pendingMainEditRef.current = message; + onCloseThread(); + return true; + } + onEdit?.(message); + return Boolean(onEdit); + }, + [isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer], + ); + const handleEditLastOwnMainMessage = React.useCallback(() => { + const target = findLastOwnEditable(mainMessages); + return target ? routeEdit(target) : false; + }, [findLastOwnEditable, mainMessages, routeEdit]); + const handleEditLastOwnThreadMessage = React.useCallback(() => { + const target = findLastOwnEditable( + threadHeadMessage + ? [threadHeadMessage, ...threadMessages] + : threadMessages, + ); + return target ? routeEdit(target) : false; + }, [findLastOwnEditable, routeEdit, threadHeadMessage, threadMessages]); + React.useEffect(() => { + const pending = pendingMainEditRef.current; + if (!pending || isSinglePanelView || channelIsCovered) return; + pendingMainEditRef.current = null; + onEdit?.(pending); + }, [channelIsCovered, isSinglePanelView, onEdit]); + return { + handleEditLastOwnMainMessage, + handleEditLastOwnThreadMessage, + routeEdit, + }; +} diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index 5334d58875f..a8afb823ecb 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -544,7 +544,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single test("timeline-body-surface: first authoritative rows wait for deferred paint", () => { // A newly selected populated channel has already resolved live rows, but the // deferred snapshot is still empty. It has never committed a settled empty - // surface, so showing its intro here would flash Create agent / Add people. + // surface, so showing its intro here would flash Add agent / Add people. assert.equal( selectTimelineBodySurface({ deferredCount: 0, diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx index 76d1960b966..fc4379f8b7f 100644 --- a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx +++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx @@ -16,6 +16,7 @@ export type ChannelIntro = { channelKindLabel: string; channelName: string; description?: string | null; + hideBeginning?: boolean; icon?: React.ReactNode; }; @@ -50,20 +51,22 @@ export function ChannelIntroBlock({

#{intro.channelName}

-

- This is the beginning of the{" "} - - {intro.channelKindLabel} - - . -

+ {intro.hideBeginning ? null : ( +

+ This is the beginning of the{" "} + + {intro.channelKindLabel} + + . +

+ )} {intro.description ? (

{intro.description}

) : null} {intro.actions?.length ? ( -
+
{intro.actions.map((action) => { const hasDescription = Boolean(action.description); @@ -72,8 +75,8 @@ export function ChannelIntroBlock({ className={cn( "flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring", hasDescription - ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4" - : "h-28 w-64 flex-col rounded-2xl p-4", + ? "h-52 w-48 flex-col rounded-2xl p-3" + : "h-24 w-56 flex-col rounded-2xl p-3", )} data-testid={action.testId} key={action.label} @@ -84,8 +87,8 @@ export function ChannelIntroBlock({ className={cn( "flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground", hasDescription - ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6" - : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4", + ? "h-10 w-10 [&_svg]:h-5 [&_svg]:w-5" + : "h-9 w-9 [&_svg]:h-4 [&_svg]:w-4", )} data-testid={ action.testId ? `${action.testId}-icon` : undefined @@ -95,7 +98,7 @@ export function ChannelIntroBlock({ ; + projectIds: Set; +}; + +function formatAgentFailures( + failures: ReadonlyArray<{ name: string; error: string }>, +) { + if (failures.length === 1) { + const [failure] = failures; + return `The project was created, but adding ${failure.name} failed: ${failure.error}`; + } + return `The project was created, but adding agents failed: ${failures + .map((failure) => `${failure.name}: ${failure.error}`) + .join("; ")}`; +} + +async function publishProjectEvent(event: RelayEvent) { + try { + await relayClient.publishEvent( + event, + "Timed out creating project.", + "Failed to create project.", + ); + } catch (error) { + if (isUnsupportedProjectKindError(error)) { + throw new Error( + "This relay does not support projects yet, so a project channel cannot be published here.", + ); + } + throw error; + } +} + +async function publishRepositoryEvent(event: RelayEvent) { + await relayClient.publishEvent( + event, + "Timed out creating the project repository.", + "Failed to create the project repository.", + ); +} + +function readCreatedProject( + projectEvent: RelayEvent, + repositoryEvent: RelayEvent | null, +): Project { + const [project] = buildProjectReadModels({ + projectEvents: [projectEvent], + repositoryEvents: repositoryEvent ? [repositoryEvent] : [], + relayOrigin: getCachedRelayOrigin(), + }); + if (!project) { + throw new Error("The project was created but could not be read."); + } + return project; +} + +async function addRequestedAgents( + channelId: string, + agents: readonly CreateChannelManagedAgentInput[] | undefined, +) { + if (!agents || agents.length === 0) return; + const result = await createChannelManagedAgents(channelId, agents); + if (result.failures.length > 0) { + throw new Error(formatAgentFailures(result.failures)); + } +} + +async function fetchOwnHead( + kind: number, + ownerPubkey: string, + dtag: string, +): Promise { + const events = await relayClient.fetchEvents({ + kinds: [kind], + authors: [ownerPubkey], + "#d": [dtag], + limit: 1, + }); + return events[0] ?? null; +} + +async function ensureDefaultRepository({ + channelId, + input, + ownerPubkey, + project, +}: { + channelId: string; + input: CreateProjectInput; + ownerPubkey: string; + project: Project; +}): Promise { + const repositoryTemplate = buildDefaultProjectRepositoryTemplate({ + description: input.description, + name: input.name, + ownerPubkey, + projectChannelId: channelId, + }); + const existingRepository = + project.repositories.find( + (repository) => + repository.repoAddress === repositoryTemplate.repositoryAddress, + ) ?? null; + if (existingRepository) return project; + + let repositoryEvent = await fetchOwnHead( + KIND_REPO_ANNOUNCEMENT, + ownerPubkey, + repositoryTemplate.dtag, + ); + if (!repositoryEvent) { + repositoryEvent = await signRelayEvent(repositoryTemplate.repository); + await publishRepositoryEvent(repositoryEvent); + } + + if ( + project.repositoryAddresses.includes(repositoryTemplate.repositoryAddress) + ) { + const liveProject = + (await fetchOwnHead( + KIND_PROJECT_ANNOUNCEMENT, + ownerPubkey, + project.dtag, + )) ?? null; + if (!liveProject) { + throw new Error("The project was created but could not be read."); + } + return readCreatedProject(liveProject, repositoryEvent); + } + + const liveHead = await fetchOwnHead( + KIND_PROJECT_ANNOUNCEMENT, + ownerPubkey, + project.dtag, + ); + if (!liveHead) { + throw new Error( + "Could not find this project on the relay. Refresh and try again.", + ); + } + const patched = buildProjectPatchTemplate({ + liveHead, + ownerPubkey, + repositoryAddresses: [ + ...new Set([ + ...project.repositoryAddresses, + repositoryTemplate.repositoryAddress, + ]), + ].sort(), + }); + const projectEvent = await signRelayEvent(patched); + await publishProjectEvent(projectEvent); + return readCreatedProject(projectEvent, repositoryEvent); +} + +async function finishCreate( + channel: Channel | null, + project: Project, + input: CreateProjectInput, + resume: CreateProjectResumeState, + projectId: string, +): Promise { + const agentChannelId = channel?.id ?? project.projectChannelId; + if (agentChannelId && input.agents && input.agents.length > 0) { + await addRequestedAgents(agentChannelId, input.agents); + } + resume.projectIds.delete(projectId); + resume.channels.delete(projectId); + return { channel, project }; +} + +/** Creates the home channel, a bound default repository, and the NIP-MP project. */ +export async function createProject( + input: CreateProjectInput, + resume: CreateProjectResumeState, +): Promise { + const identity = await getIdentity(); + const dtagPreview = projectDtagFromName(input.name); + if (!dtagPreview) { + throw new Error("Project name must include letters or numbers."); + } + const existing = await fetchProjects(); + const ownerPubkey = identity.pubkey.toLowerCase(); + const existingProject = existing.find( + (project) => + project.owner.toLowerCase() === ownerPubkey && + project.dtag === dtagPreview, + ); + const projectId = `${ownerPubkey}:${dtagPreview}`; + const canResume = resume.projectIds.has(projectId); + if (existingProject && !canResume) { + throw new Error(`You already have a project named "${dtagPreview}".`); + } + if (existingProject && !existingProject.legacy) { + const cachedChannel = resume.channels.get(projectId) ?? null; + const channelId = + cachedChannel?.id ?? existingProject.projectChannelId ?? ""; + const project = channelId + ? await ensureDefaultRepository({ + channelId, + input, + ownerPubkey, + project: existingProject, + }) + : existingProject; + return finishCreate(cachedChannel, project, input, resume, projectId); + } + const conflict = conflictingListedProject(existing, { + dtag: dtagPreview, + name: input.name, + ownerPubkey, + }); + if (conflict) { + throw new Error( + `A project named "${conflict.name}" already exists. Open that one instead of creating another.`, + ); + } + + resume.projectIds.add(projectId); + let channel = resume.channels.get(projectId); + if (!channel) { + channel = await createChannel({ + channelType: "stream", + description: input.description, + name: input.name.trim(), + visibility: input.channelVisibility ?? "open", + }); + resume.channels.set(projectId, channel); + } + + const templates = buildProjectBootstrapTemplates({ + description: input.description, + name: input.name, + ownerPubkey: identity.pubkey, + projectChannelId: channel.id, + projectVisibility: input.projectVisibility ?? "listed", + }); + const existingRepositoryEvent = await fetchOwnHead( + KIND_REPO_ANNOUNCEMENT, + ownerPubkey, + templates.dtag, + ); + const projectEvent = await signRelayEvent(templates.project); + await publishProjectEvent(projectEvent); + + let repositoryEvent = existingRepositoryEvent; + if (!repositoryEvent) { + repositoryEvent = await signRelayEvent(templates.repository); + await publishRepositoryEvent(repositoryEvent); + } + + const project = readCreatedProject(projectEvent, repositoryEvent); + return finishCreate(channel, project, input, resume, projectId); +} diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index ebfc15a083e..1e397c7141e 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -78,11 +78,9 @@ export type { ProjectPullRequestCommentAnchor, Repository, }; - export type ProjectPullRequestCommentDecision = "request-changes"; const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1"; - export type RepoState = { branches: Array<{ name: string; commit: string }>; tags: Array<{ name: string; commit: string }>; @@ -172,9 +170,11 @@ export async function fetchProjects( signal?: AbortSignal, ): Promise { // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which - // is the pure, Tauri-free core of this operation. That helper's javadoc - // explains the fail-closed tombstone contract and the NIP-OA owner-deletion - // relay-side-suppression decision. + // is the pure, Tauri-free core of this operation. Its javadoc explains + // fail-closed tombstones and NIP-OA owner-deletion suppression. + const viewerPubkey = await getIdentity() + .then((identity) => identity.pubkey) + .catch(() => undefined); const fetcher: FetchProjectEventsExhaustively = fetchExhaustively ?? ((kinds, extraFilter) => @@ -182,6 +182,7 @@ export async function fetchProjects( return buildProjectsFromFetcher(fetcher, { relayOrigin: getCachedRelayOrigin(), hiddenAddresses: new Set(readHiddenProjectCards()), + viewerPubkey, }); } @@ -210,8 +211,10 @@ function eventToRepoState(event: RelayEvent): RepoState { updatedAt: event.created_at, }; } - -async function fetchRepoState(project: Repository): Promise { +/** Load the trusted relay state used to resolve a repository's live refs. */ +export async function fetchRepoState( + project: Repository, +): Promise { const relaySelf = await getRelaySelf(); const trustedAuthors = [ ...new Set( diff --git a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs index 5c620c73947..05fa5402e9f 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.test.mjs +++ b/desktop/src/features/projects/lib/projectAgentConversation.test.mjs @@ -4,6 +4,7 @@ import { beforeEach, test } from "node:test"; import { isAtOrAfterConversationOpener, mergeProjectAgentConversationEvents, + projectAgentMembershipInput, restoreProjectsAgentConversation, submitProjectAgentMessage, visibleConversationMessages, @@ -148,6 +149,53 @@ test("pointers to unknown channels or agents are not restorable", () => { ); }); +test("a stored project-channel pointer restores when it matches the home channel", () => { + const home = { + id: "project-channel-1", + channelType: "stream", + isMember: true, + memberPubkeys: [SELF_PUBKEY, AGENT_PUBKEY], + participantPubkeys: [], + }; + const restored = restoreProjectsAgentConversation({ + stored: { + agentPubkey: AGENT_PUBKEY, + channelId: home.id, + opener: OPENER, + }, + channels: [home], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + homeChannelId: home.id, + }); + assert.equal(restored?.channel, home); + assert.equal(restored?.agent, AGENT); +}); + +test("a stored project-channel pointer does not restore a different home", () => { + const home = { + id: "project-channel-1", + channelType: "stream", + isMember: true, + memberPubkeys: [SELF_PUBKEY], + participantPubkeys: [], + }; + assert.equal( + restoreProjectsAgentConversation({ + stored: { + agentPubkey: AGENT_PUBKEY, + channelId: home.id, + opener: OPENER, + }, + channels: [home], + candidates: [AGENT], + currentPubkey: SELF_PUBKEY, + homeChannelId: "other-project-channel", + }), + null, + ); +}); + test("a pointer naming a non-DM or foreign-participant channel is not restorable", () => { const stored = { agentPubkey: AGENT_PUBKEY, @@ -345,6 +393,24 @@ test("storage round-trips opener-anchored pointers and clears them", () => { assert.equal(readStoredProjectsAgentConversation(WORKSPACE_ID), null); }); +test("project-home membership carries the captured relay and signer scopes", () => { + assert.deepEqual( + projectAgentMembershipInput({ + channelId: "project-home", + agentPubkey: AGENT_PUBKEY, + relayScope: "wss://tenant-a.example", + signerScope: SELF_PUBKEY, + }), + { + channelId: "project-home", + pubkeys: [AGENT_PUBKEY], + role: "bot", + expectedRelayUrl: "wss://tenant-a.example", + expectedSignerPubkey: SELF_PUBKEY, + }, + ); +}); + // ── submitProjectAgentMessage ─────────────────────────────────────────────── /** Models the backend's fail-closed scope checks: commands resolve the active @@ -532,6 +598,29 @@ test("the captured scope rides every relay side effect of a first send", async ( assert.equal(result.channel.id, "dm-on-wss://tenant-a.example"); }); +test("a home channel first send does not open a DM", async () => { + const backend = makeScopedBackend("wss://tenant-a.example"); + const home = { id: "project-channel-1" }; + const result = await submitProjectAgentMessage({ + agent: { pubkey: AGENT_PUBKEY, isManaged: false, isActive: true }, + conversation: null, + content: "build this project", + mentionPubkeys: [AGENT_PUBKEY], + relayScope: "wss://tenant-a.example", + signerScope: SELF_PUBKEY, + homeChannel: home, + startAgent: backend.startAgent, + openDm: () => { + throw new Error("project home chat must use the project channel"); + }, + send: backend.send, + }); + + assert.deepEqual(backend.state.dmOpens, []); + assert.equal(result.channel.id, home.id); + assert.equal(backend.state.sends[0].request.channelId, home.id); +}); + test("follow-ups reply to the opener so same-second id ordering cannot hide them", async () => { const backend = makeScopedBackend("wss://tenant-a.example"); await submitProjectAgentMessage({ diff --git a/desktop/src/features/projects/lib/projectAgentConversation.ts b/desktop/src/features/projects/lib/projectAgentConversation.ts index 823791edc6c..cd6180fc971 100644 --- a/desktop/src/features/projects/lib/projectAgentConversation.ts +++ b/desktop/src/features/projects/lib/projectAgentConversation.ts @@ -2,13 +2,33 @@ import type { ProjectsConversationOpener, StoredProjectsAgentConversation, } from "@/features/projects/lib/projectAgentConversationStorage"; -import type { Channel } from "@/shared/api/types"; +import type { AddChannelMembersInput, Channel } from "@/shared/api/types"; import { KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, } from "@/shared/constants/kinds"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function projectAgentMembershipInput({ + channelId, + agentPubkey, + relayScope, + signerScope, +}: { + channelId: string; + agentPubkey: string; + relayScope: string | null; + signerScope: string | null; +}): AddChannelMembersInput { + return { + channelId, + pubkeys: [agentPubkey], + role: "bot", + expectedRelayUrl: relayScope ?? undefined, + expectedSignerPubkey: signerScope ?? undefined, + }; +} + /** * True when `event` is the conversation opener or comes after it in the * timeline's `(created_at, event_id)` ordering (`compareRelayOrder` in @@ -54,11 +74,14 @@ export function restoreProjectsAgentConversation< channels, candidates, currentPubkey, + homeChannelId, }: { stored: StoredProjectsAgentConversation | null; channels: readonly Channel[]; candidates: readonly Agent[]; currentPubkey: string | null; + /** When set, a stored pointer to this project channel (not a DM) can restore. */ + homeChannelId?: string | null; }): { channel: Channel; agent: Agent; @@ -74,9 +97,16 @@ export function restoreProjectsAgentConversation< const agent = candidates.find( (candidate) => candidate.pubkey === agentPubkey, ); - if (!channel || !agent || channel.channelType !== "dm") return null; - const participants = channel.participantPubkeys.map(normalizePubkey); + if (!channel || !agent) return null; const self = normalizePubkey(currentPubkey); + if (homeChannelId && channel.id === homeChannelId) { + // Project-home chat lives on the project channel. Membership is the + // restore proof — the channel is not a 1:1 DM. + if (!channel.isMember) return null; + return { agent, channel, opener: stored.opener }; + } + if (channel.channelType !== "dm") return null; + const participants = channel.participantPubkeys.map(normalizePubkey); const hasAgent = participants.includes(agentPubkey); // The contract is participants === {agent, self}: requiring the current // user's own membership matters as much as rejecting strangers — a stored @@ -159,6 +189,7 @@ export async function submitProjectAgentMessage({ mediaTags, relayScope, signerScope, + homeChannel, startAgent, openDm, send, @@ -174,6 +205,8 @@ export async function submitProjectAgentMessage({ /** Signing identity (owner pubkey, hex) captured together with * `relayScope`; null when unknown. */ signerScope: string | null; + /** When set, the first message lands here instead of opening a 1:1 DM. */ + homeChannel?: Ch | null; startAgent: (input: { pubkey: string; expectedRelayUrl?: string; @@ -205,6 +238,7 @@ export async function submitProjectAgentMessage({ } const channel = conversation?.channel ?? + homeChannel ?? (await openDm({ pubkeys: [agent.pubkey], expectedRelayUrl, diff --git a/desktop/src/features/projects/lib/projectAgentSelection.test.mjs b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs new file mode 100644 index 00000000000..ba764c88184 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentSelection.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pickDefaultProjectsAgent } from "./projectAgentSelection.ts"; + +test("prefers Fizz over the first running agent", () => { + const implementationPartner = { + name: "Implementation Partner", + personaId: "custom:implementation", + }; + const fizz = { name: "Fizz", personaId: "builtin:fizz" }; + assert.equal(pickDefaultProjectsAgent([implementationPartner, fizz]), fizz); +}); + +test("ignores an unmanaged agent using the Fizz display name", () => { + const managed = { name: "Builder", personaId: "custom:builder" }; + const spoofedFizz = { name: "Fizz" }; + assert.equal(pickDefaultProjectsAgent([managed, spoofedFizz]), managed); + assert.equal(pickDefaultProjectsAgent([managed]), managed); + assert.equal(pickDefaultProjectsAgent([]), null); +}); diff --git a/desktop/src/features/projects/lib/projectAgentSelection.ts b/desktop/src/features/projects/lib/projectAgentSelection.ts new file mode 100644 index 00000000000..0c41a13d922 --- /dev/null +++ b/desktop/src/features/projects/lib/projectAgentSelection.ts @@ -0,0 +1,12 @@ +const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz"; + +/** Prefers the built-in welcome lead for a new Projects conversation. */ +export function pickDefaultProjectsAgent< + Agent extends { name: string; personaId?: string | null }, +>(agents: readonly Agent[]): Agent | null { + return ( + agents.find((agent) => agent.personaId === WELCOME_GUIDE_PERSONA_ID) ?? + agents[0] ?? + null + ); +} diff --git a/desktop/src/features/projects/lib/projectCollection.test.mjs b/desktop/src/features/projects/lib/projectCollection.test.mjs new file mode 100644 index 00000000000..801f249befa --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + absorbStandaloneProjectRepositories, + homeRepositoriesToBind, +} from "./projectCollection.ts"; + +const OWNER = "a".repeat(64); +const AGENT = "b".repeat(64); +const CHANNEL = "11111111-1111-4111-8111-111111111111"; + +function explicitProject(overrides = {}) { + return { + id: `30621:${OWNER}:space-invaders-3d`, + dtag: "space-invaders-3d", + name: "Space Invaders 3D", + description: "Recreating Space Invaders the Game but in 3D", + owner: OWNER, + createdAt: 100, + projectChannelId: CHANNEL, + relatedChannelIds: [], + status: "active", + projectAddress: `30621:${OWNER}:space-invaders-3d`, + primaryRepositoryAddress: null, + repositoryAddresses: [], + repositoryRelayHints: {}, + repositories: [], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: false, + ...overrides, + }; +} + +function standaloneRepo(overrides = {}) { + const owner = overrides.owner ?? AGENT; + const dtag = overrides.dtag ?? "space-invaders-3d"; + const repoAddress = `30617:${owner}:${dtag}`; + const repository = { + id: `${owner}:${dtag}`, + dtag, + name: "Space Invaders 3D", + description: "A 3D remake of Space Invaders built with three.js", + cloneUrls: [], + webUrl: null, + owner, + contributors: [owner], + createdAt: 200, + status: "active", + defaultBranch: "main", + repoAddress, + channelId: CHANNEL, + ...overrides.repository, + }; + return { + id: repoAddress, + dtag, + name: repository.name, + description: repository.description, + owner, + createdAt: 200, + projectChannelId: null, + relatedChannelIds: [], + status: "active", + projectAddress: repoAddress, + primaryRepositoryAddress: repoAddress, + repositoryAddresses: [repoAddress], + repositoryRelayHints: {}, + repositories: [repository], + unavailableRepositoryAddresses: [], + visibility: "listed", + legacy: true, + ...overrides.card, + }; +} + +test("absorbStandaloneProjectRepositories folds an authorized home-channel repo into the project", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ repository: { maintainers: [OWNER] } }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 1); + assert.equal(folded[0].legacy, false); + assert.equal(folded[0].repositories.length, 1); + assert.equal(folded[0].repositories[0].repoAddress, repoCard.projectAddress); + assert.equal(folded[0].repositoryAddresses[0], repoCard.projectAddress); +}); + +test("absorbStandaloneProjectRepositories rejects a hostile home-channel claim", () => { + const project = explicitProject(); + const repoCard = standaloneRepo(); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 2); + assert.deepEqual(folded[0].repositories, []); + assert.equal(folded[1].projectAddress, repoCard.projectAddress); +}); + +test("absorbStandaloneProjectRepositories folds the owner's same-slug repo", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ + owner: OWNER, + repository: { channelId: null }, + }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 1); + assert.equal(folded[0].repositories[0].owner, OWNER); +}); + +test("absorbStandaloneProjectRepositories keeps an unrelated standalone repo", () => { + const project = explicitProject(); + const repoCard = standaloneRepo({ + dtag: "other-game", + owner: AGENT, + repository: { channelId: null, dtag: "other-game" }, + }); + const folded = absorbStandaloneProjectRepositories([project, repoCard]); + + assert.equal(folded.length, 2); + assert.equal( + folded.some((item) => item.legacy), + true, + ); +}); + +test("homeRepositoriesToBind lists authorized absorbed channel repos missing from the signed project", () => { + const repo = standaloneRepo({ repository: { maintainers: [OWNER] } }); + const project = explicitProject({ + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], + }); + const pending = homeRepositoriesToBind(project, []); + assert.equal(pending.length, 1); + assert.equal(pending[0].repoAddress, repo.projectAddress); +}); + +test("homeRepositoriesToBind rejects a hostile absorbed channel repo", () => { + const repo = standaloneRepo(); + const project = explicitProject({ + repositories: [repo.repositories[0]], + repositoryAddresses: [repo.projectAddress], + }); + + assert.deepEqual(homeRepositoriesToBind(project, []), []); +}); + +test("homeRepositoriesToBind ignores repos already on the signed project", () => { + const repo = standaloneRepo().repositories[0]; + const project = explicitProject({ + repositories: [repo], + repositoryAddresses: [repo.repoAddress], + }); + assert.equal(homeRepositoriesToBind(project, [repo.repoAddress]).length, 0); +}); diff --git a/desktop/src/features/projects/lib/projectCollection.ts b/desktop/src/features/projects/lib/projectCollection.ts new file mode 100644 index 00000000000..ea329855f09 --- /dev/null +++ b/desktop/src/features/projects/lib/projectCollection.ts @@ -0,0 +1,120 @@ +import type { Project, Repository } from "@/features/projects/projectModels"; + +function withAbsorbedRepository( + project: Project, + repository: Repository, +): Project { + if (project.repositoryAddresses.includes(repository.repoAddress)) { + return project; + } + return { + ...project, + primaryRepositoryAddress: + project.primaryRepositoryAddress ?? repository.repoAddress, + repositories: [...project.repositories, repository], + repositoryAddresses: [ + ...project.repositoryAddresses, + repository.repoAddress, + ], + }; +} + +function repositoryAuthorizesProjectOwner( + project: Project, + repository: Repository, +): boolean { + const projectOwner = project.owner.toLowerCase(); + if (repository.owner.toLowerCase() === projectOwner) return true; + return Boolean( + repository.maintainers?.some( + (maintainer) => maintainer.toLowerCase() === projectOwner, + ), + ); +} + +function hostForStandaloneRepository( + explicitProjects: Project[], + repository: Repository, +): Project | undefined { + const channelHost = repository.channelId + ? explicitProjects.find( + (project) => + project.projectChannelId === repository.channelId && + repositoryAuthorizesProjectOwner(project, repository), + ) + : undefined; + if (channelHost) return channelHost; + return explicitProjects.find( + (project) => + project.owner === repository.owner && project.dtag === repository.dtag, + ); +} + +function repositoryBelongsOnProjectHome( + project: Project, + repository: Repository, +): boolean { + return Boolean( + (repository.channelId && + repository.channelId === project.projectChannelId && + repositoryAuthorizesProjectOwner(project, repository)) || + (repository.owner.toLowerCase() === project.owner.toLowerCase() && + repository.dtag === project.dtag), + ); +} + +/** + * Repositories already shown on the project (after absorb) that are not yet + * on the signed `kind:30621` `a` tag set. The owner should bind them so + * other clients see the same grouping. + */ +export function homeRepositoriesToBind( + project: Project, + signedAddresses: ReadonlyArray | ReadonlySet, +): Repository[] { + const signed = new Set(signedAddresses); + return project.repositories.filter( + (repository) => + !signed.has(repository.repoAddress) && + repositoryBelongsOnProjectHome(project, repository), + ); +} + +/** + * After the NIP-MP fold, keep a repository off the standalone-project list + * when it already belongs to a listing-eligible project's home channel, or + * when the same owner already has an explicit project with that slug. + * + * Agents often announce a repo (`repos create --channel`) without + * `projects add-repo`. Without this, the same work shows up as a second card. + */ +export function absorbStandaloneProjectRepositories( + projects: Project[], +): Project[] { + const explicitProjects = projects.filter((project) => !project.legacy); + if (explicitProjects.length === 0) return projects; + + const absorbed = new Set(); + let nextExplicit = explicitProjects; + for (const card of projects) { + if (!card.legacy) continue; + const repository = card.repositories[0]; + if (!repository) continue; + const host = hostForStandaloneRepository(nextExplicit, repository); + if (!host) continue; + absorbed.add(card.projectAddress); + nextExplicit = nextExplicit.map((project) => + project.projectAddress === host.projectAddress + ? withAbsorbedRepository(project, repository) + : project, + ); + } + + if (absorbed.size === 0) return projects; + return [ + ...nextExplicit, + ...projects.filter( + (project) => project.legacy && !absorbed.has(project.projectAddress), + ), + ]; +} diff --git a/desktop/src/features/projects/lib/projectDetailSearch.test.mjs b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs new file mode 100644 index 00000000000..d64cab98296 --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProjectDetailSearch, + wantsProjectRepositorySurface, +} from "./projectDetailSearch.ts"; + +test("parseProjectDetailSearch keeps forge params and channel panel params", () => { + const search = parseProjectDetailSearch({ + repositoryId: "30617:owner:buzz", + tab: "files", + filePath: "src/main.ts", + thread: "abc123", + agentSession: "def456", + channelManagement: "1", + extra: "dropped", + }); + + assert.equal(search.repositoryId, "30617:owner:buzz"); + assert.equal(search.tab, "files"); + assert.equal(search.filePath, "src/main.ts"); + assert.equal(search.thread, "abc123"); + assert.equal(search.agentSession, "def456"); + assert.equal(search.channelManagement, "1"); + assert.equal("extra" in search, false); +}); + +test("parseProjectDetailSearch drops empty channel panel params", () => { + const search = parseProjectDetailSearch({ + thread: "", + messageId: "", + tab: "not-a-tab", + }); + + assert.equal(search.thread, undefined); + assert.equal(search.messageId, undefined); + assert.equal(search.tab, undefined); +}); + +test("wantsProjectRepositorySurface is false for channel-first project home", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + }), + false, + ); +}); + +test("wantsProjectRepositorySurface is true for repo, tab, or work-item params", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + repositoryId: "30617:owner:buzz", + }), + true, + ); + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30621:owner:platform", + tab: "files", + }), + true, + ); + assert.equal( + wantsProjectRepositorySurface({ + filePath: "src/main.ts", + projectId: "30621:owner:platform", + }), + true, + ); +}); + +test("wantsProjectRepositorySurface is true for a legacy kind:30617 project id", () => { + assert.equal( + wantsProjectRepositorySurface({ + projectId: "30617:owner:buzz", + }), + true, + ); +}); diff --git a/desktop/src/features/projects/lib/projectDetailSearch.ts b/desktop/src/features/projects/lib/projectDetailSearch.ts new file mode 100644 index 00000000000..460ade0400c --- /dev/null +++ b/desktop/src/features/projects/lib/projectDetailSearch.ts @@ -0,0 +1,68 @@ +import { + parseProfilePanelTab, + parseProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; +import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { isEntityLinkTab } from "@/shared/lib/entityLink"; + +function optionalSearchString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Project detail URLs carry forge params (repository, tab, issue) and, on + * channel-first home, the same auxiliary-panel params a stream channel uses + * (thread, agent session, profile). Both must survive `validateSearch` or + * ChannelScreen cannot keep threads and agent activity on this route. + */ +export function parseProjectDetailSearch(search: Record) { + return { + commitHash: optionalSearchString(search.commitHash), + filePath: optionalSearchString(search.filePath), + pullRequestId: optionalSearchString(search.pullRequestId), + issueId: optionalSearchString(search.issueId), + repositoryId: optionalSearchString(search.repositoryId), + tab: isEntityLinkTab(search.tab) ? search.tab : undefined, + agentSession: nonEmptyString(search.agentSession), + agentSessionChannel: nonEmptyString(search.agentSessionChannel), + autoSend: nonEmptyString(search.autoSend), + channelManagement: nonEmptyString(search.channelManagement), + messageId: nonEmptyString(search.messageId), + profile: nonEmptyString(search.profile), + profileTab: parseProfilePanelTab(search.profileTab) ?? undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, + thread: nonEmptyString(search.thread), + threadRootId: nonEmptyString(search.threadRootId), + }; +} + +/** + * Channel-first project home is the default. A repository forge surface is + * requested by an explicit repo/work-item search param, or by a legacy + * kind:30617 project id (the project *is* that repository). + */ +export function wantsProjectRepositorySurface(input: { + commitHash?: string; + filePath?: string; + issueId?: string; + projectId: string; + pullRequestId?: string; + repositoryId?: string; + tab?: string; +}): boolean { + if ( + input.repositoryId || + input.tab || + input.issueId || + input.pullRequestId || + input.commitHash || + input.filePath + ) { + return true; + } + return input.projectId.startsWith(`${KIND_REPO_ANNOUNCEMENT}:`); +} diff --git a/desktop/src/features/projects/lib/projectHomeChannel.test.mjs b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs new file mode 100644 index 00000000000..3faaab0c0c0 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + findProjectHomeByChannelId, + hasAuthoritativeHomeBinding, + isProjectHomeChannel, +} from "./projectHomeChannel.ts"; + +const OWNER = "a".repeat(64); +const MAINTAINER = "b".repeat(64); + +function project(overrides = {}) { + return { + owner: OWNER, + projectChannelId: "channel-a", + repositories: [ + { + channelId: "channel-a", + owner: OWNER, + }, + ], + ...overrides, + }; +} + +test("isProjectHomeChannel accepts an owner-bound repository", () => { + assert.equal(isProjectHomeChannel("channel-a", [project()]), true); +}); + +test("isProjectHomeChannel accepts a repository that authorizes the project owner", () => { + assert.equal( + isProjectHomeChannel("channel-a", [ + project({ + owner: MAINTAINER.toUpperCase(), + repositories: [ + { + channelId: "channel-a", + maintainers: [OWNER, MAINTAINER], + owner: OWNER, + }, + ], + }), + ]), + true, + ); +}); + +test("hasAuthoritativeHomeBinding rejects a bare project route assertion", () => { + assert.equal( + hasAuthoritativeHomeBinding(project({ repositories: [] })), + false, + ); +}); + +test("isProjectHomeChannel rejects a bare project channel assertion", () => { + assert.equal( + isProjectHomeChannel("channel-a", [project({ repositories: [] })]), + false, + ); +}); + +test("isProjectHomeChannel rejects unauthorized and mismatched repository bindings", () => { + assert.equal( + isProjectHomeChannel("channel-a", [ + project({ + owner: MAINTAINER, + repositories: [{ channelId: "channel-a", owner: OWNER }], + }), + project({ + repositories: [{ channelId: "channel-b", owner: OWNER }], + }), + ]), + false, + ); +}); + +test("isProjectHomeChannel is false for unbound channels", () => { + assert.equal(isProjectHomeChannel("channel-z", [project()]), false); + assert.equal(isProjectHomeChannel(null, [project()]), false); +}); + +test("findProjectHomeByChannelId rejects bare and unauthorized route assertions", () => { + const base = { + createdAt: 0, + legacy: false, + owner: OWNER, + projectChannelId: "channel-a", + visibility: "listed", + }; + + assert.equal( + findProjectHomeByChannelId("channel-a", [ + { ...base, id: "bare", repositories: [] }, + { + ...base, + id: "unauthorized", + owner: MAINTAINER, + repositories: [{ channelId: "channel-a", owner: OWNER }], + }, + ]), + null, + ); +}); + +test("findProjectHomeByChannelId ignores an older unauthorized competitor", () => { + const base = { + createdAt: 0, + legacy: false, + owner: OWNER, + projectChannelId: "channel-a", + visibility: "listed", + }; + const selected = findProjectHomeByChannelId("channel-a", [ + { ...base, createdAt: 50, id: "attacker", repositories: [] }, + { + ...base, + createdAt: 100, + id: "authorized", + repositories: [{ channelId: "channel-a", owner: OWNER }], + }, + ]); + assert.equal(selected?.id, "authorized"); +}); + +test("findProjectHomeByChannelId prefers the oldest listed authoritative home", () => { + const base = { + createdAt: 0, + legacy: false, + owner: OWNER, + projectChannelId: "channel-a", + repositories: [{ channelId: "channel-a", owner: OWNER }], + visibility: "listed", + }; + const selected = findProjectHomeByChannelId("channel-a", [ + { ...base, createdAt: 200, id: "later" }, + { ...base, createdAt: 50, id: "hidden", visibility: "unlisted" }, + { ...base, createdAt: 100, id: "original" }, + ]); + assert.equal(selected?.id, "original"); +}); diff --git a/desktop/src/features/projects/lib/projectHomeChannel.ts b/desktop/src/features/projects/lib/projectHomeChannel.ts new file mode 100644 index 00000000000..5389aa22fda --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeChannel.ts @@ -0,0 +1,66 @@ +import { useProjectsQuery } from "@/features/projects/hooks"; +import type { Project } from "@/features/projects/projectModels"; + +/** Resolves the canonical visible project home for a channel. */ +export function findProjectHomeByChannelId( + channelId: string | null | undefined, + projects: readonly Project[], +): Project | null { + if (!channelId) return null; + const matching = projects + .filter( + (project) => + !project.legacy && + project.projectChannelId === channelId && + hasAuthoritativeHomeBinding(project), + ) + .sort((left, right) => left.createdAt - right.createdAt); + return ( + matching.find((project) => project.visibility !== "unlisted") ?? + matching[0] ?? + null + ); +} + +export type ProjectHomeCandidate = { + owner: string; + projectChannelId: string | null; + repositories: ReadonlyArray<{ + channelId?: string | null; + maintainers?: ReadonlyArray; + owner: string; + }>; +}; + +export function hasAuthoritativeHomeBinding( + project: ProjectHomeCandidate, +): boolean { + const channelId = project.projectChannelId; + if (!channelId) return false; + + const projectOwner = project.owner.toLowerCase(); + return project.repositories.some((repository) => { + if (repository.channelId !== channelId) return false; + if (repository.owner.toLowerCase() === projectOwner) return true; + return repository.maintainers?.some( + (maintainer) => maintainer.toLowerCase() === projectOwner, + ); + }); +} + +export function isProjectHomeChannel( + channelId: string | null | undefined, + projects: ReadonlyArray, +): boolean { + if (!channelId) return false; + return projects.some( + (project) => + project.projectChannelId === channelId && + hasAuthoritativeHomeBinding(project), + ); +} + +export function useIsProjectHomeChannel(channelId: string | null | undefined) { + const projectsQuery = useProjectsQuery(); + return isProjectHomeChannel(channelId, projectsQuery.data ?? []); +} diff --git a/desktop/src/features/projects/lib/projectHomeSummary.test.mjs b/desktop/src/features/projects/lib/projectHomeSummary.test.mjs new file mode 100644 index 00000000000..7e24b2f6385 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSummary.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { presentContextCount } from "./projectHomeSummary.ts"; + +test("presentContextCount hides empty values", () => { + assert.equal(presentContextCount(undefined), undefined); + assert.equal(presentContextCount(0), undefined); + assert.equal(presentContextCount(3), 3); +}); diff --git a/desktop/src/features/projects/lib/projectHomeSummary.ts b/desktop/src/features/projects/lib/projectHomeSummary.ts new file mode 100644 index 00000000000..8928276002f --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeSummary.ts @@ -0,0 +1,6 @@ +/** Right-edge context counts omit empty values, matching the Projects overview. */ +export function presentContextCount( + value: number | undefined, +): number | undefined { + return value != null && value > 0 ? value : undefined; +} diff --git a/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs new file mode 100644 index 00000000000..6f6ebfd8f5e --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeTemplate.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + applyProjectHomeCanvas, + PROJECT_HOME_CHANNEL_TEMPLATE, + PROJECT_HOME_TEMPLATE_ID, + renderProjectHomeCanvas, +} from "./projectHomeTemplate.ts"; + +test("project home is the built-in default project template", () => { + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.id, PROJECT_HOME_TEMPLATE_ID); + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.isBuiltin, true); + assert.equal(PROJECT_HOME_CHANNEL_TEMPLATE.name, "Project home"); +}); + +test("project home dispatches its rendered canvas to the created channel", async () => { + const calls = []; + const originalWindow = globalThis.window; + const tauriInternals = { + invoke: async (command, args) => { + calls.push({ command, args }); + return { ok: true, event_id: "event-1" }; + }, + }; + globalThis.window = { __TAURI_INTERNALS__: tauriInternals }; + globalThis.__TAURI_INTERNALS__ = tauriInternals; + try { + const applied = await applyProjectHomeCanvas({ + channelId: "11111111-1111-4111-8111-111111111111", + project: { + id: "30621:owner:space-invaders", + dtag: "space-invaders", + name: "Space Invaders", + owner: "a".repeat(64), + repositories: [], + }, + }); + assert.equal(applied, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "set_canvas"); + assert.equal( + calls[0].args.channelId, + "11111111-1111-4111-8111-111111111111", + ); + assert.match(calls[0].args.content, /# Project Channel: Space Invaders/); + } finally { + globalThis.window = originalWindow; + delete globalThis.__TAURI_INTERNALS__; + } +}); + +test("project home canvas fills project, repository, and channel values", () => { + const content = renderProjectHomeCanvas({ + channelId: "11111111-1111-4111-8111-111111111111", + project: { + id: "30621:owner:space-invaders", + dtag: "space-invaders", + name: "Space Invaders", + owner: "a".repeat(64), + repositories: [ + { + cloneUrls: ["https://relay.example/git/owner/space-invaders"], + dtag: "space-invaders", + owner: "b".repeat(64), + }, + ], + }, + }); + + assert.match(content, /# Project Channel: Space Invaders/); + assert.match(content, /`space-invaders`/); + assert.match(content, /b{64}/); + assert.match(content, /https:\/\/relay\.example\/git\/owner\/space-invaders/); + assert.match(content, /11111111-1111-4111-8111-111111111111/); + assert.equal(content.includes("{{"), false); + assert.match(content, /buzz issues status --issue /); + assert.match(content, /buzz pr open --repo-owner/); + assert.match(content, /buzz canvas set .* --content -/); +}); diff --git a/desktop/src/features/projects/lib/projectHomeTemplate.ts b/desktop/src/features/projects/lib/projectHomeTemplate.ts new file mode 100644 index 00000000000..bb6545466a3 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeTemplate.ts @@ -0,0 +1,101 @@ +import { setCanvas } from "@/shared/api/tauri"; +import type { ChannelTemplate } from "@/shared/api/types"; +import type { Project } from "@/features/projects/hooks"; + +export const PROJECT_HOME_TEMPLATE_ID = "builtin:project-home"; + +export const PROJECT_HOME_CANVAS_TEMPLATE = `# Project Channel: {{PROJECT_NAME}} + +This channel is the working home of **{{PROJECT_NAME}}**. + +- Initial repository: \`{{REPO_SLUG}}\` +- Repository owner: \`{{REPO_OWNER_HEX}}\` +- Clone URL: \`{{REPO_CLONE_URL}}\` +- Project channel: \`{{CHANNEL_UUID}}\` + +Everything about this project—decisions, tasks, code review, and releases—happens here, in the open. + +## How to think about this channel + +- **The channel is the project's memory.** If you did it and did not post it, it did not happen. Milestones (picked up, blocked, PR up, merged, done) are top-level posts; details go in threads. +- **Issues are the task queue.** Work starts from an issue. No issue? Create one before you build. +- **The repository is the source of truth for code; the channel is the source of truth for intent.** Read both before acting. +- **One owner per task.** Claim before you build. If it is assigned to someone else, review or unblock—do not duplicate. + +## What you can do here + +| Action | Command | +| --- | --- | +| Inspect the repository | \`buzz repos get --owner {{REPO_OWNER_HEX}} --id {{REPO_SLUG}}\` | +| Create a task | \`buzz issues create --channel {{CHANNEL_UUID}} --title "..." --content -\` | +| Claim or assign a task | \`buzz issues assign --issue --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --assignee \` | +| Track task state | \`buzz issues status --issue --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status open|resolved|closed|draft\` | +| Open a review | \`buzz pr open --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --subject "..." --body-file - --commit --clone {{REPO_CLONE_URL}} --branch-name --channel {{CHANNEL_UUID}}\` | +| Update a review | \`buzz pr update --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --pr --pr-author --commit --clone {{REPO_CLONE_URL}}\` | +| Mark a review merged or closed | \`buzz pr status --pr --repo-owner {{REPO_OWNER_HEX}} --repo-id {{REPO_SLUG}} --status merged|closed\` | +| Share files or artifacts | \`buzz upload file --file \` | +| Update this living document | \`buzz canvas set --channel {{CHANNEL_UUID}} --content -\` | + +## Workflow + +1. **Pick up:** Find or create an issue, self-assign it, and post a one-line “picked up” message in the channel. +2. **Build:** Clone or reuse a checkout under \`REPOS/\`. Work on a branch, never the default branch. Follow the repository's configured commit and sign-off policy. +3. **Verify:** Run the fullest relevant test suite before calling anything done. +4. **Ship:** Open a review and post the returned Buzz link verbatim so it renders as a card. Mark the issue resolved when merged. +5. **Report:** @mention whoever delegated the work in the message that delivers the result or blocker—not in acknowledgements. + +## Norms + +- Reply in-thread to continue a topic; use a top-level post for a new topic. Avoid bare acknowledgements. +- @mention only when someone must act; naming someone in narrative does not require an @mention. +- Blocked for more than 30 minutes after honest effort? Post the blocker and what you tried. +- Praise in public; correct the work, not the person. +- Give decisions of record—scope cuts, API choices, and deferrals—their own top-level post so they remain findable. + +Keep this canvas current as the project evolves.`; + +export const PROJECT_HOME_CHANNEL_TEMPLATE: ChannelTemplate = { + id: PROJECT_HOME_TEMPLATE_ID, + name: "Project home", + description: null, + channelType: "stream", + visibility: "open", + canvasTemplate: PROJECT_HOME_CANVAS_TEMPLATE, + agents: { personas: [], teams: [] }, + isBuiltin: true, + createdAt: "", + updatedAt: "", +}; + +export function renderProjectHomeCanvas(input: { + channelId: string; + project: Project; +}) { + const repository = input.project.repositories[0]; + const values: Record = { + CHANNEL_UUID: input.channelId, + PROJECT_NAME: input.project.name, + REPO_CLONE_URL: repository?.cloneUrls[0] ?? "Unavailable", + REPO_OWNER_HEX: repository?.owner ?? input.project.owner, + REPO_SLUG: repository?.dtag ?? input.project.dtag, + }; + return Object.entries(values).reduce( + (content, [key, value]) => content.replaceAll(`{{${key}}}`, value), + PROJECT_HOME_CANVAS_TEMPLATE, + ); +} + +export async function applyProjectHomeCanvas(input: { + channelId: string; + project: Project; +}) { + try { + await setCanvas({ + channelId: input.channelId, + content: renderProjectHomeCanvas(input), + }); + return true; + } catch { + return false; + } +} diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs new file mode 100644 index 00000000000..68ee2d82393 --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, + projectHomeWorkspaceSheetTitle, +} from "./projectHomeWorkspaceSheet.ts"; + +test("isProjectHomeWorkspaceSheetTab accepts overview workspace rows", () => { + assert.equal(isProjectHomeWorkspaceSheetTab("issues"), true); + assert.equal(isProjectHomeWorkspaceSheetTab("files"), true); + assert.equal(isProjectHomeWorkspaceSheetTab("channels"), false); + assert.equal(isProjectHomeWorkspaceSheetTab(undefined), false); +}); + +test("projectHomeWorkspaceSheetTitle matches overview row labels", () => { + assert.equal(projectHomeWorkspaceSheetTitle("issues"), "Tasks"); + assert.equal(projectHomeWorkspaceSheetTitle("prs"), "Reviews"); + assert.equal(projectHomeWorkspaceSheetTitle("commits"), "Commits"); + assert.equal(projectHomeWorkspaceSheetTitle("files"), "Files"); + assert.equal(projectHomeWorkspaceSheetTitle("contributors"), "People"); +}); + +test("projectHomeWorkspaceSheetExpandTab keeps the selected repository menu", () => { + assert.equal(projectHomeWorkspaceSheetExpandTab("issues"), "issues"); + assert.equal(projectHomeWorkspaceSheetExpandTab("prs"), "prs"); + assert.equal(projectHomeWorkspaceSheetExpandTab("commits"), "commits"); + assert.equal(projectHomeWorkspaceSheetExpandTab("files"), "files"); + assert.equal( + projectHomeWorkspaceSheetExpandTab("contributors"), + "contributors", + ); +}); diff --git a/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts new file mode 100644 index 00000000000..bf3ce4f65dc --- /dev/null +++ b/desktop/src/features/projects/lib/projectHomeWorkspaceSheet.ts @@ -0,0 +1,40 @@ +export const PROJECT_HOME_WORKSPACE_SHEET_TABS = [ + "issues", + "prs", + "commits", + "files", + "contributors", +] as const; + +export type ProjectHomeWorkspaceSheetTab = + (typeof PROJECT_HOME_WORKSPACE_SHEET_TABS)[number]; + +export function isProjectHomeWorkspaceSheetTab( + value: string | undefined, +): value is ProjectHomeWorkspaceSheetTab { + return ( + value != null && + (PROJECT_HOME_WORKSPACE_SHEET_TABS as readonly string[]).includes(value) + ); +} + +const WORKSPACE_SHEET_TITLES: Record = { + commits: "Commits", + contributors: "People", + files: "Files", + issues: "Tasks", + prs: "Reviews", +}; + +export function projectHomeWorkspaceSheetTitle( + tab: ProjectHomeWorkspaceSheetTab, +): string { + return WORKSPACE_SHEET_TITLES[tab]; +} + +/** Repository workspace tab to open when expanding a home-channel sheet. */ +export function projectHomeWorkspaceSheetExpandTab( + tab: ProjectHomeWorkspaceSheetTab, +): ProjectHomeWorkspaceSheetTab { + return tab; +} diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs index ef94588ccc3..e6fc3196f20 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs +++ b/desktop/src/features/projects/lib/projectRelatedChannels.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { + collapseProjectRelatedChannelRows, collectProjectRelatedChannelRows, + listProjectBoundChannels, + listProjectChildChannels, projectRelatedChannelRowKey, uniqueProjectRelatedChannelCount, } from "./projectRelatedChannels.ts"; @@ -104,6 +107,37 @@ test("collects one row per repository channel binding", () => { ); }); +test("collapses repositories sharing one project channel", () => { + const rows = collectProjectRelatedChannelRows([ + makeProject({ + repositories: [ + makeRepository({ name: "web" }), + makeRepository({ id: "repo-mobile", name: "mobile" }), + makeRepository({ + channelId: CHANNEL_B, + id: "repo-relay", + name: "relay", + }), + ], + }), + ]); + + assert.deepEqual(collapseProjectRelatedChannelRows(rows), [ + { + channelId: CHANNEL_A, + projectId: "project-buzz", + projectName: "buzz", + repositoryNames: ["web", "mobile"], + }, + { + channelId: CHANNEL_B, + projectId: "project-buzz", + projectName: "buzz", + repositoryNames: ["relay"], + }, + ]); +}); + test("keeps a project channel only when no repository in that project shares it", () => { assert.deepEqual( collectProjectRelatedChannelRows([ @@ -183,3 +217,135 @@ test("row keys distinguish project-level bindings from repository bindings", () `${CHANNEL_A}:project-buzz:repo-buzz`, ); }); + +test("listProjectBoundChannels puts the home channel first", () => { + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_B, + repositories: [ + makeRepository({ channelId: CHANNEL_A }), + makeRepository({ + id: "repo-relay", + name: "relay-tools", + channelId: CHANNEL_A, + }), + ], + }), + ), + [ + { + channelId: CHANNEL_B, + repositoryId: null, + role: "home", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("listProjectBoundChannels omits a repository channel that is the home channel", () => { + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_A, + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: CHANNEL_A, + repositoryId: null, + role: "home", + }, + ], + ); +}); + +test("listProjectBoundChannels is empty when nothing is bound", () => { + assert.deepEqual(listProjectBoundChannels(makeProject()), []); +}); + +test("listProjectBoundChannels includes extra related channels after home", () => { + const related = "33333333-3333-4333-8333-333333333333"; + assert.deepEqual( + listProjectBoundChannels( + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: CHANNEL_B, + repositoryId: null, + role: "home", + }, + { + channelId: related, + repositoryId: null, + role: "related", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("listProjectChildChannels omits the home channel", () => { + const related = "33333333-3333-4333-8333-333333333333"; + assert.deepEqual( + listProjectChildChannels( + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository({ channelId: CHANNEL_A })], + }), + ), + [ + { + channelId: related, + repositoryId: null, + role: "related", + }, + { + channelId: CHANNEL_A, + repositoryId: "repo-buzz", + role: "related", + }, + ], + ); +}); + +test("collectProjectRelatedChannelRows includes extra related channels", () => { + const related = "33333333-3333-4333-8333-333333333333"; + const rows = collectProjectRelatedChannelRows([ + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related, CHANNEL_A], + repositories: [makeRepository()], + }), + ]); + assert.equal( + rows.some((row) => row.channelId === related && row.repositoryId == null), + true, + ); + assert.equal( + uniqueProjectRelatedChannelCount([ + makeProject({ + projectChannelId: CHANNEL_B, + relatedChannelIds: [related], + repositories: [makeRepository()], + }), + ]), + 3, + ); +}); diff --git a/desktop/src/features/projects/lib/projectRelatedChannels.ts b/desktop/src/features/projects/lib/projectRelatedChannels.ts index 102e9d9a03c..d5166a48536 100644 --- a/desktop/src/features/projects/lib/projectRelatedChannels.ts +++ b/desktop/src/features/projects/lib/projectRelatedChannels.ts @@ -4,6 +4,7 @@ export type ProjectRelatedChannelSource = { id: string; name: string; projectChannelId: string | null; + relatedChannelIds?: readonly string[]; repositories: Array<{ id: string; name: string; @@ -19,6 +20,14 @@ export type ProjectRelatedChannelRow = { repositoryName: string | null; }; +/** One display row per distinct channel within a project. */ +export type ProjectRelatedChannelDisplayRow = { + channelId: string; + projectId: string; + projectName: string; + repositoryNames: string[]; +}; + function trimmedChannelId(value: string | null | undefined) { const channelId = value?.trim() ?? ""; return channelId.length > 0 ? channelId : null; @@ -57,6 +66,19 @@ export function collectProjectRelatedChannelRows( repositoryId: null, repositoryName: null, }); + repositoryChannelIds.add(projectChannelId); + } + for (const relatedChannelId of project.relatedChannelIds ?? []) { + const channelId = trimmedChannelId(relatedChannelId); + if (!channelId || repositoryChannelIds.has(channelId)) continue; + repositoryChannelIds.add(channelId); + rows.push({ + channelId, + projectId: project.id, + projectName: project.name, + repositoryId: null, + repositoryName: null, + }); } } return rows; @@ -73,3 +95,102 @@ export function uniqueProjectRelatedChannelCount( export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) { return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`; } + +/** Collapses repository bindings that point at the same project channel. */ +export function collapseProjectRelatedChannelRows( + rows: readonly ProjectRelatedChannelRow[], +): ProjectRelatedChannelDisplayRow[] { + const collapsed = new Map(); + for (const row of rows) { + const key = `${row.projectId}:${row.channelId}`; + const current = collapsed.get(key); + if (current) { + if ( + row.repositoryName && + !current.repositoryNames.includes(row.repositoryName) + ) { + current.repositoryNames.push(row.repositoryName); + } + continue; + } + collapsed.set(key, { + channelId: row.channelId, + projectId: row.projectId, + projectName: row.projectName, + repositoryNames: row.repositoryName ? [row.repositoryName] : [], + }); + } + return [...collapsed.values()]; +} + +/** Stable key for one collapsed project-channel row. */ +export function projectRelatedChannelDisplayRowKey( + row: ProjectRelatedChannelDisplayRow, +) { + return `${row.channelId}:${row.projectId}`; +} + +export type ProjectBoundChannel = { + channelId: string; + repositoryId: string | null; + role: "home" | "related"; +}; + +/** + * Unique channels bound to one project: the home stream first, then each + * repository channel that is not already the home channel. + */ +export function listProjectBoundChannels( + project: Pick< + ProjectRelatedChannelSource, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, +): ProjectBoundChannel[] { + const channels: ProjectBoundChannel[] = []; + const seen = new Set(); + const homeChannelId = trimmedChannelId(project.projectChannelId); + if (homeChannelId) { + channels.push({ + channelId: homeChannelId, + repositoryId: null, + role: "home", + }); + seen.add(homeChannelId); + } + for (const relatedChannelId of project.relatedChannelIds ?? []) { + const channelId = trimmedChannelId(relatedChannelId); + if (!channelId || seen.has(channelId)) continue; + channels.push({ + channelId, + repositoryId: null, + role: "related", + }); + seen.add(channelId); + } + for (const repository of project.repositories) { + const channelId = trimmedChannelId(repository.channelId); + if (!channelId || seen.has(channelId)) continue; + channels.push({ + channelId, + repositoryId: repository.id, + role: "related", + }); + seen.add(channelId); + } + return channels; +} + +/** + * Nested sidebar rows under a project: bound streams except the home + * channel, which is the project row itself. + */ +export function listProjectChildChannels( + project: Pick< + ProjectRelatedChannelSource, + "projectChannelId" | "relatedChannelIds" | "repositories" + >, +): ProjectBoundChannel[] { + return listProjectBoundChannels(project).filter( + (channel) => channel.role !== "home", + ); +} diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs new file mode 100644 index 00000000000..38832d05060 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsActivityDigest.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildProjectsActivityDigest } from "./projectsActivityDigest.ts"; + +const NOW = 2_000_000_000; + +test("summarizes recent activity in a short highlighted sentence", () => { + const project = { id: "project-a" }; + const digest = buildProjectsActivityDigest({ + issues: [ + { project, issue: { createdAt: NOW - 60 } }, + { project, issue: { createdAt: NOW - 120 } }, + ], + nowSeconds: NOW, + projects: [project], + pullRequests: [{ project, pullRequest: { createdAt: NOW - 180 } }], + snapshots: { + "project-a": { + commits: [ + { timestamp: NOW - 30 }, + { timestamp: NOW - 90 }, + { timestamp: NOW - 8 * 24 * 60 * 60 }, + ], + }, + }, + }); + + assert.equal(digest.prefix, "This week:"); + assert.deepEqual(digest.highlights, [ + "2 new commits", + "2 tasks opened", + "1 review opened", + "1 active project", + ]); + assert.ok( + `${digest.prefix} ${digest.highlights.join(", ")}${digest.suffix}`.split( + /\s+/, + ).length <= 30, + ); +}); + +test("falls back to current totals when no recent activity is loaded", () => { + const digest = buildProjectsActivityDigest({ + issues: [], + nowSeconds: NOW, + projects: [{ id: "a" }, { id: "b" }], + pullRequests: [], + summaries: { + a: { issueCount: 4, prCount: 2 }, + b: { issueCount: 1, prCount: 3 }, + }, + }); + + assert.equal(digest.prefix, "Currently tracking"); + assert.deepEqual(digest.highlights, ["2 projects", "5 tasks", "5 reviews"]); +}); diff --git a/desktop/src/features/projects/lib/projectsActivityDigest.ts b/desktop/src/features/projects/lib/projectsActivityDigest.ts new file mode 100644 index 00000000000..08edc4fdda5 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsActivityDigest.ts @@ -0,0 +1,88 @@ +import type { + Project, + ProjectActivitySummary, + ProjectIssueListItem, + ProjectPullRequestListItem, + ProjectRepoSnapshot, +} from "@/features/projects/hooks"; + +const WEEK_SECONDS = 7 * 24 * 60 * 60; + +export type ProjectsActivityDigest = { + highlights: string[]; + prefix: string; + suffix: string; +}; + +function plural(count: number, singular: string, pluralForm = `${singular}s`) { + return `${count} ${count === 1 ? singular : pluralForm}`; +} + +/** Builds a short, deterministic sentence from the currently loaded activity. */ +export function buildProjectsActivityDigest({ + issues, + nowSeconds, + projects, + pullRequests, + snapshots, + summaries, +}: { + issues: ProjectIssueListItem[]; + nowSeconds: number; + projects: Project[]; + pullRequests: ProjectPullRequestListItem[]; + snapshots?: Record; + summaries?: Record; +}): ProjectsActivityDigest { + const since = nowSeconds - WEEK_SECONDS; + const activeProjectIds = new Set(); + let commitCount = 0; + for (const [projectId, snapshot] of Object.entries(snapshots ?? {})) { + const recent = snapshot.commits.filter( + (commit) => commit.timestamp >= since, + ).length; + commitCount += recent; + if (recent > 0) activeProjectIds.add(projectId); + } + const taskCount = issues.filter(({ issue, project }) => { + const recent = issue.createdAt >= since; + if (recent) activeProjectIds.add(project.id); + return recent; + }).length; + const reviewCount = pullRequests.filter(({ project, pullRequest }) => { + const recent = pullRequest.createdAt >= since; + if (recent) activeProjectIds.add(project.id); + return recent; + }).length; + const highlights = [ + commitCount > 0 ? `${plural(commitCount, "new commit")}` : null, + taskCount > 0 ? `${plural(taskCount, "task")} opened` : null, + reviewCount > 0 ? `${plural(reviewCount, "review")} opened` : null, + ].filter((value): value is string => value !== null); + + if (highlights.length > 0) { + highlights.push(`${plural(activeProjectIds.size, "active project")}`); + return { + highlights, + prefix: "This week:", + suffix: ".", + }; + } + + const totals = Object.values(summaries ?? {}).reduce( + (result, summary) => ({ + reviews: result.reviews + summary.prCount, + tasks: result.tasks + summary.issueCount, + }), + { reviews: 0, tasks: 0 }, + ); + return { + highlights: [ + plural(projects.length, "project"), + plural(totals.tasks, "task"), + plural(totals.reviews, "review"), + ], + prefix: "Currently tracking", + suffix: ".", + }; +} diff --git a/desktop/src/features/projects/lib/projectsSearch.test.mjs b/desktop/src/features/projects/lib/projectsSearch.test.mjs new file mode 100644 index 00000000000..47fa06e89dc --- /dev/null +++ b/desktop/src/features/projects/lib/projectsSearch.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { matchesProjectsSearch } from "./projectsSearch.ts"; + +test("matches every case-insensitive token across fields", () => { + assert.equal( + matchesProjectsSearch("buzz mobile", [ + "Buzz Platform", + "Desktop, relay, and mobile clients", + ]), + true, + ); + assert.equal( + matchesProjectsSearch("buzz missing", [ + "Buzz Platform", + "Desktop, relay, and mobile clients", + ]), + false, + ); +}); + +test("empty search matches everything", () => { + assert.equal(matchesProjectsSearch(" ", []), true); +}); diff --git a/desktop/src/features/projects/lib/projectsSearch.ts b/desktop/src/features/projects/lib/projectsSearch.ts new file mode 100644 index 00000000000..290e985e468 --- /dev/null +++ b/desktop/src/features/projects/lib/projectsSearch.ts @@ -0,0 +1,10 @@ +/** Case-insensitive token matching for Projects-local search. */ +export function matchesProjectsSearch( + query: string, + values: ReadonlyArray, +) { + const tokens = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + const haystack = values.filter(Boolean).join(" ").toLocaleLowerCase(); + return tokens.every((token) => haystack.includes(token)); +} diff --git a/desktop/src/features/projects/lib/useProjectSelection.tsx b/desktop/src/features/projects/lib/useProjectSelection.tsx index 551e59afd64..eec6883d4a7 100644 --- a/desktop/src/features/projects/lib/useProjectSelection.tsx +++ b/desktop/src/features/projects/lib/useProjectSelection.tsx @@ -25,10 +25,12 @@ const ProjectSelectionContext = export function ProjectSelectionProvider({ children, + onClear, onSelect, resetKey, }: { children: React.ReactNode; + onClear?: () => void; onSelect?: () => void; resetKey: string; }) { @@ -42,6 +44,9 @@ export function ProjectSelectionProvider({ } const onSelectRef = React.useRef(onSelect); onSelectRef.current = onSelect; + const onClearRef = React.useRef(onClear); + onClearRef.current = onClear; + const wasActiveRef = React.useRef(false); const clear = React.useCallback(() => { setState(EMPTY_PROJECT_SELECTION); @@ -61,7 +66,10 @@ export function ProjectSelectionProvider({ }, []); React.useEffect(() => { - if (state.items.length > 0) onSelectRef.current?.(); + const active = state.items.length > 0; + if (active && !wasActiveRef.current) onSelectRef.current?.(); + if (!active && wasActiveRef.current) onClearRef.current?.(); + wasActiveRef.current = active; }, [state.items.length]); React.useEffect(() => { diff --git a/desktop/src/features/projects/projectChannelCreation.test.mjs b/desktop/src/features/projects/projectChannelCreation.test.mjs new file mode 100644 index 00000000000..3782a919051 --- /dev/null +++ b/desktop/src/features/projects/projectChannelCreation.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { buildProjectRelatedChannelPatchTemplate } from "./projectChannelCreation.ts"; +import { + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, +} from "./projectModels.ts"; + +const OWNER = "a".repeat(64); +const OTHER = "b".repeat(64); +const CHANNEL_A = "11111111-1111-4111-8111-111111111111"; +const CHANNEL_B = "22222222-2222-4222-8222-222222222222"; + +function liveHead(tags = []) { + return { + content: "", + created_at: 100, + id: "project-head", + kind: 30621, + pubkey: OWNER, + sig: "sig", + tags: [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL_A], + ...tags, + ], + }; +} + +test("appends a related channel tag and preserves the live head", () => { + const patched = buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead([["description", "A project"]]), + ownerPubkey: OWNER, + }); + + assert.equal(patched.alreadyBound, false); + assert.deepEqual(patched.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL_A], + ["description", "A project"], + [PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B], + ]); +}); + +test("is idempotent when the related channel is already tagged", () => { + const patched = buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead([[PROJECT_RELATED_CHANNEL_TAG, CHANNEL_B]]), + ownerPubkey: OWNER, + }); + + assert.equal(patched.alreadyBound, true); + assert.equal( + patched.project.tags.filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) + .length, + 1, + ); +}); + +test("refuses to bind the home channel as a related channel", () => { + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_A, + liveHead: liveHead(), + ownerPubkey: OWNER, + }), + /already this project's home/, + ); +}); + +test("only the project owner can add related channels", () => { + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead(), + ownerPubkey: OTHER, + }), + /Only the project owner/, + ); +}); + +test("caps extra related channels", () => { + const tags = Array.from( + { length: MAX_PROJECT_RELATED_CHANNELS }, + (_, index) => { + const suffix = String(index + 1).padStart(12, "0"); + return [PROJECT_RELATED_CHANNEL_TAG, `33333333-3333-4333-8333-${suffix}`]; + }, + ); + assert.throws( + () => + buildProjectRelatedChannelPatchTemplate({ + channelId: CHANNEL_B, + liveHead: liveHead(tags), + ownerPubkey: OWNER, + }), + /extra channels/, + ); +}); diff --git a/desktop/src/features/projects/projectChannelCreation.ts b/desktop/src/features/projects/projectChannelCreation.ts new file mode 100644 index 00000000000..a75425c23be --- /dev/null +++ b/desktop/src/features/projects/projectChannelCreation.ts @@ -0,0 +1,71 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds"; +import { + isValidProjectChannelId, + MAX_PROJECT_RELATED_CHANNELS, + PROJECT_RELATED_CHANNEL_TAG, + validateProjectEventEnvelope, +} from "@/features/projects/projectModels"; +import type { ProjectEventTemplate } from "./projectCreation"; + +/** + * Appends a `buzz-related-channel` tag to a live project head. Every other + * tag is preserved so adding a stream cannot erase unknown metadata. + */ +export function buildProjectRelatedChannelPatchTemplate({ + channelId, + liveHead, + ownerPubkey, +}: { + channelId: string; + liveHead: RelayEvent; + ownerPubkey: string; +}): { alreadyBound: boolean; project: ProjectEventTemplate } { + const normalizedOwner = ownerPubkey.trim().toLowerCase(); + if (normalizedOwner !== liveHead.pubkey.toLowerCase()) { + throw new Error("Only the project owner can add channels."); + } + const normalizedChannelId = channelId.trim(); + if (!isValidProjectChannelId(normalizedChannelId)) { + throw new Error("Project channel is invalid."); + } + const homeChannelId = liveHead.tags.find( + (tag) => tag[0] === "buzz-channel", + )?.[1]; + if (homeChannelId === normalizedChannelId) { + throw new Error("That channel is already this project's home."); + } + const existingRelated = liveHead.tags + .filter((tag) => tag[0] === PROJECT_RELATED_CHANNEL_TAG) + .map((tag) => tag[1]) + .filter((value): value is string => Boolean(value)); + if (existingRelated.includes(normalizedChannelId)) { + validateProjectEventEnvelope(liveHead.tags, liveHead.content); + return { + alreadyBound: true, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: liveHead.content, + tags: liveHead.tags.map((tag) => [...tag]), + }, + }; + } + if (existingRelated.length >= MAX_PROJECT_RELATED_CHANNELS) { + throw new Error( + `A project cannot contain more than ${MAX_PROJECT_RELATED_CHANNELS} extra channels.`, + ); + } + const tags = [ + ...liveHead.tags.map((tag) => [...tag]), + [PROJECT_RELATED_CHANNEL_TAG, normalizedChannelId], + ]; + validateProjectEventEnvelope(tags, liveHead.content); + return { + alreadyBound: false, + project: { + kind: KIND_PROJECT_ANNOUNCEMENT, + content: liveHead.content, + tags, + }, + }; +} diff --git a/desktop/src/features/projects/projectChannelRequest.test.mjs b/desktop/src/features/projects/projectChannelRequest.test.mjs new file mode 100644 index 00000000000..4d109ffb218 --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequest.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseProjectChannelRequest, + PROJECT_CHANNEL_REQUEST, +} from "./projectChannelRequest.ts"; + +const HOME_CHANNEL = "11111111-1111-4111-8111-111111111111"; + +test("parses a narrow project channel request", () => { + assert.deepEqual( + parseProjectChannelRequest({ + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release-planning", + description: "Coordinate the release.", + visibility: "private", + ttlSeconds: 3600, + templateName: "Release team", + }, + }), + { + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release-planning", + description: "Coordinate the release.", + visibility: "private", + ttlSeconds: 3600, + templateName: "Release team", + }, + }, + ); +}); + +test("rejects unknown fields and invalid values", () => { + assert.equal( + parseProjectChannelRequest({ + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release", + visibility: "public", + }, + }), + null, + ); + assert.equal( + parseProjectChannelRequest({ + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: "request-1", + request: { + homeChannelId: HOME_CHANNEL, + name: "release", + visibility: "open", + secret: "nope", + }, + }), + null, + ); +}); diff --git a/desktop/src/features/projects/projectChannelRequest.ts b/desktop/src/features/projects/projectChannelRequest.ts new file mode 100644 index 00000000000..944dc293352 --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequest.ts @@ -0,0 +1,81 @@ +export const PROJECT_CHANNEL_REQUEST = "project_channel_request" as const; + +export type ProjectChannelRequest = { + type: typeof PROJECT_CHANNEL_REQUEST; + action: "create"; + requestId: string; + request: { + homeChannelId: string; + name: string; + description?: string; + visibility: "open" | "private"; + ttlSeconds?: number; + templateName?: string; + }; +}; + +function isText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isTextWithin(value: unknown, max: number): value is string { + return isText(value) && value.length <= max; +} + +/** Parses the narrow, no-secret owner-review contract for project channels. */ +export function parseProjectChannelRequest( + value: unknown, +): ProjectChannelRequest | null { + if (typeof value !== "object" || value === null) return null; + const payload = value as Record; + if ( + payload.type !== PROJECT_CHANNEL_REQUEST || + payload.action !== "create" || + !isText(payload.requestId) || + typeof payload.request !== "object" || + payload.request === null + ) { + return null; + } + const request = payload.request as Record; + const allowed = [ + "homeChannelId", + "name", + "description", + "visibility", + "ttlSeconds", + "templateName", + ]; + if ( + Object.keys(request).some((key) => !allowed.includes(key)) || + !isTextWithin(request.homeChannelId, 128) || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + request.homeChannelId, + ) || + !isTextWithin(request.name, 120) || + (request.visibility !== "open" && request.visibility !== "private") || + (request.description !== undefined && + !isTextWithin(request.description, 2_048)) || + (request.templateName !== undefined && + !isTextWithin(request.templateName, 300)) || + (request.ttlSeconds !== undefined && + (typeof request.ttlSeconds !== "number" || + !Number.isSafeInteger(request.ttlSeconds) || + request.ttlSeconds <= 0)) + ) { + return null; + } + return { + type: PROJECT_CHANNEL_REQUEST, + action: "create", + requestId: payload.requestId, + request: { + homeChannelId: request.homeChannelId, + name: request.name, + visibility: request.visibility, + ...(request.description ? { description: request.description } : {}), + ...(request.ttlSeconds ? { ttlSeconds: request.ttlSeconds } : {}), + ...(request.templateName ? { templateName: request.templateName } : {}), + }, + }; +} diff --git a/desktop/src/features/projects/projectChannelRequestQueue.test.mjs b/desktop/src/features/projects/projectChannelRequestQueue.test.mjs new file mode 100644 index 00000000000..4b1bfebc960 --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequestQueue.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + advanceProjectChannelRequestQueue, + createProjectChannelRequestQueue, + enqueueProjectChannelRequest, + MAX_PENDING_PROJECT_CHANNEL_REQUESTS, +} from "./projectChannelRequestQueue.ts"; + +function candidate(requestId) { + return { + agentPubkey: `agent-${requestId}`, + request: { requestId }, + }; +} + +test("accepted requests advance in order while duplicate active requests stay suppressed", () => { + const queue = createProjectChannelRequestQueue(); + const first = candidate("request-a"); + const second = candidate("request-b"); + + assert.deepEqual(enqueueProjectChannelRequest(queue, first), { + status: "show", + candidate: first, + }); + assert.deepEqual(enqueueProjectChannelRequest(queue, second), { + status: "queued", + }); + assert.deepEqual(enqueueProjectChannelRequest(queue, first), { + status: "duplicate", + }); + assert.equal(advanceProjectChannelRequestQueue(queue), second); + assert.deepEqual(enqueueProjectChannelRequest(queue, first), { + status: "duplicate", + }); + assert.equal(advanceProjectChannelRequestQueue(queue), null); +}); + +test("accepted queue drops newest overflow while preserving FIFO and retryability", () => { + const queue = createProjectChannelRequestQueue(); + const first = candidate("request-0"); + assert.equal(enqueueProjectChannelRequest(queue, first).status, "show"); + + const pending = Array.from( + { length: MAX_PENDING_PROJECT_CHANNEL_REQUESTS }, + (_, index) => candidate(`request-${index + 1}`), + ); + for (const request of pending) { + assert.equal(enqueueProjectChannelRequest(queue, request).status, "queued"); + } + + const overflow = candidate("request-overflow"); + assert.deepEqual(enqueueProjectChannelRequest(queue, overflow), { + status: "overflow", + }); + assert.equal(queue.pending.length, MAX_PENDING_PROJECT_CHANNEL_REQUESTS); + assert.equal(queue.seenRequestIds.has(overflow.request.requestId), false); + + for (const request of pending) { + assert.equal(advanceProjectChannelRequestQueue(queue), request); + } + assert.equal(advanceProjectChannelRequestQueue(queue), null); + assert.deepEqual(enqueueProjectChannelRequest(queue, overflow), { + status: "show", + candidate: overflow, + }); +}); + +test("dedup history stays bounded without forgetting active or pending requests", () => { + const queue = createProjectChannelRequestQueue(); + for (let index = 0; index < 500; index += 1) { + const request = candidate(`request-${index}`); + assert.equal(enqueueProjectChannelRequest(queue, request).status, "show"); + assert.deepEqual(enqueueProjectChannelRequest(queue, request), { + status: "duplicate", + }); + assert.equal(advanceProjectChannelRequestQueue(queue), null); + } + + assert.ok(queue.seenRequestIds.size <= 201); +}); diff --git a/desktop/src/features/projects/projectChannelRequestQueue.ts b/desktop/src/features/projects/projectChannelRequestQueue.ts new file mode 100644 index 00000000000..7fd587a4d81 --- /dev/null +++ b/desktop/src/features/projects/projectChannelRequestQueue.ts @@ -0,0 +1,81 @@ +import type { ProjectChannelRequest } from "@/features/projects/projectChannelRequest"; + +export const MAX_PENDING_PROJECT_CHANNEL_REQUESTS = 100; +const MAX_SEEN_PROJECT_CHANNEL_REQUEST_IDS = + MAX_PENDING_PROJECT_CHANNEL_REQUESTS * 2 + 1; + +export type AcceptedProjectChannelRequest = { + agentPubkey: string; + request: ProjectChannelRequest; +}; + +export type ProjectChannelRequestQueue = { + activeRequestId: string | null; + pending: AcceptedProjectChannelRequest[]; + seenRequestIds: Set; +}; + +export type EnqueueProjectChannelRequestResult = + | { status: "show"; candidate: AcceptedProjectChannelRequest } + | { status: "queued" | "duplicate" | "overflow" }; + +export function createProjectChannelRequestQueue(): ProjectChannelRequestQueue { + return { + activeRequestId: null, + pending: [], + seenRequestIds: new Set(), + }; +} + +export function enqueueProjectChannelRequest( + queue: ProjectChannelRequestQueue, + candidate: AcceptedProjectChannelRequest, +): EnqueueProjectChannelRequestResult { + const requestId = candidate.request.requestId; + if (queue.seenRequestIds.has(requestId)) return { status: "duplicate" }; + + if ( + queue.activeRequestId !== null && + queue.pending.length >= MAX_PENDING_PROJECT_CHANNEL_REQUESTS + ) { + // Keep the requests already visible to the owner and drop the newest one. + // Do not mark it seen: a later retry may be accepted after space opens. + return { status: "overflow" }; + } + + queue.seenRequestIds.add(requestId); + pruneSeenRequestIds(queue); + + if (queue.activeRequestId === null) { + queue.activeRequestId = requestId; + return { status: "show", candidate }; + } + + queue.pending.push(candidate); + return { status: "queued" }; +} + +export function advanceProjectChannelRequestQueue( + queue: ProjectChannelRequestQueue, +): AcceptedProjectChannelRequest | null { + const next = queue.pending.shift() ?? null; + queue.activeRequestId = next?.request.requestId ?? null; + pruneSeenRequestIds(queue); + return next; +} + +function pruneSeenRequestIds(queue: ProjectChannelRequestQueue) { + if (queue.seenRequestIds.size <= MAX_SEEN_PROJECT_CHANNEL_REQUEST_IDS) return; + + const pendingIds = new Set( + queue.pending.map((candidate) => candidate.request.requestId), + ); + for (const requestId of queue.seenRequestIds) { + if (requestId !== queue.activeRequestId && !pendingIds.has(requestId)) { + queue.seenRequestIds.delete(requestId); + if (queue.seenRequestIds.size <= MAX_SEEN_PROJECT_CHANNEL_REQUEST_IDS) { + return; + } + } + } +} diff --git a/desktop/src/features/projects/projectCreation.test.mjs b/desktop/src/features/projects/projectCreation.test.mjs index ed6e9328cd2..9a3cf0212ba 100644 --- a/desktop/src/features/projects/projectCreation.test.mjs +++ b/desktop/src/features/projects/projectCreation.test.mjs @@ -2,77 +2,198 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - buildInitialProjectEventTemplates, + buildDefaultProjectRepositoryTemplate, + buildProjectAnnouncementTemplate, + buildProjectBootstrapTemplates, + conflictingListedProject, isUnsupportedProjectKindError, } from "./projectCreation.ts"; const OWNER = "a".repeat(64); const CHANNEL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; -test("buildInitialProjectEventTemplates emits a NIP-MP project", () => { - const templates = buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, - cloneUrl: "https://relay.example/git/owner/sprout.git", - description: "A multi-repository workspace", +test("buildProjectAnnouncementTemplate emits a channel-first NIP-MP project", () => { + const templates = buildProjectAnnouncementTemplate({ + description: "A workspace that starts as a conversation", name: "Sprout", ownerPubkey: OWNER, - webUrl: "https://example.com/sprout", + projectChannelId: CHANNEL, }); assert.equal(templates.dtag, "sprout"); assert.equal(templates.project.kind, 30621); - assert.equal(templates.repository.kind, 30617); assert.deepEqual(templates.project.tags, [ ["d", "sprout"], ["name", "Sprout"], ["buzz-channel", CHANNEL], - ["description", "A multi-repository workspace"], - ["a", `30617:${OWNER}:sprout`], + ["description", "A workspace that starts as a conversation"], ]); assert.equal(templates.project.content, ""); + assert.equal( + templates.project.tags.some((tag) => tag[0] === "a"), + false, + ); +}); + +test("buildProjectAnnouncementTemplate records unlisted visibility and members", () => { + const address = `30617:${OWNER}:sprout`; + const templates = buildProjectAnnouncementTemplate({ + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + projectVisibility: "unlisted", + repositoryAddresses: [address], + }); + + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["buzz-visibility", "unlisted"], + ["a", address], + ]); +}); + +test("buildProjectBootstrapTemplates binds a default repository to the home channel", () => { + const templates = buildProjectBootstrapTemplates({ + description: "A workspace that starts as a conversation", + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + }); + const repositoryAddress = `30617:${OWNER}:sprout`; + + assert.equal(templates.dtag, "sprout"); + assert.equal(templates.repositoryAddress, repositoryAddress); + assert.equal(templates.project.kind, 30621); + assert.equal(templates.repository.kind, 30617); + assert.deepEqual(templates.project.tags, [ + ["d", "sprout"], + ["name", "Sprout"], + ["buzz-channel", CHANNEL], + ["description", "A workspace that starts as a conversation"], + ["a", repositoryAddress], + ]); assert.deepEqual(templates.repository.tags, [ ["d", "sprout"], ["name", "Sprout"], ["buzz-channel", CHANNEL], - ["description", "A multi-repository workspace"], - ["clone", "https://relay.example/git/owner/sprout.git"], - ["web", "https://example.com/sprout"], + ["description", "A workspace that starts as a conversation"], ]); }); -test("buildInitialProjectEventTemplates rejects names without an identifier", () => { +test("buildDefaultProjectRepositoryTemplate uses the project slug as the repo id", () => { + const template = buildDefaultProjectRepositoryTemplate({ + name: "Space Invaders 3D", + ownerPubkey: OWNER, + projectChannelId: CHANNEL, + }); + + assert.equal(template.dtag, "space-invaders-3d"); + assert.equal(template.repositoryAddress, `30617:${OWNER}:space-invaders-3d`); +}); + +test("buildProjectAnnouncementTemplate rejects names without an identifier", () => { assert.throws( () => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ name: "!!!", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), /letters or numbers/, ); }); -test("buildInitialProjectEventTemplates enforces the description tag byte limit", () => { +test("buildProjectAnnouncementTemplate enforces the description tag byte limit", () => { assert.doesNotThrow(() => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ description: "🙂".repeat(512), name: "Sprout", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), ); assert.throws( () => - buildInitialProjectEventTemplates({ - accessChannelId: CHANNEL, + buildProjectAnnouncementTemplate({ description: "🙂".repeat(513), name: "Sprout", ownerPubkey: OWNER, + projectChannelId: CHANNEL, }), /2,048 bytes/, ); }); +test("buildProjectAnnouncementTemplate rejects an invalid project channel", () => { + assert.throws( + () => + buildProjectAnnouncementTemplate({ + name: "Sprout", + ownerPubkey: OWNER, + projectChannelId: "not-a-channel", + }), + /Project channel is invalid/, + ); +}); + +test("conflictingListedProject ignores the caller's own slug and legacy cards", () => { + assert.equal( + conflictingListedProject( + [ + { + dtag: "sprout", + legacy: false, + name: "Sprout", + owner: OWNER, + }, + ], + { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER }, + ), + null, + ); + assert.equal( + conflictingListedProject( + [ + { + dtag: "sprout", + legacy: true, + name: "Sprout", + owner: "b".repeat(64), + }, + ], + { dtag: "sprout", name: "Sprout", ownerPubkey: OWNER }, + ), + null, + ); +}); + +test("conflictingListedProject blocks another listed project with the same name or slug", () => { + const other = { + dtag: "space-invaders-3d", + legacy: false, + name: "Space Invaders 3D", + owner: "b".repeat(64), + }; + assert.deepEqual( + conflictingListedProject([other], { + dtag: "space-invaders-3d", + name: "Space Invaders 3D", + ownerPubkey: OWNER, + }), + other, + ); + assert.deepEqual( + conflictingListedProject([other], { + dtag: "space-invaders-3d-remake", + name: "Space Invaders 3D", + ownerPubkey: OWNER, + }), + other, + ); +}); + test("isUnsupportedProjectKindError recognizes relay kind compatibility failures", () => { assert.equal( isUnsupportedProjectKindError( diff --git a/desktop/src/features/projects/projectCreation.ts b/desktop/src/features/projects/projectCreation.ts index a42cf7bfd79..38bd4521e0e 100644 --- a/desktop/src/features/projects/projectCreation.ts +++ b/desktop/src/features/projects/projectCreation.ts @@ -10,13 +10,18 @@ export type ProjectEventTemplate = { tags: string[][]; }; -export type InitialProjectEventTemplates = { +export type ProjectAnnouncementTemplate = { dtag: string; project: ProjectEventTemplate; +}; + +export type ProjectBootstrapTemplates = ProjectAnnouncementTemplate & { repository: ProjectEventTemplate; repositoryAddress: string; }; +export type ProjectListingVisibility = "listed" | "unlisted"; + export function isUnsupportedProjectKindError(error: unknown): boolean { return ( error instanceof Error && @@ -24,28 +29,64 @@ export function isUnsupportedProjectKindError(error: unknown): boolean { ); } -function projectDtagFromName(name: string): string { +export function projectDtagFromName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); } -export function buildInitialProjectEventTemplates({ - accessChannelId, - cloneUrl, +export type ListedProjectIdentity = { + dtag: string; + legacy: boolean; + name: string; + owner: string; +}; + +/** + * A second listed project with the same slug or display name is the duplicate + * card users see when an agent runs `projects create` inside an existing + * project. Same-owner + same-slug is handled as resume/idempotent create by + * the caller; this finds a *different* listed project that should block create. + */ +export function conflictingListedProject( + projects: readonly ListedProjectIdentity[], + input: { dtag: string; name: string; ownerPubkey: string }, +): ListedProjectIdentity | null { + const ownerPubkey = input.ownerPubkey.toLowerCase(); + const normalizedName = input.name.trim().toLowerCase(); + return ( + projects.find((project) => { + if (project.legacy) return false; + const sameOwnerSlug = + project.owner.toLowerCase() === ownerPubkey && + project.dtag === input.dtag; + if (sameOwnerSlug) return false; + return ( + project.dtag === input.dtag || + project.name.trim().toLowerCase() === normalizedName + ); + }) ?? null + ); +} + +function normalizeProjectAnnouncementInput({ description, name, ownerPubkey, - webUrl, + projectChannelId, }: { - accessChannelId: string; - cloneUrl?: string; description?: string; name: string; ownerPubkey: string; - webUrl?: string; -}): InitialProjectEventTemplates { + projectChannelId: string; +}): { + dtag: string; + normalizedDescription: string; + normalizedName: string; + normalizedOwner: string; + normalizedProjectChannelId: string; +} { const normalizedName = name.trim(); if (!normalizedName) { throw new Error("Project name is required."); @@ -66,36 +107,74 @@ export function buildInitialProjectEventTemplates({ if (new TextEncoder().encode(normalizedDescription).byteLength > 2_048) { throw new Error("Project description must not exceed 2,048 bytes."); } - const repositoryTags: string[][] = [ - ["d", dtag], - ["name", normalizedName], - ]; + const normalizedProjectChannelId = projectChannelId.trim(); + if (!isValidProjectChannelId(normalizedProjectChannelId)) { + throw new Error("Project channel is invalid."); + } + + return { + dtag, + normalizedDescription, + normalizedName, + normalizedOwner, + normalizedProjectChannelId, + }; +} + +/** Channel-first NIP-MP project: metadata, home channel, optional members. */ +export function buildProjectAnnouncementTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility = "listed", + repositoryAddresses = [], +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; + projectVisibility?: ProjectListingVisibility; + repositoryAddresses?: readonly string[]; +}): ProjectAnnouncementTemplate { + const { + dtag, + normalizedDescription, + normalizedName, + normalizedProjectChannelId, + } = normalizeProjectAnnouncementInput({ + description, + name, + ownerPubkey, + projectChannelId, + }); + + if (new Set(repositoryAddresses).size !== repositoryAddresses.length) { + throw new Error("A project cannot contain duplicate repositories."); + } + if ( + repositoryAddresses.some( + (address) => !/^30617:[0-9a-f]{64}:.+$/.test(address), + ) + ) { + throw new Error("Repository address is invalid."); + } + const projectTags: string[][] = [ ["d", dtag], ["name", normalizedName], + ["buzz-channel", normalizedProjectChannelId], ]; - const normalizedAccessChannelId = accessChannelId.trim(); - if (!isValidProjectChannelId(normalizedAccessChannelId)) { - throw new Error("Repository access channel is invalid."); - } - repositoryTags.push(["buzz-channel", normalizedAccessChannelId]); - projectTags.push(["buzz-channel", normalizedAccessChannelId]); if (normalizedDescription) { - repositoryTags.push(["description", normalizedDescription]); projectTags.push(["description", normalizedDescription]); } - const normalizedCloneUrl = cloneUrl?.trim(); - if (normalizedCloneUrl) { - repositoryTags.push(["clone", normalizedCloneUrl]); + if (projectVisibility === "unlisted") { + projectTags.push(["buzz-visibility", "unlisted"]); } - const normalizedWebUrl = webUrl?.trim(); - if (normalizedWebUrl) { - repositoryTags.push(["web", normalizedWebUrl]); + for (const address of [...repositoryAddresses].sort()) { + projectTags.push(["a", address]); } - const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; - projectTags.push(["a", repositoryAddress]); - return { dtag, project: { @@ -103,11 +182,88 @@ export function buildInitialProjectEventTemplates({ content: "", tags: projectTags, }, + }; +} + +/** Default 30617 bound to the project home channel, using the project slug. */ +export function buildDefaultProjectRepositoryTemplate({ + description, + name, + ownerPubkey, + projectChannelId, +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; +}): { + dtag: string; + repository: ProjectEventTemplate; + repositoryAddress: string; +} { + const { + dtag, + normalizedDescription, + normalizedName, + normalizedOwner, + normalizedProjectChannelId, + } = normalizeProjectAnnouncementInput({ + description, + name, + ownerPubkey, + projectChannelId, + }); + const repositoryAddress = `${KIND_REPO_ANNOUNCEMENT}:${normalizedOwner}:${dtag}`; + const repositoryTags: string[][] = [ + ["d", dtag], + ["name", normalizedName], + ["buzz-channel", normalizedProjectChannelId], + ]; + if (normalizedDescription) { + repositoryTags.push(["description", normalizedDescription]); + } + return { + dtag, + repositoryAddress, repository: { kind: KIND_REPO_ANNOUNCEMENT, content: normalizedDescription, tags: repositoryTags, }, - repositoryAddress, + }; +} + +/** Home channel + default repository already listed on the project. */ +export function buildProjectBootstrapTemplates({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility = "listed", +}: { + description?: string; + name: string; + ownerPubkey: string; + projectChannelId: string; + projectVisibility?: ProjectListingVisibility; +}): ProjectBootstrapTemplates { + const repository = buildDefaultProjectRepositoryTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + }); + const announcement = buildProjectAnnouncementTemplate({ + description, + name, + ownerPubkey, + projectChannelId, + projectVisibility, + repositoryAddresses: [repository.repositoryAddress], + }); + return { + ...announcement, + repository: repository.repository, + repositoryAddress: repository.repositoryAddress, }; } diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index ac494321042..6a1929fc2c6 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -5,6 +5,7 @@ import { KIND_PROJECT_ANNOUNCEMENT, KIND_REPO_ANNOUNCEMENT, } from "@/shared/constants/kinds"; +import { absorbStandaloneProjectRepositories } from "./lib/projectCollection"; import { buildProjectReadModels, type Project } from "./projectModels"; const PROJECT_ENUMERATION_PAGE_SIZE = 500; @@ -173,6 +174,7 @@ export async function buildProjectsFromFetcher( options: { relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; + viewerPubkey?: string | null; } = {}, ): Promise { const [projectEvents, repositoryEvents] = await Promise.all([ @@ -200,11 +202,14 @@ export async function buildProjectsFromFetcher( ); } - return buildProjectReadModels({ - projectEvents, - repositoryEvents, - deletionEvents: tombstoneResult.events, - relayOrigin: options.relayOrigin ?? null, - hiddenAddresses: options.hiddenAddresses ?? new Set(), - }).sort((a, b) => b.createdAt - a.createdAt); + return absorbStandaloneProjectRepositories( + buildProjectReadModels({ + projectEvents, + repositoryEvents, + deletionEvents: tombstoneResult.events, + relayOrigin: options.relayOrigin ?? null, + hiddenAddresses: options.hiddenAddresses ?? new Set(), + viewerPubkey: options.viewerPubkey, + }), + ).sort((a, b) => b.createdAt - a.createdAt); } diff --git a/desktop/src/features/projects/projectModels.test.mjs b/desktop/src/features/projects/projectModels.test.mjs index 7be105584a9..837a0deb531 100644 --- a/desktop/src/features/projects/projectModels.test.mjs +++ b/desktop/src/features/projects/projectModels.test.mjs @@ -90,6 +90,31 @@ test("buildProjectReadModels resolves repositories with a deterministic selectio projects[0].repositoryRelayHints[backendAddress], "wss://relay.example", ); + assert.equal( + projects[0].projectChannelId, + "11111111-1111-4111-8111-111111111111", + ); + assert.deepEqual(projects[0].relatedChannelIds, []); +}); + +test("buildProjectReadModels keeps extra related channel ids", () => { + const relatedA = "22222222-2222-4222-8222-222222222222"; + const relatedB = "33333333-3333-4333-8333-333333333333"; + const home = "11111111-1111-4111-8111-111111111111"; + const projects = buildProjectReadModels({ + projectEvents: [ + projectEvent([ + ["buzz-related-channel", relatedA], + ["buzz-related-channel", home], + ["buzz-related-channel", relatedB], + ["buzz-related-channel", relatedA], + ]), + ], + repositoryEvents: [], + relayOrigin: RELAY_ORIGIN, + }); + + assert.deepEqual(projects[0].relatedChannelIds, [relatedA, relatedB]); }); test("buildProjectReadModels keeps unclaimed repositories as implicit projects", () => { @@ -194,6 +219,44 @@ test("selectProjectRepository honors a request and falls back to primary", () => assert.equal(selectProjectRepository(projects[0], null)?.dtag, "backend"); }); +test("buildProjectReadModels keeps the viewer's own unlisted project", () => { + const repoAddress = `30617:${PROJECT_OWNER}:secret`; + const unlisted = { + ...projectEvent([["a", repoAddress]]), + tags: [ + ["d", "secret"], + ["name", "Secret"], + ["buzz-channel", "11111111-1111-4111-8111-111111111111"], + ["buzz-visibility", "unlisted"], + ["a", repoAddress], + ], + }; + const asStranger = buildProjectReadModels({ + projectEvents: [unlisted], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")], + relayOrigin: RELAY_ORIGIN, + }); + const asOwner = buildProjectReadModels({ + projectEvents: [unlisted], + repositoryEvents: [repositoryEvent(PROJECT_OWNER, "secret")], + relayOrigin: RELAY_ORIGIN, + viewerPubkey: PROJECT_OWNER, + }); + + assert.equal( + asStranger.some((project) => project.dtag === "secret" && !project.legacy), + false, + ); + assert.equal( + asStranger.some((project) => project.legacy && project.dtag === "secret"), + true, + ); + assert.equal(asOwner.length, 1); + assert.equal(asOwner[0]?.legacy, false); + assert.equal(asOwner[0]?.dtag, "secret"); + assert.equal(asOwner[0]?.visibility, "unlisted"); +}); + function coordinateParts(coordinate) { const first = coordinate.indexOf(":"); const second = coordinate.indexOf(":", first + 1); diff --git a/desktop/src/features/projects/projectModels.ts b/desktop/src/features/projects/projectModels.ts index 6d539b54d9e..21afa771d7c 100644 --- a/desktop/src/features/projects/projectModels.ts +++ b/desktop/src/features/projects/projectModels.ts @@ -32,6 +32,12 @@ export type Project = { owner: string; createdAt: number; projectChannelId: string | null; + /** + * Extra streams linked to this project via repeatable + * `buzz-related-channel` tags. Client convention: NIP-MP treats the tag as + * unrecognized metadata, so older readers ignore it. + */ + relatedChannelIds: string[]; status: string; projectAddress: string; primaryRepositoryAddress: string | null; @@ -43,6 +49,11 @@ export type Project = { legacy: boolean; }; +/** True for an announced NIP-MP project, excluding repository-only read models. */ +export function isExplicitProject(project: Project): boolean { + return !project.legacy; +} + type BuildProjectReadModelsInput = { projectEvents: RelayEvent[]; repositoryEvents: RelayEvent[]; @@ -50,6 +61,12 @@ type BuildProjectReadModelsInput = { deletionEvents?: RelayEvent[]; relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; + /** + * When set, the viewer's own unlisted projects stay in the collection so the + * creator can still open them. Other viewers keep the NIP-MP fold: unlisted + * projects are absent and do not claim members. + */ + viewerPubkey?: string | null; }; const MAX_D_TAG_BYTES = 1_024; @@ -109,6 +126,12 @@ export function isValidProjectChannelId(value: string): boolean { ); } +/** Repeatable project tag naming an extra stream besides `buzz-channel`. */ +export const PROJECT_RELATED_CHANNEL_TAG = "buzz-related-channel"; + +/** Cap extra project streams so a tag list cannot grow without bound. */ +export const MAX_PROJECT_RELATED_CHANNELS = 64; + const SINGLETON_METADATA_TAGS = [ "name", "description", @@ -339,6 +362,16 @@ export function eventToExplicitProject( const visibility = rawVisibility === "unlisted" ? ("unlisted" as const) : ("listed" as const); const channel = getTag(event, "buzz-channel"); + const projectChannelId = + channel && isValidProjectChannelId(channel) ? channel : null; + const relatedChannelIds = [ + ...new Set( + getAllTags(event, PROJECT_RELATED_CHANNEL_TAG).filter( + (channelId) => + isValidProjectChannelId(channelId) && channelId !== projectChannelId, + ), + ), + ].slice(0, MAX_PROJECT_RELATED_CHANNELS); return { id: projectAddress, dtag, @@ -346,8 +379,8 @@ export function eventToExplicitProject( description: getTag(event, "description") ?? "", owner, createdAt: event.created_at, - projectChannelId: - channel && isValidProjectChannelId(channel) ? channel : null, + projectChannelId, + relatedChannelIds, status: visibility === "listed" ? "active" : "unlisted", projectAddress, primaryRepositoryAddress, @@ -374,6 +407,7 @@ function repositoryToLegacyProject(repository: Repository): Project { owner: repository.owner, createdAt: repository.createdAt, projectChannelId: null, + relatedChannelIds: [], status: repository.status, projectAddress: repository.repoAddress, primaryRepositoryAddress: repository.repoAddress, @@ -417,12 +451,23 @@ function buildDeletionThresholds( return thresholds; } +function projectIsListingEligible( + project: Project, + viewerPubkey: string | null | undefined, +): boolean { + if (project.visibility !== "unlisted") return true; + return Boolean( + viewerPubkey && project.owner === viewerPubkey.trim().toLowerCase(), + ); +} + export function buildProjectReadModels({ projectEvents, repositoryEvents, deletionEvents = [], relayOrigin, hiddenAddresses = new Set(), + viewerPubkey, }: BuildProjectReadModelsInput): Project[] { const deletionThresholds = buildDeletionThresholds(deletionEvents); @@ -463,7 +508,7 @@ export function buildProjectReadModels({ visibleRepositoriesByAddress, ); return project && - project.visibility === "listed" && + projectIsListingEligible(project, viewerPubkey) && !hiddenAddresses.has(project.projectAddress) ? [project] : []; @@ -548,3 +593,21 @@ export function addRepositoryToProject( ) ?? [], }; } + +/** Returns the optimistic read model after linking an extra project stream. */ +export function addRelatedChannelToProject( + project: Project, + channelId: string, + createdAt: number, +): Project { + const relatedChannelIds = [ + ...new Set([...(project.relatedChannelIds ?? []), channelId]), + ].filter( + (id) => id !== project.projectChannelId && isValidProjectChannelId(id), + ); + return { + ...project, + createdAt, + relatedChannelIds: relatedChannelIds.slice(0, MAX_PROJECT_RELATED_CHANNELS), + }; +} diff --git a/desktop/src/features/projects/projectWorkItems.test.mjs b/desktop/src/features/projects/projectWorkItems.test.mjs index 21ee577b54e..d4060e130a9 100644 --- a/desktop/src/features/projects/projectWorkItems.test.mjs +++ b/desktop/src/features/projects/projectWorkItems.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { fetchProjectsWorkItems } from "./projectWorkItems.ts"; +import { + fetchProjectsWorkItems, + projectsWithWorkItemRepositories, +} from "./projectWorkItems.ts"; // ── Work-item deduplication ───────────────────────────────────────────────── // @@ -27,8 +30,31 @@ const projectB = { repositories: [{ repoAddress: REPO_ADDRESS }], }; +test("work-item scope keeps explicit and repository-only read models", () => { + const explicitProject = { + id: "explicit", + legacy: false, + repositories: [{ repoAddress: REPO_ADDRESS }], + }; + const repositoryOnlyProject = { + id: "repository-only", + legacy: true, + repositories: [{ repoAddress: `30617:${REPO_OWNER}:standalone` }], + }; + const emptyProject = { id: "empty", legacy: false, repositories: [] }; + + assert.deepEqual( + projectsWithWorkItemRepositories([ + explicitProject, + repositoryOnlyProject, + emptyProject, + ]).map((project) => project.id), + ["explicit", "repository-only"], + ); +}); + // Minimal valid NIP-34 issue event for the shared repo. -function makeIssue(id, updatedAt = 100) { +function makeIssue(id, updatedAt = 100, repoAddress = REPO_ADDRESS) { return { id, kind: 1621, @@ -36,12 +62,34 @@ function makeIssue(id, updatedAt = 100) { created_at: updatedAt, content: "An issue", tags: [ - ["a", REPO_ADDRESS], + ["a", repoAddress], ["subject", "Fix the thing"], ], }; } +test("fetchProjectsWorkItems accumulates issues from every project repository", async () => { + const secondAddress = `30617:${REPO_OWNER}:desktop`; + const project = { + repositories: [ + { repoAddress: REPO_ADDRESS }, + { repoAddress: secondAddress }, + ], + }; + const result = await fetchProjectsWorkItems( + [project], + makeFetchEvents([ + makeIssue(ISSUE_ID, 100, REPO_ADDRESS), + makeIssue("j".repeat(64), 90, secondAddress), + ]), + ); + + assert.deepEqual( + result.issues.items.map(({ repository }) => repository.repoAddress).sort(), + [REPO_ADDRESS, secondAddress].sort(), + ); +}); + // Minimal valid NIP-34 pull request event for the shared repo. function makePR(id, updatedAt = 100) { return { diff --git a/desktop/src/features/projects/projectWorkItems.ts b/desktop/src/features/projects/projectWorkItems.ts index 11acbc8b34b..d6dfd49a865 100644 --- a/desktop/src/features/projects/projectWorkItems.ts +++ b/desktop/src/features/projects/projectWorkItems.ts @@ -62,6 +62,13 @@ export type ProjectsWorkItemsResult = { }; }; +/** Includes every repository-bearing read model, including repository-only ones. */ +export function projectsWithWorkItemRepositories< + TProject extends ProjectReference, +>(projects: readonly TProject[]): TProject[] { + return projects.filter((project) => project.repositories.length > 0); +} + function groupByRepoAddress(events: RelayEvent[]): Map { const grouped = new Map(); for (const event of events) { diff --git a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx index 609c38a2cc6..e47aceb2a13 100644 --- a/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx +++ b/desktop/src/features/projects/ui/AddProjectRepositoryDialog.tsx @@ -22,6 +22,7 @@ export function AddProjectRepositoryDialog({ onOpenChange, open, project, + projects, }: { accessChannelId?: string; channels: Channel[]; @@ -29,37 +30,51 @@ export function AddProjectRepositoryDialog({ onAdd: (input: AddProjectRepositoryInput) => Promise; onOpenChange: (open: boolean) => void; open: boolean; - project: Project; + project?: Project; + projects?: Project[]; }) { + const projectOptions = React.useMemo( + () => projects ?? (project ? [project] : []), + [project, projects], + ); + const [selectedProjectId, setSelectedProjectId] = React.useState( + project?.id ?? projectOptions[0]?.id ?? "", + ); + const selectedProject = + projectOptions.find((candidate) => candidate.id === selectedProjectId) ?? + projectOptions[0]; const [name, setName] = React.useState(""); const [cloneUrl, setCloneUrl] = React.useState(""); const [selectedChannelId, setSelectedChannelId] = React.useState(""); const [errorMessage, setErrorMessage] = React.useState(null); const nameInputRef = React.useRef(null); + const projectSelectRef = React.useRef(null); React.useEffect(() => { if (!open) return; setName(""); setCloneUrl(""); + setSelectedProjectId(project?.id ?? projectOptions[0]?.id ?? ""); setSelectedChannelId(accessChannelId ?? ""); setErrorMessage(null); const timerId = globalThis.setTimeout( - () => nameInputRef.current?.focus(), + () => + (projects ? projectSelectRef.current : nameInputRef.current)?.focus(), 50, ); return () => globalThis.clearTimeout(timerId); - }, [accessChannelId, open]); + }, [accessChannelId, open, project?.id, projectOptions, projects]); async function handleSubmit(event: React.FormEvent) { event.preventDefault(); - if (!name.trim() || !selectedChannelId) return; + if (!name.trim() || !selectedChannelId || !selectedProject) return; setErrorMessage(null); try { await onAdd({ accessChannelId: selectedChannelId, cloneUrl: cloneUrl.trim() || undefined, name: name.trim(), - project, + project: selectedProject, }); onOpenChange(false); } catch (error) { @@ -81,11 +96,20 @@ export function AddProjectRepositoryDialog({ className="max-w-lg" contentClassName="pt-3" data-testid="add-project-repository-dialog" - description={`Add another repository to ${project.name}.`} + description={ + selectedProject + ? `Add another repository to ${selectedProject.name}.` + : "Choose a project for this repository." + } footer={
-
- -
- -
-

- Members of this channel can access project repositories. -

-
-
-
- -
- { - setCloneUrl(event.target.value); - setErrorMessage(null); - }} - placeholder="https://relay.example.com/git/bee-garden-game.git" - spellCheck={false} - value={cloneUrl} - /> -
-
- -
- -
- { - setWebUrl(event.target.value); - setErrorMessage(null); - }} - placeholder="https://github.com/owner/repo" - spellCheck={false} - value={webUrl} - /> -
-
+ {errorMessage ? (

{errorMessage}

diff --git a/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx new file mode 100644 index 00000000000..8d1ae7d8f70 --- /dev/null +++ b/desktop/src/features/projects/ui/CreateProjectFormSettings.tsx @@ -0,0 +1,265 @@ +import { ChevronDown, Plus } from "lucide-react"; +import * as React from "react"; + +import { ChannelPermissionsSettings } from "@/features/channels/ui/ChannelPermissionsSettings"; +import type { CreateProjectFormSettingsState } from "@/features/projects/ui/useCreateProjectFormSettings"; +import { TemplateFormDialog } from "@/features/settings/ui/ChannelTemplatesSettingsCard"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { cn } from "@/shared/lib/cn"; + +const NONE_AGENT_VALUE = "__none__"; +const NONE_TEAM_VALUE = "__no-team__"; +const NO_TEMPLATE_VALUE = "__no-template__"; + +const SETTINGS_ROW_CLASS = + "flex min-h-12 items-center justify-between gap-4 rounded-xl border border-input bg-background px-3 py-3"; + +export function CreateProjectFormSettings({ + agentPersonaId, + disabled, + handleTemplateChange, + handleTemplateCreated, + personas, + projectVisibility, + runtimesAvailable, + setAgentPersonaId, + setChannelVisibility, + setProjectVisibility, + setTeamId, + teamId, + teams, + templateId, + templates, + channelVisibility, +}: CreateProjectFormSettingsState & { disabled: boolean }) { + const [isCreateTemplateOpen, setIsCreateTemplateOpen] = React.useState(false); + const selectedPersona = personas.find( + (persona) => persona.id === agentPersonaId, + ); + const selectedTeam = teams.find((team) => team.id === teamId); + const selectedTemplate = templates.find( + (template) => template.id === templateId, + ); + const listingLabel = projectVisibility === "unlisted" ? "Unlisted" : "Listed"; + const agentLabel = selectedPersona?.displayName ?? "None"; + const agentDisabled = disabled || (!runtimesAvailable && personas.length > 0); + const teamDisabled = disabled || (!runtimesAvailable && teams.length > 0); + + return ( + <> + + +
+ + Template + + Project home by default + + + + + + + + + handleTemplateChange(value === NO_TEMPLATE_VALUE ? "" : value) + } + value={templateId || NO_TEMPLATE_VALUE} + > + + None + + {templates.map((template) => ( + + {template.name} + + ))} + + + setIsCreateTemplateOpen(true)}> + + Create new channel template… + + + + +
+ +
+ + Team + + Optional + + + + + + + + + setTeamId(value === NONE_TEAM_VALUE ? "" : value) + } + value={teamId || NONE_TEAM_VALUE} + > + + None + + {teams.map((team) => ( + + {team.name} + + ))} + + + +
+ +
+ + Project list + + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + setProjectVisibility( + value === "unlisted" ? "unlisted" : "listed", + ) + } + value={projectVisibility} + > + + Listed + + + Unlisted + + + + +
+ +
+ + Coding agent + + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + setAgentPersonaId(value === NONE_AGENT_VALUE ? "" : value) + } + value={agentPersonaId || NONE_AGENT_VALUE} + > + + None + + {personas.map((persona) => ( + + {persona.displayName} + + ))} + + + +
+ + ); +} diff --git a/desktop/src/features/projects/ui/DiscussionChannels.tsx b/desktop/src/features/projects/ui/DiscussionChannels.tsx index 665f77b6f2a..bd5d370524e 100644 --- a/desktop/src/features/projects/ui/DiscussionChannels.tsx +++ b/desktop/src/features/projects/ui/DiscussionChannels.tsx @@ -27,6 +27,7 @@ import { ProjectEntityFacepile, ProjectEntityListRow, } from "./ProjectEntityListRow"; +import { ProjectPanelState } from "./ProjectPanelState"; import { useProjectConversationPanel } from "./ProjectConversationPanelContext"; // Relay search caps a page at 500. Use the full page and surface a lower-bound @@ -400,13 +401,12 @@ export function DiscussionChannelsPanel({ } if (channels.length === 0) { return ( -

- No channels reference this repository yet. Paste its link (or a review - or task link) in a channel and it will show up here. -

+ ); } diff --git a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx index 14013a6157a..77210c82dcf 100644 --- a/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx @@ -9,7 +9,9 @@ import { normalizeRelayUrl } from "@/features/communities/communityStorage"; import { useCommunities } from "@/features/communities/useCommunities"; import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; import { projectDetailAgentContextBlock } from "@/features/projects/lib/projectDetailAgentContext"; +import { pickDefaultProjectsAgent } from "@/features/projects/lib/projectAgentSelection"; import { + projectAgentMembershipInput, restoreProjectsAgentConversation, submitProjectAgentMessage, } from "@/features/projects/lib/projectAgentConversation"; @@ -24,6 +26,7 @@ import { import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; +import { addChannelMembers } from "@/shared/api/tauri"; import { sendChannelMessage } from "@/shared/api/tauriMessages"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -45,25 +48,29 @@ type ProjectAgentConversation = { }; export function ProjectAgentChatPanel({ - canResetWidth, + canResetWidth = false, constrainToAvailableSpace = true, context, detached = false, + homeChannel = null, + layout = "pane", onClose, onResetWidth, onResizeStart, sharedHeaderBackdrop, - widthPx, + widthPx = 0, }: { - canResetWidth: boolean; + canResetWidth?: boolean; constrainToAvailableSpace?: boolean; context: ProjectDetailAgentContext; detached?: boolean; + homeChannel?: Channel | null; + layout?: "pane" | "canvas"; onClose?: () => void; - onResetWidth: () => void; - onResizeStart: (event: React.PointerEvent) => void; + onResetWidth?: () => void; + onResizeStart?: (event: React.PointerEvent) => void; sharedHeaderBackdrop?: boolean; - widthPx: number; + widthPx?: number; }) { const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); @@ -97,7 +104,8 @@ export function ProjectAgentChatPanel({ const profileQuery = useProfileQuery(); const openDmMutation = useOpenDmMutation(); const startAgentMutation = useStartManagedAgentMutation(); - const selectedAgent = conversation?.agent ?? candidates[0] ?? null; + const selectedAgent = + conversation?.agent ?? pickDefaultProjectsAgent(candidates); const candidateProfilesQuery = useUsersBatchQuery( selectedAgent ? [selectedAgent.pubkey] : [], ); @@ -119,11 +127,13 @@ export function ProjectAgentChatPanel({ candidates, channels: channelsQuery.data ?? [], currentPubkey: identityQuery.data?.pubkey ?? null, + homeChannelId: homeChannel?.id ?? null, stored: storedConversation, }), [ candidates, channelsQuery.data, + homeChannel?.id, identityQuery.data?.pubkey, storedConversation, ], @@ -148,10 +158,30 @@ export function ProjectAgentChatPanel({ // `submitProjectAgentMessage` binds every relay side effect to the // scope captured here (fail closed), and threads follow-ups onto the // opener so a same-second follow-up cannot be hidden by id ordering. + if ( + homeChannel && + (!conversation || conversation.channel.id === homeChannel.id) + ) { + const alreadyMember = homeChannel.memberPubkeys.some( + (pubkey) => + normalizePubkey(pubkey) === normalizePubkey(selectedAgent.pubkey), + ); + if (!alreadyMember) { + await addChannelMembers( + projectAgentMembershipInput({ + channelId: homeChannel.id, + agentPubkey: selectedAgent.pubkey, + relayScope, + signerScope, + }), + ); + } + } const { channel, sent } = await submitProjectAgentMessage({ agent: selectedAgent, conversation, content: `${trimmed}${contextPayload}`, + homeChannel, mentionPubkeys: [ ...new Set([...mentionPubkeys, selectedAgent.pubkey]), ], @@ -209,11 +239,13 @@ export function ProjectAgentChatPanel({ [ contextPayload, conversation, + homeChannel, identityQuery.data?.pubkey, isSending, openDmMutation, relayScope, selectedAgent, + signerScope, startAgentMutation, storageScope, ], @@ -225,98 +257,122 @@ export function ProjectAgentChatPanel({ setConversation(null); }, [storageScope]); - return ( - -
+ {layout === "pane" ? ( -
-
- {conversation ? ( - - ) : ( -
-

- Ask about this page -

-

- Start a conversation with the project agent. -

-
- )} -
- {context.selection?.length ? ( - - ) : null} - - - {conversation ? ( - - ) : null} - - } - /> + ) : null} +
+
+ {conversation ? ( + + ) : ( +
+

+ {homeChannel + ? "Explain what this project should be" + : "Ask about this page"} +

+

+ {homeChannel + ? "The project agent will build it out from this channel." + : "Start a conversation with the project agent."} +

+
+ )}
+ {context.selection?.length ? ( + + ) : null} + + + {conversation ? ( + + ) : null} + + } + /> +
+
+ ); + + if (layout === "canvas") { + return ( +
+ {conversationBody}
+ ); + } + + return ( + {})} + onResizeStart={onResizeStart ?? (() => {})} + testId="project-agent-chat-panel" + widthPx={widthPx} + > + {conversationBody} ); } diff --git a/desktop/src/features/projects/ui/ProjectChannelHome.tsx b/desktop/src/features/projects/ui/ProjectChannelHome.tsx new file mode 100644 index 00000000000..d7b0d766fad --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelHome.tsx @@ -0,0 +1,436 @@ +import { useSearch } from "@tanstack/react-router"; +import { Maximize2, Plus } from "lucide-react"; +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; +import { useProfileQuery } from "@/features/profile/hooks"; +import type { Project } from "@/features/projects/hooks"; +import { + isProjectHomeWorkspaceSheetTab, + projectHomeWorkspaceSheetExpandTab, + projectHomeWorkspaceSheetTitle, + type ProjectHomeWorkspaceSheetTab, +} from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; +import { useHealProjectHomeRepositories } from "@/features/projects/useHealProjectHomeRepositories"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { RelayEvent } from "@/shared/api/types"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; +import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; +import { SIDEBAR_WIDTH_MIN } from "@/shared/layout/sidebarLayout"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; +import { ProjectContextRail } from "./ProjectContextRail"; +import { ProjectDetailChrome } from "./ProjectDetailChrome"; +import { ProjectHomeColumn } from "./ProjectHomeColumn"; +import { ProjectHomeContextPanel } from "./ProjectHomeContextPanel"; +import { + ProjectHomeWorkspaceSheet, + type ProjectHomeWorkspaceCreateAction, + type ProjectHomeWorkspaceDetail, +} from "./ProjectHomeWorkspaceSheet"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; + +const EMPTY_TARGET_MESSAGE_EVENTS: RelayEvent[] = []; +const PROJECT_HOME_SUMMARY_WIDTH_KEY = + "buzz.desktop.project-home-summary-width"; + +const ChannelScreenView = React.lazy(async () => { + const module = await import("@/features/channels/ui/ChannelScreen"); + return { default: module.ChannelScreen }; +}); + +function ignoreForumPost() {} +function ignoreForumPostSelect() {} + +function ProjectHomeHeaderToggle({ + children, + label, + onClick, + open, + testId, +}: { + children: React.ReactNode; + label: string; + onClick: () => void; + open: boolean; + testId: string; +}) { + return ( + + + + + {label} + + ); +} + +export function ProjectChannelHome({ + autoSendDraftKey, + project, + projects, + targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS, + targetMessageId, +}: { + autoSendDraftKey?: string | null; + project: Project; + projects: Project[]; + targetMessageEvents?: RelayEvent[]; + targetMessageId?: string | null; +}) { + const { goChannel, goProject, goProjects } = useAppNavigation(); + const sidebar = useOptionalSidebar(); + const identityQuery = useIdentityQuery(); + const profileQuery = useProfileQuery(); + const channelsQuery = useChannelsQuery(); + const search = useSearch({ strict: false }) as { + autoSend?: string; + messageId?: string; + }; + const [summaryOpen, setSummaryOpen] = React.useState(true); + const [addRepositoryOpen, setAddRepositoryOpen] = React.useState(false); + const [workspaceSheetTab, setWorkspaceSheetTab] = + React.useState(null); + const [workspaceRepositoryId, setWorkspaceRepositoryId] = React.useState< + string | null + >(null); + const [workspaceCreateAction, setWorkspaceCreateAction] = + React.useState(null); + const [workspaceDetail, setWorkspaceDetail] = + React.useState(null); + const summaryWidth = useThreadPanelWidth(undefined, { + minWidthPx: SIDEBAR_WIDTH_MIN, + sessionKey: PROJECT_HOME_SUMMARY_WIDTH_KEY, + }); + const homeChannel = + channelsQuery.data?.find( + (channel) => channel.id === project.projectChannelId, + ) ?? null; + const waitingForChannel = channelsQuery.isPending && !homeChannel; + const workspaceRepository = + project.repositories.find( + (repository) => repository.id === workspaceRepositoryId, + ) ?? + project.repositories[0] ?? + null; + const workspaceSheetOpen = + workspaceSheetTab != null && workspaceRepository != null; + const previousWorkspaceSheetOpenRef = React.useRef(workspaceSheetOpen); + const workspaceSheetVisibilityChanged = + previousWorkspaceSheetOpenRef.current !== workspaceSheetOpen; + React.useEffect(() => { + previousWorkspaceSheetOpenRef.current = workspaceSheetOpen; + }, [workspaceSheetOpen]); + const summaryVisible = summaryOpen && !workspaceSheetOpen; + + const openWorkspaceSheet = React.useCallback( + (tab: ProjectHomeWorkspaceSheetTab, repositoryId?: string) => { + if (repositoryId) { + setWorkspaceRepositoryId(repositoryId); + } + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceSheetTab((current) => (current === tab ? null : tab)); + }, + [], + ); + const closeWorkspaceSheet = React.useCallback(() => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceSheetTab(null); + }, []); + const handleOpenWorkspace = React.useCallback( + (repositoryId: string, tab?: EntityLinkTab) => { + if (!isProjectHomeWorkspaceSheetTab(tab)) { + void goProject(project.id, { repositoryId, tab }); + return; + } + openWorkspaceSheet(tab, repositoryId); + }, + [goProject, openWorkspaceSheet, project.id], + ); + const handleOpenRepository = React.useCallback( + (repositoryId: string) => { + void goProject(project.id, { repositoryId }); + }, + [goProject, project.id], + ); + const handleRepositoryChange = React.useCallback(() => { + void goProject(project.id); + }, [goProject, project.id]); + const handleAddFiles = React.useCallback(() => { + setAddRepositoryOpen(true); + }, []); + const handleFilesAdded = React.useCallback((repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + setWorkspaceSheetTab("files"); + }, []); + const handleWorkspaceRepositoryChange = React.useCallback( + (repositoryId: string) => { + setWorkspaceCreateAction(null); + setWorkspaceDetail(null); + setWorkspaceRepositoryId(repositoryId); + }, + [], + ); + useHealProjectHomeRepositories(project, identityQuery.data?.pubkey); + const handleOpenCommit = React.useCallback( + (commitHash: string) => { + if (!workspaceRepository) return; + void goProject(project.id, { + commitHash, + repositoryId: workspaceRepository.id, + tab: "commits", + }); + }, + [goProject, project.id, workspaceRepository], + ); + const handleExpandWorkspace = React.useCallback(() => { + if (!workspaceRepository || !workspaceSheetTab) return; + void goProject(project.id, { + repositoryId: workspaceRepository.id, + ...workspaceDetail?.navigation, + tab: projectHomeWorkspaceSheetExpandTab(workspaceSheetTab), + }); + }, [ + goProject, + project.id, + workspaceDetail?.navigation, + workspaceRepository, + workspaceSheetTab, + ]); + const expandLabel = workspaceSheetTab + ? `Open ${projectHomeWorkspaceSheetTitle(workspaceSheetTab)} in repository` + : "Open in repository"; + const workspaceSheet = + workspaceSheetOpen && workspaceSheetTab && workspaceRepository ? ( + + ) : null; + + return ( + +
+
+ { + if (workspaceSheetOpen) { + closeWorkspaceSheet(); + return; + } + setSummaryOpen((open) => !open); + }} + open={summaryVisible} + testId="project-home-drawer-toggle" + > + + + } + activeTabCrumb={null} + activeWorkItemCrumb={null} + onGoProjectHome={() => undefined} + onGoProjects={() => { + void goProjects(); + }} + project={project} + /> + {waitingForChannel ? ( + + ) : homeChannel ? ( + + } + > + + {workspaceCreateAction ? ( + + + + + + {workspaceCreateAction.label} + + + ) : null} + + + + + {expandLabel} + + + ), + backLabel: workspaceDetail?.backLabel, + onBack: workspaceDetail?.onBack, + }} + idleAuxiliaryOverridesThread={workspaceSheetOpen} + idleAuxiliaryTitle={ + workspaceSheetTab + ? projectHomeWorkspaceSheetTitle(workspaceSheetTab) + : "" + } + onAddFiles={handleAddFiles} + onCloseIdleAuxiliaryPanel={closeWorkspaceSheet} + onCloseForumPost={ignoreForumPost} + onSelectForumPost={ignoreForumPostSelect} + selectedForumPostId={null} + targetForumReplyId={null} + targetMessageEvents={targetMessageEvents} + targetMessageId={ + targetMessageId === undefined + ? (search.messageId ?? null) + : targetMessageId + } + /> + + ) : ( +
+

+ This project's channel could not be found. +

+
+ )} +
+ + + {summaryVisible ? ( + + { + void goChannel(channelId); + }} + onOpenRepository={handleOpenRepository} + onOpenWorkspace={handleOpenWorkspace} + onRepositoryChange={handleRepositoryChange} + project={project} + projects={projects} + /> + + ) : null} + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelIcon.tsx b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx new file mode 100644 index 00000000000..524003ad1eb --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelIcon.tsx @@ -0,0 +1,20 @@ +import { Folders, Hash } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; + +/** Projects glyph with a small channel hash nested in the lower right. */ +export function ProjectChannelIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelManagement.tsx b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx new file mode 100644 index 00000000000..9c774c9a3b2 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelManagement.tsx @@ -0,0 +1,82 @@ +import { Plus } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useIsManagedAgent } from "@/features/agent-memory/hooks"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import type { Project } from "@/features/projects/hooks"; +import { useAddProjectChannelMutation } from "@/features/projects/useAddProjectChannel"; +import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog"; +import { Button } from "@/shared/ui/button"; + +export function ProjectChannelManagement({ + identityPubkey, + project, +}: { + identityPubkey?: string; + project: Project; +}) { + const { goChannel } = useAppNavigation(); + const [createOpen, setCreateOpen] = React.useState(false); + const createMutation = useAddProjectChannelMutation(); + const ownerProfileQuery = useUsersBatchQuery([project.owner], { + enabled: Boolean(identityPubkey), + }); + const projectOwnerProfile = + ownerProfileQuery.data?.profiles[project.owner.toLowerCase()]; + const projectOwnerIsManaged = useIsManagedAgent(project.owner) === true; + const viewerIsProjectOwner = + identityPubkey?.toLowerCase() === project.owner.toLowerCase(); + const viewerOwnsProjectAgent = ownsAuthorAgent( + projectOwnerProfile, + identityPubkey, + ); + const canEdit = + !project.legacy && + (viewerIsProjectOwner || projectOwnerIsManaged || viewerOwnsProjectAgent); + const ownerControlAgentPubkey = + viewerOwnsProjectAgent && !projectOwnerIsManaged && !viewerIsProjectOwner + ? project.owner + : undefined; + + return ( + <> + {canEdit ? ( + { + const result = await createMutation.mutateAsync({ + ...input, + ownerControlAgentPubkey, + project, + }); + toast.success(`Channel "#${result.channel.name}" created.`); + await goChannel(result.channel.id); + }} + onOpenChange={setCreateOpen} + testId="create-project-channel-dialog" + title="Create a project channel" + /> + ) : null} + + + ); +} diff --git a/desktop/src/features/projects/ui/ProjectChannelRequestDialog.test.mjs b/desktop/src/features/projects/ui/ProjectChannelRequestDialog.test.mjs new file mode 100644 index 00000000000..1e4a30f2100 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectChannelRequestDialog.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ProjectChannelRequestDetails } from "./ProjectChannelRequestDialog.tsx"; + +function request(overrides = {}) { + return { + homeChannelId: "11111111-1111-4111-8111-111111111111", + name: "release-planning", + visibility: "private", + ...overrides, + }; +} + +test("owner review shows an agent-requested temporary channel lifetime and cleanup consequence", () => { + const html = renderToStaticMarkup( + React.createElement(ProjectChannelRequestDetails, { + request: request({ ttlSeconds: 90_000 }), + }), + ); + + assert.match(html, />Lifetime { + const html = renderToStaticMarkup( + React.createElement(ProjectChannelRequestDetails, { request: request() }), + ); + + assert.doesNotMatch(html, />Lifetime +
+
Name
+
#{request.name}
+
+ {request.description ? ( +
+
Description
+
+ {request.description} +
+
+ ) : null} +
+
Visibility
+
{request.visibility}
+
+ {request.ttlSeconds ? ( +
+
Lifetime
+
+ Temporary · {formatTtlDuration(request.ttlSeconds)}. Cleans up + automatically after that period of inactivity. +
+
+ ) : null} + {request.templateName ? ( +
+
Template
+
+ {request.templateName} +
+
+ ) : null} + + ); +} + +/** Global owner-review surface for project-channel requests from managed agents. */ +export function ProjectChannelRequestDialog() { + const management = useProjectChannelRequests(); + const request = management.request?.request; + + return ( + { + if (!open) management.dismiss(); + }} + open={request != null} + > + + + Create project channel? + + Your agent requested a new channel in{" "} + {management.project?.name ?? "this project"}. Review the details + before creating it. + + + {request ? : null} + {management.error ? ( +

{management.error}

+ ) : null} + + + Cancel + + { + event.preventDefault(); + void management.approve(); + }} + > + {management.isPending ? "Creating…" : "Create channel"} + + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectContextRail.tsx b/desktop/src/features/projects/ui/ProjectContextRail.tsx index 86204111030..7f0c0a8b70c 100644 --- a/desktop/src/features/projects/ui/ProjectContextRail.tsx +++ b/desktop/src/features/projects/ui/ProjectContextRail.tsx @@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn"; const CONTEXT_RAIL_GUTTER_PX = 8; export function ProjectContextRail({ + animateWidth = true, children, open, panelWidthPx, @@ -12,6 +13,7 @@ export function ProjectContextRail({ rounded = true, testId = "project-context-rail", }: { + animateWidth?: boolean; children: React.ReactNode; open: boolean; panelWidthPx: number; @@ -24,7 +26,7 @@ export function ProjectContextRail({ aria-hidden={!open} className={cn( "relative z-30 h-full shrink-0 overflow-hidden motion-reduce:transition-none", - resizing + resizing || !animateWidth ? "transition-none" : "transition-[width] duration-200 ease-linear", )} diff --git a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx index f9740b8aa32..0293d1b2b93 100644 --- a/desktop/src/features/projects/ui/ProjectDetailChrome.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailChrome.tsx @@ -26,8 +26,63 @@ export function ProjectDetailChrome({ onGoProjectHome: () => void; onGoProjects: () => void; project: Project; - repository: Repository; + repository?: Repository | null; }) { + const repositoryCrumb = repository ? ( + activeWorkItemCrumb ? ( + <> + + + + + + {activeWorkItemCrumb.title} + + + ) : activeTabCrumb ? ( + <> + + + + {activeTabCrumb} + + + ) : ( + + {repository.name} + + ) + ) : null; return (
- - - {activeWorkItemCrumb ? ( + {repositoryCrumb ? ( <> - - - - {activeWorkItemCrumb.title} - - - ) : activeTabCrumb ? ( - <> - - - - {activeTabCrumb} - + {repositoryCrumb} ) : ( - {repository.name} + {project.name} )} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index 36238689857..d960d3f09fc 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -24,18 +24,17 @@ import { } from "@/features/profile/lib/identity"; import { CircleDot, + FolderGit2, GitBranch, GitCommitHorizontal, GitPullRequest, } from "lucide-react"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; -import { - PROJECT_DETAIL_PANEL_CLASS, - PROJECT_DETAIL_PANEL_MESSAGE_CLASS, -} from "./projectPanelStyles"; +import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectWorkItemRow } from "./ProjectWorkItemRow"; +import { ProjectPanelState } from "./ProjectPanelState"; function pluralize(count: number, singular: string, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; @@ -152,12 +151,10 @@ export function ContributorsPanel({ if (rows.length === 0) { return ( -

- No git contributors are available yet. -

+ ); } @@ -240,6 +237,7 @@ export function ContributorsPanel({ export function ActivityPanel({ branch, + commitItems, snapshot, isLoading, error, @@ -252,10 +250,18 @@ export function ActivityPanel({ viewerGitIdentity, }: { branch?: string; + commitItems?: Array<{ + branch?: string; + commit: ProjectRepoCommit; + project: Repository; + projectId: string; + pullRequests?: ProjectPullRequest[]; + repoContributors?: ProjectRepoContributor[]; + }>; snapshot: ProjectRepoSnapshot | null | undefined; isLoading: boolean; error: unknown; - onSelectCommit?: (commit: ProjectRepoCommit) => void; + onSelectCommit?: (commit: ProjectRepoCommit, project: Repository) => void; profiles?: UserProfileLookup; project: Repository; projectId: string; @@ -263,21 +269,33 @@ export function ActivityPanel({ repoContributors: ProjectRepoContributor[]; viewerGitIdentity?: ViewerGitIdentity | null; }) { - const commits = snapshot?.commits ?? []; - const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( - pullRequests ?? [], - ); - const rangeItems = commits.map((commit) => { - const matchedProfile = profileForCommit( + const items = + commitItems ?? + (snapshot?.commits ?? []).map((commit) => ({ + branch, commit, + project, + projectId, + pullRequests, + repoContributors, + })); + const showRepositoryName = + commitItems !== undefined && + new Set(items.map((item) => item.project.repoAddress)).size > 1; + const rangeItems = items.map((item) => { + const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( + item.pullRequests ?? [], + ); + const matchedProfile = profileForCommit( + item.commit, profiles, commitAuthorPubkeys, viewerGitIdentity, ); return commitSelectionItem( - commit, - project, - projectId, + item.commit, + item.project, + item.projectId, matchedProfile?.pubkey, ); }); @@ -286,25 +304,31 @@ export function ActivityPanel({ return ; } - if (commits.length === 0) { + if (items.length === 0) { return ( -

- {error - ? "Could not load repository activity from git." - : "No commits are available yet."} -

+ ); } return (
- {commits.map((commit) => { + {items.map((item) => { + const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests( + item.pullRequests ?? [], + ); const matchedProfile = profileForCommit( - commit, + item.commit, profiles, commitAuthorPubkeys, viewerGitIdentity, @@ -314,36 +338,51 @@ export function ActivityPanel({ pubkey: matchedProfile.pubkey, profiles, }) - : commit.authorName || commit.authorEmail || "Unknown author"; - const matchingContributor = repoContributors.find( + : item.commit.authorName || + item.commit.authorEmail || + "Unknown author"; + const matchingContributor = (item.repoContributors ?? []).find( (contributor) => contributor.name.trim().toLowerCase() === - commit.authorName.trim().toLowerCase() || + item.commit.authorName.trim().toLowerCase() || contributor.email.trim().toLowerCase() === - commit.authorEmail.trim().toLowerCase(), + item.commit.authorEmail.trim().toLowerCase(), ); return ( - - {branch} + {showRepositoryName ? ( + <> + + {item.project.name} + + ) : ( + <> + + {item.branch} + + )} ) : undefined } - onOpen={onSelectCommit ? () => onSelectCommit(commit) : undefined} + onOpen={ + onSelectCommit + ? () => onSelectCommit(item.commit, item.project) + : undefined + } selection={{ item: commitSelectionItem( - commit, - project, - projectId, + item.commit, + item.project, + item.projectId, matchedProfile?.pubkey, ), rangeItems, @@ -352,7 +391,7 @@ export function ActivityPanel({ } testId="project-activity-feed-item" - title={commit.subject} + title={item.commit.subject} trailing={ <> - {relativeTime(commit.timestamp)} + {relativeTime(item.commit.timestamp)} } diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index b7f39a8cf33..827bbce1002 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -45,6 +45,8 @@ import { projectRepoUnavailableReason, refineRepoUnavailableReason, } from "@/features/projects/lib/projectRepoAvailability"; +import { wantsProjectRepositorySurface } from "@/features/projects/lib/projectDetailSearch"; +import { hasAuthoritativeHomeBinding } from "@/features/projects/lib/projectHomeChannel"; import { selectProjectRepository } from "@/features/projects/projectModels"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess"; @@ -61,6 +63,7 @@ import { ProjectDetailChrome } from "./ProjectDetailChrome"; import { ProjectConversationPanelController } from "./ProjectConversationPanelContext"; import { ProjectDetailRightPanel } from "./ProjectDetailRightPanel"; import { ProjectDetailUnavailableState } from "./ProjectDetailUnavailableState"; +import { ProjectChannelHome } from "./ProjectChannelHome"; import { ProjectRightPanelControls } from "./ProjectRightPanelControls"; import { buildProjectDetailCrumbs } from "./useProjectDetailCrumbs"; import { useProjectDetailPeople } from "./useProjectDetailPeople"; @@ -84,6 +87,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const { commitHash, entityNavigationId, + filePath, projectId, pullRequestId, issueId, @@ -282,15 +286,17 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const handleBranchChange = React.useCallback( (branch: string | null) => { selectBranch(branch); + if (!branch) return; + const localBranches = repoSyncStatusQuery.data?.localBranches; if ( - branch && repoSource === "local" && - branch !== repoSyncStatusQuery.data?.localBranch + localBranches && + !localBranches.includes(branch) ) { setRepoSource("remote"); } }, - [repoSource, repoSyncStatusQuery.data?.localBranch, selectBranch], + [repoSource, repoSyncStatusQuery.data?.localBranches, selectBranch], ); const handleTagChange = React.useCallback( (tag: string) => { @@ -673,6 +679,25 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { /> ); } + const showChannelHome = + hasAuthoritativeHomeBinding(project) && + !wantsProjectRepositorySurface({ + commitHash, + filePath, + issueId, + projectId, + pullRequestId, + repositoryId, + tab, + }); + if (showChannelHome) { + return ( + + ); + } if (!repository) { return ( { + if (project.projectChannelId) { + void goProject(project.id); + return; + } + handleGoToProjectHome(); + }; const agentPageContext = buildProjectDetailAgentContext({ activeTab, branch: activeBranch, @@ -834,7 +866,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { actions={repositoryPanelAction} activeTabCrumb={activeTabCrumb} activeWorkItemCrumb={activeWorkItemCrumb} - onGoProjectHome={handleGoToProjectHome} + onGoProjectHome={goChannelHome} onGoProjects={() => { void goProjects(); }} @@ -856,6 +888,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ? workspaceTabForShareTab(requestedTab) : undefined } + initialFilePath={filePath} initialTabRequestKey={entityNavigationId} fileContentSource={fileContentSource} commitDiff={commitDiffQuery.data} @@ -900,6 +933,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { handleSelectedPullRequestIdChange } onSelectedTabChange={setActiveTab} + onBack={goChannelHome} profiles={profiles} project={repository} projectId={project.id} diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index d0769e31e50..8acfd90b129 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -295,7 +295,7 @@ export function ProjectEntityListRow({ {affiliation ? ( {peopleContent} - {count != null ? ( + {count != null || countTestId ? ( - - - {count} - {countSuffix} - + {count != null ? ( + <> + + + {count} + {countSuffix} + + + ) : null} ) : null} {beforeDate ? ( diff --git a/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx new file mode 100644 index 00000000000..038902f97fc --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCodebasePanel.tsx @@ -0,0 +1,133 @@ +import { ChevronDown, FolderGit2 } from "lucide-react"; + +import { + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, + type Repository, +} from "@/features/projects/hooks"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { RepositoryFilesPanel } from "./ProjectRepositoryPanel"; +import { useRepositoryFileContentSource } from "./useRepositoryFileContentSource"; + +export function ProjectHomeCodebasePanel({ + identityPubkey, + onFilesContextChange, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, +}: { + identityPubkey?: string; + onFilesContextChange?: (context: { + kind: "file" | "folder"; + onBack?: () => void; + path: string; + }) => void; + onOpenCommit?: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Repository | null; +}) { + const repoStateQuery = useRepoStateQuery(repository); + const defaultBranch = repository + ? resolveProjectDefaultBranch(repository.defaultBranch, repoStateQuery.data) + : null; + const snapshotQuery = useProjectRepoSnapshotQuery( + repository, + defaultBranch, + null, + null, + Boolean(repository), + ); + const fileContentSource = useRepositoryFileContentSource({ + activeBranch: defaultBranch, + activeTag: null, + pullRequest: null, + repository, + selectedTag: null, + source: "remote", + }); + const snapshot = snapshotQuery.data ?? null; + const files = snapshot?.files ?? []; + + if (!repository) { + return ( +
+

+ Attach a repository to browse the file tree beside this channel. +

+ +
+ ); + } + + return ( +
+ {project.repositories.length > 1 ? ( +
+ + + + + + {project.repositories.map((candidate) => ( + onSelectRepository(candidate.id)} + > + {candidate.name} + + ))} + + +
+ ) : null} +
+ +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeColumn.tsx b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx new file mode 100644 index 00000000000..628f0988e55 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeColumn.tsx @@ -0,0 +1,46 @@ +import type * as React from "react"; + +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; + +export function ProjectHomeColumn({ + bodyClassName, + canResetWidth, + children, + onResetWidth, + onResizeStart, + testId, + widthPx, +}: { + bodyClassName?: string; + canResetWidth: boolean; + children: React.ReactNode; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + testId: string; + widthPx: number; +}) { + return ( + +
+ + {children} + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs new file mode 100644 index 00000000000..a55a935cf46 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +function repository(id, name) { + return { + id, + name, + repoAddress: `30617:owner:${id}`, + defaultBranch: "main", + }; +} + +test("multi-repository commits remain visibly degraded when one repository fails", async () => { + const { cleanup, render, screen } = await import("@testing-library/react"); + const { ProjectHomeCommitsPanel } = await import( + "./ProjectHomeCommitsPanel.tsx" + ); + const loadedRepository = repository("loaded", "Loaded"); + const failedRepository = repository("failed", "Failed"); + + const React = await import("react"); + try { + render( + React.createElement(ProjectHomeCommitsPanel, { + onSelectCommit: () => {}, + projectId: "project-1", + pullRequests: [], + results: [ + { + error: null, + isLoading: false, + repository: loadedRepository, + snapshot: { + contributors: [], + commits: [ + { + hash: "a".repeat(40), + shortHash: "aaaaaaa", + authorName: "Alice", + authorEmail: "alice@example.com", + timestamp: 2, + subject: "Loaded commit", + }, + ], + }, + }, + { + error: new Error("unavailable"), + isLoading: false, + repository: failedRepository, + snapshot: null, + }, + ], + }), + ); + + assert.match( + screen.getByTestId("project-home-commits-degraded").textContent, + /Showing commits from 1 of 2 repositories/, + ); + assert.match(document.body.textContent, /Loaded commit/); + } finally { + cleanup(); + } +}); + +test("multi-repository commits are merged in descending timestamp order", async () => { + const { cleanup, render } = await import("@testing-library/react"); + const { ProjectHomeCommitsPanel } = await import( + "./ProjectHomeCommitsPanel.tsx" + ); + const React = await import("react"); + const result = (id, subject, timestamp) => ({ + error: null, + isLoading: false, + repository: repository(id, id), + snapshot: { + contributors: [], + commits: [ + { + hash: id.repeat(40), + shortHash: id.repeat(7), + authorName: id, + authorEmail: `${id}@example.com`, + timestamp, + subject, + }, + ], + }, + }); + + try { + render( + React.createElement(ProjectHomeCommitsPanel, { + onSelectCommit: () => {}, + projectId: "project-1", + pullRequests: [], + results: [ + result("a", "Older commit", 1), + result("b", "Newer commit", 2), + ], + }), + ); + + assert.ok( + document.body.textContent.indexOf("Newer commit") < + document.body.textContent.indexOf("Older commit"), + ); + } finally { + cleanup(); + } +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx new file mode 100644 index 00000000000..9876c0fcbb2 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeCommitsPanel.tsx @@ -0,0 +1,107 @@ +import type { ProjectPullRequest, Repository } from "@/features/projects/hooks"; +import { AlertTriangle } from "lucide-react"; +import { + projectRepoUnavailablePresentation, + projectRepoUnavailableReason, +} from "@/features/projects/lib/projectRepoAvailability"; +import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching"; +import type { ProjectRepositorySnapshotResult } from "@/features/projects/useProjectRepositorySnapshots"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ProjectRepoCommit } from "@/shared/api/types"; +import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { ProjectPanelState } from "./ProjectPanelState"; +import { ActivityPanel } from "./ProjectDetailFeedPanels"; + +export function ProjectHomeCommitsPanel({ + onSelectCommit, + profiles, + projectId, + pullRequests, + results, + viewerGitIdentity, +}: { + onSelectCommit: (commit: ProjectRepoCommit, repository: Repository) => void; + profiles?: UserProfileLookup; + projectId: string; + pullRequests: ProjectPullRequest[]; + results: ProjectRepositorySnapshotResult[]; + viewerGitIdentity?: ViewerGitIdentity | null; +}) { + const loaded = results.filter( + (result) => (result.snapshot?.commits.length ?? 0) > 0, + ); + const commitItems = loaded + .flatMap(({ repository, snapshot }) => + (snapshot?.commits ?? []).map((commit) => ({ + branch: repository.defaultBranch, + commit, + project: repository, + projectId, + pullRequests, + repoContributors: snapshot?.contributors ?? [], + })), + ) + .sort((left, right) => right.commit.timestamp - left.commit.timestamp); + const failed = results.filter((result) => result.error); + const firstFailure = failed[0]; + const failure = firstFailure + ? projectRepoUnavailablePresentation( + projectRepoUnavailableReason(firstFailure.error), + ) + : null; + if (results.some((result) => result.isLoading) && loaded.length === 0) { + return ; + } + if (loaded.length === 0) { + return ( + 1 + ? ` ${failed.length - 1} other repositories also failed.` + : "" + }` + : "Commits pushed to this project's repositories will appear here." + } + error={failed.length > 0} + title={failure?.title ?? "No commits yet"} + /> + ); + } + + const firstItem = commitItems[0]; + if (!firstItem) return null; + return ( +
+ {failed.length > 0 ? ( +
+ +

+ Showing commits from {loaded.length} of {results.length}{" "} + repositories. {failed.length}{" "} + {failed.length === 1 ? "repository could" : "repositories could"}{" "} + not be loaded. +

+
+ ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx new file mode 100644 index 00000000000..63245ab632c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeContextPanel.tsx @@ -0,0 +1,397 @@ +import { + ChevronDown, + CircleDot, + FileCode2, + FolderGit2, + GitCommitHorizontal, + GitPullRequest, + Hash, + Users, +} from "lucide-react"; +import * as React from "react"; + +import { presentContextCount } from "@/features/projects/lib/projectHomeSummary"; +import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import { listProjectBoundChannels } from "@/features/projects/lib/projectRelatedChannels"; +import { + useProjectActivitySummariesQuery, + useProjectRepoSnapshotQuery, + useRepoStateQuery, + type Project, +} from "@/features/projects/hooks"; +import { ProjectChannelIcon } from "@/features/projects/ui/ProjectChannelIcon"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import type { EntityLinkTab } from "@/shared/lib/entityLink"; +import { Button } from "@/shared/ui/button"; +import { ProjectChannelManagement } from "./ProjectChannelManagement"; +import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement"; +import { SECTION_ACTION_VISIBILITY_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles"; + +const PROJECT_HOME_SIDEBAR_ROW_CLASS = + "h-8 w-full justify-start gap-2 rounded-md px-2 py-1.5 text-left text-sm font-normal text-sidebar-foreground/80 transition-[background-color,color] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50"; + +function ContextSection({ + children, + collapsible = false, + headerAction, + testId, + title, +}: { + children: React.ReactNode; + collapsible?: boolean; + headerAction?: React.ReactNode; + testId?: string; + title?: string; +}) { + const [expanded, setExpanded] = React.useState(true); + return ( +
+ {title || headerAction ? ( +
+ {title && collapsible ? ( + + ) : title ? ( +

+ {title} +

+ ) : ( + + )} + {headerAction ? ( + + {headerAction} + + ) : null} +
+ ) : null} + {!collapsible || expanded ? children : null} +
+ ); +} + +function ContextRowContent({ + children, + count, + icon, +}: { + children: React.ReactNode; + count?: number; + icon: React.ReactNode; +}) { + return ( + <> + + {icon} + + {children} + + {count ?? ""} + + + ); +} + +function ContextNavButton({ + children, + count, + disabled, + icon, + onClick, + pressed, + testId, + title, +}: { + children: React.ReactNode; + count?: number; + disabled?: boolean; + icon: React.ReactNode; + onClick?: () => void; + pressed?: boolean; + testId?: string; + title?: string; +}) { + return ( + + ); +} + +function ChannelContextRow({ + channel, + onClick, + projectHome, + testId, +}: { + channel: Channel; + onClick?: () => void; + projectHome?: boolean; + testId: string; +}) { + const Icon = projectHome ? ProjectChannelIcon : Hash; + if (onClick) { + return ( + } onClick={onClick} testId={testId}> + {channel.name} + + ); + } + return ( +
+ }>{channel.name} +
+ ); +} + +export function ProjectHomeContextPanel({ + activeWorkspaceTab, + channel, + channels = [], + identityPubkey, + onAddRepository, + onOpenChannel, + onOpenRepository, + onOpenWorkspace, + onRepositoryChange, + project, + projects, +}: { + activeWorkspaceTab?: ProjectHomeWorkspaceSheetTab | null; + channel: Channel | null; + channels?: Channel[]; + identityPubkey?: string; + onAddRepository?: () => void; + onOpenChannel?: (channelId: string) => void; + onOpenRepository: (repositoryId: string) => void; + onOpenWorkspace: (repositoryId: string, tab?: EntityLinkTab) => void; + onRepositoryChange: (repositoryId: string) => void; + project: Project; + projects: Project[]; +}) { + const firstRepository = project.repositories[0] ?? null; + const addRepositoryTitle = firstRepository + ? undefined + : "Add a repository to this project"; + const openWorkspace = (tab: EntityLinkTab) => { + if (firstRepository) { + onOpenWorkspace(firstRepository.id, tab); + return; + } + onAddRepository?.(); + }; + const peopleCount = new Set([ + project.owner, + ...project.repositories.flatMap((repository) => repository.contributors), + ]).size; + const activityQuery = useProjectActivitySummariesQuery([project]); + const activity = activityQuery.data?.[project.id]; + const repoStateQuery = useRepoStateQuery(firstRepository); + const defaultBranch = firstRepository + ? resolveProjectDefaultBranch( + firstRepository.defaultBranch, + repoStateQuery.data, + ) + : null; + const snapshotQuery = useProjectRepoSnapshotQuery( + firstRepository, + defaultBranch, + null, + null, + Boolean(firstRepository), + ); + const channelsById = new Map( + channels.map((candidate) => [candidate.id, candidate]), + ); + const boundChannels = listProjectBoundChannels(project).flatMap((binding) => { + const boundChannel = channelsById.get(binding.channelId); + if (!boundChannel) return []; + return [{ ...binding, channel: boundChannel }]; + }); + const listedChannels = + boundChannels.length > 0 + ? boundChannels + : channel + ? [ + { + channel, + channelId: channel.id, + repositoryId: null, + role: "home" as const, + }, + ] + : []; + + return ( +
+ + } + onClick={() => openWorkspace("issues")} + pressed={activeWorkspaceTab === "issues"} + testId="project-home-context-tasks" + title={addRepositoryTitle} + > + Tasks + + } + onClick={() => openWorkspace("prs")} + pressed={activeWorkspaceTab === "prs"} + testId="project-home-context-reviews" + title={addRepositoryTitle} + > + Reviews + + } + onClick={() => openWorkspace("commits")} + pressed={activeWorkspaceTab === "commits"} + testId="project-home-context-commits" + title={addRepositoryTitle} + > + Commits + + } + onClick={() => openWorkspace("files")} + pressed={activeWorkspaceTab === "files"} + testId="project-home-context-files" + title={addRepositoryTitle} + > + Files + + } + onClick={() => + firstRepository && + onOpenWorkspace(firstRepository.id, "contributors") + } + pressed={activeWorkspaceTab === "contributors"} + testId="project-home-context-people" + title={addRepositoryTitle} + > + People + + + + } + testId="project-home-context-channel" + title="Channels" + > + {listedChannels.length > 0 ? ( + listedChannels.map((binding) => { + const isHome = binding.role === "home"; + return ( + onOpenChannel(binding.channel.id) + } + projectHome={isHome} + testId={ + isHome + ? "project-home-context-home-channel" + : `project-home-context-channel-${binding.channel.name}` + } + /> + ); + }) + ) : ( +

+ }>Unavailable +

+ )} +
+ + } + testId="project-home-context-codebase" + title="Codebase" + > + {project.repositories.length > 0 ? ( + project.repositories.map((repository) => ( + } + key={repository.id} + onClick={() => onOpenRepository(repository.id)} + testId={`project-home-context-repo-${repository.dtag}`} + > + {repository.name} + + )) + ) : ( +

+ None yet +

+ )} +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.test.mjs b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.test.mjs new file mode 100644 index 00000000000..07e03a73354 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync( + new URL("./ProjectHomeWorkspaceSheet.tsx", import.meta.url), + "utf8", +); + +test("aggregated commit detail uses the repository that owns the selected commit", () => { + const detailPanel = source.match(//)?.[0]; + + assert.ok(detailPanel, "expected the commit detail panel to be rendered"); + assert.match(detailPanel, /project=\{selectedCommitRepository\}/); +}); diff --git a/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx new file mode 100644 index 00000000000..eca3cb6a614 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectHomeWorkspaceSheet.tsx @@ -0,0 +1,423 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + useProjectPullRequestsQuery, + useProjectRepoSnapshotQuery, + useProjectsWorkItemsQuery, + useRepoStateQuery, + type Project, +} from "@/features/projects/hooks"; +import { gitContributorPubkeysFromCommits } from "@/features/projects/lib/projectContributorMatching"; +import { resolveProjectDefaultBranch } from "@/features/projects/lib/projectBranches"; +import type { ProjectHomeWorkspaceSheetTab } from "@/features/projects/lib/projectHomeWorkspaceSheet"; +import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; +import { useProjectRepositorySnapshots } from "@/features/projects/useProjectRepositorySnapshots"; +import { CreateProjectIssueDialog } from "./CreateProjectIssueDialog"; +import { CreatePullRequestDialog } from "./CreatePullRequestDialog"; +import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; +import { ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ProjectHomeCodebasePanel } from "./ProjectHomeCodebasePanel"; +import { ProjectHomeCommitsPanel } from "./ProjectHomeCommitsPanel"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { PullRequestsPanel } from "./ProjectPullRequestsPanel"; +import { PROJECT_DETAIL_PANEL_CLASS } from "./projectPanelStyles"; +import { useProjectDetailPeople } from "./useProjectDetailPeople"; + +export type ProjectHomeWorkspaceCreateAction = { + disabled?: boolean; + label: string; + onClick: () => void; + title?: string; +}; + +export type ProjectHomeWorkspaceDetail = { + backLabel: string; + navigation: { + commitHash?: string; + filePath?: string; + issueId?: string; + pullRequestId?: string; + repositoryId?: string; + }; + onBack: () => void; +}; + +export function ProjectHomeWorkspaceSheet({ + identityPubkey, + onCreateActionChange, + onDetailChange, + onOpenCommit, + onRepositoryAdded, + onSelectRepository, + project, + projects, + repository, + tab, +}: { + identityPubkey?: string; + onCreateActionChange?: ( + action: ProjectHomeWorkspaceCreateAction | null, + ) => void; + onDetailChange?: (detail: ProjectHomeWorkspaceDetail | null) => void; + onOpenCommit: (commitHash: string) => void; + onRepositoryAdded: (repositoryId: string) => void; + onSelectRepository: (repositoryId: string) => void; + project: Project; + projects: Project[]; + repository: Project["repositories"][number]; + tab: ProjectHomeWorkspaceSheetTab; +}) { + const { goProject } = useAppNavigation(); + const { activeCommunity } = useCommunities(); + const [selectedIssueId, setSelectedIssueId] = React.useState( + null, + ); + const [selectedPullRequestId, setSelectedPullRequestId] = React.useState< + string | null + >(null); + const [selectedCommitHash, setSelectedCommitHash] = React.useState< + string | null + >(null); + const [selectedCommitRepositoryId, setSelectedCommitRepositoryId] = + React.useState(null); + const [filesContext, setFilesContext] = React.useState<{ + kind: "file" | "folder"; + onBack?: () => void; + path: string; + } | null>(null); + const [createIssueOpen, setCreateIssueOpen] = React.useState(false); + const [createPullRequestOpen, setCreatePullRequestOpen] = + React.useState(false); + + const projectScope = React.useMemo(() => [project], [project]); + const workItemsQuery = useProjectsWorkItemsQuery(projectScope); + const pullRequestsQuery = useProjectPullRequestsQuery(repository); + const issueItems = React.useMemo( + () => + (workItemsQuery.data?.issues.items ?? []).map( + ({ issue, repository: issueRepository }) => ({ + issue, + project: issueRepository, + }), + ), + [workItemsQuery.data?.issues.items], + ); + const issues = React.useMemo( + () => issueItems.map(({ issue }) => issue), + [issueItems], + ); + const pullRequests = pullRequestsQuery.data ?? []; + const people = useProjectDetailPeople({ + issues, + pullRequests, + repository, + }); + const repoStateQuery = useRepoStateQuery(repository); + const defaultBranch = resolveProjectDefaultBranch( + repository.defaultBranch, + repoStateQuery.data, + ); + const snapshotQuery = useProjectRepoSnapshotQuery( + repository, + defaultBranch, + null, + null, + true, + ); + const snapshot = snapshotQuery.data ?? null; + const repositorySnapshots = useProjectRepositorySnapshots( + project.repositories, + tab === "commits", + ); + const selectedCommitResult = + repositorySnapshots.find( + ({ repository: candidate }) => + candidate.id === selectedCommitRepositoryId, + ) ?? null; + const selectedCommitRepository = + selectedCommitResult?.repository ?? repository; + const commitDiffQuery = useProjectCommitDiffQuery( + selectedCommitRepository, + selectedCommitHash, + "remote", + activeCommunity?.reposDir, + ); + const contributorPubkeysByGitIdentity = React.useMemo( + () => + gitContributorPubkeysFromCommits(snapshot?.commits ?? [], pullRequests), + [pullRequests, snapshot?.commits], + ); + const selectedPullRequest = + pullRequests.find( + (pullRequest) => pullRequest.id === selectedPullRequestId, + ) ?? null; + const selectedIssueItem = + issueItems.find(({ issue }) => issue.id === selectedIssueId) ?? null; + const selectedCommit = + selectedCommitResult?.snapshot?.commits.find( + (commit) => commit.hash === selectedCommitHash, + ) ?? + snapshot?.commits.find((commit) => commit.hash === selectedCommitHash) ?? + null; + const selectedCommitPullRequest = selectedCommitHash + ? selectedCommitRepository.id === repository.id + ? pullRequests.find( + (pullRequest) => + pullRequest.commit === selectedCommitHash || + pullRequest.initialCommit === selectedCommitHash, + ) + : null + : null; + const handleIssueCreated = React.useCallback( + async ( + createdProject: Project, + _createdRepository: Project["repositories"][number], + issueId: string, + ) => { + if (createdProject.id !== project.id) { + await goProject(createdProject.id, { issueId }); + return; + } + await workItemsQuery.refetch(); + setSelectedIssueId(issueId); + }, + [goProject, project.id, workItemsQuery], + ); + const handlePullRequestCreated = React.useCallback( + async ( + createdProject: Project, + createdRepository: Project["repositories"][number], + pullRequestId: string, + ) => { + if (createdProject.id !== project.id) { + await goProject(createdProject.id, { + pullRequestId, + repositoryId: createdRepository.id, + }); + return; + } + if (createdRepository.id !== repository.id) { + onSelectRepository(createdRepository.id); + } + await pullRequestsQuery.refetch(); + setSelectedPullRequestId(pullRequestId); + }, + [ + goProject, + onSelectRepository, + project.id, + pullRequestsQuery, + repository.id, + ], + ); + const detail = React.useMemo(() => { + if (tab === "issues" && selectedIssueId) { + return { + backLabel: "Back to Tasks", + navigation: { + issueId: selectedIssueId, + repositoryId: selectedIssueItem?.project.id, + }, + onBack: () => setSelectedIssueId(null), + }; + } + if (tab === "prs" && selectedPullRequestId) { + return { + backLabel: "Back to Reviews", + navigation: { pullRequestId: selectedPullRequestId }, + onBack: () => setSelectedPullRequestId(null), + }; + } + if (tab === "commits" && selectedCommitHash) { + return { + backLabel: "Back to Commits", + navigation: { + commitHash: selectedCommitHash, + repositoryId: selectedCommitRepository.id, + }, + onBack: () => { + setSelectedCommitHash(null); + setSelectedCommitRepositoryId(null); + }, + }; + } + if (tab === "files" && filesContext?.onBack) { + return { + backLabel: "Back to Files", + navigation: { filePath: filesContext.path }, + onBack: filesContext.onBack, + }; + } + return null; + }, [ + filesContext, + selectedCommitHash, + selectedCommitRepository.id, + selectedIssueId, + selectedIssueItem?.project.id, + selectedPullRequestId, + tab, + ]); + React.useEffect(() => { + onDetailChange?.(detail); + }, [detail, onDetailChange]); + React.useEffect( + () => () => { + onDetailChange?.(null); + }, + [onDetailChange], + ); + React.useEffect(() => { + if (tab === "issues" && !selectedIssueId) { + onCreateActionChange?.({ + disabled: project.repositories.length === 0, + label: "Create task", + onClick: () => setCreateIssueOpen(true), + }); + return; + } + if (tab === "prs" && !selectedPullRequestId) { + onCreateActionChange?.({ + disabled: projects.length === 0, + label: "Create review", + onClick: () => setCreatePullRequestOpen(true), + title: "Create review — choose a repository and branches to compare", + }); + return; + } + onCreateActionChange?.(null); + }, [ + onCreateActionChange, + project.repositories.length, + projects.length, + selectedIssueId, + selectedPullRequestId, + tab, + ]); + React.useEffect( + () => () => { + onCreateActionChange?.(null); + }, + [onCreateActionChange], + ); + + let body: React.ReactNode; + switch (tab) { + case "issues": + body = ( + + ); + break; + case "prs": + body = ( + + ); + break; + case "commits": + body = selectedCommitHash ? ( + + ) : ( + { + setSelectedCommitRepositoryId(commitRepository.id); + setSelectedCommitHash(commit.hash); + }} + profiles={people.profiles} + projectId={project.id} + pullRequests={pullRequests} + results={repositorySnapshots} + viewerGitIdentity={people.viewerGitIdentity} + /> + ); + break; + case "files": + body = ( + + ); + break; + case "contributors": + body = ( + + ); + break; + } + + const listPanel = + (tab === "issues" && !selectedIssueId) || + (tab === "prs" && !selectedPullRequestId); + + return ( +
+ {listPanel ? ( +
+ {body} +
+ ) : ( + body + )} + {createPullRequestOpen ? ( + + ) : null} + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index 1f1046a422f..21fc916db04 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -56,6 +56,7 @@ import { } from "./ProjectStatusProgressIcon"; import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup"; import { ProjectWorkItemRow } from "./ProjectWorkItemRow"; +import { ProjectPanelState } from "./ProjectPanelState"; export function issueStatusClassName(status: ProjectIssue["status"]) { if (status === "Triage" || status === "In Progress") return "text-amber-500"; @@ -115,6 +116,11 @@ const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [ "Closed", ]; +export type ProjectIssuePanelItem = { + issue: ProjectIssue; + project: Project; +}; + function issueMembers( project: Project, issue: ProjectIssue, @@ -414,50 +420,69 @@ export function ProjectIssueDetail({ } export function ProjectIssuesPanel({ + error, + isLoading, + issueItems, onSelectedIssueIdChange, profiles, project, selectedIssueId, }: { + error?: unknown; + isLoading?: boolean; + issueItems?: ProjectIssuePanelItem[]; onSelectedIssueIdChange: (id: string | null) => void; profiles?: UserProfileLookup; project: Project; selectedIssueId: string | null; }) { - const issuesQuery = useProjectIssuesQuery(project); - const issues = issuesQuery.data ?? []; - const selectedIssue = - issues.find((issue) => issue.id === selectedIssueId) ?? null; + const issuesQuery = useProjectIssuesQuery( + issueItems === undefined ? project : null, + ); + const resolvedItems = + issueItems ?? (issuesQuery.data ?? []).map((issue) => ({ issue, project })); + const selectedItem = + resolvedItems.find(({ issue }) => issue.id === selectedIssueId) ?? null; + const loading = isLoading ?? issuesQuery.isLoading; + const loadError = error ?? issuesQuery.error; - if (issuesQuery.isLoading) { + if (loading) { return ; } - if (issues.length === 0) { + if (resolvedItems.length === 0) { return ( -

- {issuesQuery.error - ? "Could not load tasks for this repository." - : "No tasks yet."} -

+ ); } - if (selectedIssue) { + if (selectedItem) { return ( ); } const groups = ISSUE_STATUS_ORDER.map((status) => ({ - items: issues.filter((issue) => issue.status === status), + items: resolvedItems.filter(({ issue }) => issue.status === status), status, })).filter((group) => group.items.length > 0); - const rangeItems = issues.map((issue) => issueSelectionItem(project, issue)); + const rangeItems = resolvedItems.map(({ issue, project: itemProject }) => + issueSelectionItem(itemProject, issue), + ); return (
@@ -472,17 +497,19 @@ export function ProjectIssuesPanel({ state={visual.progress} /> } - items={items.map((issue) => issueSelectionItem(project, issue))} + items={items.map(({ issue, project: itemProject }) => + issueSelectionItem(itemProject, issue), + )} key={status} label={status} > - {items.map((issue) => ( + {items.map(({ issue, project: itemProject }) => ( onSelectedIssueIdChange(issue.id)} profiles={profiles} - project={project} + project={itemProject} rangeItems={rangeItems} /> ))} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index 36a937ad5a4..22927798504 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -62,7 +62,7 @@ export function ProjectOverviewPanel({ unavailableReason, }: ProjectOverviewPanelProps) { return ( -
+
{/* ReadmePanel renders its own "no README" fallback while keeping repository recovery actions reachable. */} + +
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {action} +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx index 9bc89e52131..9f6bb57c51d 100644 --- a/desktop/src/features/projects/ui/ProjectReadmePanel.tsx +++ b/desktop/src/features/projects/ui/ProjectReadmePanel.tsx @@ -4,6 +4,7 @@ import { ExternalLink, Globe, Loader2, + MessageCircle, } from "lucide-react"; import type { ProjectRepoFile } from "@/features/projects/hooks"; @@ -26,6 +27,7 @@ import { } from "./ProjectRepositorySource"; import { GitHubMark } from "./GitHubMark"; import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState"; +import { ProjectPanelState } from "./ProjectPanelState"; export function findReadmeFile(files: ProjectRepoFile[]) { const readmes = files.filter((file) => @@ -260,16 +262,37 @@ export function ReadmePanel({ } if (!file || !fileContent.content) { + const loadError = Boolean(fileContent.error); + const emptyRepository = gitDataState === "empty"; return ( -
+
{header} -
- {fileContent.error - ? "Could not load this README. Try again after refreshing the repository." - : gitDataState === "empty" - ? "No files have been pushed to this repository yet." - : "Add a README to this repository to describe setup, usage, and project context."} -
+ + + Chat with an agent + + ) : undefined + } + description={ + loadError + ? "Refresh the repository or ask an agent to investigate." + : emptyRepository + ? "Ask an agent to create the initial codebase or connect an existing repository." + : "Add a README to describe setup, usage, and project context." + } + error={loadError} + panel={false} + title={ + loadError + ? "Could not load the README" + : emptyRepository + ? "No files have been pushed yet" + : "No README yet" + } + />
); } diff --git a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx index fd08d490511..c31db004e55 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryManagement.tsx @@ -23,20 +23,29 @@ import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog"; export function ProjectRepositoryManagement({ compact = false, + createOpen: createOpenProp, + hideTriggers = false, identityPubkey, onChange, + onCreateOpenChange, project, projects, repository, }: { compact?: boolean; + createOpen?: boolean; + hideTriggers?: boolean; identityPubkey?: string; onChange: (repositoryId: string) => void; + onCreateOpenChange?: (open: boolean) => void; project: Project; projects: Project[]; - repository: Repository; + repository?: Repository | null; }) { - const [createOpen, setCreateOpen] = React.useState(false); + const [uncontrolledCreateOpen, setUncontrolledCreateOpen] = + React.useState(false); + const createOpen = createOpenProp ?? uncontrolledCreateOpen; + const setCreateOpen = onCreateOpenChange ?? setUncontrolledCreateOpen; const [attachOpen, setAttachOpen] = React.useState(false); const channelsQuery = useChannelsQuery(); const createMutation = useAddProjectRepositoryMutation(); @@ -71,18 +80,19 @@ export function ProjectRepositoryManagement({ [channelsQuery.data], ); const inheritedChannelId = [ - repository.channelId, + repository?.channelId, project.projectChannelId, project.repositories.find( - (candidate) => candidate.id !== repository.id && candidate.channelId, + (candidate) => candidate.id !== repository?.id && candidate.channelId, )?.channelId, ].find( (candidate) => candidate && accessChannels.some((channel) => channel.id === candidate), ); const canManageAccess = + Boolean(repository) && accessChannels.length > 0 && - identityPubkey?.toLowerCase() === repository.owner.toLowerCase(); + identityPubkey?.toLowerCase() === repository?.owner.toLowerCase(); const attachCandidates = React.useMemo(() => { const currentAddresses = new Set(project.repositoryAddresses); const candidates = new Map(); @@ -132,18 +142,24 @@ export function ProjectRepositoryManagement({ project={project} repositories={attachCandidates} /> - {canEdit ? ( + {!hideTriggers ? (
-
{stateMessage}
+ {state}
); } @@ -790,10 +781,7 @@ export function RepositoryFilesPanel({ { - setSelectedFile(null); - openPath(path); - }} + onOpenPath={openPath} /> ); } diff --git a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx index 1e382326e2b..0127bc43a3b 100644 --- a/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx +++ b/desktop/src/features/projects/ui/ProjectRightPanelControls.tsx @@ -1,4 +1,4 @@ -import { Info, MessageCircle } from "lucide-react"; +import { MessageCircle } from "lucide-react"; import { toggleTerminalPanel, @@ -6,6 +6,7 @@ import { } from "@/features/terminal/terminalPanelStore"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; import { TerminalPanelIcon } from "@/shared/ui/TerminalPanelIcon"; export type ProjectRightPanelMode = "chat" | "repository"; @@ -122,12 +123,10 @@ export function ProjectRightPanelControls({ type="button" variant="ghost" > -
diff --git a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx index e49f80af620..15690d24cb1 100644 --- a/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx +++ b/desktop/src/features/projects/ui/ProjectSelectableGroup.tsx @@ -15,6 +15,7 @@ export function ProjectSelectableGroup({ icon, items, label, + labelClassName, labelTestId, testId, }: { @@ -27,6 +28,7 @@ export function ProjectSelectableGroup({ icon: React.ReactNode; items: ProjectSelectionItem[]; label: string; + labelClassName?: string; labelTestId?: string; testId: string; }) { @@ -44,6 +46,7 @@ export function ProjectSelectableGroup({
{label} diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx index b8bb317c1bc..58b6dd93364 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabList.tsx @@ -1,4 +1,4 @@ -import { Glasses } from "lucide-react"; +import { ArrowLeft } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { TabsList, TabsTrigger } from "@/shared/ui/tabs"; @@ -7,49 +7,62 @@ export const PROJECT_TAB_TRIGGER_CLASS = "h-7 shrink-0 rounded-full bg-muted/30 px-3 text-xs font-medium leading-5 tracking-tight text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none"; export const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground"; -const PROJECT_OVERVIEW_TAB_CLASS = - "h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none"; +const PROJECT_TAB_ICON_BUTTON_CLASS = + "h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground"; function ProjectTabLabel({ children }: { children: string }) { return {children}; } -export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) { +export function ProjectTabsList({ + onBack, + prsActive, +}: { + onBack: () => void; + prsActive?: boolean; +}) { return ( - - + + + + Overview + + + Files + + + Commits + + + Tasks + + + Review + + + Channels + + + Contributors + + +
); } diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx index 2c526900d38..cfac55fa69f 100644 --- a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -53,17 +53,16 @@ import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailabl import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS, PROJECT_DETAIL_PANEL_CLASS, - PROJECT_DETAIL_PANEL_MESSAGE_CLASS, + PROJECT_SECTION_HEADER_CLASS, } from "./projectPanelStyles"; import { ProjectSectionHeader } from "./ProjectSectionHeader"; +import { ProjectPanelState } from "./ProjectPanelState"; import { CreatePullRequestDialog } from "./CreatePullRequestDialog"; import { CreateIssueDialog, type CreateIssueDialogInput, } from "./CreateIssueDialog"; -const SECTION_HEADER_CLASS = "mx-4 mb-2 rounded-xl bg-muted/40"; - type CreatePullRequestAction = { projects: Project[]; reposDir?: string | null; @@ -96,6 +95,7 @@ export function WorkspaceTabs({ createPullRequestRequestKey, updatePullRequestAction, initialTab, + initialFilePath, initialTabRequestKey, fileContentSource, localSnapshot, @@ -119,6 +119,7 @@ export function WorkspaceTabs({ onSelectedIssueIdChange, onSelectedPullRequestIdChange, onSelectedTabChange, + onBack, onOpenMergeRecoveryTerminal, snapshot, snapshotError, @@ -142,6 +143,8 @@ export function WorkspaceTabs({ updatePullRequestAction?: UpdatePullRequestAction; /** Tab to open on mount (workspace vocabulary), e.g. from a share link. */ initialTab?: string; + /** File or folder to open when entering the repository Files tab. */ + initialFilePath?: string; /** Changes for every entity-link activation, including repeated links. */ initialTabRequestKey?: string; fileContentSource?: RepositoryFileContentSource; @@ -170,6 +173,7 @@ export function WorkspaceTabs({ onSelectedPullRequestIdChange: (id: string | null) => void; /** Reports the active tab so the screen breadcrumb can mirror it. */ onSelectedTabChange?: (tab: string) => void; + onBack: () => void; onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal; snapshot: ProjectRepoSnapshot | null | undefined; snapshotError: unknown; @@ -350,13 +354,13 @@ export function WorkspaceTabs({ const sectionHeader = selectedTab === "files" && files.length > 0 ? ( ) : selectedTab === "activity" && !selectedCommitHash ? ( @@ -367,7 +371,7 @@ export function WorkspaceTabs({ label: "Create task", onClick: () => setCreateIssueOpen(true), }} - className={SECTION_HEADER_CLASS} + className={PROJECT_SECTION_HEADER_CLASS} icon={CircleDot} title="Tasks" /> @@ -381,19 +385,19 @@ export function WorkspaceTabs({ onClick: () => setCreatePullRequestOpen(true), title: "Create review — choose a repository and branches to compare", }} - className={SECTION_HEADER_CLASS} + className={PROJECT_SECTION_HEADER_CLASS} icon={GitPullRequest} title="Reviews" /> ) : selectedTab === "channels" ? ( ) : selectedTab === "contributors" ? ( @@ -412,7 +416,7 @@ export function WorkspaceTabs({ }`} data-testid="project-workspace-tab-menu" > - +
{updatePullRequestAction ? (
+ ) : ( ; }; @@ -117,54 +104,6 @@ function contentPreview(content: string) { return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280); } -function activitySelectionItem( - item: ProjectActivityItem, -): ProjectSelectionItem | null { - const project = item.target.project; - const repository = - item.target.type === "issue" || item.target.type === "pull-request" - ? item.target.repository - : project.repositories[0]; - const channelId = repository?.channelId ?? project.projectChannelId; - if (item.target.type === "commit") { - return selectionItemFromCommit({ - author: item.actorPubkey, - channelId, - commitHash: item.target.commitHash, - projectId: project.id, - shareLink: repository - ? commitShareLink(repository, item.target.commitHash) - : null, - title: item.title, - }); - } - if (item.target.type === "issue") { - return selectionItemFromTask({ - author: item.target.issue.author, - channelId, - id: item.target.issue.id, - shareLink: issueShareLink(item.target.issue), - title: item.target.issue.title, - }); - } - if (item.target.type === "pull-request") { - return selectionItemFromReview({ - author: item.target.pullRequest.author, - channelId, - id: item.target.pullRequest.id, - shareLink: pullRequestShareLink(item.target.pullRequest), - title: item.target.pullRequest.title, - }); - } - return selectionItemFromProject({ - channelId: project.projectChannelId, - id: project.id, - owner: project.owner, - shareLink: projectShareLink(project), - title: project.name, - }); -} - function buildActivityItems({ issues, projects, @@ -402,7 +341,6 @@ function ActivityCard({ onOpen, onOpenProject, profiles, - rangeItems, }: { compact: boolean; isFirst: boolean; @@ -411,7 +349,6 @@ function ActivityCard({ onOpen: () => void; onOpenProject: () => void; profiles?: UserProfileLookup; - rangeItems: ProjectSelectionItem[]; }) { const visual = PROJECT_EVENT_VISUALS[item.kind]; const TypeIcon = visual.icon; @@ -421,21 +358,12 @@ function ActivityCard({ const actorLabel = item.actorPubkey ? resolveUserLabel({ profiles, pubkey: item.actorPubkey }) : item.actorName || "Someone"; - const selection = useProjectSelection(); - const selectionItem = activitySelectionItem(item); - const selected = Boolean( - selectionItem && selection?.isSelected(selectionItem.id), - ); - const showSelectControl = Boolean(selectionItem && selection && selected); - return (
- {open ? ( -
-
- - - -
-
- ) : null} - - ); -} diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index c7c55367be5..aa464bf3f1f 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -21,6 +21,7 @@ import { } from "@/shared/hooks/useIncrementalMount"; import { cn } from "@/shared/lib/cn"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; @@ -30,6 +31,7 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectSelectableGroup } from "./ProjectSelectableGroup"; +import { ProjectPanelState } from "./ProjectPanelState"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups"; @@ -253,21 +255,37 @@ export function ProjectsIssuesList({ ); if (error && issues.length === 0) { - return loadNotice; + return ( + + {isRetrying ? "Retrying..." : "Retry"} + + } + description={ + error instanceof Error ? error.message : "The relay request failed." + } + error + panel={false} + title="Could not load tasks" + /> + ); } if (issues.length === 0) { return (
{loadNotice} -
- {emptyMessage} -
+
); } diff --git a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx index 99587133974..4f1f1696ea0 100644 --- a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx +++ b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx @@ -1,128 +1,48 @@ import type { - ProjectsFilter, - ProjectsRepositoryScope, ProjectsSort, ProjectsViewMode, - ProjectsWorkItemScope, } from "@/features/projects/lib/projectsViewHelpers"; -import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown"; import { ProjectsViewModeToggle } from "@/features/projects/ui/ProjectsToolbar"; -const PROJECT_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Projects", value: "mine" }, - { label: "Local", value: "local" }, -]; -const REPOSITORY_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Repositories", value: "mine" }, - { label: "Local", value: "local" }, - { label: "Buzz-hosted", value: "buzz" }, - { label: "Linked", value: "linked" }, -]; -const PULL_REQUEST_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Reviews", value: "mine" }, -]; -const ISSUE_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Tasks", value: "mine" }, - { label: "Assigned to me", value: "assigned" }, -]; - type ProjectsListHeaderBarProps = { - filter: ProjectsFilter; - issueScope: ProjectsWorkItemScope; - onIssueScopeChange: (scope: ProjectsWorkItemScope) => void; - onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void; - onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void; - onSortChange: (sort: ProjectsSort) => void; onViewModeChange: (viewMode: ProjectsViewMode) => void; - pullRequestScope: ProjectsWorkItemScope; - repositoryScope: ProjectsRepositoryScope; - sort: ProjectsSort; viewMode: ProjectsViewMode; }; -/** - * Compact controls rendered in the Projects section header. - */ +/** Shared Projects sort control used by the top navigation/search row. */ +export function ProjectsSortSelect({ + onChange, + sort, +}: { + onChange: (sort: ProjectsSort) => void; + sort: ProjectsSort; +}) { + return ( + + ); +} + +/** Compact layout controls rendered in the Projects section header. */ export function ProjectsListHeaderBar({ - filter, - issueScope, - onIssueScopeChange, - onPullRequestScopeChange, - onRepositoryScopeChange, - onSortChange, onViewModeChange, - pullRequestScope, - repositoryScope, - sort, viewMode, }: ProjectsListHeaderBarProps) { - const scopeDropdown = - filter === "prs" ? ( - - ) : filter === "issues" ? ( - - ) : filter === "projects" ? ( - - ) : ( - - ); - return (
- {scopeDropdown} - - diff --git a/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx b/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx index c964bad15cc..116292e5585 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewContextSheet.tsx @@ -1,8 +1,7 @@ -import { Info } from "lucide-react"; import * as React from "react"; -import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; +import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon"; import { Sheet, SheetContent, SheetTitle } from "@/shared/ui/sheet"; export const ProjectsOverviewNarrowContextToggle = React.forwardRef< @@ -21,12 +20,10 @@ export const ProjectsOverviewNarrowContextToggle = React.forwardRef< type="button" variant="ghost" > - )); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx index 3c23c7452b0..c0e410ff22a 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewItems.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { FolderGit2, Folders } from "lucide-react"; import type { Project, ProjectActivitySummary, @@ -17,14 +18,16 @@ import { } from "@/features/projects/lib/projectShareLinks"; import { isProjectOwnedByCurrentUser, + isProjectMine, projectPeople, - type ProjectsFilter, type ProjectsViewMode, } from "@/features/projects/lib/projectsViewHelpers"; import { + type ProjectSelectionItem, selectionItemFromProject, selectionItemFromRepository, } from "@/features/projects/lib/projectSelection"; +import { ProjectSelectableGroup } from "@/features/projects/ui/ProjectSelectableGroup"; import { EmptyFilteredState, ProjectGridCard, @@ -35,7 +38,78 @@ import { RepositoryListRow, } from "@/features/projects/ui/RepositoryCards"; import { useIncrementalMount } from "@/shared/hooks/useIncrementalMount"; -import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const RESPONSIVE_CARD_GRID_CLASS = + "grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr))]"; + +function CollectionGroup({ + children, + icon, + items, + title, +}: { + children: React.ReactNode; + icon: React.ReactNode; + items: ProjectSelectionItem[]; + title: string; +}) { + return ( + + {children} + + ); +} + +function repositoryIsMine( + repository: Repository, + currentPubkey: string | undefined, +) { + if (!currentPubkey) return false; + const viewer = normalizePubkey(currentPubkey); + return ( + normalizePubkey(repository.owner) === viewer || + repository.contributors.some((pubkey) => normalizePubkey(pubkey) === viewer) + ); +} + +function projectSelectionItems(projects: readonly Project[]) { + return projects.map((project) => + selectionItemFromProject({ + channelId: project.projectChannelId, + id: project.id, + owner: project.owner, + shareLink: projectShareLink(project), + title: project.name, + }), + ); +} + +function repositorySelectionItems( + rows: ReadonlyArray<{ project: Project; repository: Repository }>, +) { + return rows.map((row) => + selectionItemFromRepository({ + channelId: row.repository.channelId ?? row.project.projectChannelId, + id: row.repository.id, + owner: row.repository.owner, + shareLink: repositoryShareLink(row.repository), + title: row.repository.name, + }), + ); +} // Stable fallback so a cache miss cannot hand a memoized card a fresh array. const EMPTY_PEOPLE: string[] = []; @@ -43,7 +117,6 @@ const EMPTY_PEOPLE: string[] = []; export function ProjectsOverviewProjectItems({ currentPubkey, deleteDisabled, - filter, localRepoNames, onDelete, onOpen, @@ -56,7 +129,6 @@ export function ProjectsOverviewProjectItems({ }: { currentPubkey: string | undefined; deleteDisabled: boolean; - filter: ProjectsFilter; localRepoNames: Set; onDelete: (project: Project) => void; onOpen: (project: Project) => void; @@ -114,82 +186,122 @@ export function ProjectsOverviewProjectItems({ () => visibleProjects.slice(0, mountedCount), [mountedCount, visibleProjects], ); + const mountedProjectIds = React.useMemo( + () => new Set(mountedProjects.map((project) => project.id)), + [mountedProjects], + ); if (visibleProjects.length === 0) { return ; } + const groups = [ + { + items: visibleProjects.filter((project) => + isProjectMine(project, currentPubkey), + ), + title: "Mine", + }, + { + items: visibleProjects.filter( + (project) => !isProjectMine(project, currentPubkey), + ), + title: "Other projects", + }, + ].filter((group) => group.items.length > 0); if (viewMode === "grid") { return ( -
- {mountedProjects.map((project) => { - const summary = summaries?.[project.id]; - return ( -
- +
+ {groups.map((group) => ( + } + items={projectSelectionItems(group.items)} + key={group.title} + title={group.title} + > +
+ {group.items + .filter((project) => mountedProjectIds.has(project.id)) + .map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ +
+ ); + })}
- ); - })} +
+ ))}
); } return ( -
- {visibleProjects.map((project) => { - const summary = summaries?.[project.id]; - return ( -
- +
+ {groups.map((group) => ( + } + items={projectSelectionItems(group.items)} + key={group.title} + title={group.title} + > +
+ {group.items.map((project) => { + const summary = summaries?.[project.id]; + return ( +
+ +
+ ); + })}
- ); - })} +
+ ))}
); } export function ProjectsOverviewRepositoryItems({ + currentPubkey, localRepoNames, onOpen, onOpenTerminal, @@ -198,6 +310,7 @@ export function ProjectsOverviewRepositoryItems({ viewMode, visibleRepositories, }: { + currentPubkey: string | undefined; localRepoNames: Set; onOpen: (project: Project, repository: Repository) => void; onOpenTerminal: (repository: Repository) => void; @@ -233,53 +346,102 @@ export function ProjectsOverviewRepositoryItems({ () => visibleRepositories.slice(0, mountedCount), [mountedCount, visibleRepositories], ); + const mountedRepositoryAddresses = React.useMemo( + () => + new Set( + mountedRepositories.map(({ repository }) => repository.repoAddress), + ), + [mountedRepositories], + ); if (visibleRepositories.length === 0) { return ; } + const groups = [ + { + items: visibleRepositories.filter(({ repository }) => + repositoryIsMine(repository, currentPubkey), + ), + title: "Mine", + }, + { + items: visibleRepositories.filter( + ({ repository }) => !repositoryIsMine(repository, currentPubkey), + ), + title: "Other repositories", + }, + ].filter((group) => group.items.length > 0); if (viewMode === "grid") { return ( -
- {mountedRepositories.map(({ project, repository }) => ( -
+ {groups.map((group) => ( + } + items={repositorySelectionItems(group.items)} + key={group.title} + title={group.title} > - -
+
+ {group.items + .filter(({ repository }) => + mountedRepositoryAddresses.has(repository.repoAddress), + ) + .map(({ project, repository }) => ( +
+ +
+ ))} +
+ ))}
); } return ( -
- {visibleRepositories.map(({ project, repository }) => ( -
+ {groups.map((group) => ( + } + items={repositorySelectionItems(group.items)} + key={group.title} + title={group.title} > - -
+
+ {group.items.map(({ project, repository }) => ( +
+ +
+ ))} +
+ ))}
); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index f774c192d51..6f34d053957 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -10,6 +10,7 @@ import { } from "lucide-react"; import * as React from "react"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Project, ProjectActivitySummary, @@ -21,19 +22,18 @@ import { projectSelectionPresentation, } from "@/features/projects/lib/projectSelection"; import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers"; +import type { ProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest"; import { useProjectSelection } from "@/features/projects/lib/useProjectSelection"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { ProjectsCreateMenu } from "./ProjectsCreateMenu"; +import { ProjectsOverviewPeople } from "./ProjectsOverviewRail"; import { ProjectsSelectionCountMenu } from "./ProjectsSelectionCountMenu"; -import { useCommunities } from "@/features/communities/useCommunities"; -import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { + type OverviewContextAction, type OverviewContextStatIcon, type ProjectsOverviewSection, projectsOverviewContext, } from "./projectsOverviewContext"; -import { ProjectsOverviewPeople } from "./ProjectsOverviewRail"; export type { ProjectsOverviewSection }; @@ -56,37 +56,65 @@ type ProjectsOverviewPanelProps = { type ProjectsOverviewContextPanelProps = { filter: ProjectsFilter; + canCreateTarget: boolean; issues: ProjectIssue[]; + onAddChannel: () => void; + onAddRepository: () => void; onChatWithAgent: (items: ProjectSelectionItem[]) => void; onCreateIssue: () => void; onCreateProject: () => void; onCreatePullRequest: () => void; onSelectSection: (section: ProjectsOverviewSection) => void; profiles?: UserProfileLookup; + projectReadModels: Project[]; projects: Project[]; pullRequests: ProjectPullRequest[]; + repositorySummaries?: Record; summaries?: Record; }; -function OverviewActionButton({ - children, - onClick, - testId, +function OverviewCreateButton({ + action, + canCreateTarget, + onAddChannel, + onAddRepository, + onCreateIssue, + onCreateProject, + onCreatePullRequest, }: { - children: React.ReactNode; - onClick: () => void; - testId?: string; + action: Exclude; + canCreateTarget: boolean; + onAddChannel: () => void; + onAddRepository: () => void; + onCreateIssue: () => void; + onCreateProject: () => void; + onCreatePullRequest: () => void; }) { + const actionHandler = + action.kind === "issue" + ? onCreateIssue + : action.kind === "pullRequest" + ? onCreatePullRequest + : action.kind === "project" + ? onCreateProject + : action.kind === "channel" + ? onAddChannel + : onAddRepository; + const requiresProject = + action.kind === "channel" || action.kind === "repository"; return ( ); } @@ -113,7 +141,9 @@ function OverviewStatRow({ {label} - {count} + + {count} + ); } @@ -128,59 +158,61 @@ export function ProjectsOverviewPanel({ ); } -export function ProjectsActivityIntro() { - const { activeCommunity } = useCommunities(); - const communityIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl); - const communityIcon = communityIconQuery.data ?? null; - +export function ProjectsActivityIntro({ + digest, +}: { + digest: ProjectsActivityDigest; +}) { return (
-
- {communityIcon ? ( - - ) : ( - - )} -

Projects Activity

-

- Keeping up with the community has never been easier—or mattered more. +

+ {digest.prefix}{" "} + {digest.highlights.map((highlight, index) => ( + + {index > 0 + ? index === digest.highlights.length - 1 + ? ", and " + : ", " + : null} + + {highlight} + + + ))} + {digest.suffix}

); } export function ProjectsOverviewContextPanel({ + canCreateTarget, filter, issues, + onAddChannel, + onAddRepository, onChatWithAgent, onCreateIssue, onCreateProject, onCreatePullRequest, onSelectSection, profiles, + projectReadModels, projects, pullRequests, + repositorySummaries, summaries, }: ProjectsOverviewContextPanelProps) { const selection = useProjectSelection(); @@ -196,22 +228,28 @@ export function ProjectsOverviewContextPanel({ projectsOverviewContext({ filter, issues, + projectReadModels, projects, pullRequests, + repositorySummaries, summaries, }), - [filter, issues, projects, pullRequests, summaries], + [ + filter, + issues, + projectReadModels, + projects, + pullRequests, + repositorySummaries, + summaries, + ], ); - const actionHandler = - context.action?.kind === "issue" - ? onCreateIssue - : context.action?.kind === "pullRequest" - ? onCreatePullRequest - : onCreateProject; - return (
@@ -230,41 +268,35 @@ export function ProjectsOverviewContextPanel({ > {context.title} - + {context.action ? ( + + ) : null}
)} {selectionPresentation ? null : ( - <> -
- {context.action ? ( - - - {context.action.label} - - ) : null} -
- {context.stats.map((stat) => ( - onSelectSection(stat.section)} - /> - ))} -
-
+
+
+ {context.stats.map((stat) => ( + onSelectSection(stat.section)} + /> + ))} +
{context.people.length > 0 ? (
) : null} - +
)}
diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index f4315576f57..1a209df6de2 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -21,6 +21,7 @@ import { type UserProfileLookup, } from "@/features/profile/lib/identity"; import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState"; +import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem"; @@ -30,12 +31,14 @@ import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon"; import { PROJECT_GRID_CARD_BODY_CLASS } from "./projectGridCardStyles"; import { ProjectListRowMenu } from "./ProjectListRowMenu"; import { ProjectSelectableGroup } from "./ProjectSelectableGroup"; +import { ProjectPanelState } from "./ProjectPanelState"; import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice"; import { groupProjectWorkItemsByProject } from "./projectWorkItemGroups"; type ProjectsPullRequestsListProps = { /** Render without container chrome — a parent table container provides border and rounding. */ embedded?: boolean; + emptyMessage?: string; error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; @@ -199,6 +202,7 @@ const PullRequestListRow = React.memo(function PullRequestListRow({ export function ProjectsPullRequestsList({ embedded, + emptyMessage = "No reviews yet", error, failedSections, isLoading, @@ -258,21 +262,37 @@ export function ProjectsPullRequestsList({ ); if (error && pullRequests.length === 0) { - return loadNotice; + return ( + + {isRetrying ? "Retrying..." : "Retry"} + + } + description={ + error instanceof Error ? error.message : "The relay request failed." + } + error + panel={false} + title="Could not load reviews" + /> + ); } if (pullRequests.length === 0) { return (
{loadNotice} -
- No reviews yet. -
+
); } diff --git a/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx new file mode 100644 index 00000000000..c6b05cc9b1c --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectsSectionSearch.tsx @@ -0,0 +1,157 @@ +import { Search, X } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { + ProjectsFilter, + ProjectsSort, +} from "@/features/projects/lib/projectsViewHelpers"; +import { ProjectsSortSelect } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { projectsSectionTitle } from "@/features/projects/ui/projectsSectionMeta"; +import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; +import { Button } from "@/shared/ui/button"; + +export function ProjectsSectionSearch({ + filter, + onFilterChange, + onQueryChange, + onSortChange, + sort, +}: { + filter: ProjectsFilter; + onFilterChange: (filter: ProjectsFilter) => void; + onQueryChange: (query: string) => void; + onSortChange: (sort: ProjectsSort) => void; + sort: ProjectsSort; +}) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const deferredQuery = React.useDeferredValue(query); + const focusFrameRef = React.useRef(null); + const reduceMotion = useReducedMotion(); + const transition = { + duration: reduceMotion ? 0 : 0.06, + ease: [0.2, 0.8, 0.2, 1] as const, + }; + const close = React.useCallback(() => { + setOpen(false); + setQuery(""); + onQueryChange(""); + }, [onQueryChange]); + + React.useEffect(() => { + onQueryChange(deferredQuery); + }, [deferredQuery, onQueryChange]); + const focusSearchInput = React.useCallback( + (input: HTMLInputElement | null) => { + if (!input) return; + focusFrameRef.current = window.requestAnimationFrame(() => input.focus()); + }, + [], + ); + React.useEffect( + () => () => { + if (focusFrameRef.current !== null) { + window.cancelAnimationFrame(focusFrameRef.current); + } + }, + [], + ); + + return ( +
+ +
+ + {open ? ( + + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + close(); + }} + placeholder={`Search ${projectsSectionTitle(filter).toLocaleLowerCase()}`} + ref={focusSearchInput} + type="search" + value={query} + /> + {filter !== "all" && filter !== "channels" ? ( +
+ +
+ ) : null} +
+ ) : ( + + + + )} +
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx index b03d5e243a3..72e93bab1e0 100644 --- a/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsSelectionCountMenu.tsx @@ -1,4 +1,15 @@ -import { Bot, GitPullRequest, Link2, X } from "lucide-react"; +import { + Bot, + CircleDot, + FolderGit2, + Folders, + GitCommitHorizontal, + GitPullRequest, + Hash, + Link2, + ListChecks, + X, +} from "lucide-react"; import * as React from "react"; import { @@ -19,6 +30,16 @@ function selectionActionIcon(id: ProjectSelectionAction["id"]) { return Link2; } +function selectionKindIcon(kind: ProjectSelectionItem["kind"] | undefined) { + if (kind === "channel") return Hash; + if (kind === "commit") return GitCommitHorizontal; + if (kind === "project") return Folders; + if (kind === "repository") return FolderGit2; + if (kind === "review") return GitPullRequest; + if (kind === "task") return CircleDot; + return ListChecks; +} + /** Inline actions for the current Projects selection. */ export function ProjectsSelectionCountMenu({ onChatWithAgent, @@ -33,6 +54,8 @@ export function ProjectsSelectionCountMenu({ }) { const selection = useProjectSelection(); const openChannelWithDraft = useProjectDiscussInChannel(selectionItems); + const selectionKind = selectionItems[0]?.kind; + const SelectionIcon = selectionKindIcon(selectionKind); const discussInChannel = React.useCallback( (channelId: string) => { @@ -67,13 +90,26 @@ export function ProjectsSelectionCountMenu({ return (
-

- {presentation.title} -

-
+ +

+ {presentation.title} +

+
+
{presentation.actions .filter( (action) => diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 24a933bcd66..84cf3f678e3 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -1,4 +1,5 @@ import { LayoutGrid, List } from "lucide-react"; +import { motion } from "motion/react"; import * as React from "react"; import type { @@ -26,6 +27,7 @@ const MASK_RIGHT = type ProjectsToolbarProps = { filter: ProjectsFilter; onFilterChange: (filter: ProjectsFilter) => void; + reduceMotion?: boolean; }; export function ProjectsViewModeToggle({ @@ -104,6 +106,7 @@ function useHorizontalOverflow(ref: React.RefObject) { export function ProjectsToolbar({ filter, onFilterChange, + reduceMotion = false, }: ProjectsToolbarProps) { const scrollRef = React.useRef(null); const overflow = useHorizontalOverflow(scrollRef); @@ -149,29 +152,45 @@ export function ProjectsToolbar({ > Project owner filter {filterOptions.map((option) => ( - + + ))}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index be0d3da1f0c..f33659bcfc5 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -1,9 +1,10 @@ -import { Search } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; import { type Project, type ProjectIssue, @@ -17,17 +18,18 @@ import { } from "@/features/projects/hooks"; import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks"; import { useCreateProjectMutation } from "@/features/projects/useCreateProject"; +import { isExplicitProject } from "@/features/projects/projectModels"; +import { projectsWithWorkItemRepositories } from "@/features/projects/projectWorkItems"; import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; import { buildProjectSelectionAgentContext } from "@/features/projects/lib/projectDetailAgentContext"; +import { buildProjectsActivityDigest } from "@/features/projects/lib/projectsActivityDigest"; +import { matchesProjectsSearch } from "@/features/projects/lib/projectsSearch"; import type { ProjectSelectionItem } from "@/features/projects/lib/projectSelection"; import { useMemberChannelIds, useRepositoryUnavailableReasonFor, } from "@/features/projects/useRepositoryAccess"; -import { - projectRepoHostForProject, - projectRepoHostForRepository, -} from "@/features/projects/lib/projectRepoHost"; +import { projectRepoHostForProject } from "@/features/projects/lib/projectRepoHost"; import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed"; import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList"; import { @@ -42,7 +44,6 @@ import { import { ProjectsOverviewChromeActions } from "@/features/projects/ui/ProjectsOverviewChromeActions"; import { ProjectContextRail } from "@/features/projects/ui/ProjectContextRail"; import { - openAppSearch, projectsSectionIcon, projectsSectionTitle, } from "@/features/projects/ui/projectsSectionMeta"; @@ -55,42 +56,29 @@ import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog" import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog"; import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog"; import { ProjectAgentChatPanel } from "@/features/projects/ui/ProjectAgentChatPanel"; +import { ProjectsCategoryCreateDialogs } from "@/features/projects/ui/ProjectsCategoryCreateDialogs"; import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList"; import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChrome"; import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList"; import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice"; import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { ProjectsSectionSearch } from "@/features/projects/ui/ProjectsSectionSearch"; import { ProjectSectionHeader } from "@/features/projects/ui/ProjectSectionHeader"; import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "@/features/projects/ui/projectPanelStyles"; -import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection"; -import { - hasLocalCheckout, - hasLocalRepositoryCheckout, -} from "@/features/projects/lib/projectLocalRepos"; +import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos"; import { getProjectUpdatedAt, - isProjectAccessibleToViewer, - isProjectMine, - isRepositoryAccessibleToViewer, projectHasAgent, projectOwnerIsUser, projectPeople, type ProjectsFilter, - type ProjectsRepositoryScope, type ProjectsSort, type ProjectsViewMode, - type ProjectsWorkItemScope, readStoredFilter, - readStoredIssueScope, - readStoredPullRequestScope, - readStoredRepositoryScope, readStoredSort, readStoredViewMode, writeStoredFilter, - writeStoredIssueScope, - writeStoredPullRequestScope, - writeStoredRepositoryScope, writeStoredSort, writeStoredViewMode, } from "@/features/projects/lib/projectsViewHelpers"; @@ -101,6 +89,7 @@ import { useProjectPanelWidths, } from "@/features/projects/ui/useProjectPanelWidths"; import { useMediaBreakpoint } from "@/shared/hooks/use-mobile"; +import { useNow } from "@/shared/lib/useNow"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; @@ -130,7 +119,12 @@ export function ProjectsView() { useProjectsScrollIndicator(); const projectsQuery = useProjectsQuery(); const identityQuery = useIdentityQuery(); - const projects = projectsQuery.data ?? []; + const managedAgentsQuery = useManagedAgentsQuery(); + const projectReadModels = projectsQuery.data ?? []; + const projects = React.useMemo( + () => projectReadModels.filter(isExplicitProject), + [projectReadModels], + ); const localRepositoriesQuery = useProjectLocalRepositoriesQuery( activeCommunity?.reposDir, ); @@ -140,9 +134,14 @@ export function ProjectsView() { ? "repositories" : storedFilter; }); + const [searchQuery, setSearchQuery] = React.useState(""); const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true); const [narrowContextOpen, setNarrowContextOpen] = React.useState(false); const contextToggleRef = React.useRef(null); + const selectionDrawerStateRef = React.useRef<{ + narrow: boolean; + open: boolean; + } | null>(null); const isNarrowProjectsLayout = useMediaBreakpoint( PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX, ); @@ -150,22 +149,13 @@ export function ProjectsView() { useProjectPanelWidths("chat"); const activitySummariesQuery = useProjectActivitySummariesQuery(projects); const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery( - filter === "repositories" ? projects : [], + filter === "repositories" ? projectReadModels : [], ); - const [repositoryScope, setRepositoryScope] = - React.useState(() => { - const storedScope = readStoredRepositoryScope(); - return filter === "projects" && - (storedScope === "buzz" || storedScope === "linked") - ? "all" - : storedScope; - }); - const [pullRequestScope, setPullRequestScope] = - React.useState(() => readStoredPullRequestScope()); - const [issueScope, setIssueScope] = React.useState( - () => readStoredIssueScope(), + const workItemProjects = React.useMemo( + () => projectsWithWorkItemRepositories(projectReadModels), + [projectReadModels], ); - const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects); + const projectsWorkItemsQuery = useProjectsWorkItemsQuery(workItemProjects); // One blobless clone per primary Buzz repository, only while the overview // header is visible. const snapshotProjects = React.useMemo( @@ -188,6 +178,8 @@ export function ProjectsView() { memberChannelIds, ); const [createProjectOpen, setCreateProjectOpen] = React.useState(false); + const [createChannelOpen, setCreateChannelOpen] = React.useState(false); + const [createRepositoryOpen, setCreateRepositoryOpen] = React.useState(false); const [createIssueOpen, setCreateIssueOpen] = React.useState(false); const [createPullRequestOpen, setCreatePullRequestOpen] = React.useState(false); @@ -231,8 +223,63 @@ export function ProjectsView() { enabled: projectPubkeys.length > 0, }); const profiles = profilesQuery.data?.profiles; + const activityDigestNow = useNow(600_000); + const activityDigest = React.useMemo( + () => + buildProjectsActivityDigest({ + issues: projectsWorkItemsQuery.data?.issues.items ?? [], + nowSeconds: Math.floor(activityDigestNow / 1_000), + projects, + pullRequests: projectsWorkItemsQuery.data?.pullRequests.items ?? [], + snapshots: repoSnapshotsQuery.data?.snapshots, + summaries: activitySummariesQuery.data, + }), + [ + activityDigestNow, + activitySummariesQuery.data, + projects, + projectsWorkItemsQuery.data, + repoSnapshotsQuery.data?.snapshots, + ], + ); const deleteProjectMutation = useDeleteProjectMutation(); const currentPubkey = identityQuery.data?.pubkey; + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + const editableProjects = React.useMemo(() => { + if (!currentPubkey) return []; + const viewer = normalizePubkey(currentPubkey); + return projects.filter((project) => { + const owner = normalizePubkey(project.owner); + return ( + owner === viewer || + managedAgentPubkeys.has(owner) || + ownsAuthorAgent(profiles?.[owner], currentPubkey) + ); + }); + }, [currentPubkey, managedAgentPubkeys, profiles, projects]); + const ownerControlAgentPubkeyFor = React.useCallback( + (project: Project) => { + const owner = normalizePubkey(project.owner); + if ( + owner === normalizePubkey(currentPubkey ?? "") || + managedAgentPubkeys.has(owner) + ) { + return undefined; + } + return ownsAuthorAgent(profiles?.[owner], currentPubkey) + ? project.owner + : undefined; + }, + [currentPubkey, managedAgentPubkeys, profiles], + ); const handleViewModeChange = React.useCallback( (nextViewMode: ProjectsViewMode) => { @@ -242,30 +289,6 @@ export function ProjectsView() { [], ); - const handleRepositoryScopeChange = React.useCallback( - (scope: ProjectsRepositoryScope) => { - setRepositoryScope(scope); - writeStoredRepositoryScope(scope); - }, - [], - ); - - const handlePullRequestScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setPullRequestScope(scope); - writeStoredPullRequestScope(scope); - }, - [], - ); - - const handleIssueScopeChange = React.useCallback( - (scope: ProjectsWorkItemScope) => { - setIssueScope(scope); - writeStoredIssueScope(scope); - }, - [], - ); - const handleSortChange = React.useCallback((nextSort: ProjectsSort) => { setSort(nextSort); writeStoredSort(nextSort); @@ -281,16 +304,6 @@ export function ProjectsView() { [localRepositoriesQuery.data], ); - const repositoryAccessInput = React.useMemo( - () => ({ - currentPubkey, - localRepoNames, - memberChannelIds, - relayOrigin, - }), - [currentPubkey, localRepoNames, memberChannelIds, relayOrigin], - ); - const visibleProjects = React.useMemo(() => { if (filter !== "projects" && filter !== "agents" && filter !== "users") { return []; @@ -298,22 +311,20 @@ export function ProjectsView() { const sortedProjects = projects .filter((project) => { + if ( + !matchesProjectsSearch(searchQuery, [ + project.name, + project.description, + ...project.repositories.flatMap((repository) => [ + repository.name, + repository.description, + ]), + ]) + ) { + return false; + } const summary = activitySummariesQuery.data?.[project.id]; const people = projectPeople(project, summary); - if (repositoryScope === "accessible") - return isProjectAccessibleToViewer(project, repositoryAccessInput); - if (repositoryScope === "mine") - return isProjectMine(project, currentPubkey); - if (repositoryScope === "local") - return hasLocalCheckout(project, localRepoNames); - if (repositoryScope === "buzz") - return ( - projectRepoHostForProject(project, relayOrigin).kind === "buzz" - ); - if (repositoryScope === "linked") - return ( - projectRepoHostForProject(project, relayOrigin).kind === "external" - ); if (filter === "agents") { return projectHasAgent(project, people, profiles); } @@ -338,14 +349,10 @@ export function ProjectsView() { return sortedProjects; }, [ activitySummariesQuery.data, - currentPubkey, filter, - localRepoNames, profiles, projects, - relayOrigin, - repositoryAccessInput, - repositoryScope, + searchQuery, sort, ]); @@ -353,7 +360,7 @@ export function ProjectsView() { if (filter !== "repositories") return []; const repositories = [ ...new Map( - projects + projectReadModels .flatMap((project) => project.repositories.map((repository) => ({ project, @@ -364,40 +371,13 @@ export function ProjectsView() { ).values(), ]; return repositories - .filter(({ repository }) => { - if (repositoryScope === "accessible") { - return isRepositoryAccessibleToViewer( - repository, - repositoryAccessInput, - ); - } - if (repositoryScope === "mine") { - if (!currentPubkey) return false; - const normalizedCurrentPubkey = normalizePubkey(currentPubkey); - return ( - normalizePubkey(repository.owner) === normalizedCurrentPubkey || - repository.contributors.some( - (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, - ) - ); - } - if (repositoryScope === "local") { - return hasLocalRepositoryCheckout(repository, localRepoNames); - } - if (repositoryScope === "buzz") { - return ( - projectRepoHostForRepository(repository, relayOrigin).kind === - "buzz" - ); - } - if (repositoryScope === "linked") { - return ( - projectRepoHostForRepository(repository, relayOrigin).kind === - "external" - ); - } - return true; - }) + .filter(({ project, repository }) => + matchesProjectsSearch(searchQuery, [ + repository.name, + repository.description, + project.name, + ]), + ) .sort((left, right) => { if (sort === "name") { return left.repository.name.localeCompare(right.repository.name); @@ -414,61 +394,58 @@ export function ProjectsView() { return rightUpdatedAt - leftUpdatedAt; }); }, [ - currentPubkey, filter, - localRepoNames, - projects, - relayOrigin, - repositoryAccessInput, + projectReadModels, repositoryActivitySummariesQuery.data, - repositoryScope, + searchQuery, sort, ]); const visiblePullRequests = React.useMemo(() => { const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? []; - const scopedPullRequests = - pullRequestScope === "mine" && currentPubkey - ? pullRequests.filter( - ({ pullRequest }) => - normalizePubkey(pullRequest.author) === - normalizePubkey(currentPubkey), - ) - : pullRequests; - return [...scopedPullRequests].sort((left, right) => { - if (sort === "name") { - return left.pullRequest.title.localeCompare(right.pullRequest.title); - } - if (sort === "created") { - return right.pullRequest.createdAt - left.pullRequest.createdAt; - } - return right.pullRequest.updatedAt - left.pullRequest.updatedAt; - }); - }, [currentPubkey, projectsWorkItemsQuery.data, pullRequestScope, sort]); + return pullRequests + .filter(({ project, pullRequest, repository }) => + matchesProjectsSearch(searchQuery, [ + pullRequest.title, + pullRequest.content, + pullRequest.status, + project.name, + repository.name, + ]), + ) + .sort((left, right) => { + if (sort === "name") { + return left.pullRequest.title.localeCompare(right.pullRequest.title); + } + if (sort === "created") { + return right.pullRequest.createdAt - left.pullRequest.createdAt; + } + return right.pullRequest.updatedAt - left.pullRequest.updatedAt; + }); + }, [projectsWorkItemsQuery.data, searchQuery, sort]); const visibleIssues = React.useMemo(() => { const issues = projectsWorkItemsQuery.data?.issues.items ?? []; - const viewer = currentPubkey ? normalizePubkey(currentPubkey) : null; - const scopedIssues = - issueScope === "mine" && viewer - ? issues.filter(({ issue }) => normalizePubkey(issue.author) === viewer) - : issueScope === "assigned" && viewer - ? issues.filter(({ issue }) => - issue.assignees.some( - (assignee) => normalizePubkey(assignee) === viewer, - ), - ) - : issues; - return [...scopedIssues].sort((left, right) => { - if (sort === "name") { - return left.issue.title.localeCompare(right.issue.title); - } - if (sort === "created") { - return right.issue.createdAt - left.issue.createdAt; - } - return right.issue.updatedAt - left.issue.updatedAt; - }); - }, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]); + return issues + .filter(({ issue, project, repository }) => + matchesProjectsSearch(searchQuery, [ + issue.title, + issue.content, + issue.status, + project.name, + repository.name, + ]), + ) + .sort((left, right) => { + if (sort === "name") { + return left.issue.title.localeCompare(right.issue.title); + } + if (sort === "created") { + return right.issue.createdAt - left.issue.createdAt; + } + return right.issue.updatedAt - left.issue.updatedAt; + }); + }, [projectsWorkItemsQuery.data, searchQuery, sort]); const { agentContext: selectionAgentContext, overviewContext: overviewAgentContext, @@ -491,18 +468,11 @@ export function ProjectsView() { // lets React keep the click responsive and paint the previous tab until // the new tree is ready instead of blocking the main thread. React.startTransition(() => { - if ( - nextFilter === "projects" && - (repositoryScope === "buzz" || repositoryScope === "linked") - ) { - setRepositoryScope("all"); - writeStoredRepositoryScope("all"); - } setSelectionAgentContext(null); setFilter(nextFilter); }); }, - [repositoryScope, setSelectionAgentContext], + [setSelectionAgentContext], ); // Route by the canonical `owner:dtag` project ID — a bare dtag is @@ -595,7 +565,7 @@ export function ProjectsView() { ); } - if (projects.length === 0) { + if (projectReadModels.length === 0) { return ; } @@ -603,7 +573,6 @@ export function ProjectsView() { ); @@ -675,22 +636,28 @@ export function ProjectsView() { pullRequests={ projectsWorkItemsQuery.data?.pullRequests.items ?? EMPTY_ITEMS } + searchQuery={searchQuery} snapshots={repoSnapshotsQuery.data?.snapshots} /> ); const contextPanelProps = { + canCreateTarget: editableProjects.length > 0, filter, issues: contextIssues, + onAddChannel: () => setCreateChannelOpen(true), + onAddRepository: () => setCreateRepositoryOpen(true), onChatWithAgent: (items: ProjectSelectionItem[]) => setSelectionAgentContext(buildProjectSelectionAgentContext(items)), onCreateIssue: () => setCreateIssueOpen(true), onCreateProject: () => setCreateProjectOpen(true), onCreatePullRequest: () => setCreatePullRequestOpen(true), profiles, + projectReadModels, projects, pullRequests: contextPullRequests, + repositorySummaries: repositoryActivitySummariesQuery.data, summaries: activitySummariesQuery.data, }; const contextOpen = isNarrowProjectsLayout @@ -722,8 +689,27 @@ export function ProjectsView() { return ( { + const previous = selectionDrawerStateRef.current; + selectionDrawerStateRef.current = null; + if (!previous) return; + if (previous.narrow) { + setNarrowContextOpen(previous.open); + } else { + setOverviewPanelOpen(previous.open); + } + }} onSelect={() => { - if (!isNarrowProjectsLayout) setOverviewPanelOpen(true); + if (selectionDrawerStateRef.current) return; + selectionDrawerStateRef.current = { + narrow: isNarrowProjectsLayout, + open: isNarrowProjectsLayout ? narrowContextOpen : overviewPanelOpen, + }; + if (isNarrowProjectsLayout) { + setNarrowContextOpen(true); + } else { + setOverviewPanelOpen(true); + } }} resetKey={filter} > @@ -765,8 +751,7 @@ export function ProjectsView() { } else { toast.success(`Project "${result.project.name}" created.`); } - handleRepositoryScopeChange("all"); - handleFilterChange("projects"); + await goProject(result.project.id); }} onOpenChange={setCreateProjectOpen} open={createProjectOpen} @@ -800,6 +785,14 @@ export function ProjectsView() { open={createIssueOpen} projects={projects} /> +
- -
- -
+
{filter === "all" ? ( - +
{activityFeed}
@@ -855,7 +837,7 @@ export function ProjectsView() { ) : ( <> ) : filter === "channels" ? ( - + ) : filter === "projects" ? ( projectItems ) : ( @@ -947,11 +942,12 @@ export function ProjectsView() {
+ ) + } displayName={team.name} encodeSnapshot={encodeSnapshot} hasMemoryOptions diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index 986c9bdc26b..debaf3206db 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -21,6 +21,7 @@ import { SectionHeader } from "@/shared/ui/PageHeader"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; +import { teamCatalogCopy } from "./teamLibraryCopy"; const TEAM_CARD_COLUMN_CLASS = "w-full"; @@ -36,6 +37,7 @@ type TeamsSectionProps = { onDelete: (team: AgentTeam) => void; onAddToChannel: (team: AgentTeam) => void; onShare: (team: AgentTeam) => void; + onDiscover: () => void; onImport: () => void; }; @@ -51,6 +53,7 @@ export function TeamsSection({ onDelete, onAddToChannel, onShare, + onDiscover, onImport, }: TeamsSectionProps) { return ( @@ -87,6 +90,7 @@ export function TeamsSection({ {teams.map((team) => { @@ -191,10 +195,12 @@ export function TeamsSection({ function NewTeamCard({ isPending, onCreate, + onDiscover, onImport, }: { isPending: boolean; onCreate: () => void; + onDiscover: () => void; onImport: () => void; }) { return ( @@ -209,6 +215,13 @@ function NewTeamCard({ Create team + + {teamCatalogCopy.chooseFromCatalog} + Import diff --git a/desktop/src/features/agents/ui/catalogOwnerLabel.ts b/desktop/src/features/agents/ui/catalogOwnerLabel.ts new file mode 100644 index 00000000000..aad1d2d0c98 --- /dev/null +++ b/desktop/src/features/agents/ui/catalogOwnerLabel.ts @@ -0,0 +1,15 @@ +/** + * Derives the "Added by" label for a catalog entry from a resolved profile + * summary. Prefers `displayName`, falls back to `name`, then to the default + * "Community member" string when both are absent, null, or whitespace-only. + */ +export function resolveCatalogOwnerLabel( + summary: + | { displayName?: string | null; name?: string | null } + | null + | undefined, +): string { + return ( + summary?.displayName?.trim() || summary?.name?.trim() || "Community member" + ); +} diff --git a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs index 0022be3d381..5fe8aadd88a 100644 --- a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs +++ b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs @@ -3,10 +3,8 @@ import test from "node:test"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { - AgentInstructionReview, - resolveCatalogOwnerLabel, -} from "./PersonaCatalogDialog.tsx"; +import { AgentInstructionReview } from "./CommunityCatalogDialog.tsx"; +import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel.ts"; // ── null / undefined summary ────────────────────────────────────────────────── diff --git a/desktop/src/features/agents/ui/teamLibraryCopy.ts b/desktop/src/features/agents/ui/teamLibraryCopy.ts new file mode 100644 index 00000000000..9c32a829a0f --- /dev/null +++ b/desktop/src/features/agents/ui/teamLibraryCopy.ts @@ -0,0 +1,51 @@ +export const teamCatalogCopy = { + chooseFromCatalog: "Choose from catalog", + dialogTitle: "Team Catalog", + dialogDescription: "Browse teams shared to this relay.", + emptyCatalogTitle: "No teams are being shared", + emptyCatalogDescription: "Shared teams will appear here.", + addAction: "Add team", + addedAction: "Added to my teams", + addingAction: "Adding…", + shareTitle: "Share to catalog", + shareDescription: + "Anyone in this community can find and add a copy of this team. Both the team instructions and every member’s instructions are shared as plaintext. Memories and secrets aren’t included.", +} as const; + +/** + * The warning notice shown when the backend automatically queues a retraction + * for a shared team that can no longer be projected. + * + * "Queued" is accurate — the tombstone has been enqueued for the flush loop + * but the relay head may still be discoverable until the flush succeeds. + * Using "queued for removal" rather than "was removed" avoids a false claim + * that the catalog has already changed. + */ +export function teamAutoRetractedNotice( + teamName: string, + reason: string, +): string { + return `"${teamName}" has been queued for removal from the community catalog because it can no longer be projected: ${reason}`; +} + +/** + * The result message for a share toggle. + * + * `queued` is not a failure: the head is durably enqueued and the flush loop + * will publish it, so the copy promises eventual visibility rather than + * claiming the catalog already changed. + */ +export function teamShareNotice( + teamName: string, + shared: boolean, + publicationStatus: "published" | "queued", +): string { + if (publicationStatus === "queued") { + return shared + ? `Sharing ${teamName} is queued. It will appear after the relay accepts the update.` + : `Removing ${teamName} is queued. It may remain discoverable until the relay accepts the update.`; + } + return shared + ? `Published ${teamName} to the community catalog.` + : `${teamName} is no longer discoverable in the community catalog.`; +} diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index b4ef5afb6c8..268d336eaa5 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -115,7 +115,6 @@ export function usePersonaActions() { React.useState(null); const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); - const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -439,12 +438,6 @@ export function usePersonaActions() { setPersonaDialogState(duplicatePersonaDialogState(persona)); } - function openCatalog() { - clearFeedback("catalog"); - void catalogQuery.refetch(); - setIsCatalogDialogOpen(true); - } - function openDelete(persona: AgentPersona) { clearFeedback("library"); setPersonaToDelete(persona); @@ -585,8 +578,6 @@ export function usePersonaActions() { setPersonaToDelete, personaToShare, setPersonaToShare, - isCatalogDialogOpen, - setIsCatalogDialogOpen, personaNoticeMessage, personaErrorMessage, personaFeedbackSurface, @@ -597,7 +588,6 @@ export function usePersonaActions() { prepareCreate, openEdit, openDuplicate, - openCatalog, openDelete, openShare, personaToExportSnapshot, diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts index 3652acf9541..2e838c0f523 100644 --- a/desktop/src/features/agents/ui/useTeamActions.ts +++ b/desktop/src/features/agents/ui/useTeamActions.ts @@ -10,7 +10,20 @@ import { useTeamsQuery, useUpdateTeamMutation, } from "@/features/agents/hooks"; +import type { CatalogTeamShareLevel } from "@/features/agents/lib/teamCatalogRelay"; +import { + catalogTeamsFromPublications, + type CatalogTeam, +} from "@/features/agents/lib/teamCatalogRelay"; +import { + useAddTeamFromCatalogMutation, + useSetTeamCatalogSharedMutation, + useTeamCatalogLiveUpdates, + useTeamCatalogQuery, +} from "@/features/agents/lib/useTeamCatalogRelay"; +import { useCommunities } from "@/features/communities/useCommunities"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { deletePersona } from "@/shared/api/tauriPersonas"; import { confirmTeamSnapshotImport, @@ -28,6 +41,7 @@ import type { UpdateTeamInput, } from "@/shared/api/types"; import { deriveImportToast } from "./teamSnapshotImport.lib"; +import { teamShareNotice } from "./teamLibraryCopy"; type TeamDialogState = { description: string; @@ -51,7 +65,14 @@ export function useTeamActions( refetch: RefetchCallbacks, ) { const queryClient = useQueryClient(); + const { activeCommunity } = useCommunities(); + const identityQuery = useIdentityQuery(); + const communityId = activeCommunity?.id ?? null; const teamsQuery = useTeamsQuery(); + const catalogQuery = useTeamCatalogQuery(communityId); + useTeamCatalogLiveUpdates(communityId); + const setCatalogSharedMutation = useSetTeamCatalogSharedMutation(communityId); + const addTeamFromCatalogMutation = useAddTeamFromCatalogMutation(); const createTeamMutation = useCreateTeamMutation(); const updateTeamMutation = useUpdateTeamMutation(); const deleteTeamMutation = useDeleteTeamMutation(); @@ -103,6 +124,16 @@ export function useTeamActions( }); const teams = teamsQuery.data ?? []; + const publications = catalogQuery.data ?? []; + const catalogTeams = React.useMemo( + () => + catalogTeamsFromPublications( + publications, + teams, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, publications, teams], + ); async function handleTeamSubmit(input: CreateTeamInput | UpdateTeamInput) { actions.setActionNoticeMessage(null); @@ -233,6 +264,73 @@ export function useTeamActions( setTeamToShare(team); } + function getTeamCatalogShareLevel(team: AgentTeam): CatalogTeamShareLevel { + return team.shared ? "none" : "not-shared"; + } + + async function setTeamCatalogShareLevel( + team: AgentTeam, + shareLevel: CatalogTeamShareLevel, + ): Promise { + if (team.isBuiltin) return; + + actions.setActionNoticeMessage(null); + actions.setActionErrorMessage(null); + const shared = shareLevel !== "not-shared"; + try { + const result = await setCatalogSharedMutation.mutateAsync({ + id: team.id, + shared, + }); + // The open dialog holds its own copy of the team, so re-point it at the + // returned record — otherwise the toggle snaps back to its old value. + setTeamToShare((current) => + current?.id === result.team.id ? result.team : current, + ); + actions.setActionNoticeMessage( + teamShareNotice(team.name, shared, result.publicationStatus), + ); + } catch (error) { + actions.setActionErrorMessage( + error instanceof Error + ? error.message + : `Failed to ${shared ? "share" : "unshare"} team.`, + ); + } + } + + /** + * Add a published team. + * + * Only the coordinate is sent; the backend re-verifies the head, so an entry + * retracted or republished while the dialog sat open fails loudly here + * rather than copying a stale projection. + */ + async function handleAddTeamFromCatalog( + team: CatalogTeam, + onSuccess?: () => void, + ): Promise { + actions.setActionNoticeMessage(null); + actions.setActionErrorMessage(null); + try { + const result = await addTeamFromCatalogMutation.mutateAsync({ + ownerPubkey: team.ownerPubkey, + teamDTag: team.teamDTag, + eventId: team.eventId, + }); + actions.setActionNoticeMessage( + result.alreadyPresent + ? `${result.team.name} is already in your teams.` + : `Added ${result.team.name} to your teams.`, + ); + onSuccess?.(); + } catch (error) { + actions.setActionErrorMessage( + error instanceof Error ? error.message : "Failed to add team.", + ); + } + } + function handleExportTeamSnapshot( team: AgentTeam, memoryLevel: SnapshotMemoryLevel, @@ -317,6 +415,10 @@ export function useTeamActions( return { teams, teamsQuery, + catalogQuery, + catalogTeams, + isAddingFromCatalog: addTeamFromCatalogMutation.isPending, + isCatalogSharePending: setCatalogSharedMutation.isPending, createTeamMutation, updateTeamMutation, deleteTeamMutation, @@ -344,6 +446,9 @@ export function useTeamActions( openEditDialog, openExportSnapshot, openShare, + getTeamCatalogShareLevel, + setTeamCatalogShareLevel, + handleAddTeamFromCatalog, handleExportTeamSnapshot, handleImportTeamSnapshotFile, handleConfirmTeamSnapshotImport, diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 3153fb4be1c..106016a8f8e 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -9,6 +9,16 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { Avatar, AvatarFallback, AvatarImage } from "@/shared/ui/avatar"; import { Spinner } from "@/shared/ui/spinner"; +/** + * A `data:` URL is inlined bytes — rendering it makes no network request, so an + * untrusted publisher cannot use it to observe the viewer's IP or browse + * timing. Every other scheme (`http(s):`, `blob:`, relative) can reach the + * network and is suppressed under `untrusted`. + */ +function isInlineDataUrl(url: string): boolean { + return /^data:/i.test(url); +} + type ProfileAvatarProps = { avatarUrl: string | null; avatarDataUrl?: string | null; @@ -18,6 +28,17 @@ type ProfileAvatarProps = { imageClassName?: string; plain?: boolean; testId?: string; + /** + * Suppress every network image request for a publisher-controlled avatar + * URL, rendering the initials/icon placeholder instead. Community-catalog + * browse projects avatar URLs straight from untrusted publications; loading + * them would hand the viewer's IP and browse timing to up to 64 attacker- + * chosen hosts before the user adds anything. Inline `data:` avatars (e.g. + * emoji avatars, and a trusted locally cached `avatarDataUrl`) carry no + * network origin, so they still render — only network-capable schemes are + * blocked. + */ + untrusted?: boolean; }; export function ProfileAvatar({ @@ -29,6 +50,7 @@ export function ProfileAvatar({ imageClassName, plain = false, testId, + untrusted = false, }: ProfileAvatarProps) { const initials = getInitials(label); const presentation = useAvatarPresentation(avatarUrl); @@ -45,8 +67,19 @@ export function ProfileAvatar({ : presentedAvatarUrl; // Compute the live (proxied) source. Failures are tracked per resolved URL so - // the poster and hover animation can recover independently. - const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null; + // the poster and hover animation can recover independently. Under `untrusted` + // (publisher-controlled catalog browse) only an inline `data:` URL renders — + // it carries no network origin, so it can't leak the viewer's IP; every + // network-capable scheme is suppressed to the placeholder. This keeps emoji + // avatars (persisted as inline `data:image/svg+xml`) visible while blocking + // the up-to-64 attacker-chosen host fetches Carl flagged. + const liveSrc = !baseUrl + ? null + : untrusted + ? isInlineDataUrl(baseUrl) + ? baseUrl + : null + : rewriteRelayUrl(baseUrl); const [failedSrc, setFailedSrc] = React.useState(null); const liveFailed = liveSrc !== null && failedSrc === liveSrc; diff --git a/desktop/src/features/profile/ui/ProfileAvatarUntrusted.test.mjs b/desktop/src/features/profile/ui/ProfileAvatarUntrusted.test.mjs new file mode 100644 index 00000000000..a090bd4a4de --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileAvatarUntrusted.test.mjs @@ -0,0 +1,136 @@ +/** + * Untrusted catalog-browse avatars must never fire a network image request. + * + * The community catalog projects publisher-controlled `avatarUrl` values into + * member/persona rows. Radix's `AvatarImage` resolves its loading status by + * assigning `image.src` on a `new window.Image()`, so a browsed row would fetch + * up to 64 attacker-chosen hosts — handing the viewer's IP and browse timing to + * publishers — before the user adds anything. `referrerPolicy` does not stop the + * request itself. `ProfileAvatar untrusted` must render the initials/icon + * placeholder with zero remote fetch; a trusted local `avatarDataUrl` still + * renders because it carries no network origin. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +// Radix builds a detached `new window.Image()` and assigns `.src` to probe +// load status; that assignment is the actual network request. Spy on it so the +// test observes the fetch rather than post-load DOM (which never mounts in +// jsdom because the probe never fires `load`). +const imageSrcAssignments = []; + +class SpyImage { + constructor() { + this.complete = false; + this.naturalWidth = 0; + this._src = ""; + } + addEventListener() {} + removeEventListener() {} + set src(value) { + this._src = value; + imageSrcAssignments.push(value); + } + get src() { + return this._src; + } +} + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); + dom.window.Image = SpyImage; + globalThis.Image = SpyImage; +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); + imageSrcAssignments.length = 0; +}); + +after(() => dom.window.close()); + +let React; +let render; +let act; +let ProfileAvatar; + +before(async () => { + React = (await import("react")).default; + ({ render, act } = await import("@testing-library/react")); + ({ ProfileAvatar } = await import("./ProfileAvatar.tsx")); +}); + +const PUBLISHER_URL = "https://attacker.example/beacon.png"; + +const networkAssignments = () => + imageSrcAssignments.filter((src) => /^https?:/i.test(src)); + +async function renderAvatar(props) { + await act(async () => { + render(React.createElement(ProfileAvatar, props)); + }); +} + +test("untrusted avatar fires no network image request", async () => { + await renderAvatar({ + avatarUrl: PUBLISHER_URL, + label: "Mallory", + untrusted: true, + }); + + assert.deepEqual(networkAssignments(), []); +}); + +test("a trusted avatar still fetches the publisher URL", async () => { + // Reversal witness: the guard is what suppresses the fetch. Without + // `untrusted`, the same URL is requested — the exact leak Carl flagged. + await renderAvatar({ avatarUrl: PUBLISHER_URL, label: "Mallory" }); + + assert.deepEqual(networkAssignments(), [PUBLISHER_URL]); +}); + +test("untrusted avatar still renders a locally cached data URL", async () => { + // A trusted, locally cached data URL carries no network origin, so it must + // keep rendering even while the remote fetch is blocked. + const dataUrl = "data:image/png;base64,AA"; + await renderAvatar({ + avatarUrl: PUBLISHER_URL, + avatarDataUrl: dataUrl, + label: "Mallory", + untrusted: true, + }); + + assert.deepEqual(networkAssignments(), []); + assert.deepEqual(imageSrcAssignments, [dataUrl]); +}); + +test("untrusted avatar renders an inline data: avatarUrl (emoji avatar)", async () => { + // Emoji avatars persist as an inline `data:image/svg+xml` value in + // `avatarUrl`, not a hosted URL. Blocking it isn't privacy — a `data:` URL + // makes zero network requests — it's a regression that drops every emoji + // avatar in catalog browse to initials. Under `untrusted`, an inline `data:` + // scheme must still render. + const emojiDataUrl = + "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'/%3E"; + await renderAvatar({ + avatarUrl: emojiDataUrl, + label: "Mallory", + untrusted: true, + }); + + assert.deepEqual(networkAssignments(), []); + assert.deepEqual(imageSrcAssignments, [emojiDataUrl]); +}); diff --git a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx index 229941ec50c..98a1df20187 100644 --- a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx +++ b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx @@ -47,8 +47,8 @@ import { const CUSTOM_ENTRY_ID = "\u0000custom"; /** - * "Add runtimes" — master-detail catalog dialog, modeled on the Agent - * Catalog (PersonaCatalogDialog): searchable left chooser, right detail pane + * "Add runtimes" — master-detail catalog dialog, modeled on the Community + * Catalog (CommunityCatalogDialog): searchable left chooser, right detail pane * with one neutral vendor-sourced sentence, operational setup state, and * technical details, plus a primary Install / setup-guide CTA pinned in a * bottom action bar (same position as the custom-harness Save button). diff --git a/desktop/src/shared/api/tauriTeams.ts b/desktop/src/shared/api/tauriTeams.ts index a2c7fdf1d72..e83201249c2 100644 --- a/desktop/src/shared/api/tauriTeams.ts +++ b/desktop/src/shared/api/tauriTeams.ts @@ -2,9 +2,16 @@ import { invokeTauri } from "@/shared/api/tauri"; import type { AgentTeam, CreateTeamInput, + TeamCatalogSourceCoordinate, UpdateTeamInput, } from "@/shared/api/types"; +/** Wire shape of `TeamCatalogSource` — snake_case, like its parent record. */ +type RawTeamCatalogSource = { + owner_pubkey: string; + team_d_tag: string; +}; + type RawTeam = { id: string; name: string; @@ -12,6 +19,8 @@ type RawTeam = { instructions?: string | null; persona_ids: string[]; is_builtin?: boolean; + shared?: boolean; + catalog_source?: RawTeamCatalogSource | null; source_dir?: string | null; is_symlink?: boolean; symlink_target?: string | null; @@ -20,6 +29,14 @@ type RawTeam = { updated_at: string; }; +function fromRawCatalogSource( + source: RawTeamCatalogSource | null | undefined, +): TeamCatalogSourceCoordinate | null { + return source + ? { ownerPubkey: source.owner_pubkey, teamDTag: source.team_d_tag } + : null; +} + function fromRawTeam(team: RawTeam): AgentTeam { return { id: team.id, @@ -28,6 +45,8 @@ function fromRawTeam(team: RawTeam): AgentTeam { instructions: team.instructions ?? null, personaIds: team.persona_ids, isBuiltin: team.is_builtin ?? false, + shared: team.shared ?? false, + catalogSource: fromRawCatalogSource(team.catalog_source), sourceDir: team.source_dir ?? null, isSymlink: team.is_symlink ?? false, symlinkTarget: team.symlink_target ?? null, @@ -72,6 +91,71 @@ export async function deleteTeam(id: string): Promise { await invokeTauri("delete_team", { id }); } +// ── Team catalog commands ──────────────────────────────────────────────────── + +export type TeamSharePublicationResult = { + team: AgentTeam; + /** `queued` means the head is durably enqueued but the relay has not yet + * accepted it, so catalog visibility lags the toggle. */ + publicationStatus: "published" | "queued"; +}; + +type RawTeamSharePublicationResult = { + team: RawTeam; + publicationStatus: "published" | "queued"; +}; + +/** Publish this team's catalog head, or replace it with an untagged one. */ +export async function setTeamShared( + id: string, + shared: boolean, +): Promise { + const raw = await invokeTauri( + "set_team_shared", + { id, shared }, + ); + return { + team: fromRawTeam(raw.team), + publicationStatus: raw.publicationStatus, + }; +} + +export type AddTeamFromCatalogResult = { + team: AgentTeam; + /** True when the team was already added and nothing was written. */ + alreadyPresent: boolean; +}; + +type RawAddTeamFromCatalogResult = { + team: RawTeam; + alreadyPresent: boolean; +}; + +/** + * Copy a published team into the local stores. + * + * Only the coordinate crosses the boundary — never the projection the UI is + * displaying. The backend re-fetches the current head at + * `30178::` and rejects the add if it is not `eventId` or has + * stopped being shared, so a catalog entry that moved or was retracted while + * the dialog sat open cannot be copied. + */ +export async function addTeamFromCatalog( + source: TeamCatalogSourceCoordinate & { eventId: string }, +): Promise { + const raw = await invokeTauri( + "add_team_from_catalog", + { + input: { + ownerPubkey: source.ownerPubkey, + teamDTag: source.teamDTag, + eventId: source.eventId, + }, + }, + ); + return { team: fromRawTeam(raw.team), alreadyPresent: raw.alreadyPresent }; +} + // ── Team snapshot types ───────────────────────────────────────────────────── export type SnapshotFormat = "json" | "png"; @@ -113,27 +197,10 @@ export type TeamSnapshotImportMemberResult = { profileSyncError: string | null; }; -/** Wire shape of the nested `TeamRecord` — Rust has no `rename_all` so fields - * arrive in snake_case, matching the existing `RawTeam` convention. */ -type RawTeamRecord = { - id: string; - name: string; - description: string | null; - persona_ids: string[]; - instructions: string | null; - is_builtin: boolean; - source_dir: string | null; - is_symlink: boolean; - symlink_target: string | null; - version: string | null; - created_at: string; - updated_at: string; -}; - /** Raw wire shape of the import result — outer struct is camelCase, * but the nested `team` field is snake_case (no `rename_all` on TeamRecord). */ type RawTeamSnapshotImportResult = { - team: RawTeamRecord; + team: RawTeam; personaIds: string[]; members: TeamSnapshotImportMemberResult[]; }; diff --git a/desktop/src/shared/api/teamTypes.ts b/desktop/src/shared/api/teamTypes.ts new file mode 100644 index 00000000000..bd0e19617ae --- /dev/null +++ b/desktop/src/shared/api/teamTypes.ts @@ -0,0 +1,61 @@ +/** + * Team library wire types. + * + * Split out of `types.ts` the same way `searchTypes` and `socialTypes` are: + * they are one cohesive group, and `types.ts` is at its size ceiling. + */ + +/** + * A publication's coordinate in the kind:30178 team catalog. Mirrors the + * backend `TeamCatalogSource`. + * + * Deliberately not `CatalogSourceCoordinate`: that one addresses a kind:30175 + * persona, and a team `d`-tag resolved in the persona namespace names a + * different — possibly unrelated — event. + */ +export type TeamCatalogSourceCoordinate = { + ownerPubkey: string; + teamDTag: string; +}; + +export type AgentTeam = { + id: string; + name: string; + description: string | null; + instructions: string | null; + personaIds: string[]; + isBuiltin: boolean; + /** Whether this team is discoverable in the active community catalog. */ + shared: boolean; + /** + * Set only on a local copy of another owner's shared team. A copy carries a + * fresh local `id`, so this coordinate is the only thing that can answer "is + * this catalog entry already added" without minting a duplicate. + */ + catalogSource: TeamCatalogSourceCoordinate | null; + /** Absolute path to the team's backing directory (if directory-backed). */ + sourceDir: string | null; + /** Whether sourceDir is a symlink to an external directory. */ + isSymlink: boolean; + /** Resolved symlink target path (for display). Only set when isSymlink is true. */ + symlinkTarget: string | null; + /** Version from the team's plugin.json manifest. */ + version: string | null; + createdAt: string; + updatedAt: string; +}; + +export type CreateTeamInput = { + name: string; + description?: string; + instructions?: string; + personaIds: string[]; +}; + +export type UpdateTeamInput = { + id: string; + name: string; + description?: string; + instructions?: string; + personaIds: string[]; +}; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d2251c25a56..7528998592d 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -788,39 +788,13 @@ export type UpdatePersonaInput = { }; // ── Team types ──────────────────────────────────────────────────────────────── -export type AgentTeam = { - id: string; - name: string; - description: string | null; - instructions: string | null; - personaIds: string[]; - isBuiltin: boolean; - /** Absolute path to the team's backing directory (if directory-backed). */ - sourceDir: string | null; - /** Whether sourceDir is a symlink to an external directory. */ - isSymlink: boolean; - /** Resolved symlink target path (for display). Only set when isSymlink is true. */ - symlinkTarget: string | null; - /** Version from the team's plugin.json manifest. */ - version: string | null; - createdAt: string; - updatedAt: string; -}; - -export type CreateTeamInput = { - name: string; - description?: string; - instructions?: string; - personaIds: string[]; -}; +export type { + AgentTeam, + CreateTeamInput, + TeamCatalogSourceCoordinate, + UpdateTeamInput, +} from "./teamTypes"; -export type UpdateTeamInput = { - id: string; - name: string; - description?: string; - instructions?: string; - personaIds: string[]; -}; // ── Channel Template types ───────────────────────────────────────────────────── export type TemplateBackend = diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 4f8b7afe2bd..5d4308c5c27 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -57,6 +57,11 @@ export const KIND_COMMUNITY_THEME = 30078; export const KIND_PERSONA = 30175; export const KIND_TEAM = 30176; export const KIND_MANAGED_AGENT = 30177; +// Team catalog projection: a self-contained snapshot of a team plus every +// member's safe definition, so a recipient can rebuild it without reading the +// publisher's personas. Separate from KIND_TEAM (30176, the team's own wire +// body) so an ordinary team edit cannot disturb catalog share state. +export const KIND_TEAM_CATALOG = 30178; export const KIND_USER_STATUS = 30315; export const KIND_AGENT_OBSERVER_FRAME = 24200; export const KIND_AGENT_TURN_METRIC = 44200; diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 5fd2593c79c..644181a3881 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -419,7 +419,7 @@ * SCOPED to the app sidebar container, NOT :root - the `bg-sidebar-active` / * `text-sidebar-active-foreground` tokens are also consumed by non-sidebar * controls (avatar edit buttons in ProfileSettingsCard / AgentCreationPreview, - * the selected persona row in PersonaCatalogDialog). A root-level override + * the selected row in CommunityCatalogDialog). A root-level override * turned those white (white-on-white under Buzz Dark); scoping keeps them on * the normal accent-driven active colors. */ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b06b1fa627c..814bd86223e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -59,6 +59,7 @@ import { KIND_STREAM_MESSAGE_EDIT, KIND_SYSTEM_MESSAGE, KIND_TEXT_NOTE, + KIND_TEAM_CATALOG, KIND_USER_STATUS, } from "@/shared/constants/kinds"; import type { @@ -314,6 +315,10 @@ type E2eConfig = { /** Outcomes for successive explicit persona share publications. */ personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; + /** Community team-catalog (kind:30178) heads returned by relay queries. */ + teamCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit team share publications. */ + teamSharePublicationStatuses?: Array<"published" | "queued">; relayAgents?: MockRelayAgentSeed[]; /** Reject successive relay-agent directory reads, then resume. */ relayAgentListErrors?: (string | null)[]; @@ -1008,6 +1013,8 @@ type RawTeam = { description: string | null; persona_ids: string[]; is_builtin: boolean; + shared?: boolean; + catalog_source?: { owner_pubkey: string; team_d_tag: string } | null; source_dir: string | null; is_symlink: boolean; symlink_target: string | null; @@ -1243,6 +1250,12 @@ declare global { members: MockHuddleMemberSeed[]; transcriptionEnabled: boolean; }) => Promise; + /** + * Replace the stored kind:30178 head for a coordinate WITHOUT notifying + * live subscribers. Reproduces a head that moved on the relay while a + * catalog dialog sat open holding the superseded event id. + */ + __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (event: RelayEvent) => void; __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem; /** Replace an existing feed item by id (or push if not found) and fire the updated event. */ __BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__?: ( @@ -3140,6 +3153,7 @@ const deferredSendMessageLiveEchoes: Array<{ const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; +const mockTeamCatalogEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); const mockAuthResponses: Array<{ success: boolean; message: string }> = []; @@ -3303,7 +3317,7 @@ function mockPersonaCatalogPublications() { const coordinate = `${ownerPubkey}:${sourcePersonaId}`; if (claimed.has(coordinate)) continue; claimed.add(coordinate); - if (!personaHasExactSharedTag(event)) continue; + if (!hasExactSharedTag(event)) continue; let content: Record; try { content = JSON.parse(event.content) as Record; @@ -3407,6 +3421,73 @@ function mockPersonaCatalogPublications() { return publications; } +function resetMockTeamCatalogEvents(config: E2eConfig | undefined) { + mockTeamCatalogEvents.length = 0; + for (const event of config?.mock?.teamCatalogEvents ?? []) { + mockTeamCatalogEvents.push({ + ...event, + tags: event.tags.map((tag) => [...tag]), + }); + } +} + +// Mirrors the head-selection half of `fetch_team_catalog` (team_catalog.rs): +// NIP-33 head selection per coordinate and the exact `shared` gate. A +// coordinate is claimed before the shared/parse checks so an unshared or +// malformed newest head cannot resurrect an older shared one. Content parsing +// here is a shallow shape check (`v`, `name`, `members` is an array), not the +// native per-member validation — production trust rests on the Rust command. +function mockTeamCatalogPublications() { + const publications = []; + const claimed = new Set(); + for (const event of [...mockTeamCatalogEvents].sort( + (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), + )) { + const dTags = event.tags.filter((tag) => tag[0] === "d"); + if (dTags.length !== 1 || !dTags[0]?.[1]) continue; + const teamDTag = dTags[0][1]; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${teamDTag}`; + if (claimed.has(coordinate)) continue; + claimed.add(coordinate); + if (!hasExactSharedTag(event)) continue; + let content: Record; + try { + content = JSON.parse(event.content) as Record; + } catch { + continue; + } + if (content.v !== 1 || typeof content.name !== "string") continue; + if (!Array.isArray(content.members)) continue; + const optionalString = (value: unknown) => + typeof value === "string" && value.trim() ? value : null; + publications.push({ + eventId: event.id, + ownerPubkey, + teamDTag, + name: content.name, + description: optionalString(content.description), + instructions: optionalString(content.instructions), + members: content.members.map((member) => { + const record = member as Record; + return { + memberKey: record.member_key, + displayName: record.display_name, + systemPrompt: + typeof record.system_prompt === "string" + ? record.system_prompt + : "", + avatarUrl: optionalString(record.avatar_url), + runtime: optionalString(record.runtime), + model: optionalString(record.model), + provider: optionalString(record.provider), + }; + }), + }); + } + return publications; +} + // Mesh-compute mock state — TEST-ONLY. // // This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__` @@ -4710,9 +4791,9 @@ function emitOrDeferMockSendMessageLiveEcho( function emitMockGlobalEvent(event: RelayEvent) { if ( - event.kind === KIND_PERSONA && + (event.kind === KIND_PERSONA || event.kind === KIND_TEAM_CATALOG) && event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && - !personaHasExactSharedTag(event) + !hasExactSharedTag(event) ) { return; } @@ -8311,6 +8392,7 @@ const MOCK_PASSPHRASE_WORDS = [ // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; +let teamSharePublicationCallCount = 0; // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; @@ -8704,7 +8786,7 @@ async function handleSetPersonaActive(args: { return { ...persona }; } -function personaHasExactSharedTag(event: RelayEvent): boolean { +function hasExactSharedTag(event: RelayEvent): boolean { const tags = event.tags.filter((tag) => tag[0] === "shared"); return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "true"; } @@ -8833,11 +8915,12 @@ function ensureMockPersonaIdsAreActive(personaIds: string[]) { } } +function cloneMockTeam(team: RawTeam): RawTeam { + return { ...team, persona_ids: [...team.persona_ids] }; +} + async function handleListTeams(): Promise { - return mockTeams.map((team) => ({ - ...team, - persona_ids: [...team.persona_ids], - })); + return mockTeams.map(cloneMockTeam); } async function handleCreateTeam(args: { @@ -8896,6 +8979,184 @@ async function handleDeleteTeam(args: { id: string }): Promise { mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id); } +// ── Team catalog (kind:30178) ─────────────────────────────────────────────── + +/** The team's catalog projection, as `team_catalog_content` builds it. */ +function mockTeamCatalogContent(team: RawTeam): string { + return JSON.stringify({ + v: 1, + name: team.name, + description: team.description, + instructions: null, + members: team.persona_ids.map((personaId) => { + const persona = mockPersonas.find( + (candidate) => candidate.id === personaId, + ); + return { + member_key: personaId, + display_name: persona?.display_name ?? personaId, + system_prompt: persona?.system_prompt ?? "", + avatar_url: persona?.avatar_url ?? null, + runtime: persona?.runtime ?? null, + model: persona?.model ?? null, + }; + }), + }); +} + +function upsertMockTeamCatalogEvent( + team: RawTeam, + identity?: TestIdentity, +): void { + const template = { + created_at: Math.floor(Date.now() / 1_000), + kind: KIND_TEAM_CATALOG, + tags: [["d", team.id], ...(team.shared ? [["shared", "true"]] : [])], + content: mockTeamCatalogContent(team), + }; + const event: RelayEvent = identity + ? finalizeEvent(template, hexToBytes(identity.privateKey)) + : { + ...template, + id: mockEventId(), + pubkey: MOCK_IDENTITY_PUBKEY, + sig: "0".repeat(128), + }; + const existingIndex = mockTeamCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === team.id), + ); + if (existingIndex >= 0) { + mockTeamCatalogEvents.splice(existingIndex, 1); + } + mockTeamCatalogEvents.push(event); + emitMockGlobalEvent(event); +} + +type MockTeamPublicationResult = { + team: RawTeam; + publicationStatus: "published" | "queued"; +}; + +/** + * Mirrors `set_team_shared`. A `queued` outcome must NOT make the head visible + * to catalog readers — that lag is exactly what the UI copy reports. + */ +async function handleSetTeamShared( + args: { id: string; shared: boolean }, + config?: E2eConfig, +): Promise { + const team = mockTeams.find((candidate) => candidate.id === args.id); + if (!team) { + throw new Error(`Team ${args.id} not found.`); + } + if (team.is_builtin) { + throw new Error("Built-in teams cannot be shared to the catalog."); + } + team.shared = args.shared; + team.updated_at = new Date().toISOString(); + + const publicationStatus = + config?.mock?.teamSharePublicationStatuses?.[ + teamSharePublicationCallCount++ + ] ?? "published"; + if (publicationStatus === "published") { + upsertMockTeamCatalogEvent(team, getActiveIdentity(config)); + } + return { + team: cloneMockTeam(team), + publicationStatus, + }; +} + +/** + * Mirrors `add_team_from_catalog`, including the canonical-head check: the + * coordinate is re-resolved against the current heads and the add is rejected + * unless that head is still `eventId` and still shared. A test that stales the + * head must see the same failure the real command produces. + */ +async function handleAddTeamFromCatalog(args: { + input: { ownerPubkey: string; teamDTag: string; eventId: string }; +}): Promise<{ team: RawTeam; alreadyPresent: boolean }> { + const { ownerPubkey, teamDTag, eventId } = args.input; + const owner = ownerPubkey.toLowerCase(); + const head = mockTeamCatalogEvents + .filter( + (event) => + event.pubkey.toLowerCase() === owner && + event.tags.filter((tag) => tag[0] === "d").length === 1 && + event.tags.some((tag) => tag[0] === "d" && tag[1] === teamDTag), + ) + .sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + )[0]; + + if (!head || !hasExactSharedTag(head)) { + throw new Error("This team is no longer shared to the catalog."); + } + if (head.id !== eventId) { + throw new Error( + "This team was updated since you opened the catalog. Reopen it and try again.", + ); + } + + const existing = mockTeams.find( + (candidate) => + candidate.catalog_source?.owner_pubkey === owner && + candidate.catalog_source?.team_d_tag === teamDTag, + ); + if (existing) { + return { team: cloneMockTeam(existing), alreadyPresent: true }; + } + + const content = JSON.parse(head.content) as { + name: string; + description: string | null; + members: Array<{ + member_key: string; + display_name: string; + system_prompt: string; + avatar_url: string | null; + }>; + }; + const now = new Date().toISOString(); + const personaIds = content.members.map((member) => { + const id = crypto.randomUUID(); + mockPersonas.push({ + id, + display_name: member.display_name, + avatar_url: member.avatar_url, + system_prompt: member.system_prompt, + is_builtin: false, + is_active: true, + shared: false, + env_vars: {}, + created_at: now, + updated_at: now, + }); + return id; + }); + const team: RawTeam = { + id: crypto.randomUUID(), + name: content.name, + description: content.description, + persona_ids: personaIds, + is_builtin: false, + shared: false, + catalog_source: { owner_pubkey: owner, team_d_tag: teamDTag }, + source_dir: null, + is_symlink: false, + symlink_target: null, + version: null, + created_at: now, + updated_at: now, + }; + mockTeams.push(team); + return { team: cloneMockTeam(team), alreadyPresent: false }; +} + async function handleExportTeamToJson(args: { id: string }): Promise { const team = mockTeams.find((candidate) => candidate.id === args.id); if (!team) { @@ -10467,7 +10728,7 @@ function sendToMockSocket(args: { if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; if ( event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && - !personaHasExactSharedTag(event) + !hasExactSharedTag(event) ) { continue; } @@ -10479,6 +10740,27 @@ function sendToMockSocket(args: { return; } + if (filter.kinds?.includes(KIND_TEAM_CATALOG)) { + const authors = filter.authors?.map((author) => author.toLowerCase()); + const teamDTags = filter["#d"]; + for (const event of mockTeamCatalogEvents) { + if (authors && !authors.includes(event.pubkey.toLowerCase())) continue; + // Own heads are readable unshared (the owner sees their own state); + // anyone else's must carry the exact shared tag, like the relay gate. + if ( + event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() && + !hasExactSharedTag(event) + ) { + continue; + } + const teamDTag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (teamDTags && (!teamDTag || !teamDTags.includes(teamDTag))) continue; + sendWsText(socket.handler, ["EVENT", subId, event]); + } + sendWsText(socket.handler, ["EOSE", subId]); + return; + } + // Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a` // tag or by issue/PR root `e` tag (discussions, approvals, review // requests, assignment operations). Channel messages are kind 9, so a @@ -10619,7 +10901,7 @@ function sendToMockSocket(args: { const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); if ( sharedTags.length > 1 || - (sharedTags.length === 1 && !personaHasExactSharedTag(event)) + (sharedTags.length === 1 && !hasExactSharedTag(event)) ) { sendWsText(socket.handler, [ "OK", @@ -10819,6 +11101,7 @@ export function maybeInstallE2eTauriMocks() { resetMockUserStatuses(); resetMockPersonaCatalogEvents(config); resetMockObservedUnread(); + resetMockTeamCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); @@ -10931,6 +11214,18 @@ export function maybeInstallE2eTauriMocks() { ownerPubkey, kind, }) => hasMockOwnerKindSubscription(ownerPubkey, kind); + window.__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__ = (event) => { + const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; + const existingIndex = mockTeamCatalogEvents.findIndex( + (candidate) => + candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() && + candidate.tags.some((tag) => tag[0] === "d" && tag[1] === dTag), + ); + if (existingIndex >= 0) { + mockTeamCatalogEvents.splice(existingIndex, 1); + } + mockTeamCatalogEvents.push(event); + }; window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => { const category = item.category === "mention" ? "mentions" : item.category; mockFeedOverrides[category].unshift(item); @@ -12899,6 +13194,15 @@ export function maybeInstallE2eTauriMocks() { ); case "list_teams": return handleListTeams(); + case "set_team_shared": + return handleSetTeamShared( + payload as Parameters[0], + activeConfig, + ); + case "add_team_from_catalog": + return handleAddTeamFromCatalog( + payload as Parameters[0], + ); case "list_channel_templates": return (activeConfig?.mock?.channelTemplates ?? []).map((template) => ({ id: template.id, @@ -14004,6 +14308,8 @@ export function maybeInstallE2eTauriMocks() { return null; case "fetch_persona_catalog": return mockPersonaCatalogPublications(); + case "fetch_team_catalog": + return mockTeamCatalogPublications(); case "channel_head_cache_load": { const args = payload as { scope: { pubkey: string; relayUrl: string }; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index b81c5889f3a..ac57b30aee7 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -86,7 +86,7 @@ async function openPersonaCatalog(page: import("@playwright/test").Page) { async function getCatalogOrder(page: import("@playwright/test").Page) { return page - .locator('[data-testid^="persona-catalog-list-item-"]') + .locator('[data-testid^="community-catalog-agent-"]') .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid") ?? ""), ); @@ -96,7 +96,7 @@ async function selectCatalogPersona( page: import("@playwright/test").Page, personaId: string, ) { - await page.getByTestId(`persona-catalog-list-item-${personaId}`).click(); + await page.getByTestId(`community-catalog-agent-${personaId}`).click(); } async function sharePersonaToCatalog( @@ -245,24 +245,26 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({ await openPersonaCatalog(page); for (const personaName of ["Fizz", "Honey", "Pollen"]) { - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - personaName, - ); + await expect( + page.getByTestId("community-catalog-dialog"), + ).not.toContainText(personaName); } - await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible(); - await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible(); await expect( - page.getByText("No shared agents", { exact: true }), + page.getByTestId("community-catalog-dialog-header"), ).toBeVisible(); + await expect(page.getByTestId("community-catalog-dialog-body")).toBeVisible(); + const emptyState = page.getByTestId("community-catalog-empty-state"); + await expect(emptyState).toContainText("Nothing shared yet"); await expect( - page.locator('[data-testid^="persona-catalog-list-item-"]'), - ).toHaveCount(0); + emptyState.getByTestId("community-catalog-empty-artwork"), + ).toBeVisible(); await expect( - page.getByTestId("persona-catalog-use-agent-target"), + page.locator('[data-testid^="community-catalog-agent-"]'), ).toHaveCount(0); + await expect(page.getByTestId("community-catalog-use-agent")).toHaveCount(0); await page - .getByTestId("persona-catalog-dialog") + .getByTestId("community-catalog-dialog") .getByRole("button", { name: "Close" }) .click(); await page.getByLabel("Open actions for Fizz").click(); @@ -277,19 +279,17 @@ test("catalog empty state remains available after reopening", async ({ await gotoApp(page); await page.getByTestId("open-agents-view").click(); await openPersonaCatalog(page); - await expect( - page.getByText("No shared agents", { exact: true }), - ).toBeVisible(); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); await page - .getByTestId("persona-catalog-dialog") + .getByTestId("community-catalog-dialog") .getByRole("button", { name: "Close" }) .click(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible(); + await expect(page.getByTestId("community-catalog-dialog")).not.toBeVisible(); await openPersonaCatalog(page); - await expect( - page.getByText("No shared agents", { exact: true }), - ).toBeVisible(); + await expect(page.getByTestId("community-catalog-empty-state")).toContainText( + "Nothing shared yet", + ); }); test("built-in persona edits persist", async ({ page }) => { @@ -451,7 +451,7 @@ test("the new agent card opens unified create, catalog, and import flows", async ); await newAgentCard.click(); - const catalogDialog = page.getByTestId("persona-catalog-dialog"); + const catalogDialog = page.getByTestId("community-catalog-dialog"); await expect(catalogDialog).toBeVisible(); await expect(page.getByTestId("agent-catalog-create")).toHaveAttribute( "aria-current", @@ -825,28 +825,28 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { await selectCatalogPersona(page, personaId); const useAgentTarget = page.getByTestId( - `persona-catalog-use-agent-target-${personaId}`, + `community-catalog-use-agent-${personaId}`, ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Researcher", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Added by You", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Research the question and cite the evidence.", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Custom agent", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Preferred model", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Preferred runtime", ); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Agent instruction", ); await expect(useAgentTarget).toHaveAttribute( @@ -1507,7 +1507,7 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByTestId("open-agents-view").click(); await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toHaveCount(0); await page.keyboard.press("Escape"); @@ -1559,11 +1559,11 @@ This deliberately long fenced-code example must not establish the minimum width await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toContainText("Catalog Analyst"); await selectCatalogPersona(page, personaId); - const catalogDialog = page.getByTestId("persona-catalog-dialog"); - const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane"); + const catalogDialog = page.getByTestId("community-catalog-dialog"); + const catalogDetailPane = page.getByTestId("community-catalog-detail-pane"); await expect(catalogDetailPane).toContainText("Design System And Styling"); await expect(catalogDialog).toBeVisible(); await expect(catalogDetailPane).toBeVisible(); @@ -1628,7 +1628,7 @@ This deliberately long fenced-code example must not establish the minimum width await openPersonaCatalog(page); await selectCatalogPersona(page, personaId); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Review the latest catalog changes.", ); await page.keyboard.press("Escape"); @@ -1645,7 +1645,7 @@ This deliberately long fenced-code example must not establish the minimum width await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toHaveCount(0); }); @@ -1682,7 +1682,7 @@ test("a queued catalog share is not presented as relay-published", async ({ await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${personaId}`), + page.getByTestId(`community-catalog-agent-${personaId}`), ).toHaveCount(0); }); @@ -1708,11 +1708,9 @@ test("a foreign reader does not receive an unshared kind 30175 persona", async ( await openPersonaCatalog(page); await expect( - page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + page.getByTestId(`community-catalog-agent-${remoteCatalogId}`), ).toHaveCount(0); - await expect( - page.getByText("No shared agents", { exact: true }), - ).toBeVisible(); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); }); test("catalog exposes exact instructions and rejects hidden Unicode controls", async ({ @@ -1768,16 +1766,16 @@ test("catalog exposes exact instructions and rejects hidden Unicode controls", a const bidiCatalogId = `catalog:${TEST_IDENTITIES.alice.pubkey}:${bidiPersonaId}`; await expect( - page.getByTestId(`persona-catalog-list-item-${visibleCatalogId}`), + page.getByTestId(`community-catalog-agent-${visibleCatalogId}`), ).toBeVisible(); await expect( - page.getByTestId(`persona-catalog-list-item-${emojiCatalogId}`), + page.getByTestId(`community-catalog-agent-${emojiCatalogId}`), ).toContainText("Emoji Reviewer 👩‍💻"); await expect( - page.getByTestId(`persona-catalog-list-item-${zeroWidthCatalogId}`), + page.getByTestId(`community-catalog-agent-${zeroWidthCatalogId}`), ).toHaveCount(0); await expect( - page.getByTestId(`persona-catalog-list-item-${bidiCatalogId}`), + page.getByTestId(`community-catalog-agent-${bidiCatalogId}`), ).toHaveCount(0); await selectCatalogPersona(page, visibleCatalogId); @@ -1820,12 +1818,12 @@ test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => { // An `` carrying the avatar — not the initials fallback — in both the // list row and the detail header is what proves the projection kept it. const remoteEntry = page.getByTestId( - `persona-catalog-list-item-${remoteCatalogId}`, + `community-catalog-agent-${remoteCatalogId}`, ); await expect(remoteEntry.locator("img")).toHaveAttribute("src", avatarUrl); await remoteEntry.click(); await expect( - page.getByTestId("persona-catalog-detail-pane").locator("img").first(), + page.getByTestId("community-catalog-detail-pane").locator("img").first(), ).toHaveAttribute("src", avatarUrl); }); @@ -1849,19 +1847,19 @@ test("a community member can discover and add another member's catalog agent", a await openPersonaCatalog(page); const remoteEntry = page.getByTestId( - `persona-catalog-list-item-${remoteCatalogId}`, + `community-catalog-agent-${remoteCatalogId}`, ); await expect(remoteEntry).toContainText("Alice’s Reviewer"); await remoteEntry.click(); // The detail pane resolves the publisher's display name; 'Community member' // is only the fallback for an unresolvable pubkey. - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Added by alice", ); await page .getByRole("button", { - name: "Add Alice’s Reviewer from Agent Catalog", + name: "Add Alice’s Reviewer from Community Catalog", }) .click(); await expect @@ -1895,10 +1893,10 @@ test("a community member can discover and add another member's catalog agent", a // The entry now projects onto the local copy, so its list-item testid is the // local persona id rather than the catalog coordinate. await expect( - page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`), + page.getByTestId(`community-catalog-agent-${remoteCatalogId}`), ).toHaveCount(0); await page - .locator('[data-testid^="persona-catalog-list-item-"]') + .locator('[data-testid^="community-catalog-agent-"]') .filter({ hasText: "Alice’s Reviewer" }) .click(); const addedTarget = page.getByRole("button", { @@ -1934,10 +1932,10 @@ test("catalog detail shows Community member when the publisher profile cannot be await page .getByTestId( - `persona-catalog-list-item-catalog:${unknownPubkey}:${personaId}`, + `community-catalog-agent-catalog:${unknownPubkey}:${personaId}`, ) .click(); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( "Added by Community member", ); }); diff --git a/desktop/tests/e2e/team-catalog-screenshots.spec.ts b/desktop/tests/e2e/team-catalog-screenshots.spec.ts new file mode 100644 index 00000000000..caaac40ed68 --- /dev/null +++ b/desktop/tests/e2e/team-catalog-screenshots.spec.ts @@ -0,0 +1,302 @@ +import { hexToBytes } from "@noble/hashes/utils.js"; +import { expect, test } from "@playwright/test"; +import { finalizeEvent } from "nostr-tools/pure"; + +import type { RelayEvent } from "@/shared/api/types"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +function ownerPrivateKeyFor(pubkey: string): Uint8Array { + const privateKey = Object.values(TEST_IDENTITIES).find( + (identity) => identity.pubkey === pubkey, + )?.privateKey; + if (!privateKey) { + throw new Error(`No test private key for ${pubkey}`); + } + return hexToBytes(privateKey); +} + +const SHOTS = "test-results/team-catalog"; + +type CatalogMember = { + memberKey: string; + displayName: string; + systemPrompt: string; + model?: string | null; + runtime?: string | null; + provider?: string | null; +}; + +/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. */ +function createTeamCatalogEvent(input: { + ownerPubkey: string; + teamDTag: string; + name: string; + description?: string | null; + instructions?: string | null; + members: CatalogMember[]; +}): RelayEvent { + return finalizeEvent( + { + created_at: 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.teamDTag], + ["shared", "true"], + ], + content: JSON.stringify({ + v: 1, + name: input.name, + description: input.description ?? null, + instructions: input.instructions ?? null, + members: input.members.map((member) => ({ + member_key: member.memberKey, + display_name: member.displayName, + system_prompt: member.systemPrompt, + avatar_url: null, + runtime: member.runtime ?? null, + model: member.model ?? null, + provider: member.provider ?? null, + })), + }), + }, + ownerPrivateKeyFor(input.ownerPubkey), + ); +} + +/** A kind:30175 head, shaped exactly as `persona_catalog_content` projects it. */ +function createPersonaCatalogEvent(input: { + ownerPubkey: string; + sourcePersonaId: string; + displayName: string; + systemPrompt: string; +}): RelayEvent { + return finalizeEvent( + { + created_at: 1_721_750_400, + kind: 30175, + tags: [ + ["d", input.sourcePersonaId], + ["shared", "true"], + ], + content: JSON.stringify({ + display_name: input.displayName, + system_prompt: input.systemPrompt, + avatar_url: null, + runtime: null, + model: null, + provider: null, + name_pool: [], + }), + }, + ownerPrivateKeyFor(input.ownerPubkey), + ); +} + +const PERSONA_CATALOG_EVENTS: RelayEvent[] = [ + createPersonaCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.bob.pubkey, + sourcePersonaId: "code-reviewer", + displayName: "Code Reviewer", + systemPrompt: "Review pull requests for correctness and edge cases.", + }), +]; + +const CATALOG_EVENTS: RelayEvent[] = [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: "release-review", + name: "Release Review", + description: + "Reads the diff, drafts the release note, and files the follow-ups.", + instructions: + "Coordinate as a unit. The reviewer and scribe share findings before the triager acts.", + members: [ + { + memberKey: "reviewer", + displayName: "Reviewer", + systemPrompt: "Review the diff for correctness and edge cases.", + model: "claude-sonnet-4-5", + runtime: "claude-code", + provider: "anthropic", + }, + { + memberKey: "scribe", + displayName: "Scribe", + systemPrompt: "Write the release note from the merged changes.", + }, + { + memberKey: "triager", + displayName: "Triager", + systemPrompt: "File follow-ups for anything the review deferred.", + model: "gpt-5-codex", + }, + ], + }), + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.bob.pubkey, + teamDTag: "incident-desk", + name: "Incident Desk", + description: "Two agents that hold the timeline during an incident.", + members: [ + { + memberKey: "commander", + displayName: "Commander", + systemPrompt: "Own the incident timeline and the comms cadence.", + }, + { + memberKey: "investigator", + displayName: "Investigator", + systemPrompt: "Chase the root cause and report findings.", + }, + ], + }), +]; + +async function gotoAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); +} + +async function openTeamCatalog(page: import("@playwright/test").Page) { + await page.getByTestId("new-team-card").click(); + await page.getByTestId("team-catalog-open").click(); + await expect(page.getByTestId("community-catalog-dialog")).toBeVisible(); + await waitForAnimations(page); +} + +test.describe("team catalog screenshots", () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test("01 — catalog browse, add, and added states", async ({ page }) => { + test.setTimeout(60_000); + await installMockBridge(page, { teamCatalogEvents: CATALOG_EVENTS }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + // 1. Browsing another member's publication: list, provenance, per-member + // model. Release Review is not the default selection, so click it. + const releaseReview = `community-catalog-team-${TEST_IDENTITIES.alice.pubkey}:release-review`; + await page.getByTestId(releaseReview).click(); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-browse.png`, + }); + + // 2. Expand the Reviewer member row to show metadata + instruction. + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-member-expanded.png`, + }); + + // 3. Team instructions section is visible (Release Review has instructions). + // Collapse the expanded member row first so the team-instructions state + // is visually distinct from the expanded-member screenshot above. + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-team-instructions.png`, + }); + + // 4. Adding closes the dialog and names the team in the notice. + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText("Added Release Review to your teams."), + ).toBeVisible(); + + // 5. Reopened: the action reads "Added to my teams" and is inert, so a + // second copy of the same publication is not offered. + await openTeamCatalog(page); + await page.getByTestId(releaseReview).click(); + await expect(page.getByTestId("community-catalog-add-team")).toHaveText( + "Added to my teams", + ); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-added.png`, + }); + }); + + test("02 — empty catalog", async ({ page }) => { + await installMockBridge(page, { teamCatalogEvents: [] }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-empty.png`, + }); + }); + + test("03 — share dialog before and after publishing", async ({ page }) => { + test.setTimeout(60_000); + await installMockBridge(page, { + personas: [ + { + id: "custom:release-analyst", + displayName: "Release Analyst", + systemPrompt: "Summarise the release.", + }, + { + id: "custom:release-scribe", + displayName: "Release Scribe", + systemPrompt: "Write the release note.", + }, + ], + teams: [ + { + id: "team-release-010", + name: "Release Crew", + description: "Ships the release notes.", + personaIds: ["custom:release-analyst", "custom:release-scribe"], + }, + ], + }); + await gotoAgentsView(page); + + await page.getByLabel("Release Crew team actions").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("team-share-dialog")).toBeVisible(); + await waitForAnimations(page); + + // 4. Catalog access sits alongside the existing snapshot-share controls, + // defaulting to unchecked (not shared). + await page.getByTestId("team-share-dialog").screenshot({ + path: `${SHOTS}/share-not-shared.png`, + }); + + // 5. Published: toggle the Switch to share, the toast names the effect. + await page.getByTestId("team-share-catalog-access").click(); + await expect(page.getByTestId("team-share-catalog-access")).toBeChecked(); + await expect( + page.getByText("Published Release Crew to the community catalog."), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/share-published.png` }); + }); + + test("04 — both sections populated (agents + teams)", async ({ page }) => { + await installMockBridge(page, { + personaCatalogEvents: PERSONA_CATALOG_EVENTS, + teamCatalogEvents: CATALOG_EVENTS, + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + // The Agents section header is visible alongside Teams in the sidebar. + await expect( + page.locator('[data-testid^="community-catalog-agent-"]'), + ).toHaveCount(1); + await expect( + page.locator('[data-testid^="community-catalog-team-"]'), + ).toHaveCount(2); + await waitForAnimations(page); + await page.getByTestId("community-catalog-dialog").screenshot({ + path: `${SHOTS}/catalog-both-sections.png`, + }); + }); +}); diff --git a/desktop/tests/e2e/team-catalog.spec.ts b/desktop/tests/e2e/team-catalog.spec.ts new file mode 100644 index 00000000000..f9ac7a94b81 --- /dev/null +++ b/desktop/tests/e2e/team-catalog.spec.ts @@ -0,0 +1,606 @@ +import { expect, test } from "@playwright/test"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import { finalizeEvent } from "nostr-tools/pure"; + +import type { RelayEvent } from "@/shared/api/types"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { seedActiveIdentity } from "../helpers/onboarding"; + +type CatalogMember = { + memberKey: string; + displayName: string; + systemPrompt: string; + model?: string | null; + runtime?: string | null; + provider?: string | null; +}; + +/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. + * + * The catalog read path runs through the shared signature gate (the #4220 + * hardening ported into `catalogRelay.ts`), so a discoverable head must carry + * a valid signature over its own contents. Sign with the owner's test key + * rather than stamping a placeholder `sig`, mirroring the persona fixtures in + * `agents.spec.ts`. The signed `id` is content-derived, so callers read it off + * the returned event instead of supplying one. */ +function createTeamCatalogEvent(input: { + ownerPubkey: string; + teamDTag: string; + name: string; + description?: string | null; + instructions?: string | null; + members: CatalogMember[]; + createdAt?: number; + shared?: boolean; +}): RelayEvent { + const ownerPrivateKey = Object.values(TEST_IDENTITIES).find( + (identity) => identity.pubkey === input.ownerPubkey, + )?.privateKey; + if (!ownerPrivateKey) { + throw new Error(`No test private key for ${input.ownerPubkey}`); + } + return finalizeEvent( + { + created_at: input.createdAt ?? 1_721_750_400, + kind: 30178, + tags: [ + ["d", input.teamDTag], + ...(input.shared === false ? [] : [["shared", "true"]]), + ], + content: JSON.stringify({ + v: 1, + name: input.name, + description: input.description ?? null, + instructions: input.instructions ?? null, + members: input.members.map((member) => ({ + member_key: member.memberKey, + display_name: member.displayName, + system_prompt: member.systemPrompt, + avatar_url: null, + runtime: member.runtime ?? null, + model: member.model ?? null, + provider: member.provider ?? null, + })), + }), + }, + hexToBytes(ownerPrivateKey), + ); +} + +async function gotoAgentsView(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("open-agents-view")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("open-agents-view").click(); +} + +async function openTeamCatalog(page: import("@playwright/test").Page) { + await page.getByTestId("new-team-card").click(); + await page.getByTestId("team-catalog-open").click(); + await expect(page.getByTestId("community-catalog-dialog")).toBeVisible(); +} + +async function openTeamShareDialog( + page: import("@playwright/test").Page, + teamName: string, +) { + await page.getByLabel(`${teamName} team actions`).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("team-share-dialog")).toBeVisible(); +} + +async function setTeamCatalogAccess( + page: import("@playwright/test").Page, + shared: boolean, +) { + const toggle = page.getByTestId("team-share-catalog-access"); + const isChecked = await toggle.isChecked(); + if (isChecked !== shared) { + await toggle.click(); + } +} + +async function listMockTeams(page: import("@playwright/test").Page) { + return page.evaluate(async () => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock invoke bridge is not installed."); + return (await invoke("list_teams")) as Array<{ + name: string; + persona_ids: string[]; + catalog_source: { owner_pubkey: string; team_d_tag: string } | null; + }>; + }); +} + +const ALICE_TEAM_D_TAG = "alice-review-crew"; +const ALICE_TEAM_MEMBERS: CatalogMember[] = [ + { + memberKey: "reviewer", + displayName: "Alice’s Reviewer", + systemPrompt: "Review changes for the whole community.", + }, + { + memberKey: "scribe", + displayName: "Alice’s Scribe", + systemPrompt: "Write the summary.", + }, +]; + +test("an unshared kind 30178 head from another member is not offered", async ({ + page, +}) => { + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice’s Private Crew", + members: ALICE_TEAM_MEMBERS, + shared: false, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); + await expect( + page.locator('[data-testid^="community-catalog-team-"]'), + ).toHaveCount(0); +}); + +test("adding another member's team records its catalog provenance", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice’s Review Crew", + description: "Two agents that review and summarise.", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + + const entry = page.getByTestId(`community-catalog-team-${entryKey}`); + await expect(entry).toContainText("Alice’s Review Crew"); + await entry.click(); + + const detail = page.getByTestId("community-catalog-detail-pane"); + await expect(detail).toContainText("Added by alice"); + await expect(detail).toContainText("2 members"); + await expect( + page.getByTestId("community-catalog-member-reviewer"), + ).toContainText("Alice’s Reviewer"); + await expect( + page.getByTestId("community-catalog-member-scribe"), + ).toContainText("Alice’s Scribe"); + + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText("Added Alice’s Review Crew to your teams."), + ).toBeVisible(); + + // The copy carries a fresh local id, so only the stored coordinate links it + // back to Alice's publication — that link is what stops a second copy. + const teams = await listMockTeams(page); + const added = teams.find((team) => team.name === "Alice’s Review Crew"); + expect(added).toMatchObject({ + catalog_source: { + owner_pubkey: TEST_IDENTITIES.alice.pubkey, + team_d_tag: ALICE_TEAM_D_TAG, + }, + }); + expect(added?.persona_ids).toHaveLength(2); + + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + const addButton = page.getByTestId("community-catalog-add-team"); + await expect(addButton).toHaveText("Added to my teams"); + await expect(addButton).toBeDisabled(); + expect( + (await listMockTeams(page)).filter( + (team) => team.name === "Alice’s Review Crew", + ), + ).toHaveLength(1); +}); + +test("a head that moved while the dialog was open is rejected", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice’s Review Crew", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + // Republish the coordinate without notifying subscribers: the dialog keeps + // rendering — and keeps holding — the superseded event id. + await page.evaluate( + ({ ownerPubkey, teamDTag }) => { + const replace = ( + window as Window & { + __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: ( + event: unknown, + ) => void; + } + ).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__; + if (!replace) throw new Error("Team catalog head seam is not installed."); + replace({ + id: "3".repeat(64), + pubkey: ownerPubkey, + created_at: 1_721_760_400, + kind: 30178, + tags: [ + ["d", teamDTag], + ["shared", "true"], + ], + content: JSON.stringify({ + v: 1, + name: "Alice’s Review Crew", + description: null, + instructions: null, + members: [], + }), + sig: "2".repeat(128), + }); + }, + { ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG }, + ); + + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText( + "This team was updated since you opened the catalog. Reopen it and try again.", + ), + ).toBeVisible(); + expect( + (await listMockTeams(page)).filter( + (team) => + team.catalog_source !== null && team.catalog_source !== undefined, + ), + ).toHaveLength(0); +}); + +test("at an equal timestamp the lower-id head is canonical and a superseding head is rejected as stale", async ({ + page, +}) => { + // Two signed events for the same coordinate at identical created_at. Both + // carry valid signatures (the read path verifies them), so the relay's + // tie-break decides: `created_at DESC, id ASC` makes the lower-id event + // canonical. Because the signed `id` is content-derived, we cannot pin it to + // a literal — we sign both, sort by id, and derive the expected canonical + // name from whichever id sorts first. + const SAME_TIMESTAMP = 1_721_760_000; + const crew = createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: ALICE_TEAM_MEMBERS, + createdAt: SAME_TIMESTAMP, + }); + const crewV2 = createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew v2", + members: [], + createdAt: SAME_TIMESTAMP, + }); + const [lower] = [crew, crewV2].sort((left, right) => + left.id.localeCompare(right.id), + ); + const canonicalName = JSON.parse(lower.content).name as string; + + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { teamCatalogEvents: [crew, crewV2] }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + // The lower-id event was selected as canonical, so its name is what renders. + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( + canonicalName, + ); + + // Republish the coordinate with a strictly newer head, without notifying + // subscribers: the dialog keeps holding the superseded id. The add must be + // rejected as stale. + await page.evaluate( + ({ ownerPubkey, teamDTag }) => { + const replace = ( + window as Window & { + __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: ( + event: unknown, + ) => void; + } + ).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__; + if (!replace) throw new Error("Team catalog head seam is not installed."); + replace({ + id: "9".repeat(64), + pubkey: ownerPubkey, + created_at: 1_721_760_400, + kind: 30178, + tags: [ + ["d", teamDTag], + ["shared", "true"], + ], + content: JSON.stringify({ + v: 1, + name: "Alice's Review Crew v2", + description: null, + instructions: null, + members: [], + }), + sig: "2".repeat(128), + }); + }, + { ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG }, + ); + + await page.getByTestId("community-catalog-add-team").click(); + await expect( + page.getByText( + "This team was updated since you opened the catalog. Reopen it and try again.", + ), + ).toBeVisible(); +}); + +test("sharing a team publishes it to the catalog and unsharing retracts it", async ({ + page, +}) => { + // The own head is published through the same signature-verified read path as + // foreign heads, so the viewer must hold a real key to sign it — seed one. + await seedActiveIdentity(page, TEST_IDENTITIES.tyler); + await installMockBridge(page, { + personas: [ + { + id: "custom:release-analyst", + displayName: "Release Analyst", + systemPrompt: "Summarise the release.", + }, + ], + teams: [ + { + id: "team-release-010", + name: "Release Crew", + description: "Ships the release notes.", + personaIds: ["custom:release-analyst"], + }, + ], + }); + await gotoAgentsView(page); + + await openTeamCatalog(page); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); + await page.keyboard.press("Escape"); + + await openTeamShareDialog(page, "Release Crew"); + const catalogAccess = page.getByTestId("team-share-catalog-access"); + await expect(catalogAccess).not.toBeChecked(); + await setTeamCatalogAccess(page, true); + await expect(catalogAccess).toBeChecked(); + await expect( + page.getByText("Published Release Crew to the community catalog."), + ).toBeVisible(); + await page + .getByTestId("team-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openTeamCatalog(page); + const ownEntry = page.locator('[data-testid^="community-catalog-team-"]'); + await expect(ownEntry).toHaveCount(1); + await ownEntry.click(); + await expect(page.getByTestId("community-catalog-detail-pane")).toContainText( + "Added by You", + ); + // The publisher already has the team, so the catalog must not offer a copy. + await expect(page.getByTestId("community-catalog-add-team")).toBeDisabled(); + await page.keyboard.press("Escape"); + + await openTeamShareDialog(page, "Release Crew"); + await setTeamCatalogAccess(page, false); + await expect( + page.getByText( + "Release Crew is no longer discoverable in the community catalog.", + ), + ).toBeVisible(); + await page + .getByTestId("team-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openTeamCatalog(page); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); +}); + +test("a queued team share is not presented as relay-published", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:queued-analyst", + displayName: "Queued Analyst", + systemPrompt: "Wait for relay acceptance.", + }, + ], + teams: [ + { + id: "team-queued-011", + name: "Queued Crew", + description: null, + personaIds: ["custom:queued-analyst"], + }, + ], + teamSharePublicationStatuses: ["queued"], + }); + await gotoAgentsView(page); + + await openTeamShareDialog(page, "Queued Crew"); + await setTeamCatalogAccess(page, true); + await expect( + page.getByText( + "Sharing Queued Crew is queued. It will appear after the relay accepts the update.", + ), + ).toBeVisible(); + await page + .getByTestId("team-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openTeamCatalog(page); + await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible(); +}); + +test("expanding a member row reveals its metadata and instruction", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: [ + { + memberKey: "reviewer", + displayName: "Alice's Reviewer", + systemPrompt: "Review changes for the whole community.", + model: "claude-sonnet-4-5", + runtime: "claude-code", + provider: "anthropic", + }, + ], + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + const memberRow = page.getByTestId("community-catalog-member-reviewer"); + await expect(memberRow).toBeVisible(); + + // Metadata card and instruction are hidden before expansion. + await expect(memberRow.getByTestId("agent-definition-metadata")).toBeHidden(); + + // Expand the row. + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + + // aria-expanded transitions to true. + await expect( + page.getByTestId("community-catalog-member-expand-reviewer"), + ).toHaveAttribute("aria-expanded", "true"); + + // Metadata card is now visible and contains model/runtime/provider. + const metadata = memberRow.getByTestId("agent-definition-metadata"); + await expect(metadata).toBeVisible(); + await expect(metadata).toContainText("claude-sonnet-4-5"); + await expect(metadata).toContainText("claude-code"); + await expect(metadata).toContainText("anthropic"); + + // Instruction text is visible. + await expect(memberRow).toContainText( + "Review changes for the whole community.", + ); +}); + +test("expanding a member with no system prompt shows the no-instructions placeholder", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: [ + { + memberKey: "reviewer", + displayName: "Alice's Reviewer", + systemPrompt: "", + }, + ], + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + await page.getByTestId("community-catalog-member-expand-reviewer").click(); + await expect( + page.getByTestId("community-catalog-member-reviewer"), + ).toContainText("No instructions"); +}); + +test("team instructions section is visible when the team has instructions set", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + instructions: "Always check for security issues first.", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + const detail = page.getByTestId("community-catalog-detail-pane"); + await expect(detail).toContainText("Team instructions"); + await expect(detail).toContainText("Always check for security issues first."); +}); + +test("team instructions section is absent when instructions are not set", async ({ + page, +}) => { + const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`; + await installMockBridge(page, { + teamCatalogEvents: [ + createTeamCatalogEvent({ + ownerPubkey: TEST_IDENTITIES.alice.pubkey, + teamDTag: ALICE_TEAM_D_TAG, + name: "Alice's Review Crew", + members: ALICE_TEAM_MEMBERS, + }), + ], + }); + await gotoAgentsView(page); + await openTeamCatalog(page); + await page.getByTestId(`community-catalog-team-${entryKey}`).click(); + + const detail = page.getByTestId("community-catalog-detail-pane"); + await expect(detail).not.toContainText("Team instructions"); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 284214a5493..6a6680fdba3 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -250,6 +250,10 @@ type MockBridgeOptions = { /** Outcomes for successive explicit persona share publications. */ personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; + /** Community team-catalog (kind:30178) heads returned by relay queries. */ + teamCatalogEvents?: RelayEvent[]; + /** Outcomes for successive explicit team share publications. */ + teamSharePublicationStatuses?: Array<"published" | "queued">; relayAgents?: MockRelayAgentSeed[]; /** Reject successive relay-agent directory reads, then resume. */ relayAgentListErrors?: (string | null)[]; From a3730784fc851bb1125b40cca9b0a30788a293c1 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Fri, 28 Aug 2026 15:24:32 -0400 Subject: [PATCH 093/101] refactor(db): extract domain stores from database runtime (#6987) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Finish the remaining database-store extraction tracked by [TheSentinel454/buzz#2](https://github.com/TheSentinel454/buzz/issues/2) in one reviewable PR. This consolidates the previously stacked domain slices after #6782 merged. It preserves the runtime/store boundary established by #6660, #6668, #6700, and #6782 while separating database runtime infrastructure from domain-owned persistence: - `runtime/` owns pool construction and sizing, writer/reader routing, read sessions and route proofs, transaction infrastructure, observability primitives, replica fencing, health support, migrations, and cross-cutting runtime tests. - `store/` owns domain records, SQL, row parsing, locks and invariants, `Db` domain methods, focused tests, and logical-operation datastore spans. - `lib.rs` remains a 57-line compatibility facade that preserves existing crate-root paths and `Db` method signatures through re-exports. Domain coverage includes API tokens, authentication allowlists, reminders, event queries, threads, reactions, feeds, users and DMs, push, workflows/runs/approvals, relay membership and invites, product feedback, moderation/admin moderation, relay admin actions/operators, git repositories, archived identities, usage, partition maintenance, deletion, channel membership inherited from merged #6782, and the final runtime/store layout. The branch has been rebased onto current `main`. Database changes that landed there were incorporated rather than overwritten: `relay_admin_actions.rs` and `relay_operators.rs` now live under `store/`, their 27 public `Db` wrappers and existing behavior remain intact, and every wrapper has exactly one fixed-name datastore span. Concurrent changes to migration, moderation, admin moderation, and error handling are also retained. ### Exact base and head - Base: `main` at `ed11c8d8bf0a17402be5cf243724f89471530d2f` - Head: `codex/issue-2-store-extraction` at `be24430472d1a87ac5c0d6026c620cd6caea3537` ### Related issue - Structural tracker: [TheSentinel454/buzz#2](https://github.com/TheSentinel454/buzz/issues/2) - Domain trackers: [#6](https://github.com/TheSentinel454/buzz/issues/6), [#7](https://github.com/TheSentinel454/buzz/issues/7), [#12](https://github.com/TheSentinel454/buzz/issues/12), [#13](https://github.com/TheSentinel454/buzz/issues/13) - Acceptance trackers: [#17](https://github.com/TheSentinel454/buzz/issues/17), [#19](https://github.com/TheSentinel454/buzz/issues/19) This supersedes #6783, #6784, #6787, #6788, #6789, #6792, #6820, #6794, #6796, #6797, #6798, #6799, #6804, #6805, #6806, #6808, #6809, #6811, #6812, #6813, #6814, #6815, and #6890. Their discussions remain available for review history. ### #17 / #19 acceptance - Preserves the metric names, fixed labels, transaction/lock timing boundaries, and privacy/cardinality constraints introduced by #6700. - Keeps exactly one datastore span per public logical operation, including the 27 relay-admin wrappers added on `main`. - Removes `store_ownership.rs`; physical ownership and focused source guards now enforce the boundary directly. - Leaves no `impl Db`, domain SQL, focused domain test group, or datastore span in `lib.rs`. - Preserves existing public paths such as `buzz_db::channel`, `buzz_db::event`, and `buzz_db::workflow` through crate-root re-exports while keeping internal `runtime` and `store` namespaces private. ### Non-goals - No SQL, schema, locking, transaction, retry, timeout, or client-visible behavior changes. - No generic store traits, domain handles, broad `PgExecutor` migration, new store crate, raw pool accessor, or broader directory reorganization. - No tracker issues are closed by this PR. ### Risk The cumulative diff is large but structural. Risk is primarily module-path, ownership, or conflict-resolution drift. It is mitigated by preserving public re-exports, comparing the newly moved `main` implementations to their upstream source, source guards, touched-crate compilation, PostgreSQL-backed test coverage, and an independent exact-head review on a separate clean Blox workstation. ### Testing Author workstation `buzz-tornquist-pr-6987-rebase`, rebased branch ending at exact head `be24430472d1a87ac5c0d6026c620cd6caea3537`: - `cargo fmt --all --check` - `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` - `cargo test -p buzz-db --lib` — 113 passed, 240 PostgreSQL tests intentionally ignored - `cargo test -p buzz-db --test observability_source` — 2 passed - PostgreSQL-backed `buzz-db` coverage under native PostgreSQL — 235 passed in the shared serial run; the five shared-state/config-sensitive cases passed as isolated reruns against fresh schemas, including the two owner-limit tests with their fixture's `BUZZ_MAX_COMMUNITIES_PER_OWNER=3` - `cargo test -p buzz-relay --lib -- --test-threads=1` under native PostgreSQL/Redis — 991 passed; the three current-month partition-sensitive identity-archive cases passed after provisioning the August 2026 test partition; 87 infrastructure-marked tests remained ignored - Source/diff guards — relay-admin implementation bodies match current `main`; all 27 public wrapper signatures are retained; exactly one datastore span wraps each wrapper; `lib.rs` has zero `impl Db` blocks and zero datastore spans; no duplicate top-level relay-admin modules or `store_ownership.rs`; `error.rs` matches current `main` Independent clean review workstation `buzz-tornquist-pr-6987-review`, detached at exact head `be24430472d1a87ac5c0d6026c620cd6caea3537`: - `cargo fmt --all --check` - `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` - `cargo test -p buzz-db --lib` — 113 passed, 240 ignored - `cargo test -p buzz-db --test observability_source` — 2 passed - Exact-head ownership/re-export/instrumentation audit — no remaining actionable findings --------- Signed-off-by: OpenAI Codex Signed-off-by: tornquist Co-authored-by: OpenAI Codex --- crates/buzz-db/src/lib.rs | 7540 +---------------- crates/buzz-db/src/reaction.rs | 418 - crates/buzz-db/src/{ => runtime}/migration.rs | 10 +- crates/buzz-db/src/runtime/mod.rs | 1044 +++ .../src/{ => runtime}/observability.rs | 0 .../src/{ => runtime}/replica_fence.rs | 14 +- crates/buzz-db/src/runtime/tests.rs | 2542 ++++++ .../src/{ => store}/admin_moderation.rs | 52 +- crates/buzz-db/src/store/allowlist.rs | 209 + crates/buzz-db/src/{ => store}/api_token.rs | 283 +- .../src/{ => store}/archived_identities.rs | 53 +- crates/buzz-db/src/{ => store}/channel.rs | 0 .../src/{ => store}/channel_members.rs | 2 +- crates/buzz-db/src/{ => store}/community.rs | 2 +- crates/buzz-db/src/{ => store}/deletion.rs | 18 + crates/buzz-db/src/{ => store}/dm.rs | 85 + crates/buzz-db/src/{ => store}/event.rs | 1183 +-- crates/buzz-db/src/{ => store}/feed.rs | 245 +- crates/buzz-db/src/{ => store}/git_repo.rs | 55 +- crates/buzz-db/src/store/mod.rs | 56 + crates/buzz-db/src/{ => store}/moderation.rs | 166 +- crates/buzz-db/src/{ => store}/partition.rs | 10 + .../src/{ => store}/product_feedback.rs | 21 +- crates/buzz-db/src/{ => store}/push.rs | 173 + crates/buzz-db/src/store/reaction.rs | 1149 +++ .../src/{ => store}/relay_admin_actions.rs | 390 +- .../buzz-db/src/{ => store}/relay_invite.rs | 50 +- .../buzz-db/src/{ => store}/relay_members.rs | 362 +- .../src/{ => store}/relay_operators.rs | 47 + crates/buzz-db/src/store/reminder.rs | 509 ++ crates/buzz-db/src/{ => store}/replaceable.rs | 0 crates/buzz-db/src/{ => store}/thread.rs | 299 +- crates/buzz-db/src/{ => store}/usage.rs | 206 +- crates/buzz-db/src/{ => store}/user.rs | 115 +- crates/buzz-db/src/{ => store}/workflow.rs | 419 +- crates/buzz-db/tests/observability_source.rs | 43 +- .../shared/api/relayReconnectReplay.test.mjs | 2 +- 37 files changed, 9060 insertions(+), 8712 deletions(-) delete mode 100644 crates/buzz-db/src/reaction.rs rename crates/buzz-db/src/{ => runtime}/migration.rs (99%) create mode 100644 crates/buzz-db/src/runtime/mod.rs rename crates/buzz-db/src/{ => runtime}/observability.rs (100%) rename crates/buzz-db/src/{ => runtime}/replica_fence.rs (99%) create mode 100644 crates/buzz-db/src/runtime/tests.rs rename crates/buzz-db/src/{ => store}/admin_moderation.rs (94%) create mode 100644 crates/buzz-db/src/store/allowlist.rs rename crates/buzz-db/src/{ => store}/api_token.rs (66%) rename crates/buzz-db/src/{ => store}/archived_identities.rs (80%) rename crates/buzz-db/src/{ => store}/channel.rs (100%) rename crates/buzz-db/src/{ => store}/channel_members.rs (99%) rename crates/buzz-db/src/{ => store}/community.rs (99%) rename crates/buzz-db/src/{ => store}/deletion.rs (99%) rename crates/buzz-db/src/{ => store}/dm.rs (85%) rename crates/buzz-db/src/{ => store}/event.rs (73%) rename crates/buzz-db/src/{ => store}/feed.rs (79%) rename crates/buzz-db/src/{ => store}/git_repo.rs (87%) create mode 100644 crates/buzz-db/src/store/mod.rs rename crates/buzz-db/src/{ => store}/moderation.rs (85%) rename crates/buzz-db/src/{ => store}/partition.rs (94%) rename crates/buzz-db/src/{ => store}/product_feedback.rs (89%) rename crates/buzz-db/src/{ => store}/push.rs (93%) create mode 100644 crates/buzz-db/src/store/reaction.rs rename crates/buzz-db/src/{ => store}/relay_admin_actions.rs (89%) rename crates/buzz-db/src/{ => store}/relay_invite.rs (94%) rename crates/buzz-db/src/{ => store}/relay_members.rs (71%) rename crates/buzz-db/src/{ => store}/relay_operators.rs (93%) create mode 100644 crates/buzz-db/src/store/reminder.rs rename crates/buzz-db/src/{ => store}/replaceable.rs (100%) rename crates/buzz-db/src/{ => store}/thread.rs (84%) rename crates/buzz-db/src/{ => store}/usage.rs (74%) rename crates/buzz-db/src/{ => store}/user.rs (84%) rename crates/buzz-db/src/{ => store}/workflow.rs (85%) diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 38678c923fb..ea81bc354b8 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -10,7528 +10,48 @@ //! - Uses `sqlx::query()` (runtime) not `sqlx::query!()` (compile-time). //! //! ## Runtime and store ownership -//! This crate intentionally keeps database runtime and Buzz domain persistence -//! together while maintaining an internal boundary between them: +//! Database runtime infrastructure and domain persistence are physically +//! separated behind this crate-root compatibility facade: //! -//! - Runtime concerns own pool construction, writer/replica routing, transaction -//! creation, session invariants, metrics, and health support. +//! - Runtime concerns own pool construction, writer/replica routing, +//! transactions, sessions, metrics, health support, and migrations. //! - Store concerns own domain-specific SQL, row mapping, locking, mutation //! rules, indexes, and focused persistence tests. //! -//! Transaction-required store operations accept [`sqlx::Transaction`] so their -//! composition requirement is visible in the type. Private connection helpers -//! are reserved for SQL primitives that are valid on any same-session -//! connection. New domains should prove this boundary incrementally instead of -//! exposing raw pools or introducing broad store traits. +//! Existing crate-root modules, records, and [`Db`] methods remain the public +//! API. The internal `runtime` and `store` namespaces are not public APIs. + +mod runtime; +mod store; -/// Explicit deployment-global admin report reads. -pub mod admin_moderation; -/// API token storage and lookup. -pub mod api_token; -/// Relay-scoped archived identity persistence (NIP-IA). -pub mod archived_identities; -/// Channel lifecycle and metadata persistence. -pub mod channel; -/// Channel membership and roster persistence. -pub mod channel_members; -/// Community lifecycle and host-map persistence. -pub mod community; -/// Durable whole-community deletion lifecycle and PostgreSQL adapter. -pub mod deletion; -/// Direct message channel persistence. -pub mod dm; /// Database error types. pub mod error; -/// Event storage and retrieval. -pub mod event; -/// Home feed queries. -pub mod feed; -/// Git repository name registry (NIP-34 kind:30617). -pub mod git_repo; -/// Embedded database migrations. -pub mod migration; -/// Community moderation: reports, bans/timeouts, audit actions. -pub mod moderation; -mod observability; -/// Monthly table partition management. -pub mod partition; -/// Buzz product-feedback sidecar persistence. -pub mod product_feedback; -/// Community-scoped push lease and durable wake-outbox persistence. -pub mod push; -/// Reaction persistence. -pub mod reaction; -pub mod relay_admin_actions; -/// Use-limited relay invite persistence (v2 opaque tokens). -pub mod relay_invite; -/// Relay-level membership persistence (NIP-43). -pub mod relay_members; -/// Deployment-global operator/moderator roster persistence. -pub mod relay_operators; -/// Replaceable-event persistence and coordinate locking. -pub mod replaceable; -/// Replica freshness fence for keyset-cursor read routing. -pub mod replica_fence; -/// Thread metadata persistence. -pub mod thread; -/// Per-community usage rollup queries for Prometheus gauges. -pub mod usage; -/// User profile persistence. -pub mod user; -/// Workflow, run, and approval persistence. -pub mod workflow; +pub use runtime::{ + insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, ReadSession, +}; +pub(crate) use runtime::{ + insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, + RoutePredicate, +}; +pub use store::{ + admin_moderation, allowlist, api_token, archived_identities, channel, channel_members, + community, deletion, dm, event, feed, git_repo, moderation, partition, product_feedback, push, + reaction, relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, + replaceable, thread, usage, user, workflow, +}; + +pub use allowlist::AllowlistEntry; +pub use api_token::{ApiTokenRecord, TokenSummary}; pub use community::{ ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, UnarchivedCommunityRecord, }; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; - -use buzz_datastore_tracing::datastore_span; -use chrono::{DateTime, Utc}; -use sqlx::postgres::{PgConnection, PgPoolOptions}; -use sqlx::{Connection, PgPool, QueryBuilder, Row}; -use std::time::Duration; -use uuid::Uuid; - -use buzz_core::{CommunityId, StoredEvent}; - -/// Extract p-tag mentions from an event and insert into the `event_mentions` table. -/// -/// This pool-owning wrapper propagates failures to its caller. Replacement writes -/// use the transaction-bound helper below so event storage and mention indexing -/// commit or roll back together. Duplicate inserts are silently skipped with -/// `INSERT ... ON CONFLICT DO NOTHING`. -pub async fn insert_mentions( - pool: &PgPool, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, -) -> Result<()> { - let mut tx = pool.begin().await?; - insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; - tx.commit().await?; - Ok(()) -} - -/// Insert mention rows on the caller's transaction. Replacement writes use -/// this so the authoritative event and its discovery index commit or roll back -/// as one unit. -async fn insert_mentions_in_transaction( - tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, -) -> Result<()> { - let p_tags: Vec<&str> = event - .tags - .iter() - .filter_map(|tag| { - let tag_vec = tag.as_slice(); - if tag_vec.len() >= 2 && tag_vec[0] == "p" { - Some(tag_vec[1].as_str()) - } else { - None - } - }) - .collect(); - - if p_tags.is_empty() { - return Ok(()); - } - - let event_id_bytes = event.id.as_bytes(); - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = DateTime::from_timestamp(created_at_secs, 0) - .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; - let kind = event.kind.as_u16() as u32; - - // Validate and normalize pubkeys, logging any malformed ones. - let valid_pubkeys: Vec = p_tags - .into_iter() - .filter(|pk| { - if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { - tracing::debug!( - event_id = %event.id, - invalid_ptag = pk, - "skipping malformed p-tag in insert_mentions" - ); - false - } else { - true - } - }) - .map(|pk| pk.to_ascii_lowercase()) - .collect(); - - if valid_pubkeys.is_empty() { - return Ok(()); - } - - // Multi-row INSERT ... ON CONFLICT DO NOTHING, chunked to stay under - // Postgres's 65,535 bind-parameter statement cap (6 binds per row caps a - // single statement at ~10.9k rows). Relay-signed kind 39002 rosters carry - // one p-tag per channel member and can exceed that. The caller owns the - // transaction so all chunks share its commit boundary. - const MENTION_INSERT_CHUNK_ROWS: usize = 5_000; - for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) { - let mut qb: QueryBuilder = QueryBuilder::new( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", - ); - - qb.push_values(chunk, |mut b, pubkey| { - b.push_bind(community_id.as_uuid()) - .push_bind(pubkey.as_str()) - .push_bind(event_id_bytes.as_slice()) - .push_bind(created_at) - .push_bind(channel_id) - .push_bind(kind as i32); - }); - - qb.push(" ON CONFLICT DO NOTHING"); - - qb.build().execute(&mut **tx).await?; - } - Ok(()) -} - -/// Database handle. Clone is cheap (Arc-backed pool). -#[derive(Clone, Debug)] -pub struct Db { - pub(crate) pool: PgPool, - /// Maximum connections configured for this pool (from [`DbConfig::max_connections`]). - pub(crate) max_connections: u32, - /// Optional read-replica pool (from [`DbConfig::read_database_url`]). - /// - /// `None` means no replica is configured and every read routes to the - /// writer pool — the pre-replica behavior. Only lag-tolerant reads may - /// route here (see [`Db::read`]); locks, transactions, and anything - /// consistency-critical stays on `pool`. - pub(crate) read_pool: Option, - /// Maximum connections configured for the read-replica pool (from - /// [`DbConfig::read_max_connections`], defaulting to the writer's - /// sizing). Kept separately from `max_connections` so - /// [`Db::read_pool_stats`] reports the reader's own ceiling — a - /// utilisation gauge derived from the writer's max would understate - /// reader saturation by exactly the ratio of the two pool sizes. - pub(crate) read_max_connections: u32, - /// Freshness fence gating cursor-page routing to the replica. - /// - /// Starts closed; a background probe ([`replica_fence::run_probe`]) - /// commits heartbeat tokens and retains proof entries. Routing proves - /// coverage per request on the serving reader session; when the ring is - /// empty or stale, every routed read stays on the writer. - pub(crate) fence: std::sync::Arc, - /// Bounded-staleness routing budget `B`: a read routed under - /// [`RoutePredicate::Bounded`] may be served from a proved replica - /// session only when the proved heartbeat entry is at most this old. - /// `None` disables the bounded arm entirely (the rollout default) — - /// bounded-stale read semantics are a product decision, not an - /// invariant, so the gate ships off. - pub(crate) replica_read_max_age: Option, - /// Whether the reader endpoint supports the Aurora PostgreSQL identity - /// function ([`replica_fence::AURORA_IDENTITY_FN`]) — probed - /// once per process on the first routed read (on a plain autocommit - /// checkout, outside any request transaction) and cached. Unset means - /// not yet probed (or the probe hit a transient error and will retry). - /// Shared across `Db` clones. - pub(crate) reader_aurora_identity: std::sync::Arc>, -} - -/// The session that served (or will serve) a routed read, so follow-up -/// queries in the same request (the channel-window aux closure) run on the -/// **same proved snapshot** — a different pooled reader session may sit at a -/// different replay position, and even the same connection advances its -/// snapshot between autocommit statements. -/// -/// `Replica` holds the request's `REPEATABLE READ, READ ONLY` transaction: -/// the heartbeat observation was its first statement, so the snapshot the -/// proof was taken against is exactly the snapshot every follow-up sees. -/// Dropping the session rolls the read-only transaction back and returns -/// the connection to the pool. -/// -/// `Writer` carries the writer pool: follow-ups there are authoritative by -/// construction and need no session pinning. -pub struct ReadSession { - inner: ReadSessionInner, -} - -enum ReadSessionInner { - /// The proved replica request transaction (snapshot-anchored), plus the - /// writer pool so a mid-request replica failure (e.g. a hot-standby - /// recovery conflict cancelling the held snapshot) degrades the session - /// to the writer instead of surfacing an error: degraded capacity, - /// never holes — and never a 500 the writer could have served. - Replica { - tx: sqlx::Transaction<'static, sqlx::Postgres>, - writer: PgPool, - }, - /// The writer pool (cheap clone; Arc-backed). - Writer(PgPool), -} - -impl ReadSession { - /// Query events on this session (see [`Db::query_events`]). - /// - /// If the proved replica transaction fails mid-request, the session - /// permanently degrades to the writer and the query is re-run there. - /// The writer is always at or ahead of any replica replay position, so - /// the degraded follow-up can only observe *more* than the proof-time - /// snapshot, never less — fresher aux rows, the same failure semantics - /// as a request that routed to the writer to begin with. - #[datastore_span(name = "read_session_query_events", system = "postgresql")] - pub async fn query_events(&mut self, q: &EventQuery) -> Result> { - let degraded = match &mut self.inner { - ReadSessionInner::Replica { tx, writer } => { - match event::query_events_on(tx, q).await { - Ok(rows) => return Ok(rows), - Err(e) => { - tracing::warn!( - error = %e, - "replica session query failed mid-request; degrading to writer" - ); - // Deliberately not a `buzz_db_route_decision` event: - // the page's route was already recorded, and the - // offload metric must stay one-event-per-request. - metrics::counter!("buzz_db_read_session_degraded").increment(1); - writer.clone() - } - } - } - ReadSessionInner::Writer(pool) => return event::query_events(pool, q).await, - }; - // Replacing the inner drops the replica transaction (rolling it - // back and returning the reader connection to its pool). - self.inner = ReadSessionInner::Writer(degraded.clone()); - event::query_events(°raded, q).await - } - - /// Whether this session is a proved replica connection (observability). - pub fn is_replica(&self) -> bool { - matches!(self.inner, ReadSessionInner::Replica { .. }) - } -} - -/// Where one routed read is served (see [`Db::route_read`]). -enum RouteDecision { - /// A reader request transaction whose first-statement heartbeat - /// observation proved this fence entry — the page runs inside it. The - /// `&'static str` is the metric reason (`covered`/`fresh`); the caller - /// records the route only once the page is actually served from the - /// replica, so a post-verification writer re-run or a mid-query replica - /// failure emits exactly one `buzz_db_route_decision` event per request - /// (the offload percentage is read straight off `decision="replica"`). - Replica( - sqlx::Transaction<'static, sqlx::Postgres>, - replica_fence::TokenEntry, - &'static str, - ), - /// Fail closed: serve from the writer pool (already recorded). - Writer, -} - -/// The ONLY place [`route_proof::ChannelScoped`] can be constructed. A -/// crate-root tuple struct would be mintable via `ChannelScoped(())` from -/// every descendant module — tuple-struct field privacy is module-scoped — -/// so the token lives in its own module and E0423 enforces the invariant. -mod route_proof { - use uuid::Uuid; - - /// Proof that a query/page can only return rows with - /// `channel_id IS NOT NULL` — the domain of the commit-time floor guard - /// (migration 0021). `channel_ids` (retains channel-NULL rows) and - /// `global_only = false` are explicitly NOT proofs. - /// - /// Each constructor keys off *how* its path proves channel-bearing-ness: - /// a pinned query filter, a bare `Uuid` argument, or a `NOT NULL` column - /// reached through an inner join. Do not add a universal constructor - /// callers reshape their inputs to fit, and never fabricate a throwaway - /// `EventQuery` purely to mint a token — the proof must be the SQL's - /// shape, not "someone assembled a struct". - #[derive(Clone, Copy)] - pub(crate) struct ChannelScoped(()); - - impl ChannelScoped { - /// Constructor 1: the query pins a single channel - /// (`EventQuery.channel_id = Some(_)`, compiled to a - /// `channel_id = $n` predicate). This proof covers BOTH query - /// builders — the SELECT builder (`event::query_events_on`) and the - /// COUNT builder (`event::count_events`) pin identically; if the - /// two ever drift, this comment is a lie and the routed COUNT seam - /// is unsound. - /// Sound under conjunction: any additional clause (e.g. - /// `channel_ids`, which alone retains channel-NULL rows) is ANDed, - /// and `channel_id = ` never matches NULL — the pin strictly - /// narrows and cannot be widened back out to global rows. - pub(crate) fn from_pinned_channel(q: &crate::event::EventQuery) -> Option { - q.channel_id.map(|_| ChannelScoped(())) - } - - /// Constructor 2 (thread pages): the page is an inner JOIN from - /// `thread_metadata` to `events`, and `thread_metadata.channel_id` - /// is `UUID NOT NULL` — every writer that creates a row passes a - /// concrete channel (`ThreadMetadataParams.channel_id: Uuid`, - /// non-Option). Channel-bearing by construction of the join, not by - /// query predicate. - pub(crate) fn from_thread_metadata_join() -> Self { - ChannelScoped(()) - } - - /// Constructor 3 (channel windows): the channel arrives as a bare - /// `Uuid` argument and the SQL binds it unconditionally - /// (`e.channel_id = $2` in `get_channel_window_on`); every served - /// row is channel-bearing. No `EventQuery` exists on this path. - pub(crate) fn from_channel_id(_channel_id: Uuid) -> Self { - ChannelScoped(()) - } - } -} -use route_proof::ChannelScoped; - -/// The predicate one routed read must satisfy (see [`Db::route_read`]). -/// -/// Discipline: no `Default`, no `Deserialize`, stays non-`pub` — any of -/// those re-opens the [`ChannelScoped`] mint. -enum RoutePredicate { - /// Bounded staleness: the proved entry must be within the configured - /// read budget `B` (default off). Bounds TIME — the page misses at most - /// the freshest `B` of writes. Sound for ANY query shape, including - /// global (channel-NULL) rows: it relies only on heartbeat commit order, - /// not the floor guard. - Bounded, - /// Completeness: the proved wall must cover the page's upper bound. - /// Bounds CONTENT — every row at/below `upper` is present, meaningful - /// even when the cursor is hours old, where `B`-freshness says nothing. - /// Sound ONLY on the floor guard's domain (channel-bearing rows), hence - /// the proof token. `upper` is non-optional: the no-upper-bound - /// post-verifying case is [`RoutePredicate::CoveredPostVerified`]. - /// - /// Bounds INSERT-completeness only — "no missing rows", not "no extra - /// rows". Soft deletes are `UPDATE .. SET deleted_at` commits outside - /// the floor guard and never touch `created_at`, so a covered page can - /// briefly serve a row the writer already excludes; deletion visibility - /// is bounded by replication lag under `FENCE_STALENESS` (30s), not by - /// `upper` or `B`. Do not extend the covered arm to a surface that - /// cannot absorb extra rows (this is why the routed COUNT seam is - /// bounded-only). - Covered { - upper: DateTime, - /// Never read — the field exists so constructing this variant - /// requires minting the token through `route_proof`. - #[allow(dead_code)] - proof: ChannelScoped, - }, - /// Forward-walking thread pages: no upper bound is derivable from the - /// cursor; the caller post-verifies the served rows against the proved - /// wall (full page + tail at/below the wall, else re-run on the writer). - /// Only the thread path constructs this — a general routed caller does - /// no post-verification and must never self-certify. - CoveredPostVerified { - #[allow(dead_code)] - proof: ChannelScoped, - }, - /// Either arm admits, covered tried first (it has no budget dependence). - /// For general routed reads that are channel-pinned AND carry an - /// `until` upper bound. - BoundedOrCovered { - upper: DateTime, - /// Never read — see [`RoutePredicate::Covered::proof`]. - #[allow(dead_code)] - proof: ChannelScoped, - }, -} - -impl RoutePredicate { - /// A channel-window request: cursor pages are covered-only — for deep - /// keyset pages only coverage answers "have all rows below the cursor - /// replayed?" — and a head fetch is bounded. The channel id is the - /// bare-`Uuid` proof that the window SQL pins a channel. - fn from_channel_cursor(channel_id: Uuid, cursor: &Option<(DateTime, Vec)>) -> Self { - match cursor { - Some((ts, _)) => RoutePredicate::Covered { - upper: *ts, - proof: ChannelScoped::from_channel_id(channel_id), - }, - None => RoutePredicate::Bounded, - } - } - - /// General entry point for the routed query seams: derives the strongest - /// sound predicate from the query shape. Never produces a covered arm - /// without both a channel-scope proof AND a real upper bound. - /// - /// `routing_enabled` is whether `BUZZ_REPLICA_READ_MAX_AGE_MS` is set - /// (non-zero). When it is NOT, this returns `Bounded` — which the zero - /// budget then fails closed — so the new seams are genuinely dark at - /// the deploy default even for channel-pinned queries carrying `until`. - /// Without this gate, `BoundedOrCovered` would take the covered arm - /// (which has no budget dependence) and route on day one with no env - /// var set and no kill switch short of removing the replica URL - /// (Dawn's covered-at-zero-budget catch). The pre-existing cursor - /// paths (`Covered`/`CoveredPostVerified` from channel windows and - /// thread pages) intentionally still route at B=0 — status quo, - /// unchanged. - fn for_query(q: &event::EventQuery, routing_enabled: bool) -> Self { - if !routing_enabled { - return RoutePredicate::Bounded; - } - match (ChannelScoped::from_pinned_channel(q), q.until) { - (Some(proof), Some(upper)) => RoutePredicate::BoundedOrCovered { upper, proof }, - _ => RoutePredicate::Bounded, - } - } -} - -/// Map the configured read budget (`BUZZ_REPLICA_READ_MAX_AGE_MS`) to the -/// runtime gate: `0` disables bounded-staleness routing; anything above the -/// fence staleness gate is clamped to it (an entry older than the staleness -/// gate never routes anyway, so a larger budget would only misrepresent the -/// config). -fn read_budget_from_ms(ms: u64) -> Option { - match ms { - 0 => None, - ms => Some(Duration::from_millis(ms).min(replica_fence::FENCE_STALENESS)), - } -} - -/// Snapshot of Postgres connection pool utilisation. -#[derive(Debug, Clone, Copy)] -pub struct DbPoolStats { - /// Total connections currently in the pool (idle + active). - pub size: u32, - /// Connections available for immediate reuse. - pub idle: u32, - /// Pool ceiling — the `max_connections` value set at construction. - pub max: u32, -} - -/// Owns the detached Postgres session holding the relay usage-metrics advisory lock. -/// -/// The connection deliberately does not return to the main pool: session advisory -/// locks must remain bound to this exact physical connection, and the poller -/// pings it before each leader-only collection tick. -pub struct UsageMetricsLeader { - connection: PgConnection, -} - -impl UsageMetricsLeader { - /// Returns whether the lock-owning session is still reachable. - /// - /// Bounded to 5 seconds — a blackholed connection (no RST) would otherwise - /// stall the entire poller tick until the OS TCP timeout. - pub async fn is_live(&mut self) -> bool { - tokio::time::timeout(std::time::Duration::from_secs(5), self.connection.ping()) - .await - .is_ok_and(|r| r.is_ok()) - } -} - -/// Configuration for the Postgres connection pool. -#[derive(Debug, Clone)] -pub struct DbConfig { - /// Postgres connection URL (usually sourced from `DATABASE_URL`). - pub database_url: String, - /// Optional read-replica connection URL (usually sourced from - /// `READ_DATABASE_URL`, e.g. an Aurora `cluster-ro-` endpoint). `None` - /// disables replica routing: [`Db::read`] falls back to the writer pool. - pub read_database_url: Option, - /// Maximum number of connections in the pool. - pub max_connections: u32, - /// Maximum connections in the read-replica pool (env - /// `BUZZ_DB_READ_POOL_SIZE`). `None` inherits [`Self::max_connections`]. - pub read_max_connections: Option, - /// Minimum number of idle connections to maintain. - pub min_connections: u32, - /// Seconds to wait when acquiring a connection before timing out. - pub acquire_timeout_secs: u64, - /// Maximum connection lifetime in seconds before recycling. - pub max_lifetime_secs: u64, - /// Seconds a connection may sit idle before being closed. - pub idle_timeout_secs: u64, - /// Replica read budget `B` in milliseconds (bounded arm, env - /// `BUZZ_REPLICA_READ_MAX_AGE_MS`). `0` disables bounded-staleness - /// routing — the rollout default. Values above - /// [`replica_fence::FENCE_STALENESS`] are clamped to it: an entry older - /// than the staleness gate never routes anyway, so a larger budget - /// would only misrepresent the config. - pub replica_read_max_age_ms: u64, -} - -impl Default for DbConfig { - /// Sized for a single relay pod against PG max_connections=100. - /// Staging measured 51 idle + 1 active out of 50 — most connections sat unused. - /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. - fn default() -> Self { - Self { - database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 - read_database_url: None, - max_connections: 20, - read_max_connections: None, - min_connections: 2, - acquire_timeout_secs: 3, - max_lifetime_secs: 1800, - idle_timeout_secs: 600, - replica_read_max_age_ms: 0, - } - } -} - -/// Token summary returned by [`Db::list_active_tokens`]. -#[derive(Debug, Clone)] -pub struct TokenSummary { - /// Unique token identifier. - pub id: Uuid, - /// Human-readable token name. - pub name: String, - /// Compressed public key bytes of the token owner. - pub owner_pubkey: Vec, - /// Permission scopes granted to this token. - pub scopes: Vec, - /// When the token was created. - pub created_at: DateTime, - /// Optional expiry timestamp; `None` means no expiry. - pub expires_at: Option>, -} - -impl Db { - /// Creates a new `Db` by connecting a Postgres pool with the given config. - /// - /// When `config.read_database_url` is set, a second pool with the same - /// sizing is connected to it for lag-tolerant reads (see [`Db::read`]). - /// - /// The writer pool arms the commit-time `created_at` floor guard - /// (migration 0021) on every connection by setting the - /// `buzz.created_at_floor` GUC — this is what makes the replica fence - /// proof hold for every insert path that goes through this pool. - pub async fn new(config: &DbConfig) -> Result { - let pool = Self::connect_pool(config, &config.database_url).await?; - let read_max_connections = config - .read_max_connections - .unwrap_or(config.max_connections); - let read_pool = match &config.read_database_url { - Some(url) => Some(Self::connect_read_pool(config, url, read_max_connections)?), - None => None, - }; - let replica_read_max_age = read_budget_from_ms(config.replica_read_max_age_ms); - Ok(Self { - pool, - max_connections: config.max_connections, - read_pool, - read_max_connections, - fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), - replica_read_max_age, - reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - }) - } - - /// Connect the writer pool with all session-level safety premises. - /// - /// SQLx stores one `after_connect` hook, so the floor guard and transaction - /// isolation assertion must remain in this single closure. Registering a - /// second hook replaces the first and silently disarms the floor trigger. - async fn connect_pool(config: &DbConfig, url: &str) -> Result { - let options = PgPoolOptions::new() - .max_connections(config.max_connections) - .min_connections(config.min_connections) - .acquire_timeout(Duration::from_secs(config.acquire_timeout_secs)) - .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) - .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) - .after_connect(|conn, _meta| { - Box::pin(async move { - // `SET` cannot take bind parameters; `set_config` can. - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") - .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) - .execute(&mut *conn) - .await?; - let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") - .fetch_one(&mut *conn) - .await?; - if isolation != "read committed" { - return Err(sqlx::Error::Configuration( - format!( - "writer pool requires READ COMMITTED transaction isolation, got {isolation}" - ) - .into(), - )); - } - Ok(()) - }) - }); - Ok(options.connect(url).await?) - } - - /// Reader acquire timeout — deliberately far below the writer's - /// (seconds-denominated) timeout. Failing closed to the writer must be - /// fast: a saturated reader pool that made routed reads wait the full - /// writer-style timeout would add dead latency during exactly the load - /// spike the offload exists for. A miss here surfaces as - /// `writer/reader_acquire_timeout` (see [`Db::proved_reader`] for why - /// the reason names the mechanism rather than a diagnosis). - const READER_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(150); - - /// Connect the read-replica pool **lazily** — no connection is - /// attempted at construction, so a reader that is down at boot cannot - /// crash the relay (it starts all-writer with the fence closed and - /// recovers when the replica returns). - /// - /// `min_connections` is pinned to 0 explicitly: sqlx's lazy pool still - /// spawns an eager background connect task to satisfy a nonzero - /// minimum, which would reintroduce boot-time reader dial attempts (and - /// their log noise) that "lazy" is meant to avoid. With 0, connections - /// are dialed only on first acquire; the ~10-minute reaper never tops - /// the pool back up, which is fine — routed reads re-fill it on demand. - /// - /// No floor guard or writer-isolation assertion: replica sessions are - /// read-only, so the commit-time trigger from migration 0021 never fires - /// here and the write fence that depends on READ COMMITTED is never reached. - fn connect_read_pool(config: &DbConfig, url: &str, max_connections: u32) -> Result { - Ok(PgPoolOptions::new() - .max_connections(max_connections) - .min_connections(0) - .acquire_timeout(Self::READER_ACQUIRE_TIMEOUT) - .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) - .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) - .connect_lazy(url)?) - } - - /// Spawn a one-shot reader reachability probe that only WARNs. - /// - /// With a lazy pool and `min_connections(0)`, nothing dials the replica - /// until the first routed read — so a misconfigured `READ_DATABASE_URL` - /// would otherwise be invisible until traffic arrives and quietly falls - /// back to the writer. This ping is the only boot-time reader-down - /// visibility; it must never gate startup or [`Db::spawn_fence_probe`]. - /// - /// On success it also primes the Aurora identity capability cache - /// ([`Db::reader_aurora_identity`]) on the connection it already holds, - /// so the first routed read doesn't spend a second acquire (up to - /// another [`Db::READER_ACQUIRE_TIMEOUT`]) inside - /// [`Db::reader_aurora_capability_on`]. Prime failure is fine: the routed - /// path re-probes on the connection it already holds, so a failed prime - /// costs a round trip rather than a second acquire budget. - pub fn spawn_read_pool_boot_ping(&self) { - let Some(read_pool) = self.read_pool.clone() else { - return; - }; - let aurora_identity = self.reader_aurora_identity.clone(); - tokio::spawn(async move { - match observability::acquire(&read_pool, observability::PoolRole::Reader).await { - Ok(mut conn) => { - tracing::info!("read replica reachable at boot"); - match replica_fence::reader_supports_aurora_identity(&mut conn).await { - Ok(supported) => { - let _ = aurora_identity.set(supported); - } - Err(e) => tracing::debug!( - error = %e, - "aurora identity boot prime failed; first routed read will probe" - ), - } - } - Err(e) => tracing::warn!( - "read replica unreachable at boot; serving all-writer until it recovers: {e}" - ), - } - }); - } - - /// Creates a `Db` from an existing `PgPool` (useful in tests). - pub fn from_pool(pool: PgPool) -> Self { - Self { - max_connections: pool.options().get_max_connections(), - read_max_connections: pool.options().get_max_connections(), - pool, - read_pool: None, - fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), - replica_read_max_age: None, - reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - } - } - - /// Creates a `Db` from distinct writer and read pools (useful in tests, - /// where a second database stands in for a lagged replica). - /// - /// The fence starts closed; tests that want cursor pages served by the - /// fake replica must open it via - /// [`replica_fence::ReplicaFence::force_open_for_tests`] (see - /// [`Db::fence`]). - pub fn from_pools(pool: PgPool, read_pool: PgPool) -> Self { - Self { - max_connections: pool.options().get_max_connections(), - read_max_connections: read_pool.options().get_max_connections(), - pool, - read_pool: Some(read_pool), - fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), - replica_read_max_age: None, - reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), - } - } - - /// Test hook: set the head-fetch routing budget (Predicate A), which - /// [`Db::from_pools`] leaves disabled. - pub fn set_replica_read_max_age_for_tests(&mut self, budget: Option) { - self.replica_read_max_age = budget; - } - - /// The freshness fence gating replica routing (see [`replica_fence`]). - pub fn fence(&self) -> &std::sync::Arc { - &self.fence - } - - /// Verify the floor guard end-to-end, then spawn the background fence - /// probe. Returns `Ok(false)` when no replica is configured. - /// - /// Ordering matters (Perci, PR #2084 review): this must run **after** - /// the migration decision. On a relay with `BUZZ_AUTO_MIGRATE` off, the - /// writer pool arms the GUC regardless, but if migration 0021 has not - /// been applied there is no trigger enforcing it — and a heartbeat probe - /// would open the fence over an unenforced floor. So the probe is gated - /// on an unconditional two-part verification against the live schema: - /// catalog shape ([`replica_fence::verify_floor_guard_catalog`]) and - /// observed semantics through this exact pool - /// ([`replica_fence::verify_floor_guard_behavior`]). - /// - /// On any verification failure the probe is never spawned and the fence - /// stays closed: every cursor page routes to the writer. The relay keeps - /// serving — degraded capacity, never holes. - pub async fn spawn_fence_probe(&self) -> Result { - if self.read_pool.is_none() { - return Ok(false); - } - replica_fence::verify_floor_guard_catalog(&self.pool).await?; - replica_fence::verify_floor_guard_behavior(&self.pool).await?; - tokio::spawn(replica_fence::run_probe( - self.pool.clone(), - std::sync::Arc::clone(&self.fence), - )); - Ok(true) - } - - /// The pool for lag-tolerant reads: the read replica when configured, - /// otherwise the writer pool. - /// - /// Removed as a public escape hatch (Dawn, review of 1b0aa0dfa): the - /// raw replica pool carries **no fence proof**, which is exactly the - /// bug class the routed-read machinery exists to eliminate. All replica - /// reads must go through [`Db::route_read`]-backed entry points; this - /// remains only for the fence's own plumbing tests. - #[cfg(test)] - fn read(&self) -> &PgPool { - self.read_pool.as_ref().unwrap_or(&self.pool) - } - - /// Whether a distinct read-replica pool is configured. - pub fn has_read_pool(&self) -> bool { - self.read_pool.is_some() - } - - /// Open a reader request transaction and complete the connection-local - /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ - /// ONLY`, then observe the heartbeat token/epoch as the transaction's - /// **first statement** — anchoring the snapshot every follow-up - /// statement (page, participants, aux closure) sees to exactly the - /// snapshot the proof was taken against — and resolve it against the - /// retained ring. Returns the open transaction together with the - /// strongest [`replica_fence::TokenEntry`] its observation supports, or - /// the fail-closed reason for route metrics. - /// - /// `REPEATABLE READ` is the strongest isolation a hot standby supports - /// (`SERIALIZABLE` is writer-only); `READ ONLY` documents intent and - /// rejects accidental writes. Everything but `Ok` fails closed — begin - /// failure, missing heartbeat row (migration not yet replayed there), - /// observation error, epoch mismatch, or a token below every retained - /// entry all route the request to the writer. - async fn proved_reader( - &self, - read_pool: &PgPool, - ) -> std::result::Result< - ( - sqlx::Transaction<'static, sqlx::Postgres>, - replica_fence::TokenEntry, - ), - &'static str, - > { - // One checkout per routed read. The Aurora capability probe and the - // read-only transaction share a single `acquire()` so the request path - // spends exactly one READER_ACQUIRE_TIMEOUT budget. Probing through - // `read_pool` separately would spend a second budget whenever the - // capability is uncached — i.e. after a failed boot ping, which is - // precisely the reader-unavailable case the bound must hold for. - let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { - Ok(conn) => conn, - Err(sqlx::Error::PoolTimedOut) => { - tracing::warn!("reader pool acquire timed out; routing to writer"); - return Err("reader_acquire_timeout"); - } - Err(e) => { - tracing::warn!(error = %e, "reader connection acquire failed; routing to writer"); - return Err("reader_validation_error"); - } - }; - let mut conn = conn; - let aurora = self.reader_aurora_capability_on(&mut conn).await; - let mut tx = match sqlx::Transaction::begin( - conn, - Some(sqlx::SqlStr::from_static( - "BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY", - )), - ) - .await - { - Ok(tx) => tx, - // The acquire miss gets its own reason code: the reader pool's - // short acquire timeout (READER_ACQUIRE_TIMEOUT) makes this the - // fast fail-closed path under load, and - // `buzz_db_route_decision{decision="writer",reason="reader_acquire_timeout"}` - // is the operator's alert signal for a struggling reader pool. - // - // The reason deliberately names the mechanism, not a diagnosis: - // `PoolTimedOut` proves only that no connection was handed out - // within the 150ms budget. That budget includes cold connect - // (TCP+TLS+auth), and sqlx's `size` counts in-flight dials, so - // this fires for slow connection establishment as well as for - // established-connection contention — and neither `size == 0` - // nor `size >= max` recovers the missing causal bit (in-flight - // dials hold a size slot, and a cold burst can push - // `active = size - idle` toward max with zero busy connections). - // Runbook: correlate with `buzz_db_read_pool_active` / `_max` - // and reader connection health/latency; high active suggests - // contention, but this metric alone does not distinguish - // contention from slow connects. Note the gauge is a coarse - // sample (BUZZ_POOL_METRICS_INTERVAL_SECS, default 10s) while - // the event it explains lasts ~150ms — a short burst may fall - // between samples entirely, so absence of elevated active is - // NOT evidence of a cold connect. - Err(sqlx::Error::PoolTimedOut) => { - tracing::warn!("reader pool acquire timed out; routing to writer"); - return Err("reader_acquire_timeout"); - } - Err(e) => { - tracing::warn!(error = %e, "reader transaction begin failed; routing to writer"); - return Err("reader_validation_error"); - } - }; - let obs = match replica_fence::observe_heartbeat(&mut tx, aurora).await { - Ok(Some(observation)) => observation, - Ok(None) => return Err("reader_validation_error"), - Err(e) => { - tracing::warn!(error = %e, "heartbeat observation failed; routing to writer"); - return Err("reader_validation_error"); - } - }; - match self.fence.resolve(obs.token, obs.epoch) { - replica_fence::ResolveOutcome::Proved(entry) => { - tracing::debug!( - token = obs.token, - proved_token = entry.token, - backend = %obs.backend, - "reader snapshot proved fence coverage" - ); - Ok((tx, entry)) - } - replica_fence::ResolveOutcome::EpochMismatch => Err("reader_validation_error"), - replica_fence::ResolveOutcome::TokenBehind => Err("reader_token_behind"), - } - } - - /// Whether the reader endpoint supports the Aurora PostgreSQL identity - /// function ([`replica_fence::AURORA_IDENTITY_FN`]), probed - /// once per process and cached (see [`Db::reader_aurora_identity`]). - /// The probe runs on a plain autocommit checkout — never inside the - /// request transaction, where an undefined-function error would abort - /// it. Probe failure (acquire or transient) degrades to the plain - /// identity tuple for THIS request without caching, so a later request - /// retries; identity is evidence, never a routing gate. - /// Aurora capability on a connection the caller already holds, so the - /// routed path never spends a second acquire budget. - async fn reader_aurora_capability_on( - &self, - conn: &mut sqlx::pool::PoolConnection, - ) -> bool { - if let Some(cached) = self.reader_aurora_identity.get() { - return *cached; - } - match replica_fence::reader_supports_aurora_identity(conn).await { - Ok(supported) => *self.reader_aurora_identity.get_or_init(|| supported), - Err(e) => { - tracing::debug!(error = %e, "aurora identity probe failed; will retry"); - false - } - } - } - - /// Record one route decision (Rev 2 observability): which path, where it - /// went, and why. - fn record_route(path: &'static str, decision: &'static str, reason: &'static str) { - metrics::counter!( - "buzz_db_route_decision", - "path" => path, - "decision" => decision, - "reason" => reason, - ) - .increment(1); - } - - /// Run pending database migrations. - #[datastore_span(name = "migrate", system = "postgresql")] - pub async fn migrate(&self) -> Result<()> { - migration::run_migrations(&self.pool).await - } - - /// Returns `true` if the database is reachable (used by readiness probes). - pub async fn ping(&self) -> bool { - sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() - } - - /// Validate the minimum deletion fence catalog required by serving paths. - pub async fn validate_deletion_serving_catalog(&self) -> Result<()> { - self.deletion_store().validate_serving_catalog().await - } - - /// Validate the exact live community-deletion tenant catalog for destruction. - pub async fn validate_deletion_catalog(&self) -> Result<()> { - self.deletion_store().validate_catalog().await - } - - /// Returns pool utilisation stats for metrics emission. - /// - /// `size` — total connections (idle + active) - /// `idle` — connections available for immediate reuse - /// `max` — pool ceiling set at construction - pub fn pool_stats(&self) -> DbPoolStats { - DbPoolStats { - size: self.pool.size(), - idle: self.pool.num_idle() as u32, - max: self.max_connections, - } - } - - /// Pool utilisation stats for the read-replica pool, when configured. - /// - /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not - /// the writer's: `buzz_db_read_pool_active / buzz_db_read_pool_max` is - /// the operator's utilisation signal for tuning `BUZZ_DB_READ_POOL_SIZE`, - /// and deriving it from the writer's max would misreport saturation by - /// exactly the ratio of the two pool sizes — in the direction that hides - /// the problem. - pub fn read_pool_stats(&self) -> Option { - self.read_pool.as_ref().map(|p| DbPoolStats { - size: p.size(), - idle: p.num_idle() as u32, - max: self.read_max_connections, - }) - } - - /// Try to acquire the detached session advisory lock for relay usage metrics. - /// - /// The returned guard owns the exact connection that acquired the lock. It is - /// detached from the shared pool so a stable leader neither returns a locked - /// session to other callers nor permanently consumes a pool slot. Dropping the - /// guard closes the connection and releases the session-scoped lock. - #[datastore_span(name = "try_lock_usage_metrics", system = "postgresql")] - pub async fn try_lock_usage_metrics( - &self, - lock_key: i64, - ) -> Result> { - let mut connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; - let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") - .bind(lock_key) - .fetch_one(&mut *connection) - .await?; - if acquired { - Ok(Some(UsageMetricsLeader { - connection: connection.detach(), - })) - } else { - Ok(None) - } - } - - /// List reports for the deployment-global read-only admin plane. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "admin_list_reports", system = "postgresql")] - pub async fn admin_list_reports( - &self, - community_id: Option, - status: Option<&str>, - report_type: Option<&str>, - target_kind: Option<&str>, - after: Option>, - before: Option>, - cursor: Option<(DateTime, Uuid)>, - limit: i64, - ) -> Result> { - admin_moderation::list_reports( - &self.pool, - community_id, - status, - report_type, - target_kind, - after, - before, - cursor, - limit, - ) - .await - } - - /// Fetch one report for the deployment-global read-only admin plane. - #[datastore_span(name = "admin_get_report", system = "postgresql")] - pub async fn admin_get_report( - &self, - id: Uuid, - ) -> Result> { - admin_moderation::get_report(&self.pool, id).await - } - - /// List feedback for the deployment-global read-only admin plane. - #[datastore_span(name = "admin_list_feedback", system = "postgresql")] - pub async fn admin_list_feedback( - &self, - limit: i64, - ) -> Result> { - admin_moderation::list_feedback(&self.pool, limit).await - } - - /// Fetch one feedback submission for the deployment-global admin plane. - #[datastore_span(name = "admin_get_feedback", system = "postgresql")] - pub async fn admin_get_feedback( - &self, - id: Uuid, - ) -> Result> { - admin_moderation::get_feedback(&self.pool, id).await - } - - /// Return total number of communities on this relay. - #[datastore_span(name = "usage_community_count", system = "postgresql")] - pub async fn usage_community_count(&self) -> Result { - usage::community_count(&self.pool).await - } - - /// Return per-community user counts split by human/agent. - #[datastore_span(name = "usage_user_counts", system = "postgresql")] - pub async fn usage_user_counts(&self) -> Result> { - usage::user_counts(&self.pool).await - } - - /// Return per-community channel counts by type. - #[datastore_span(name = "usage_channel_counts", system = "postgresql")] - pub async fn usage_channel_counts(&self) -> Result> { - usage::channel_counts(&self.pool).await - } - - /// Return per-community kind=9 message counts. - #[datastore_span(name = "usage_message_counts", system = "postgresql")] - pub async fn usage_message_counts(&self) -> Result> { - usage::message_counts(&self.pool).await - } - - /// Return per-community relay-member counts by role. - #[datastore_span(name = "usage_relay_member_counts", system = "postgresql")] - pub async fn usage_relay_member_counts(&self) -> Result> { - usage::relay_member_counts(&self.pool).await - } - - /// Return per-community workflow counts by status. - #[datastore_span(name = "usage_workflow_counts", system = "postgresql")] - pub async fn usage_workflow_counts(&self) -> Result> { - usage::workflow_counts(&self.pool).await - } - - /// Return per-community git-repo counts. - #[datastore_span(name = "usage_git_repo_counts", system = "postgresql")] - pub async fn usage_git_repo_counts(&self) -> Result> { - usage::git_repo_counts(&self.pool).await - } - - /// Return per-community distinct active-user counts for a given SQL interval. - /// - /// `interval_sql` must be a trusted literal such as `"1 day"` or `"7 days"`. - #[datastore_span(name = "usage_active_user_counts", system = "postgresql")] - pub async fn usage_active_user_counts( - &self, - interval_sql: &'static str, - ) -> Result> { - usage::active_user_counts(&self.pool, interval_sql).await - } - - /// Return per-community active-channel counts for a given SQL interval. - #[datastore_span(name = "usage_active_channel_counts", system = "postgresql")] - pub async fn usage_active_channel_counts( - &self, - interval_sql: &'static str, - ) -> Result> { - usage::active_channel_counts(&self.pool, interval_sql).await - } - - /// Return all community id → host mappings. - #[datastore_span(name = "usage_community_hosts", system = "postgresql")] - pub async fn usage_community_hosts(&self) -> Result> { - usage::community_hosts(&self.pool).await - } - - /// Return the shared durable whole-community deletion adapter. - pub fn deletion_store(&self) -> deletion::DeletionStore { - deletion::DeletionStore::new(self.pool.clone()) - } - - /// Begin a database transaction for atomic multi-statement operations. - /// - /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. - /// The transaction holds an owned pool handle, not a borrow. - pub async fn begin_transaction(&self) -> Result> { - let connection = - observability::acquire(&self.pool, observability::PoolRole::Writer).await?; - sqlx::Transaction::begin(connection, None) - .await - .map_err(Into::into) - } - - /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. - #[datastore_span(name = "insert_event", system = "postgresql")] - pub async fn insert_event( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event(&self.pool, community_id, event, channel_id).await?; - if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(result) - } - - /// Insert an event while holding and validating an admitted serving-write - /// lease under the community ordering lock through commit. - /// - /// External side effects use a durable lease rather than one long-lived DB - /// transaction. Their final database mutation presents that exact lease so - /// it may finish during quiescing without admitting any new serving work. - pub async fn insert_event_with_serving_write_guard( - &self, - lease: &deletion::ServingWriteLease, - event: &nostr::Event, - channel_id: Option, - ) -> Result<(StoredEvent, bool)> { - let community_id = lease.community_id; - let kind_u16 = event.kind.as_u16(); - let kind_u32 = u32::from(kind_u16); - if kind_u32 == buzz_core::kind::KIND_AUTH { - return Err(DbError::AuthEventRejected); - } - if buzz_core::kind::is_ephemeral(kind_u32) { - return Err(DbError::EphemeralEventRejected(kind_u16)); - } - - let mut tx = self.pool.begin().await?; - self.deletion_store() - .guard_transaction_with_serving_lease(&mut tx, lease) - .await?; - let result = event::insert_event_with_thread_metadata_tx( - &mut tx, - community_id, - event, - channel_id, - None, - ) - .await?; - tx.commit().await?; - if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(result) - } - - /// Queries events matching the given filter parameters. - /// - /// Always reads from the WRITER pool. If the result influences a write - /// or a permission decision, this is the method to call. Display-path - /// callers that tolerate bounded staleness should use - /// [`Db::query_events_routed`] instead — converting a caller is an - /// explicit, per-callsite decision, never a change to this method. - #[datastore_span(name = "query_events", system = "postgresql")] - pub async fn query_events(&self, q: &EventQuery) -> Result> { - event::query_events(&self.pool, q).await - } - - /// [`Db::query_events`] with replica routing — the opt-in fast path for - /// display reads. - /// - /// Rule of thumb: **if the result influences a write or a permission, - /// it reads from the writer** — do not convert such a caller to this - /// method. Every new caller must be added to the caller-classification - /// table in `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md`. - /// - /// Routing derives the strongest sound predicate from the query shape - /// ([`RoutePredicate::for_query`]): a channel-pinned query with an - /// `until` upper bound may be served covered (provably complete below - /// the fence wall); anything else is bounded-staleness only. The whole - /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when - /// unset, even covered-eligible queries stay on the writer, so merging - /// this seam is a true no-op until the budget is configured. Every - /// failure fails closed to the writer. - #[datastore_span(name = "query_events_routed", system = "postgresql")] - pub async fn query_events_routed( - &self, - path: &'static str, - q: &EventQuery, - ) -> Result> { - let predicate = RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); - match self.route_read(path, predicate).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::query_events_on(&mut tx, q).await { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - // Mid-query replica failure: fail closed to the - // writer rather than surfacing a routed error. - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::query_events(&self.pool, q).await - } - } - } - RouteDecision::Writer => event::query_events(&self.pool, q).await, - } - } - - /// [`Db::query_events_routed`] restricted to the BOUNDED arm — for - /// reads whose result feeds a COUNT rather than a displayed page. - /// - /// The covered arm bounds insert-completeness only; stale deletions can - /// briefly inflate the result set (see [`RoutePredicate::Covered`]). A - /// display page absorbs that per-row; a number derived from the rows - /// does not. Same classification-table requirement as - /// [`Db::query_events_routed`]. - #[datastore_span(name = "query_events_routed_bounded", system = "postgresql")] - pub async fn query_events_routed_bounded( - &self, - path: &'static str, - q: &EventQuery, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::query_events_on(&mut tx, q).await { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::query_events(&self.pool, q).await - } - } - } - RouteDecision::Writer => event::query_events(&self.pool, q).await, - } - } - - /// Count events matching the given query (NIP-45 COUNT support). - /// - /// Always reads from the WRITER pool — see [`Db::query_events`] for the - /// writer-vs-routed rule. - #[datastore_span(name = "count_events", system = "postgresql")] - pub async fn count_events(&self, q: &EventQuery) -> Result { - event::count_events(&self.pool, q).await - } - - /// [`Db::count_events`] with replica routing — same contract, rules, - /// and classification-table requirement as [`Db::query_events_routed`]. - /// - /// Counts route on the BOUNDED arm only, never covered: the covered - /// arm bounds insert-completeness but not deletion visibility (soft - /// deletes are UPDATEs outside the floor guard), and a count has no - /// downstream per-row re-filter to absorb extra rows — a silently - /// inflated number for up to `FENCE_STALENESS` is a different product - /// statement than a page briefly showing a deleted row. `Bounded` ties - /// the error to the accepted budget `B`. - #[datastore_span(name = "count_events_routed", system = "postgresql")] - pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::count_events_on(&mut tx, q).await { - Ok(count) => { - Self::record_route(path, "replica", reason); - Ok(count) - } - Err(e) => { - tracing::warn!(path, "replica count failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::count_events(&self.pool, q).await - } - } - } - RouteDecision::Writer => event::count_events(&self.pool, q).await, - } - } - - /// Return whether a creator-signed huddle-start event links a parent - /// channel to an ephemeral huddle channel. - #[datastore_span(name = "huddle_started_link_exists", system = "postgresql")] - pub async fn huddle_started_link_exists( - &self, - community_id: CommunityId, - parent_channel_id: Uuid, - ephemeral_channel_id: Uuid, - creator_pubkey: &[u8], - ) -> Result { - event::huddle_started_link_exists( - &self.pool, - community_id, - parent_channel_id, - ephemeral_channel_id, - creator_pubkey, - ) - .await - } - - /// Fetch the latest replaceable event for a (kind, pubkey) pair. - /// - /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. - /// This matches the write path in [`replace_addressable_event`] and handles - /// historical duplicate survivors correctly. - #[datastore_span(name = "get_latest_global_replaceable", system = "postgresql")] - pub async fn get_latest_global_replaceable( - &self, - community_id: CommunityId, - kind: i32, - pubkey_bytes: &[u8], - ) -> Result> { - event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await - } - - /// Fetches a single non-deleted event by its raw ID bytes. - /// - /// Returns `None` if the event does not exist or has been soft-deleted. - #[datastore_span(name = "get_event_by_id", system = "postgresql")] - pub async fn get_event_by_id( - &self, - community_id: CommunityId, - id_bytes: &[u8], - ) -> Result> { - event::get_event_by_id(&self.pool, community_id, id_bytes).await - } - - /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. - #[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")] - pub async fn get_event_by_id_including_deleted( - &self, - community_id: CommunityId, - id_bytes: &[u8], - ) -> Result> { - event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await - } - - /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. - #[datastore_span(name = "soft_delete_event", system = "postgresql")] - pub async fn soft_delete_event( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result { - event::soft_delete_event(&self.pool, community_id, event_id).await - } - - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` - /// when it is not newer than the deletion request. - /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; - /// `deletion_created_at_secs` is the deletion event's `created_at`. - #[datastore_span(name = "soft_delete_by_coordinate", system = "postgresql")] - pub async fn soft_delete_by_coordinate( - &self, - community_id: CommunityId, - kind: i32, - pubkey: &[u8], - d_tag: &str, - deletion_created_at_secs: i64, - ) -> Result { - event::soft_delete_by_coordinate( - &self.pool, - community_id, - kind, - pubkey, - d_tag, - deletion_created_at_secs, - ) - .await - } - - /// Atomically soft-delete an event and decrement thread reply counters. - #[datastore_span(name = "soft_delete_event_and_update_thread", system = "postgresql")] - pub async fn soft_delete_event_and_update_thread( - &self, - community_id: CommunityId, - event_id: &[u8], - parent_event_id: Option<&[u8]>, - root_event_id: Option<&[u8]>, - ) -> Result { - event::soft_delete_event_and_update_thread( - &self.pool, - community_id, - event_id, - parent_event_id, - root_event_id, - ) - .await - } - - /// Returns the most recent `created_at` for a channel. - #[datastore_span(name = "get_last_message_at", system = "postgresql")] - pub async fn get_last_message_at( - &self, - community_id: CommunityId, - channel_id: Uuid, - ) -> Result>> { - event::get_last_message_at(&self.pool, community_id, channel_id).await - } - - /// Bulk-fetch the most recent `created_at` for a set of channel IDs. - #[datastore_span(name = "get_last_message_at_bulk", system = "postgresql")] - pub async fn get_last_message_at_bulk( - &self, - community_id: CommunityId, - channel_ids: &[Uuid], - ) -> Result>> { - event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await - } - - /// Batch-fetch non-deleted events by their raw IDs. - #[datastore_span(name = "get_events_by_ids", system = "postgresql")] - pub async fn get_events_by_ids( - &self, - community_id: CommunityId, - ids: &[&[u8]], - ) -> Result> { - event::get_events_by_ids(&self.pool, community_id, ids).await - } - - /// [`Db::get_events_by_ids`] with replica routing — same contract and - /// classification-table requirement as [`Db::query_events_routed`]. - /// - /// By-id fetches route on the BOUNDED arm only: an id list carries no - /// channel pin, so no fence floor can prove insert-completeness — the - /// covered arm is structurally unavailable. Used for FTS hit hydration, - /// where a missing row degrades to a skipped search hit downstream. - #[datastore_span(name = "get_events_by_ids_routed", system = "postgresql")] - pub async fn get_events_by_ids_routed( - &self, - path: &'static str, - community_id: CommunityId, - ids: &[&[u8]], - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match event::get_events_by_ids_on(&mut tx, community_id, ids).await { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - event::get_events_by_ids(&self.pool, community_id, ids).await - } - } - } - RouteDecision::Writer => event::get_events_by_ids(&self.pool, community_id, ids).await, - } - } - - /// Exclusively claim a batch of due matcher jobs from one community. - #[datastore_span(name = "claim_due_push_match_batch", system = "postgresql")] - pub async fn claim_due_push_match_batch( - &self, - limit: i64, - lease_until: DateTime, - ) -> Result> { - push::claim_due_match_batch(&self.pool, limit, lease_until).await - } - - /// Load active endpoint-enabled leases eligible for push matching. - #[datastore_span(name = "active_push_match_leases", system = "postgresql")] - pub async fn active_push_match_leases( - &self, - community: CommunityId, - ) -> Result> { - push::active_match_leases(&self.pool, community).await - } - - /// Complete matcher jobs from one claimed batch while the fence holds. - #[datastore_span(name = "complete_push_match_batch", system = "postgresql")] - pub async fn complete_push_match_batch( - &self, - community: CommunityId, - claim_id: uuid::Uuid, - event_ids: &[Vec], - ) -> Result { - push::complete_match_batch(&self.pool, community, claim_id, event_ids).await - } - - /// Release fenced matcher claims from one batch for retry. - #[datastore_span(name = "retry_push_match_batch", system = "postgresql")] - pub async fn retry_push_match_batch( - &self, - community: CommunityId, - claim_id: uuid::Uuid, - event_ids: &[Vec], - next: DateTime, - ) -> Result { - push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await - } - - /// Delete exhausted matcher jobs (periodic sweep, off the claim path). - #[datastore_span(name = "reap_exhausted_push_matches", system = "postgresql")] - pub async fn reap_exhausted_push_matches(&self) -> Result { - push::reap_exhausted_matches(&self.pool).await - } - - /// Idempotently enqueue a wake for a matched lease and event. - #[datastore_span(name = "enqueue_push_wake", system = "postgresql")] - pub async fn enqueue_push_wake( - &self, - community: CommunityId, - author: &[u8], - installation_id: &str, - wake: push::NewWake<'_>, - ) -> Result { - push::enqueue_wake(&self.pool, community, author, installation_id, wake).await - } - - /// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch. - #[datastore_span(name = "enqueue_push_wakes", system = "postgresql")] - pub async fn enqueue_push_wakes( - &self, - community: CommunityId, - requests: &[push::WakeRequest], - ) -> Result> { - push::enqueue_wakes(&self.pool, community, requests).await - } - - /// Exclusively claim due wake jobs for one community. - #[datastore_span(name = "claim_due_push_wakes", system = "postgresql")] - pub async fn claim_due_push_wakes( - &self, - community: CommunityId, - limit: i64, - lease_until: DateTime, - ) -> Result> { - push::claim_due_wakes(&self.pool, community, limit, lease_until).await - } - - /// Revalidate a wake's claim, source event, and current lease before send. - #[datastore_span(name = "revalidate_push_wake", system = "postgresql")] - pub async fn revalidate_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - ) -> Result { - push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await - } - - /// Mark a fenced wake claim delivered. - #[datastore_span(name = "complete_push_wake", system = "postgresql")] - pub async fn complete_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - ) -> Result { - push::complete_wake(&self.pool, community, id, claim_id).await - } - - /// Release a fenced wake claim for retry at the supplied time. - #[datastore_span(name = "retry_push_wake", system = "postgresql")] - pub async fn retry_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - next: DateTime, - ) -> Result { - push::retry_wake(&self.pool, community, id, claim_id, next).await - } - - /// Mark a fenced wake claim terminally failed. - #[datastore_span(name = "fail_push_wake", system = "postgresql")] - pub async fn fail_push_wake( - &self, - community: CommunityId, - id: Uuid, - claim_id: Uuid, - ) -> Result { - push::fail_wake(&self.pool, community, id, claim_id).await - } - - /// Disable an endpoint only if the specified lease generation is current. - #[datastore_span(name = "disable_push_endpoint", system = "postgresql")] - pub async fn disable_push_endpoint( - &self, - community: CommunityId, - author: &[u8], - installation_id: &str, - generation: i64, - ) -> Result { - push::disable_endpoint_generation( - &self.pool, - community, - author, - installation_id, - generation, - ) - .await - } - - /// Atomically persist a validated kind:30350 event and its effective lease. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "accept_push_lease_event", system = "postgresql")] - pub async fn accept_push_lease_event( - &self, - community: CommunityId, - event: &nostr::Event, - installation_id: &str, - version: push::LeaseVersion<'_>, - active: Option>, - max_active_leases: i64, - ) -> Result { - push::accept_lease_event( - &self.pool, - community, - event, - installation_id, - version, - active, - max_active_leases, - ) - .await - } - - /// Atomically insert an event AND its thread metadata in a single transaction. - #[datastore_span(name = "insert_event_with_thread_metadata", system = "postgresql")] - pub async fn insert_event_with_thread_metadata( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - thread_meta: Option>, - ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event_with_thread_metadata( - &self.pool, - community_id, - event, - channel_id, - thread_meta, - ) - .await?; - if result.1 { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(result) - } - - /// Atomically insert a kind:7 reaction event and its reaction row. - #[allow(clippy::too_many_arguments)] - #[datastore_span( - name = "insert_reaction_event_with_thread_metadata", - system = "postgresql" - )] - pub async fn insert_reaction_event_with_thread_metadata( - &self, - community_id: CommunityId, - event: &nostr::Event, - channel_id: Option, - thread_meta: Option>, - target_event_id: &[u8], - actor_pubkey: &[u8], - emoji: &str, - ) -> Result { - let outcome = event::insert_reaction_event_with_thread_metadata( - &self.pool, - community_id, - event, - channel_id, - thread_meta, - target_event_id, - actor_pubkey, - emoji, - ) - .await?; - if let event::ReactionEventInsertOutcome::Inserted { - was_inserted: true, .. - } = &outcome - { - if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - Ok(outcome) - } - - /// Query due reminders ready for delivery. - #[datastore_span(name = "query_due_reminders", system = "postgresql")] - pub async fn query_due_reminders( - &self, - now_secs: i64, - batch_limit: i64, - ) -> Result> { - event::query_due_reminders(&self.pool, now_secs, batch_limit).await - } - - /// Atomically claim a due reminder for delivery (cross-pod dedup). - #[datastore_span(name = "claim_due_reminder", system = "postgresql")] - pub async fn claim_due_reminder( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - ) -> Result { - event::claim_due_reminder(&self.pool, community_id, event_id, event_created_at).await - } - - /// Atomically claim a due reminder using a caller-supplied delivery stamp. - #[datastore_span(name = "claim_due_reminder_with_stamp", system = "postgresql")] - pub async fn claim_due_reminder_with_stamp( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - delivery_stamp: i64, - ) -> Result { - event::claim_due_reminder_with_stamp( - &self.pool, - community_id, - event_id, - event_created_at, - delivery_stamp, - ) - .await - } - - /// Release a claimed due reminder after a publish failure. - #[datastore_span(name = "release_due_reminder", system = "postgresql")] - pub async fn release_due_reminder( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: chrono::DateTime, - delivery_stamp: i64, - ) -> Result { - event::release_due_reminder( - &self.pool, - community_id, - event_id, - event_created_at, - delivery_stamp, - ) - .await - } - - /// Ensure a user record exists (upsert). - /// - /// Returns `true` if a new row was inserted (first time), `false` if it - /// already existed. Callers use the `true` return to increment - /// `buzz_users_created_total`. - #[datastore_span(name = "ensure_user", system = "postgresql")] - pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { - user::ensure_user(&self.pool, community_id, pubkey).await - } - - /// Get a single user record by pubkey. - #[datastore_span(name = "get_user", system = "postgresql")] - pub async fn get_user( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result> { - user::get_user(&self.pool, community_id, pubkey).await - } - - /// Update a user's profile fields. - #[datastore_span(name = "update_user_profile", system = "postgresql")] - pub async fn update_user_profile( - &self, - community_id: CommunityId, - pubkey: &[u8], - display_name: Option<&str>, - avatar_url: Option<&str>, - about: Option<&str>, - nip05_handle: Option<&str>, - ) -> Result<()> { - user::update_user_profile( - &self.pool, - community_id, - pubkey, - display_name, - avatar_url, - about, - nip05_handle, - ) - .await - } - - /// Look up a user by NIP-05 handle. - #[datastore_span(name = "get_user_by_nip05", system = "postgresql")] - pub async fn get_user_by_nip05( - &self, - community_id: CommunityId, - local_part: &str, - domain: &str, - ) -> Result> { - user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await - } - - /// Search users by display name, NIP-05 handle, or pubkey prefix. - #[datastore_span(name = "search_users", system = "postgresql")] - pub async fn search_users( - &self, - community_id: CommunityId, - query: &str, - limit: u32, - ) -> Result> { - user::search_users(&self.pool, community_id, query, limit).await - } - - /// Atomically set agent owner — only if no owner is currently assigned. - /// Returns Ok(true) if set, Ok(false) if an owner already exists. - #[datastore_span(name = "set_agent_owner", system = "postgresql")] - pub async fn set_agent_owner( - &self, - community_id: CommunityId, - agent_pubkey: &[u8], - owner_pubkey: &[u8], - ) -> Result { - user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await - } - - /// Get the channel_add_policy and agent_owner_pubkey for a user. - #[datastore_span(name = "get_agent_channel_policy", system = "postgresql")] - pub async fn get_agent_channel_policy( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result>)>> { - user::get_agent_channel_policy(&self.pool, community_id, pubkey).await - } - - /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. - #[datastore_span(name = "is_agent_owner", system = "postgresql")] - pub async fn is_agent_owner( - &self, - community_id: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], - ) -> Result { - user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await - } - - /// Set the channel_add_policy for a user. - #[datastore_span(name = "set_channel_add_policy", system = "postgresql")] - pub async fn set_channel_add_policy( - &self, - community_id: CommunityId, - pubkey: &[u8], - policy: &str, - ) -> Result<()> { - user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await - } - - /// Find an existing DM by its participant hash. - #[datastore_span(name = "find_dm_by_participants", system = "postgresql")] - pub async fn find_dm_by_participants( - &self, - community_id: CommunityId, - participant_hash: &[u8], - ) -> Result> { - dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await - } - - /// Create or return an existing DM channel. - #[datastore_span(name = "create_dm", system = "postgresql")] - pub async fn create_dm( - &self, - community_id: CommunityId, - participants: &[&[u8]], - created_by: &[u8], - ) -> Result { - dm::create_dm(&self.pool, community_id, participants, created_by).await - } - - /// List all DMs for a user. - #[datastore_span(name = "list_dms_for_user", system = "postgresql")] - pub async fn list_dms_for_user( - &self, - community_id: CommunityId, - pubkey: &[u8], - limit: u32, - cursor: Option, - ) -> Result> { - dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await - } - - /// Open or retrieve a DM for the given participants. - #[datastore_span(name = "open_dm", system = "postgresql")] - pub async fn open_dm( - &self, - community_id: CommunityId, - pubkeys: &[&[u8]], - created_by: &[u8], - ) -> Result<(channel::ChannelRecord, bool)> { - dm::open_dm(&self.pool, community_id, pubkeys, created_by).await - } - - /// Hide a DM channel for a specific user. - /// - /// The DM is not deleted — it can be restored by opening a new DM with - /// the same participants. - #[datastore_span(name = "hide_dm", system = "postgresql")] - pub async fn hide_dm( - &self, - community_id: CommunityId, - channel_id: Uuid, - pubkey: &[u8], - ) -> Result<()> { - dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await - } - - /// Unhide a DM channel for a specific user. - #[datastore_span(name = "unhide_dm", system = "postgresql")] - pub async fn unhide_dm( - &self, - community_id: CommunityId, - channel_id: Uuid, - pubkey: &[u8], - ) -> Result<()> { - dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await - } - - /// List the channel IDs of all DMs the given user currently has hidden. - #[datastore_span(name = "list_hidden_dms", system = "postgresql")] - pub async fn list_hidden_dms( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result> { - dm::list_hidden_dms(&self.pool, community_id, pubkey).await - } - - /// Insert thread metadata. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "insert_thread_metadata", system = "postgresql")] - pub async fn insert_thread_metadata( - &self, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - channel_id: Uuid, - parent_event_id: Option<&[u8]>, - parent_event_created_at: Option>, - root_event_id: Option<&[u8]>, - root_event_created_at: Option>, - depth: i32, - broadcast: bool, - ) -> Result<()> { - thread::insert_thread_metadata( - &self.pool, - community_id, - event_id, - event_created_at, - channel_id, - parent_event_id, - parent_event_created_at, - root_event_id, - root_event_created_at, - depth, - broadcast, - ) - .await - } - - /// Fetch replies under a root event. - /// - /// Routing mirrors [`Db::get_channel_window_with_session`]: a head - /// fetch (`cursor: None`) is Predicate A (bounded staleness, gated by - /// the default-off head budget); cursor pages are Predicate B - /// (completeness). Thread pagination walks **forward** from oldest to - /// newest, so a cursor carries no upper bound — instead the served page - /// is post-verified against the wall the serving session proved: - /// - /// - an under-`limit` page is a candidate terminal page — the client - /// treats it as EOF, so it is re-run on the writer to keep the EOF - /// decision authoritative (a lagged replica could truncate the tail); - /// - a full page whose newest row exceeds the proved fence wall could - /// straddle a row the session has not replayed (commit order is not - /// `created_at` order), so it is also re-run on the writer. Only a - /// full page that sits entirely at or below the proved wall is served - /// from the replica. - /// - /// A head fetch routed under Predicate A skips the re-run: bounded - /// staleness (missing at most the freshest budget-window of replies) is - /// exactly the semantic the head gate accepts. - #[datastore_span(name = "get_thread_replies", system = "postgresql")] - pub async fn get_thread_replies( - &self, - community_id: CommunityId, - root_event_id: &[u8], - depth_limit: Option, - limit: u32, - cursor: Option<&[u8]>, - ) -> Result> { - let (path, predicate): (&'static str, RoutePredicate) = match cursor { - Some(_) => ( - "thread_cursor", - RoutePredicate::CoveredPostVerified { - proof: ChannelScoped::from_thread_metadata_join(), - }, - ), - None => ("thread_head", RoutePredicate::Bounded), - }; - if let RouteDecision::Replica(mut tx, entry, reason) = - self.route_read(path, predicate).await - { - match thread::get_thread_replies_on( - &mut tx, - community_id, - root_event_id, - depth_limit, - limit, - cursor, - ) - .await - { - Ok(replies) => { - if cursor.is_none() { - // Predicate A: bounded-stale head page, served as proved. - Self::record_route(path, "replica", reason); - return Ok(replies); - } - let full = replies.len() >= limit as usize; - let below_fence = replies - .last() - .is_some_and(|tail| tail.created_at <= entry.fence_wall); - if full && below_fence { - Self::record_route(path, "replica", reason); - return Ok(replies); - } - // Candidate terminal page, or page reaching above the - // proved wall — verify against the writer. Recorded as - // the request's ONLY route event: the replica leg was - // discarded, so counting it would overstate offload. - Self::record_route("thread_eof", "writer", "stale"); - } - Err(e) => { - // Mid-request replica failure (e.g. a hot-standby - // recovery conflict) fails closed to the writer. - tracing::warn!( - error = %e, - path, - "replica thread query failed; re-running on writer" - ); - Self::record_route(path, "writer", "replica_error"); - } - } - } - thread::get_thread_replies( - &self.pool, - community_id, - root_event_id, - depth_limit, - limit, - cursor, - ) - .await - } - - /// Fetch aggregated thread stats. - #[datastore_span(name = "get_thread_summary", system = "postgresql")] - pub async fn get_thread_summary( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result> { - thread::get_thread_summary(&self.pool, community_id, event_id).await - } - - /// One channel window: top-level rows + summaries + server `has_more`. - /// - /// Convenience wrapper over [`Db::get_channel_window_with_session`] for - /// callers with no follow-up queries; the serving session is released. - pub async fn get_channel_window( - &self, - community_id: CommunityId, - channel_id: Uuid, - limit: u32, - cursor: Option<(DateTime, Vec)>, - kind_filter: Option<&[u32]>, - ) -> Result { - self.get_channel_window_with_session(community_id, channel_id, limit, cursor, kind_filter) - .await - .map(|(window, _session)| window) - } - - /// [`Db::get_channel_window`], additionally returning the session that - /// served the page so request-scoped follow-ups (the aux closure) run on - /// the same proved connection. - /// - /// Routing: - /// - /// - **Cursor page** (Predicate B — completeness): scrolls *backward* - /// into history bounded above by the cursor timestamp (`created_at < - /// ts`, or `= ts` with the id tiebreak), so it may be served by a - /// replica session when one is configured AND that session **proves** - /// coverage of the cursor timestamp: the heartbeat token/epoch is - /// observed on the exact connection that will serve the page and - /// resolved against the fence's retained ring ([`replica_fence`]). - /// - **Head fetch** (Predicate A — bounded staleness): served by a - /// proved replica session only when the head gate is configured - /// ([`DbConfig::replica_read_max_age_ms`], default off) and the - /// proved entry is within the budget. This trades a bounded staleness - /// window (budget plus probe cadence) on the GET leg for writer - /// offload. NOTE: enabling the budget also breaks read-your-own-writes - /// on the GET leg; the client-side WS `since`-overlap union intended - /// to cover fresh events has NOT shipped yet — do not enable - /// `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` until it has, proven by a - /// post-then-immediately-refetch test. - /// - /// Every failure fails closed to the writer and is recorded in - /// `buzz_db_route_decision`. - #[datastore_span(name = "get_channel_window", system = "postgresql")] - pub async fn get_channel_window_with_session( - &self, - community_id: CommunityId, - channel_id: Uuid, - limit: u32, - cursor: Option<(DateTime, Vec)>, - kind_filter: Option<&[u32]>, - ) -> Result<(thread::ChannelWindow, ReadSession)> { - let path: &'static str = if cursor.is_some() { - "channel_cursor" - } else { - "channel_head" - }; - match self - .route_read( - path, - RoutePredicate::from_channel_cursor(channel_id, &cursor), - ) - .await - { - RouteDecision::Replica(mut tx, _entry, reason) => { - match thread::get_channel_window_on( - &mut tx, - community_id, - channel_id, - limit, - cursor.clone(), - kind_filter, - ) - .await - { - Ok(window) => { - Self::record_route(path, "replica", reason); - return Ok(( - window, - ReadSession { - inner: ReadSessionInner::Replica { - tx, - writer: self.pool.clone(), - }, - }, - )); - } - Err(e) => { - // A mid-request replica failure (e.g. a hot-standby - // recovery conflict cancelling the held snapshot) - // fails closed to the writer: a stale-but-served - // page, never an error the writer could have - // answered. Dropping `tx` rolls the reader - // transaction back. - tracing::warn!( - error = %e, - path, - "replica window query failed; re-running on writer" - ); - Self::record_route(path, "writer", "replica_error"); - } - } - } - RouteDecision::Writer => {} - } - let window = thread::get_channel_window( - &self.pool, - community_id, - channel_id, - limit, - cursor, - kind_filter, - ) - .await?; - Ok(( - window, - ReadSession { - inner: ReadSessionInner::Writer(self.pool.clone()), - }, - )) - } - - /// Shared route decision for one read: evaluate the predicate against a - /// proved reader session and record the decision. Fail closed to the - /// writer everywhere. - async fn route_read(&self, path: &'static str, predicate: RoutePredicate) -> RouteDecision { - let Some(read_pool) = &self.read_pool else { - Self::record_route(path, "writer", "disabled"); - return RouteDecision::Writer; - }; - // Cheap prechecks on the shared ring before spending a reader - // checkout; the connection-local observation still has to prove it. - let Some(newest) = self.fence.newest() else { - Self::record_route(path, "writer", "uninitialized"); - return RouteDecision::Writer; - }; - // Precheck helpers against the newest shared entry: if the newest - // cannot satisfy an arm, no proved (older-or-equal) entry can. - let bounded_precheck = - |budget: &Option| -> std::result::Result<(), &'static str> { - match budget { - Some(budget) if newest.committed_at.elapsed() <= *budget => Ok(()), - Some(_) => Err("stale"), - None => Err("disabled"), - } - }; - let covered_precheck = |upper: &DateTime| -> std::result::Result<(), &'static str> { - if *upper <= newest.fence_wall { - Ok(()) - } else { - Err("stale") - } - }; - let precheck = match &predicate { - RoutePredicate::Bounded => bounded_precheck(&self.replica_read_max_age), - RoutePredicate::Covered { upper, .. } => covered_precheck(upper), - // No upper bound: the caller post-verifies served rows. - RoutePredicate::CoveredPostVerified { .. } => Ok(()), - // Covered first (no budget dependence), else bounded. - RoutePredicate::BoundedOrCovered { upper, .. } => { - covered_precheck(upper).or_else(|_| bounded_precheck(&self.replica_read_max_age)) - } - }; - if let Err(reason) = precheck { - Self::record_route(path, "writer", reason); - return RouteDecision::Writer; - } - match self.proved_reader(read_pool).await { - Ok((tx, entry)) => { - // Re-evaluate against the entry the session actually proved - // (it may be older than the shared newest). - let bounded_holds = || { - self.replica_read_max_age - .is_some_and(|budget| entry.committed_at.elapsed() <= budget) - }; - let verdict: Option<&'static str> = match &predicate { - RoutePredicate::Bounded => bounded_holds().then_some("fresh"), - RoutePredicate::Covered { upper, .. } => { - (*upper <= entry.fence_wall).then_some("covered") - } - // No upper bound: the caller post-verifies the served - // rows against the proved wall. - RoutePredicate::CoveredPostVerified { .. } => Some("covered"), - RoutePredicate::BoundedOrCovered { upper, .. } => { - if *upper <= entry.fence_wall { - Some("covered") - } else { - bounded_holds().then_some("fresh") - } - } - }; - match verdict { - Some(reason) => RouteDecision::Replica(tx, entry, reason), - None => { - // The session proves an older entry than the - // predicate needs (replication lag) — fail closed. - Self::record_route(path, "writer", "stale"); - RouteDecision::Writer - } - } - } - Err(reason) => { - Self::record_route(path, "writer", reason); - RouteDecision::Writer - } - } - } - - /// Look up a single thread_metadata row by event_id. - #[datastore_span(name = "get_thread_metadata_by_event", system = "postgresql")] - pub async fn get_thread_metadata_by_event( - &self, - community_id: CommunityId, - event_id: &[u8], - ) -> Result> { - thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await - } - - /// Decrement reply counts. - #[datastore_span(name = "decrement_reply_count", system = "postgresql")] - pub async fn decrement_reply_count( - &self, - community_id: CommunityId, - parent_event_id: &[u8], - root_event_id: Option<&[u8]>, - ) -> Result<()> { - thread::decrement_reply_count(&self.pool, community_id, parent_event_id, root_event_id) - .await - } - - /// Add (or re-activate) a reaction. - #[datastore_span(name = "add_reaction", system = "postgresql")] - pub async fn add_reaction( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: Option<&[u8]>, - ) -> Result { - reaction::add_reaction( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - reaction_event_id, - ) - .await - } - - /// Soft-delete a reaction. - #[datastore_span(name = "remove_reaction", system = "postgresql")] - pub async fn remove_reaction( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - ) -> Result { - reaction::remove_reaction( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - ) - .await - } - - /// Soft-delete a reaction by its source event ID. - #[datastore_span(name = "remove_reaction_by_source_event_id", system = "postgresql")] - pub async fn remove_reaction_by_source_event_id( - &self, - community: CommunityId, - reaction_event_id: &[u8], - ) -> Result { - reaction::remove_reaction_by_source_event_id(&self.pool, community, reaction_event_id).await - } - - /// Look up the active reaction row for one actor + emoji + target tuple. - #[datastore_span(name = "get_active_reaction_record", system = "postgresql")] - pub async fn get_active_reaction_record( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - ) -> Result> { - reaction::get_active_reaction_record( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - ) - .await - } - - /// Backfill the source event ID on an active reaction row. - #[datastore_span(name = "set_reaction_event_id", system = "postgresql")] - pub async fn set_reaction_event_id( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: &[u8], - ) -> Result { - reaction::set_reaction_event_id( - &self.pool, - community, - event_id, - event_created_at, - pubkey, - emoji, - reaction_event_id, - ) - .await - } - - /// Get all active reactions for an event, grouped by emoji. - #[datastore_span(name = "get_reactions", system = "postgresql")] - pub async fn get_reactions( - &self, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - limit: u32, - cursor: Option<&str>, - ) -> Result> { - reaction::get_reactions( - &self.pool, - community, - event_id, - event_created_at, - limit, - cursor, - ) - .await - } - - /// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. - #[datastore_span(name = "get_reactions_bulk", system = "postgresql")] - pub async fn get_reactions_bulk( - &self, - community: CommunityId, - event_ids: &[(&[u8], DateTime)], - ) -> Result> { - reaction::get_reactions_bulk(&self.pool, community, event_ids).await - } - - /// Find events that @mention the given pubkey. - #[datastore_span(name = "query_feed_mentions", system = "postgresql")] - pub async fn query_feed_mentions( - &self, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_mentions( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - - /// [`Db::query_feed_mentions`] with replica routing — same contract and - /// classification-table requirement as [`Db::query_events_routed`]. - /// - /// Feed queries route on the BOUNDED arm only: the `accessible_channel_ids` - /// parameter admits community-global rows alongside channel rows, so no - /// single channel's fence floor can prove completeness — the covered arm - /// is structurally unavailable, not merely unchosen. - #[datastore_span(name = "query_feed_mentions_routed", system = "postgresql")] - pub async fn query_feed_mentions_routed( - &self, - path: &'static str, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match feed::query_mentions_on( - &mut tx, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - feed::query_mentions( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - RouteDecision::Writer => { - feed::query_mentions( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - - /// Find events that require action from the given pubkey. - #[datastore_span(name = "query_feed_needs_action", system = "postgresql")] - pub async fn query_feed_needs_action( - &self, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_needs_action( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - - /// [`Db::query_feed_needs_action`] with replica routing — BOUNDED arm - /// only; see [`Db::query_feed_mentions_routed`] for why the covered arm - /// is structurally unavailable to feed queries. - #[datastore_span(name = "query_feed_needs_action_routed", system = "postgresql")] - pub async fn query_feed_needs_action_routed( - &self, - path: &'static str, - community: CommunityId, - pubkey_bytes: &[u8], - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match feed::query_needs_action_on( - &mut tx, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - feed::query_needs_action( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - RouteDecision::Writer => { - feed::query_needs_action( - &self.pool, - community, - pubkey_bytes, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - - /// Find recent activity across accessible channels. - #[datastore_span(name = "query_feed_activity", system = "postgresql")] - pub async fn query_feed_activity( - &self, - community: CommunityId, - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await - } - - /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; - /// see [`Db::query_feed_mentions_routed`] for why the covered arm is - /// structurally unavailable to feed queries. - #[datastore_span(name = "query_feed_activity_routed", system = "postgresql")] - pub async fn query_feed_activity_routed( - &self, - path: &'static str, - community: CommunityId, - accessible_channel_ids: &[Uuid], - since: Option>, - limit: i64, - ) -> Result> { - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match feed::query_activity_on( - &mut tx, - community, - accessible_channel_ids, - since, - limit, - ) - .await - { - Ok(events) => { - Self::record_route(path, "replica", reason); - Ok(events) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - feed::query_activity( - &self.pool, - community, - accessible_channel_ids, - since, - limit, - ) - .await - } - } - } - RouteDecision::Writer => { - feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) - .await - } - } - } - - /// Create a new API token record. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "create_api_token", system = "postgresql")] - pub async fn create_api_token( - &self, - community_id: CommunityId, - token_hash: &[u8], - owner_pubkey: &[u8], - name: &str, - scopes: &[String], - channel_ids: Option<&[Uuid]>, - expires_at: Option>, - ) -> Result { - api_token::create_api_token( - &self.pool, - *community_id.as_uuid(), - token_hash, - owner_pubkey, - name, - scopes, - channel_ids, - expires_at, - ) - .await - } - - /// Atomic conditional INSERT with 10-token limit (per (community, owner)). - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "create_api_token_if_under_limit", system = "postgresql")] - pub async fn create_api_token_if_under_limit( - &self, - community_id: CommunityId, - token_hash: &[u8], - owner_pubkey: &[u8], - name: &str, - scopes: &[String], - channel_ids: Option<&[Uuid]>, - expires_at: Option>, - ) -> Result> { - api_token::create_api_token_if_under_limit( - &self.pool, - *community_id.as_uuid(), - token_hash, - owner_pubkey, - name, - scopes, - channel_ids, - expires_at, - ) - .await - } - - /// Look up an active (non-revoked) API token by its SHA-256 hash, - /// scoped to the request's community. - /// - /// See [`api_token::get_api_token_by_hash_including_revoked`] for the - /// row-44 conformance rationale — the `(community_id, token_hash)` key - /// is enforced both by the storage UNIQUE index and by this WHERE clause. - #[datastore_span(name = "get_api_token_by_hash", system = "postgresql")] - pub async fn get_api_token_by_hash( - &self, - community_id: CommunityId, - hash: &[u8], - ) -> Result> { - let row = sqlx::query( - r#" - SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, - created_at, expires_at, last_used_at, revoked_at - FROM api_tokens - WHERE community_id = $1 AND token_hash = $2 AND revoked_at IS NULL - "#, - ) - .bind(community_id.as_uuid()) - .bind(hash) - .fetch_optional(&self.pool) - .await?; - - match row { - None => Ok(None), - Some(r) => parse_api_token_row(r).map(Some), - } - } - - /// Look up an API token by hash, including revoked, scoped to community. - #[datastore_span( - name = "get_api_token_by_hash_including_revoked", - system = "postgresql" - )] - pub async fn get_api_token_by_hash_including_revoked( - &self, - community_id: CommunityId, - hash: &[u8], - ) -> Result> { - api_token::get_api_token_by_hash_including_revoked( - &self.pool, - *community_id.as_uuid(), - hash, - ) - .await - } - - /// Record a token usage (update `last_used_at`), scoped to community. - #[datastore_span(name = "touch_api_token", system = "postgresql")] - pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { - sqlx::query( - "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", - ) - .bind(community_id.as_uuid()) - .bind(hash) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Alias for [`Self::touch_api_token`]. - pub async fn update_token_last_used( - &self, - community_id: CommunityId, - hash: &[u8], - ) -> Result<()> { - self.touch_api_token(community_id, hash).await - } - - /// List all active (non-revoked) tokens in a community, newest first. - #[datastore_span(name = "list_active_tokens", system = "postgresql")] - pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result> { - let rows = sqlx::query( - r#" - SELECT id, name, owner_pubkey, scopes, created_at, expires_at - FROM api_tokens - WHERE community_id = $1 AND revoked_at IS NULL - ORDER BY created_at DESC - LIMIT 1000 - "#, - ) - .bind(community_id.as_uuid()) - .fetch_all(&self.pool) - .await?; - - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - let id: Uuid = row.try_get("id")?; - let scopes_json: serde_json::Value = row.try_get("scopes")?; - let scopes: Vec = serde_json::from_value(scopes_json) - .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; - - out.push(TokenSummary { - id, - name: row.try_get("name")?, - owner_pubkey: row.try_get("owner_pubkey")?, - scopes, - created_at: row.try_get("created_at")?, - expires_at: row.try_get("expires_at")?, - }); - } - Ok(out) - } - - /// List all tokens for a (community, owner) pair (including revoked). - #[datastore_span(name = "list_tokens_by_owner", system = "postgresql")] - pub async fn list_tokens_by_owner( - &self, - community_id: CommunityId, - pubkey: &[u8], - ) -> Result> { - api_token::list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await - } - - /// Revoke a single token by ID, scoped to (community, owner). - #[datastore_span(name = "revoke_token", system = "postgresql")] - pub async fn revoke_token( - &self, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], - revoked_by: &[u8], - ) -> Result { - api_token::revoke_token( - &self.pool, - *community_id.as_uuid(), - id, - owner_pubkey, - revoked_by, - ) - .await - } - - /// Revoke all active tokens for a (community, owner) pair. - #[datastore_span(name = "revoke_all_tokens", system = "postgresql")] - pub async fn revoke_all_tokens( - &self, - community_id: CommunityId, - owner_pubkey: &[u8], - revoked_by: &[u8], - ) -> Result { - api_token::revoke_all_tokens( - &self.pool, - *community_id.as_uuid(), - owner_pubkey, - revoked_by, - ) - .await - } - - /// Create a new workflow. - #[datastore_span(name = "create_workflow", system = "postgresql")] - pub async fn create_workflow( - &self, - community_id: CommunityId, - channel_id: Option, - owner_pubkey: &[u8], - name: &str, - definition_json: &str, - definition_hash: &[u8], - ) -> Result { - workflow::create_workflow( - &self.pool, - community_id, - channel_id, - owner_pubkey, - name, - definition_json, - definition_hash, - ) - .await - } - - /// Insert or update a workflow using its NIP-33 `d`-tag UUID. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "upsert_workflow", system = "postgresql")] - pub async fn upsert_workflow( - &self, - community_id: CommunityId, - id: Uuid, - channel_id: Option, - owner_pubkey: &[u8], - name: &str, - definition_json: &str, - definition_hash: &[u8], - ) -> Result<()> { - workflow::upsert_workflow( - &self.pool, - community_id, - id, - channel_id, - owner_pubkey, - name, - definition_json, - definition_hash, - ) - .await - } - - /// Fetch a single workflow by ID, scoped to its community. - #[datastore_span(name = "get_workflow", system = "postgresql")] - pub async fn get_workflow( - &self, - community_id: CommunityId, - id: Uuid, - ) -> Result { - workflow::get_workflow(&self.pool, community_id, id).await - } - - /// List workflows for a channel. - #[datastore_span(name = "list_channel_workflows", system = "postgresql")] - pub async fn list_channel_workflows( - &self, - community_id: CommunityId, - channel_id: Uuid, - limit: Option, - offset: Option, - ) -> Result> { - workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset).await - } - - /// List active, enabled workflows for a channel. - #[datastore_span(name = "list_enabled_channel_workflows", system = "postgresql")] - pub async fn list_enabled_channel_workflows( - &self, - community_id: CommunityId, - channel_id: Uuid, - ) -> Result> { - workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await - } - - /// List all active, enabled schedule-triggered workflows. - #[datastore_span(name = "list_all_enabled_workflows", system = "postgresql")] - pub async fn list_all_enabled_workflows(&self) -> Result> { - workflow::list_all_enabled_workflows(&self.pool).await - } - - /// Claim a scheduled workflow fire for an authoritative schedule instant. - /// - /// Returns `Some` only for the first pod to claim `(community_id, - /// workflow_id, scheduled_for)`; all other pods must skip creating a run. - /// `community_id` is server provenance (the workflow row's own community - /// from the scheduler scan), never client-supplied — `workflows` is keyed - /// `(community_id, id)`, so the claim must bind both to avoid fanning - /// across communities that share the workflow UUID. - #[datastore_span(name = "claim_scheduled_workflow_fire", system = "postgresql")] - pub async fn claim_scheduled_workflow_fire( - &self, - community_id: CommunityId, - workflow_id: Uuid, - scheduled_for: chrono::DateTime, - ) -> Result> { - workflow::claim_scheduled_workflow_fire( - &self.pool, - community_id, - workflow_id, - scheduled_for, - ) - .await - } - - /// Fetch the latest claimed schedule instant for interval trigger anchoring. - #[datastore_span(name = "latest_scheduled_workflow_fire", system = "postgresql")] - pub async fn latest_scheduled_workflow_fire( - &self, - community_id: CommunityId, - workflow_id: Uuid, - ) -> Result>> { - workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await - } - - /// Attach the workflow run id created from a won scheduled-fire claim. - #[datastore_span(name = "attach_scheduled_workflow_run", system = "postgresql")] - pub async fn attach_scheduled_workflow_run( - &self, - community_id: CommunityId, - workflow_id: Uuid, - scheduled_for: chrono::DateTime, - workflow_run_id: Uuid, - ) -> Result { - workflow::attach_scheduled_workflow_run( - &self.pool, - community_id, - workflow_id, - scheduled_for, - workflow_run_id, - ) - .await - } - - /// Delete old scheduled workflow fire claims before a retention cutoff. - #[datastore_span(name = "prune_scheduled_workflow_fires_before", system = "postgresql")] - pub async fn prune_scheduled_workflow_fires_before( - &self, - older_than: chrono::DateTime, - ) -> Result { - workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await - } - - /// Update a workflow's name, definition, and hash. - #[datastore_span(name = "update_workflow", system = "postgresql")] - pub async fn update_workflow( - &self, - community_id: CommunityId, - id: Uuid, - name: &str, - definition_json: &str, - definition_hash: &[u8], - ) -> Result<()> { - workflow::update_workflow( - &self.pool, - community_id, - id, - name, - definition_json, - definition_hash, - ) - .await - } - - /// Update a workflow's status. - #[datastore_span(name = "update_workflow_status", system = "postgresql")] - pub async fn update_workflow_status( - &self, - community_id: CommunityId, - id: Uuid, - status: workflow::WorkflowStatus, - ) -> Result<()> { - workflow::update_workflow_status(&self.pool, community_id, id, status).await - } - - /// Enable or disable a workflow. - #[datastore_span(name = "set_workflow_enabled", system = "postgresql")] - pub async fn set_workflow_enabled( - &self, - community_id: CommunityId, - id: Uuid, - enabled: bool, - ) -> Result<()> { - workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await - } - - /// Disable all of an owner's workflows in a channel (SEC-006, on - /// membership loss). Returns the number of workflows disabled. - #[datastore_span(name = "disable_workflows_for_owner_in_channel", system = "postgresql")] - pub async fn disable_workflows_for_owner_in_channel( - &self, - community_id: CommunityId, - channel_id: Uuid, - owner_pubkey: &[u8], - ) -> Result { - workflow::disable_workflows_for_owner_in_channel( - &self.pool, - community_id, - channel_id, - owner_pubkey, - ) - .await - } - - /// Delete a workflow and all its runs/approvals. - #[datastore_span(name = "delete_workflow", system = "postgresql")] - pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { - workflow::delete_workflow(&self.pool, community_id, id).await - } - - /// Delete a workflow only when it belongs to the provided owner. - /// Returns the deleted workflow's `channel_id`. - #[datastore_span(name = "delete_workflow_for_owner", system = "postgresql")] - pub async fn delete_workflow_for_owner( - &self, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], - ) -> Result> { - workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await - } - - /// Find a workflow by owner pubkey and name within a community. Used for - /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). - #[datastore_span(name = "find_workflow_by_owner_and_name", system = "postgresql")] - pub async fn find_workflow_by_owner_and_name( - &self, - community_id: CommunityId, - owner_pubkey: &[u8], - name: &str, - ) -> Result> { - workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await - } - - /// Create a new workflow run. - #[datastore_span(name = "create_workflow_run", system = "postgresql")] - pub async fn create_workflow_run( - &self, - community_id: CommunityId, - workflow_id: Uuid, - trigger_event_id: Option<&[u8]>, - trigger_context: Option<&serde_json::Value>, - ) -> Result { - workflow::create_workflow_run( - &self.pool, - community_id, - workflow_id, - trigger_event_id, - trigger_context, - ) - .await - } - - /// Fetch a single workflow run, scoped to its community. - #[datastore_span(name = "get_workflow_run", system = "postgresql")] - pub async fn get_workflow_run( - &self, - community_id: CommunityId, - id: Uuid, - ) -> Result { - workflow::get_workflow_run(&self.pool, community_id, id).await - } - - /// List runs for a workflow. - #[datastore_span(name = "list_workflow_runs", system = "postgresql")] - pub async fn list_workflow_runs( - &self, - community_id: CommunityId, - workflow_id: Uuid, - limit: i64, - ) -> Result> { - workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await - } - - /// List one keyset-paginated page of workflow runs. - #[datastore_span(name = "list_workflow_runs_page", system = "postgresql")] - pub async fn list_workflow_runs_page( - &self, - community_id: CommunityId, - workflow_id: Uuid, - before: Option>, - before_id: Option, - limit: i64, - ) -> Result> { - workflow::list_workflow_runs_page( - &self.pool, - community_id, - workflow_id, - before, - before_id, - limit, - ) - .await - } - - /// Update a workflow run's status. - #[datastore_span(name = "update_workflow_run", system = "postgresql")] - pub async fn update_workflow_run( - &self, - community_id: CommunityId, - id: Uuid, - status: workflow::RunStatus, - current_step: i32, - trace: &serde_json::Value, - failure: Option>, - ) -> Result<()> { - workflow::update_workflow_run( - &self.pool, - community_id, - id, - status, - current_step, - trace, - failure, - ) - .await - } - - /// Create an approval request. - #[datastore_span(name = "create_approval", system = "postgresql")] - pub async fn create_approval(&self, params: workflow::CreateApprovalParams<'_>) -> Result<()> { - workflow::create_approval(&self.pool, params).await - } - - /// Fetch an approval by raw token. - #[datastore_span(name = "get_approval", system = "postgresql")] - pub async fn get_approval( - &self, - community_id: CommunityId, - token: &str, - ) -> Result { - workflow::get_approval(&self.pool, community_id, token).await - } - - /// Fetch an approval by its already-hashed token (no re-hashing). - #[datastore_span(name = "get_approval_by_stored_hash", system = "postgresql")] - pub async fn get_approval_by_stored_hash( - &self, - community_id: CommunityId, - token_hash: &[u8], - ) -> Result { - workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await - } - - /// Fetch all approvals for a workflow run. - #[datastore_span(name = "get_run_approvals", system = "postgresql")] - pub async fn get_run_approvals( - &self, - community_id: CommunityId, - workflow_id: uuid::Uuid, - run_id: uuid::Uuid, - ) -> Result> { - workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await - } - - /// Update an approval's status. - #[datastore_span(name = "update_approval", system = "postgresql")] - pub async fn update_approval( - &self, - community_id: CommunityId, - token: &str, - status: workflow::ApprovalStatus, - approver_pubkey: Option<&[u8]>, - note: Option<&str>, - ) -> Result { - workflow::update_approval( - &self.pool, - community_id, - token, - status, - approver_pubkey, - note, - ) - .await - } - - /// Update an approval by its already-hashed token (no re-hashing). - #[datastore_span(name = "update_approval_by_stored_hash", system = "postgresql")] - pub async fn update_approval_by_stored_hash( - &self, - community_id: CommunityId, - token_hash: &[u8], - status: workflow::ApprovalStatus, - approver_pubkey: Option<&[u8]>, - note: Option<&str>, - ) -> Result { - workflow::update_approval_by_stored_hash( - &self.pool, - community_id, - token_hash, - status, - approver_pubkey, - note, - ) - .await - } - - /// Ensures monthly partitions exist for the next N months. - #[datastore_span(name = "ensure_future_partitions", system = "postgresql")] - pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { - partition::ensure_future_partitions(&self.pool, months_ahead).await - } - - /// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`. - /// - /// Idempotent — safe to call on every startup. No-ops when all rows are already populated. - /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. - #[datastore_span(name = "backfill_d_tags", system = "postgresql")] - pub async fn backfill_d_tags(&self) -> Result { - let result = sqlx::query( - "UPDATE events \ - SET d_tag = COALESCE( \ - (SELECT elem->>1 FROM jsonb_array_elements(tags) AS elem \ - WHERE elem->>0 = 'd' LIMIT 1), \ - '' \ - ) \ - WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL \ - AND community_write_allowed(community_id)", - ) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - /// Check if a pubkey is in the allowlist for `community`. - #[datastore_span(name = "is_pubkey_allowed", system = "postgresql")] - pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { - let row = sqlx::query( - "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .fetch_one(&self.pool) - .await?; - let cnt: i64 = row.try_get("cnt")?; - Ok(cnt > 0) - } - - /// Check if the community allowlist has any entries (i.e. is enforcement active). - #[datastore_span(name = "has_allowlist_entries", system = "postgresql")] - pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { - let row = - sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") - .bind(community.as_uuid()) - .fetch_one(&self.pool) - .await?; - let cnt: i64 = row.try_get("cnt")?; - Ok(cnt > 0) - } - - /// Add a pubkey to the community allowlist. - #[datastore_span(name = "add_to_allowlist", system = "postgresql")] - pub async fn add_to_allowlist( - &self, - community: CommunityId, - pubkey: &[u8], - added_by: &[u8], - note: Option<&str>, - ) -> Result { - let result = sqlx::query( - "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ - ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(added_by) - .bind(note) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - /// Remove a pubkey from the community allowlist. - #[datastore_span(name = "remove_from_allowlist", system = "postgresql")] - pub async fn remove_from_allowlist( - &self, - community: CommunityId, - pubkey: &[u8], - ) -> Result { - let result = - sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") - .bind(community.as_uuid()) - .bind(pubkey) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - - /// List all pubkeys in the community allowlist. - #[datastore_span(name = "list_allowlist", system = "postgresql")] - pub async fn list_allowlist(&self, community: CommunityId) -> Result> { - let rows = sqlx::query( - "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", - ) - .bind(community.as_uuid()) - .fetch_all(&self.pool) - .await?; - - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - out.push(AllowlistEntry { - pubkey: row.try_get("pubkey")?, - added_by: row.try_get("added_by")?, - added_at: row.try_get("added_at")?, - note: row.try_get("note")?, - }); - } - Ok(out) - } - - /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. - /// - /// Replica-routed on the bounded arm — the one PERMISSION read routed by - /// explicit product decision (bounded-stale membership beats the 10s - /// cache it replaced). Admits and revokes may lag by at most the budget - /// `B`; everything else fails closed to the writer, exactly like - /// [`Db::query_events_routed_bounded`]. Not precedent for routing other - /// permission reads. - #[datastore_span(name = "is_relay_member", system = "postgresql")] - pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { - let path = "relay_membership"; - match self.route_read(path, RoutePredicate::Bounded).await { - RouteDecision::Replica(mut tx, _entry, reason) => { - match relay_members::is_relay_member_on(&mut tx, community, pubkey).await { - Ok(is_member) => { - Self::record_route(path, "replica", reason); - Ok(is_member) - } - Err(e) => { - tracing::warn!(path, "replica read failed; re-running on writer: {e}"); - Self::record_route(path, "writer", "replica_error"); - relay_members::is_relay_member(&self.pool, community, pubkey).await - } - } - } - RouteDecision::Writer => { - relay_members::is_relay_member(&self.pool, community, pubkey).await - } - } - } - - /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. - #[datastore_span(name = "get_relay_member", system = "postgresql")] - pub async fn get_relay_member( - &self, - community: CommunityId, - pubkey: &str, - ) -> Result> { - relay_members::get_relay_member(&self.pool, community, pubkey).await - } - - /// Returns all relay members of `community` ordered by `created_at` ascending. - #[datastore_span(name = "list_relay_members", system = "postgresql")] - pub async fn list_relay_members( - &self, - community: CommunityId, - ) -> Result> { - relay_members::list_relay_members(&self.pool, community).await - } - - /// Adds a new relay member to `community`. - /// - /// Returns `true` if the row was actually inserted, `false` if the pubkey - /// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`). - #[datastore_span(name = "add_relay_member", system = "postgresql")] - pub async fn add_relay_member( - &self, - community: CommunityId, - pubkey: &str, - role: &str, - added_by: Option<&str>, - ) -> Result { - relay_members::add_relay_member(&self.pool, community, pubkey, role, added_by).await - } - - /// Claims relay membership via an invite and atomically persists the - /// accepted policy version when a policy is configured. - #[datastore_span(name = "claim_relay_membership", system = "postgresql")] - pub async fn claim_relay_membership( - &self, - community: CommunityId, - pubkey: &str, - role: &str, - policy_version: Option<&str>, - ) -> Result { - relay_members::claim_relay_membership(&self.pool, community, pubkey, role, policy_version) - .await - } - - /// Returns whether a member has persisted acceptance evidence for a policy version. - #[datastore_span(name = "has_join_policy_acceptance", system = "postgresql")] - pub async fn has_join_policy_acceptance( - &self, - community: CommunityId, - pubkey: &str, - policy_version: &str, - ) -> Result { - relay_members::has_join_policy_acceptance(&self.pool, community, pubkey, policy_version) - .await - } - - /// Removes a relay member from `community` atomically, refusing to delete the owner. - #[datastore_span(name = "remove_relay_member", system = "postgresql")] - pub async fn remove_relay_member( - &self, - community: CommunityId, - pubkey: &str, - ) -> Result { - relay_members::remove_relay_member(&self.pool, community, pubkey).await - } - - /// Removes a relay member from `community` only if their current role matches `expected_role`. - /// - /// Atomic conditional delete — eliminates the TOCTOU race between a - /// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`]. - #[datastore_span(name = "remove_relay_member_if_role", system = "postgresql")] - pub async fn remove_relay_member_if_role( - &self, - community: CommunityId, - pubkey: &str, - expected_role: &str, - ) -> Result { - relay_members::remove_relay_member_if_role(&self.pool, community, pubkey, expected_role) - .await - } - - /// Updates the role of an existing relay member in `community`. Returns `true` if updated. - #[datastore_span(name = "update_relay_member_role", system = "postgresql")] - pub async fn update_relay_member_role( - &self, - community: CommunityId, - pubkey: &str, - new_role: &str, - ) -> Result { - relay_members::update_relay_member_role(&self.pool, community, pubkey, new_role).await - } - - /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. - #[datastore_span(name = "bootstrap_owner", system = "postgresql")] - pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { - relay_members::bootstrap_owner(&self.pool, community, owner_pubkey).await - } - - /// Returns `true` if any member of `community` holds the `admin` or - /// `owner` role. - pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { - relay_members::has_admin_or_owner(&self.pool, community).await - } - - /// Atomically transfers ownership of `community` to `new_owner_pubkey`, - /// demoting the previous owner(s) to `member`. Verifies - /// `expected_owner_pubkey` matches the current owner inside the same - /// transaction to prevent stale-owner races. - #[datastore_span(name = "transfer_ownership", system = "postgresql")] - pub async fn transfer_ownership( - &self, - community: CommunityId, - new_owner_pubkey: &str, - expected_owner_pubkey: &str, - ) -> Result { - relay_members::transfer_ownership( - &self.pool, - community, - new_owner_pubkey, - expected_owner_pubkey, - ) - .await - } - - /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. - /// - /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows - /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. - #[datastore_span(name = "backfill_from_allowlist", system = "postgresql")] - pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { - relay_members::backfill_from_allowlist(&self.pool, community).await - } - - // ── relay_operators (deployment-global principal roster) ────────────────── - - /// Fetch one relay operator/moderator row by pubkey (32-byte binary). - pub async fn get_relay_operator( - &self, - pubkey: &[u8], - ) -> Result> { - relay_operators::get(&self.pool, pubkey).await - } - - /// List all relay operator/moderator rows ordered by creation time. - pub async fn list_relay_operators(&self) -> Result> { - relay_operators::list(&self.pool).await - } - - /// Insert or update a relay operator/moderator row (upsert by pubkey). - /// - /// `config_operator_exists` is the caller's request-time snapshot of - /// whether a config-backed operator is effective; a demotion that would - /// leave no effective operator is rejected with [`DbError::LastOperator`]. - pub async fn upsert_relay_operator( - &self, - pubkey: &[u8], - role: &str, - added_by: &[u8], - config_operator_exists: bool, - ) -> Result<()> { - relay_operators::upsert(&self.pool, pubkey, role, added_by, config_operator_exists).await - } - - /// Remove a relay operator/moderator row. Returns `true` if deleted. - /// Records the revocation in the append-only audit trail; `actor` is the - /// authenticated operator performing the removal. `config_operator_exists` - /// is the caller's request-time snapshot of whether a config-backed - /// operator is effective; deleting the sole effective operator is rejected - /// with [`DbError::LastOperator`]. - pub async fn remove_relay_operator( - &self, - pubkey: &[u8], - actor: &[u8], - config_operator_exists: bool, - ) -> Result { - relay_operators::remove(&self.pool, pubkey, actor, config_operator_exists).await - } - - // ── relay_admin_actions (HTTP enforcement state machine) ────────────────── - - /// Atomic decision-only report closure: CAS open→terminal + audit row in one transaction. - #[allow(clippy::too_many_arguments)] - pub async fn resolve_report_decision_atomic( - &self, - community_id: CommunityId, - report_id: uuid::Uuid, - terminal_status: &str, - audit_action: &str, - actor_pubkey: &[u8], - actor_authority: &str, - target_pubkey: Option<&[u8]>, - target_event_id: Option<&[u8]>, - channel_id: Option, - reason: Option<&str>, - ) -> Result { - relay_admin_actions::resolve_report_decision_atomic( - &self.pool, - community_id, - report_id, - terminal_status, - audit_action, - actor_pubkey, - actor_authority, - target_pubkey, - target_event_id, - channel_id, - reason, - ) - .await - } - - /// Attempt to claim a report for HTTP enforcement (CAS open → processing). - #[allow(clippy::too_many_arguments)] - pub async fn claim_report_for_enforcement( - &self, - community_id: CommunityId, - report_id: uuid::Uuid, - request_id: uuid::Uuid, - actor_pubkey: &[u8], - actor_role: &str, - action: &str, - reason: Option<&str>, - timeout_until: Option>, - audit_action: &str, - actor_authority: &str, - target_pubkey: Option<&[u8]>, - target_event_id: Option<&[u8]>, - channel_id: Option, - ) -> Result { - relay_admin_actions::claim_report( - &self.pool, - community_id, - report_id, - request_id, - actor_pubkey, - actor_role, - action, - reason, - timeout_until, - audit_action, - actor_authority, - target_pubkey, - target_event_id, - channel_id, - ) - .await - } - - /// Advance an action from 'pending' to 'enforcing'. - pub async fn begin_enforcing_action(&self, action_id: uuid::Uuid) -> Result { - relay_admin_actions::begin_enforcing(&self.pool, action_id).await - } - - /// Commit the core mutation step (advance step_marker to 'mutation_committed'). - pub async fn commit_action_mutation_step(&self, action_id: uuid::Uuid) -> Result { - relay_admin_actions::commit_mutation_step(&self.pool, action_id).await - } - - /// Finalize enforcement: action → succeeded, report → terminal status, - /// and enqueue outbox delivery rows atomically. - #[allow(clippy::too_many_arguments)] - pub async fn finalize_action_success( - &self, - action_id: uuid::Uuid, - community_id: CommunityId, - report_id: uuid::Uuid, - terminal_status: &str, - actor_pubkey: &[u8], - action_name: &str, - target_pubkey: Option<&[u8]>, - target_event_id: Option<&[u8]>, - channel_id: Option, - reason: Option<&str>, - timeout_until: Option>, - ) -> Result { - relay_admin_actions::finalize_success( - &self.pool, - action_id, - community_id, - report_id, - terminal_status, - actor_pubkey, - action_name, - target_pubkey, - target_event_id, - channel_id, - reason, - timeout_until, - ) - .await - } - - /// Atomically execute a ban mutation and commit the step marker. - /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. - pub async fn execute_ban_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], - reason: Option<&str>, - ) -> Result { - relay_admin_actions::execute_ban_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - target_pubkey, - actor_pubkey, - reason, - ) - .await - } - - /// Atomically execute a timeout mutation and commit the step marker. - /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. - #[allow(clippy::too_many_arguments)] - pub async fn execute_timeout_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - target_pubkey: &[u8], - actor_pubkey: &[u8], - until: chrono::DateTime, - reason: Option<&str>, - ) -> Result { - relay_admin_actions::execute_timeout_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - target_pubkey, - actor_pubkey, - until, - reason, - ) - .await - } - - /// Atomically execute a kick mutation and commit the step marker. - /// Returns `Removed` (member was present), `AlreadyGone` (absent before this action), - /// or `AlreadyMarked` (marker already committed by another driver or lease lost). - pub async fn execute_kick_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - channel_id: uuid::Uuid, - target_pubkey: &[u8], - actor_pubkey: &[u8], - ) -> Result { - relay_admin_actions::execute_kick_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - channel_id, - target_pubkey, - actor_pubkey, - ) - .await - } - - /// Atomically execute a soft-delete mutation and commit the step marker. - /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. - pub async fn execute_delete_with_marker( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - community_id: CommunityId, - target_event_id: &[u8], - parent_event_id: Option<&[u8]>, - root_event_id: Option<&[u8]>, - ) -> Result { - relay_admin_actions::execute_delete_with_marker( - &self.pool, - action_id, - lease_token, - community_id, - target_event_id, - parent_event_id, - root_event_id, - ) - .await - } - - /// Acquire the action mutation lease (prevents concurrent double-mutation). - pub async fn acquire_admin_action_lease( - &self, - action_id: uuid::Uuid, - lease_until: chrono::DateTime, - ) -> Result { - relay_admin_actions::acquire_action_lease(&self.pool, action_id, lease_until).await - } - - /// Release the action mutation lease. No-op if caller no longer holds the token. - pub async fn release_admin_action_lease( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - ) -> Result<()> { - relay_admin_actions::release_action_lease(&self.pool, action_id, lease_token).await - } - - /// Claim a batch of stranded `relay_admin_actions` for the action recovery worker. - pub async fn claim_stranded_admin_action_batch( - &self, - worker_id: &str, - lease_until: chrono::DateTime, - batch_size: i64, - ) -> Result> { - relay_admin_actions::claim_stranded_action_batch( - &self.pool, - worker_id, - lease_until, - batch_size, - ) - .await - } - - /// Record a pre-mutation enforcement failure (keeps report in 'processing'). - pub async fn record_action_failure( - &self, - action_id: uuid::Uuid, - lease_token: uuid::Uuid, - error: &str, - ) -> Result { - relay_admin_actions::record_failure(&self.pool, action_id, lease_token, error).await - } - - /// Cancel a pre-mutation failed action (returns report to 'open'), - /// attributing the cancel to `cancelled_by`. - pub async fn cancel_admin_action( - &self, - action_id: uuid::Uuid, - community_id: CommunityId, - report_id: uuid::Uuid, - cancelled_by: &[u8], - ) -> Result { - relay_admin_actions::cancel_action( - &self.pool, - action_id, - community_id, - report_id, - cancelled_by, - ) - .await - } - - /// Reopen a terminal report (resolved|dismissed|escalated → open) with a - /// durable `reopen` audit row, keyed idempotent on `request_id`. - pub async fn reopen_report( - &self, - community_id: CommunityId, - report_id: uuid::Uuid, - request_id: uuid::Uuid, - actor_pubkey: &[u8], - actor_role: &str, - reason: Option<&str>, - ) -> Result { - relay_admin_actions::reopen_report( - &self.pool, - community_id, - report_id, - request_id, - actor_pubkey, - actor_role, - reason, - ) - .await - } - - /// Fetch an action record by ID. - pub async fn get_admin_action( - &self, - action_id: uuid::Uuid, - ) -> Result> { - relay_admin_actions::get_action(&self.pool, action_id).await - } - - /// Enqueue an outbox artifact/notice delivery command. - pub async fn enqueue_admin_outbox( - &self, - action_id: uuid::Uuid, - task_type: &str, - payload: serde_json::Value, - dedup_key: &str, - ) -> Result<()> { - relay_admin_actions::enqueue_outbox(&self.pool, action_id, task_type, payload, dedup_key) - .await - } - - /// Mark an outbox record as delivered, fenced by the claim token. - /// Returns `true` if updated, `false` if ownership was already lost. - pub async fn mark_admin_outbox_delivered( - &self, - outbox_id: uuid::Uuid, - claim_token: uuid::Uuid, - ) -> Result { - relay_admin_actions::mark_outbox_delivered(&self.pool, outbox_id, claim_token).await - } - - /// Mark an outbox record as failed, fenced by the claim token. - /// Returns `true` if updated, `false` if ownership was already lost. - pub async fn fail_admin_outbox_row( - &self, - outbox_id: uuid::Uuid, - claim_token: uuid::Uuid, - error: &str, - ) -> Result { - relay_admin_actions::fail_outbox_row(&self.pool, outbox_id, claim_token, error).await - } - - /// Claim a batch of pending outbox rows for the given worker pod. - pub async fn claim_pending_admin_outbox_batch( - &self, - worker_id: &str, - lease_until: chrono::DateTime, - batch_size: i64, - ) -> Result> { - relay_admin_actions::claim_pending_outbox_batch( - &self.pool, - worker_id, - lease_until, - batch_size, - ) - .await - } - - /// List pending outbox records for an action. - pub async fn list_pending_admin_outbox( - &self, - action_id: uuid::Uuid, - ) -> Result> { - relay_admin_actions::list_pending_outbox(&self.pool, action_id).await - } - - /// Deployment-authority kick: remove a member without requiring tenant owner/admin actor. - pub async fn deploy_kick_member( - &self, - community_id: CommunityId, - channel_id: uuid::Uuid, - target_pubkey: &[u8], - actor_pubkey: &[u8], - ) -> Result { - relay_admin_actions::deploy_kick_member( - &self.pool, - community_id, - channel_id, - target_pubkey, - actor_pubkey, - ) - .await - } - - /// Update product_feedback status (operator-managed lifecycle). - pub async fn update_feedback_status(&self, id: uuid::Uuid, status: &str) -> Result { - relay_admin_actions::update_feedback_status(&self.pool, id, status).await - } - - /// Mints a v2 use-limited relay invite. The plaintext code is returned - /// exactly once; only its SHA-256 hash is persisted. - /// - /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. - /// `ttl_secs` must be in the shared invite lifetime range. - #[datastore_span(name = "mint_relay_invite", system = "postgresql")] - pub async fn mint_relay_invite( - &self, - community: CommunityId, - created_by: &str, - ttl_secs: u64, - max_uses: Option, - ) -> Result { - relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await - } - - /// Delete one bounded batch of invites expired before `cutoff`. - #[datastore_span(name = "reap_expired_relay_invites", system = "postgresql")] - pub async fn reap_expired_relay_invites( - &self, - cutoff: chrono::DateTime, - ) -> Result { - relay_invite::reap_expired_relay_invites(&self.pool, cutoff).await - } - - /// Atomically claims a v2 relay invite. The full redemption (membership - /// insert, policy evidence, use_count increment) runs in one PostgreSQL - /// transaction with `FOR UPDATE` on the invite row. - /// - /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). - #[datastore_span(name = "claim_relay_invite", system = "postgresql")] - pub async fn claim_relay_invite( - &self, - community: CommunityId, - token_hash: &[u8; 32], - claimer_pubkey: &str, - policy_version: Option<&str>, - ) -> Result { - relay_invite::claim_relay_invite( - &self.pool, - community, - token_hash, - claimer_pubkey, - policy_version, - ) - .await - } - - /// Sidecar an accepted product-feedback event, idempotent by event id. - #[datastore_span(name = "insert_product_feedback", system = "postgresql")] - pub async fn insert_product_feedback( - &self, - community: CommunityId, - feedback: product_feedback::NewProductFeedback<'_>, - ) -> Result { - product_feedback::insert(&self.pool, community, feedback).await - } - - /// List product feedback across the deployment, newest first. - #[datastore_span(name = "list_product_feedback", system = "postgresql")] - pub async fn list_product_feedback( - &self, - limit: i64, - ) -> Result> { - product_feedback::list(&self.pool, limit).await - } - - /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. - #[datastore_span(name = "insert_moderation_report", system = "postgresql")] - pub async fn insert_moderation_report( - &self, - community: CommunityId, - report: moderation::NewReport<'_>, - ) -> Result { - moderation::insert_report(&self.pool, community, report).await - } - - /// List moderation reports for a community, newest first. - #[datastore_span(name = "list_moderation_reports", system = "postgresql")] - pub async fn list_moderation_reports( - &self, - community: CommunityId, - status: Option<&str>, - limit: i64, - ) -> Result> { - moderation::list_reports(&self.pool, community, status, limit).await - } - - /// Fetch one moderation report by row id. - #[datastore_span(name = "get_moderation_report", system = "postgresql")] - pub async fn get_moderation_report( - &self, - community: CommunityId, - report_id: Uuid, - ) -> Result> { - moderation::get_report(&self.pool, community, report_id).await - } - - /// Fetch one moderation report by signed NIP-56 report event id. - #[datastore_span(name = "get_moderation_report_by_event", system = "postgresql")] - pub async fn get_moderation_report_by_event( - &self, - community: CommunityId, - report_event_id: &[u8], - ) -> Result> { - moderation::get_report_by_event(&self.pool, community, report_event_id).await - } - - /// Resolve, dismiss, or escalate an open moderation report. - #[datastore_span(name = "resolve_moderation_report", system = "postgresql")] - pub async fn resolve_moderation_report( - &self, - community: CommunityId, - report_id: Uuid, - status: &str, - resolved_by: &[u8], - action_id: Option, - ) -> Result { - moderation::resolve_report( - &self.pool, - community, - report_id, - status, - resolved_by, - action_id, - ) - .await - } - - /// Upsert a community ban for a member pubkey. - #[datastore_span(name = "ban_community_member", system = "postgresql")] - pub async fn ban_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - reason: Option<&str>, - expires_at: Option>, - ) -> Result<()> { - moderation::ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await - } - - /// Lift a community ban for a member pubkey. - #[datastore_span(name = "unban_community_member", system = "postgresql")] - pub async fn unban_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - ) -> Result { - moderation::unban_member(&self.pool, community, pubkey, actor).await - } - - /// Upsert a community timeout/write-block for a member pubkey. - #[datastore_span(name = "timeout_community_member", system = "postgresql")] - pub async fn timeout_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - muted_until: DateTime, - reason: Option<&str>, - ) -> Result<()> { - moderation::timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await - } - - /// Clear a community timeout/write-block for a member pubkey. - #[datastore_span(name = "untimeout_community_member", system = "postgresql")] - pub async fn untimeout_community_member( - &self, - community: CommunityId, - pubkey: &[u8], - actor: &[u8], - ) -> Result { - moderation::untimeout_member(&self.pool, community, pubkey, actor).await - } - - /// Fetch the active ban/timeout restriction state for enforcement hot paths. - #[datastore_span(name = "moderation_restriction_state", system = "postgresql")] - pub async fn moderation_restriction_state( - &self, - community: CommunityId, - pubkey: &[u8], - ) -> Result { - moderation::restriction_state(&self.pool, community, pubkey).await - } - - /// Fetch the full ban/timeout row for a member pubkey. - #[datastore_span(name = "get_community_ban", system = "postgresql")] - pub async fn get_community_ban( - &self, - community: CommunityId, - pubkey: &[u8], - ) -> Result> { - moderation::get_ban(&self.pool, community, pubkey).await - } - - /// List currently restricted members in a community. - #[datastore_span(name = "list_community_restrictions", system = "postgresql")] - pub async fn list_community_restrictions( - &self, - community: CommunityId, - ) -> Result> { - moderation::list_restricted(&self.pool, community).await - } - - /// Insert a moderation audit action row. - #[datastore_span(name = "insert_moderation_action", system = "postgresql")] - pub async fn insert_moderation_action( - &self, - community: CommunityId, - action: moderation::NewAction<'_>, - ) -> Result { - moderation::insert_action(&self.pool, community, action).await - } - - /// List moderation audit action rows, newest first. - #[datastore_span(name = "list_moderation_actions", system = "postgresql")] - pub async fn list_moderation_actions( - &self, - community: CommunityId, - limit: i64, - ) -> Result> { - moderation::list_actions(&self.pool, community, limit).await - } - - /// Return the current owner of git repo name `repo_id` in `community`, or - /// `None` if unreserved. See [`git_repo::repo_name_owner`]. - #[datastore_span(name = "repo_name_owner", system = "postgresql")] - pub async fn repo_name_owner( - &self, - community: CommunityId, - repo_id: &str, - ) -> Result> { - git_repo::repo_name_owner(&self.pool, community, repo_id).await - } - - /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). - /// - /// See [`git_repo::reserve_repo_name`] for the outcome semantics. The - /// per-pubkey quota is enforced by the caller against `count_repos_for_owner`. - #[datastore_span(name = "reserve_repo_name", system = "postgresql")] - pub async fn reserve_repo_name( - &self, - community: CommunityId, - repo_id: &str, - owner_pubkey: &str, - ) -> Result { - git_repo::reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await - } - - /// Count git repos reserved by `owner_pubkey` in `community` (quota check). - #[datastore_span(name = "count_repos_for_owner", system = "postgresql")] - pub async fn count_repos_for_owner( - &self, - community: CommunityId, - owner_pubkey: &str, - ) -> Result { - git_repo::count_repos_for_owner(&self.pool, community, owner_pubkey).await - } - - /// Release a git repo name reservation held by `owner_pubkey` (rollback). - /// - /// Returns the number of rows removed (0 or 1). See [`git_repo::release_repo_name`]. - #[datastore_span(name = "release_repo_name", system = "postgresql")] - pub async fn release_repo_name( - &self, - community: CommunityId, - repo_id: &str, - owner_pubkey: &str, - ) -> Result { - git_repo::release_repo_name(&self.pool, community, repo_id, owner_pubkey).await - } - - /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. - #[datastore_span(name = "is_archived", system = "postgresql")] - pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, community_id, pubkey).await - } - - /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. - #[allow(clippy::too_many_arguments)] - #[datastore_span(name = "archive", system = "postgresql")] - pub async fn archive( - &self, - community_id: CommunityId, - pubkey: &str, - consent_path: &str, - actor: &str, - reason: Option<&str>, - replaced_by: Option<&str>, - request_event_id: &str, - ) -> Result { - archived_identities::archive( - &self.pool, - community_id, - pubkey, - consent_path, - actor, - reason, - replaced_by, - request_event_id, - ) - .await - } - - /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. - #[datastore_span(name = "unarchive", system = "postgresql")] - pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, community_id, pubkey).await - } - - /// Returns all identities archived in `community_id`, ordered by archive time ascending. - #[datastore_span(name = "list_archived", system = "postgresql")] - pub async fn list_archived( - &self, - community_id: CommunityId, - ) -> Result> { - archived_identities::list_archived(&self.pool, community_id).await - } - - /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. - #[datastore_span(name = "soft_delete_discovery_events", system = "postgresql")] - pub async fn soft_delete_discovery_events( - &self, - community_id: CommunityId, - channel_id: Uuid, - relay_pubkey: &[u8], - ) -> Result { - let result = sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", - ) - .bind(community_id.as_uuid()) - .bind(channel_id) - .bind(relay_pubkey) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - /// Returns whether the relay-authored NIP-43 snapshot is absent or differs - /// from the canonical membership rows for `community_id`. - /// - /// Snapshot and canonical rows are compared directly rather than by - /// timestamp: relay membership events use whole-second Nostr timestamps, - /// and multiple mutations within one second must still be repaired. - #[datastore_span( - name = "nip43_membership_snapshot_needs_reconciliation", - system = "postgresql" - )] - pub async fn nip43_membership_snapshot_needs_reconciliation( - &self, - community_id: CommunityId, - relay_pubkey: &nostr::PublicKey, - ) -> Result { - let snapshot = self - .query_events(&crate::event::EventQuery { - kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]), - pubkey: Some(relay_pubkey.to_bytes().to_vec()), - global_only: true, - limit: Some(1), - ..crate::event::EventQuery::for_community(community_id) - }) - .await? - .into_iter() - .next(); - let members = self.list_relay_members(community_id).await?; - - let Some(snapshot) = snapshot else { - return Ok(true); - }; - let mut snapshot_members = snapshot - .event - .tags - .iter() - .filter_map(|tag| { - let parts = tag.as_slice(); - (parts.first().map(String::as_str) == Some("member") && parts.len() >= 3) - .then(|| (parts[1].to_ascii_lowercase(), parts[2].clone())) - }) - .collect::>(); - let mut canonical_members = members - .into_iter() - .map(|member| (member.pubkey.to_ascii_lowercase(), member.role)) - .collect::>(); - snapshot_members.sort_unstable(); - canonical_members.sort_unstable(); - - Ok(snapshot_members != canonical_members) - } - - /// Atomically publish a NIP-43 membership snapshot under a single - /// transaction-scoped advisory lock. - /// - /// This method acquires the per-community snapshot lock, reads the - /// current membership, builds the event, and replaces the prior snapshot - /// — all inside one transaction on one database connection. This - /// prevents the stale-snapshot race where a concurrent publication reads - /// older state and overwrites a newer snapshot by arrival order. - /// - #[datastore_span(name = "publish_nip43_membership_locked", system = "postgresql")] - pub async fn publish_nip43_membership_locked( - &self, - community_id: CommunityId, - relay_keypair: &nostr::Keys, - ) -> Result<(StoredEvent, bool, usize)> { - use nostr::{EventBuilder, Kind, Tag}; - - let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; - let pubkey_bytes = relay_keypair.public_key().to_bytes(); - - let lock_key = replaceable::event_replacement_lock_key( - community_id, - kind_i32, - pubkey_bytes.as_slice(), - None, - ); - - let (mut tx, transaction_timer) = observability::begin_transaction( - &self.pool, - observability::TransactionOperation::PublishNip43MembershipLocked, - ) - .await?; - let (event, received_at, was_inserted, member_count) = transaction_timer - .observe(async { - - // Acquire the per-community snapshot lock BEFORE reading members. - // This serializes the entire read-build-write cycle: a concurrent - // publication will block here until our transaction commits, then - // read the updated membership state. - observability::observe_advisory_lock( - observability::LockType::Membership, - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx), - ) - .await?; - - // Read current members inside the locked transaction. - let rows = sqlx::query( - "SELECT pubkey, role FROM relay_members \ - WHERE community_id = $1 ORDER BY created_at ASC", - ) - .bind(community_id.as_uuid()) - .fetch_all(&mut *tx) - .await?; - - let member_count = rows.len(); - - // Build the NIP-43 event from the locked member rows. - let mut tags: Vec = Vec::with_capacity(member_count + 1); - // NIP-70 protected-event marker. - tags.push(Tag::parse(["-"]).map_err(|e| { - crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")) - })?); - for row in &rows { - let pubkey: String = row.try_get("pubkey")?; - let role: String = row.try_get("role")?; - tags.push(Tag::parse(["member", &pubkey, &role]).map_err(|e| { - crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")) - })?); - } - - let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") - .tags(tags) - .sign_with_keys(relay_keypair) - .map_err(|e| { - crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")) - })?; - - let created_at_secs = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) - .ok_or(DbError::InvalidTimestamp(created_at_secs))?; - let sig_bytes = event.sig.serialize(); - let tags_json = serde_json::to_value(&event.tags)?; - let received_at = chrono::Utc::now(); - let d_tag = crate::event::extract_d_tag(&event); - - // Soft-delete prior snapshots — unconditional, the relay is authoritative. - sqlx::query( - "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ - AND channel_id IS NULL \ - AND deleted_at IS NULL", - ) - .bind(community_id.as_uuid()) - .bind(kind_i32) - .bind(pubkey_bytes.as_slice()) - .execute(&mut *tx) - .await?; - - let insert_result = sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ - ON CONFLICT DO NOTHING", - ) - .bind(community_id.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .bind(pubkey_bytes.as_slice()) - .bind(created_at) - .bind(kind_i32) - .bind(&tags_json) - .bind(&event.content) - .bind(sig_bytes.as_slice()) - .bind(received_at) - .bind::>(None) - .bind(d_tag.as_deref()) - .execute(&mut *tx) - .await?; - - let was_inserted = insert_result.rows_affected() > 0; - if was_inserted { - tx.commit().await?; - } else { - tx.rollback().await?; - } - Ok::<_, DbError>((event, received_at, was_inserted, member_count)) - }) - .await?; - - if was_inserted { - if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); - } - } - - Ok(( - StoredEvent::with_received_at(event, received_at, None, was_inserted), - was_inserted, - member_count, - )) - } -} - -/// A full API token record. -#[derive(Debug, Clone)] -pub struct ApiTokenRecord { - /// Unique token identifier. - pub id: Uuid, - /// SHA-256 hash of the raw token value. - pub token_hash: Vec, - /// Compressed public key bytes of the token owner. - pub owner_pubkey: Vec, - /// Human-readable token name. - pub name: String, - /// Permission scopes granted to this token. - pub scopes: Vec, - /// Optional channel ID restrictions. - pub channel_ids: Option>, - /// When the token was created. - pub created_at: DateTime, - /// Optional expiry timestamp. - pub expires_at: Option>, - /// When the token was last used. - pub last_used_at: Option>, - /// When the token was revoked. - pub revoked_at: Option>, -} - -/// An entry in the pubkey allowlist. -#[derive(Debug, Clone)] -pub struct AllowlistEntry { - /// The allowed pubkey. - pub pubkey: Vec, - /// Who added this entry. - pub added_by: Vec, - /// When the entry was added. - pub added_at: DateTime, - /// Optional note. - pub note: Option, -} - -fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { - let id: Uuid = row.try_get("id")?; - - let scopes_json: serde_json::Value = row.try_get("scopes")?; - let scopes: Vec = serde_json::from_value(scopes_json) - .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; - - let channel_ids: Option> = { - let raw: Option = row.try_get("channel_ids")?; - match raw { - None => None, - Some(v) => { - let strings: Vec = serde_json::from_value(v) - .map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?; - let uuids: std::result::Result, _> = - strings.iter().map(|s| s.parse::()).collect(); - Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?) - } - } - }; - - Ok(ApiTokenRecord { - id, - token_hash: row.try_get("token_hash")?, - owner_pubkey: row.try_get("owner_pubkey")?, - name: row.try_get("name")?, - scopes, - channel_ids, - created_at: row.try_get("created_at")?, - expires_at: row.try_get("expires_at")?, - last_used_at: row.try_get("last_used_at")?, - revoked_at: row.try_get("revoked_at")?, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use buzz_core::CommunityId; - use sqlx::postgres::PgPoolOptions; - use sqlx::PgPool; - use uuid::Uuid; - - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; - - async fn setup_db() -> Db { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); - let pool = PgPool::connect(&database_url) - .await - .expect("connect to test DB"); - Db::from_pool(pool) - } - - async fn make_community(pool: &PgPool) -> Uuid { - let id = Uuid::new_v4(); - let host = format!("communities-of-channels-{}.example", id.simple()); - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(id) - .bind(host) - .execute(pool) - .await - .expect("insert community"); - id - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn coordinate_delete_spares_head_newer_than_the_deletion() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let kind = buzz_core::kind::KIND_PROJECT as i32; - let d_tag = "stale-tombstone-project"; - let pubkey = keys.public_key().to_bytes().to_vec(); - let base = Timestamp::now().as_secs(); - - let version = |content: &str, offset: u64| { - EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) - .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) - .custom_created_at(Timestamp::from(base + offset)) - .sign_with_keys(&keys) - .expect("sign project version") - }; - - for (content, offset) in [("v1", 0), ("v2", 100)] { - assert!( - db.replace_parameterized_event(community, &version(content, offset), d_tag, None) - .await - .expect("store project version") - .1 - ); - } - - // Tombstone timestamped between V1 and V2: it authorizes deleting V1, - // never the newer head that replaced it. - let stale_deleted = db - .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) - .await - .expect("stale coordinate delete"); - assert!( - !stale_deleted, - "a tombstone older than the live head must delete nothing" - ); - - let live_content: Option = sqlx::query_scalar( - "SELECT content FROM events \ - WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(kind) - .bind(&pubkey) - .bind(d_tag) - .fetch_optional(&db.pool) - .await - .expect("read live head"); - assert_eq!( - live_content.as_deref(), - Some("v2"), - "the newer head must survive a stale tombstone" - ); - - // A tombstone at or after the head's own timestamp still deletes it. - let current_deleted = db - .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) - .await - .expect("current coordinate delete"); - assert!( - current_deleted, - "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn database_guard_covers_legacy_writer_and_nip09_deletion() { - use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - - let db = setup_db().await; - let community = CommunityId::from_uuid(make_community(&db.pool).await); - let keys = Keys::generate(); - let d_tag = format!("read-state:{}", "b".repeat(32)); - let tags = vec![ - Tag::parse(["d", d_tag.as_str()]).expect("d tag"), - Tag::parse(["t", "read-state"]).expect("t tag"), - ]; - let base = Timestamp::now().as_secs(); - let a = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "A") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base)) - .sign_with_keys(&keys) - .expect("sign A"); - let x = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "X") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base + 1)) - .sign_with_keys(&keys) - .expect("sign X"); - let b = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "B") - .tags(tags.clone()) - .custom_created_at(Timestamp::from(base + 2)) - .sign_with_keys(&keys) - .expect("sign B"); - let c = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "C") - .tags(tags) - .custom_created_at(Timestamp::from(base + 3)) - .sign_with_keys(&keys) - .expect("sign C"); - - async fn legacy_insert( - pool: &PgPool, - community: CommunityId, - event: &nostr::Event, - d_tag: &str, - ) -> std::result::Result { - sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \ - VALUES ($1, $2, $3, to_timestamp($4), $5, $6, $7, $8, NOW(), $9) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind(event.id.as_bytes().as_slice()) - .bind(event.pubkey.to_bytes()) - .bind(event.created_at.as_secs() as f64) - .bind(buzz_core::kind::KIND_READ_STATE as i32) - .bind(serde_json::to_value(&event.tags).expect("serialize tags")) - .bind(&event.content) - .bind(event.sig.serialize().as_slice()) - .bind(d_tag) - .execute(pool) - .await - } - - legacy_insert(&db.pool, community, &a, &d_tag) - .await - .expect("legacy insert A"); - let duplicate = legacy_insert(&db.pool, community, &a, &d_tag) - .await - .expect("legacy duplicate A remains idempotent"); - assert_eq!(duplicate.rows_affected(), 0); - - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("c".repeat(64)) - .bind(a.id.as_bytes().as_slice()) - .bind(a.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert live mention"); - - // Emulate the pre-PR replacement path after migration 0007: soft-delete - // the live row, then insert B without any application watermark write. - sqlx::query( - "UPDATE events SET deleted_at=NOW() \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .execute(&db.pool) - .await - .expect("legacy soft-delete A"); - let mentions_after_delete: i64 = sqlx::query_scalar( - "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", - ) - .bind(community.as_uuid()) - .bind(a.id.as_bytes().as_slice()) - .fetch_one(&db.pool) - .await - .expect("count mentions after delete"); - assert_eq!(mentions_after_delete, 0); - - let stale_mention = sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("d".repeat(64)) - .bind(a.id.as_bytes().as_slice()) - .bind(a.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("stale post-commit mention is skipped"); - assert_eq!(stale_mention.rows_affected(), 0); - - legacy_insert(&db.pool, community, &b, &d_tag) - .await - .expect("legacy insert B"); - let duplicate_b = legacy_insert(&db.pool, community, &b, &d_tag) - .await - .expect("live duplicate B is skipped"); - assert_eq!(duplicate_b.rows_affected(), 0); - - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("e".repeat(64)) - .bind(b.id.as_bytes().as_slice()) - .bind(b.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert B mention"); - - // Exercise the new Rust hard-delete path independently. An in-flight - // mention holds KEY SHARE on B, so replacement by C must block, then - // complete after the mention commits and remove both B and its mention. - let mut rust_mention_tx = db - .pool - .begin() - .await - .expect("begin Rust mention transaction"); - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind("e".repeat(64)) - .bind(b.id.as_bytes().as_slice()) - .bind(b.created_at.as_secs() as f64) - .execute(&mut *rust_mention_tx) - .await - .expect("hold B live-event key-share lock"); - - let replace_db = db.clone(); - let replace_d_tag = d_tag.clone(); - let replace_c = c.clone(); - let replace_task = tokio::spawn(async move { - replace_db - .replace_parameterized_event(community, &replace_c, &replace_d_tag, None) - .await - }); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert!( - !replace_task.is_finished(), - "Rust hard delete should wait for mention lock" - ); - rust_mention_tx - .commit() - .await - .expect("release Rust mention lock"); - let replaced = tokio::time::timeout(std::time::Duration::from_secs(2), replace_task) - .await - .expect("Rust hard delete deadlocked with mention insert") - .expect("replacement task panicked") - .expect("replace B with C"); - assert!(replaced.1, "C must replace B"); - let b_mentions: i64 = sqlx::query_scalar( - "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", - ) - .bind(community.as_uuid()) - .bind(b.id.as_bytes().as_slice()) - .fetch_one(&db.pool) - .await - .expect("count B mentions after Rust replacement"); - assert_eq!(b_mentions, 0); - - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078)", - ) - .bind(community.as_uuid()) - .bind("f".repeat(64)) - .bind(c.id.as_bytes().as_slice()) - .bind(c.created_at.as_secs() as f64) - .execute(&db.pool) - .await - .expect("insert C mention"); - - // Exercise legacy UPDATE-trigger deletion with the same barrier. While - // deletion waits on C's KEY SHARE lock, an exact replay must already be - // a zero-row trigger no-op; it must not wait for deletion or resurrect C. - let mut legacy_mention_tx = db - .pool - .begin() - .await - .expect("begin legacy mention transaction"); - sqlx::query( - "INSERT INTO event_mentions \ - (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ - VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", - ) - .bind(community.as_uuid()) - .bind("f".repeat(64)) - .bind(c.id.as_bytes().as_slice()) - .bind(c.created_at.as_secs() as f64) - .execute(&mut *legacy_mention_tx) - .await - .expect("hold C live-event key-share lock"); - - let delete_pool = db.pool.clone(); - let delete_pubkey = keys.public_key().to_bytes(); - let delete_d_tag = d_tag.clone(); - let delete_task = tokio::spawn(async move { - sqlx::query( - "UPDATE events SET deleted_at=NOW() \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .bind(delete_pubkey) - .bind(delete_d_tag) - .execute(&delete_pool) - .await - }); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert!( - !delete_task.is_finished(), - "legacy delete should wait for mention lock" - ); - - let replay_while_delete_waits = legacy_insert(&db.pool, community, &c, &d_tag) - .await - .expect("concurrent exact C replay is skipped"); - assert_eq!(replay_while_delete_waits.rows_affected(), 0); - - legacy_mention_tx - .commit() - .await - .expect("release legacy mention lock"); - tokio::time::timeout(std::time::Duration::from_secs(2), delete_task) - .await - .expect("legacy delete deadlocked with mention insert") - .expect("delete task panicked") - .expect("legacy NIP-09 delete C"); - - let payloads: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count retained payloads"); - assert_eq!( - payloads, 0, - "legacy soft deletes must not retain NIP-RS payloads" - ); - - // Opposite commit order: deletion has committed before exact replay. - // Equality remains an observable zero-row no-op, never a resurrection. - let replay_c = legacy_insert(&db.pool, community, &c, &d_tag) - .await - .expect("post-delete exact C replay is skipped"); - assert_eq!(replay_c.rows_affected(), 0); - let payloads_after_exact_replay: i64 = sqlx::query_scalar( - "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("count payloads after exact replay"); - assert_eq!(payloads_after_exact_replay, 0); - - let replay = legacy_insert(&db.pool, community, &x, &d_tag).await; - assert!( - replay.is_err(), - "database guard must reject A < X < C replay" - ); - - let watermark: (chrono::DateTime, Vec) = sqlx::query_as( - "SELECT created_at, event_id FROM parameterized_event_watermarks \ - WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", - ) - .bind(community.as_uuid()) - .bind(keys.public_key().to_bytes()) - .bind(&d_tag) - .fetch_one(&db.pool) - .await - .expect("read C watermark"); - assert_eq!(watermark.0.timestamp(), base as i64 + 3); - assert_eq!(watermark.1, c.id.as_bytes().as_slice()); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { - // Use a private scratch database — not the shared TEST_DATABASE_URL. - // Postgres advisory locks are per-database; hardcoding the production - // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB - // races any live buzz-relay on the same database (see #3619). - let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); - let admin = PgPoolOptions::new() - .max_connections(1) - .connect(&admin_url) - .await - .expect("connect admin to create scratch db"); - let (pool, scratch_name) = create_scratch_db(&admin, "usage_metrics_lock").await; - let first = Db::from_pool(pool.clone()); - let second = Db::from_pool(pool.clone()); - // Same key as production (`buzz-relay` USAGE_METRICS_LOCK_KEY) — safe here - // because the scratch DB is empty of other holders. - let key = 0x4255_5A5A_4D45_5452; - - let mut leader = first - .try_lock_usage_metrics(key) - .await - .expect("first lock attempt") - .expect("first database handle becomes leader"); - assert!(leader.is_live().await, "lock owner remains reachable"); - assert!( - second - .try_lock_usage_metrics(key) - .await - .expect("second lock attempt") - .is_none(), - "another session cannot become leader while the guard exists" - ); - - drop(leader); - assert!( - second - .try_lock_usage_metrics(key) - .await - .expect("lock attempt after leader drop") - .is_some(), - "dropping the detached session releases its advisory lock" - ); - - // Release any remaining session state before DROP DATABASE. - drop(first); - drop(second); - drop_scratch_db(&admin, pool, &scratch_name).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn allowlist_is_scoped_to_community() { - let db = setup_db().await; - let community_a = CommunityId::from_uuid(make_community(&db.pool).await); - let community_b = CommunityId::from_uuid(make_community(&db.pool).await); - let pubkey = [7u8; 32]; - let added_by = [9u8; 32]; - - assert!(db - .add_to_allowlist(community_a, &pubkey, &added_by, Some("a-only")) - .await - .expect("add allowlist row")); - assert!(!db - .add_to_allowlist(community_a, &pubkey, &added_by, Some("duplicate")) - .await - .expect("duplicate allowlist row is idempotent")); - - assert!( - db.is_pubkey_allowed(community_a, &pubkey) - .await - .expect("allowlist check A"), - "pubkey added to A must be allowed in A" - ); - assert!( - !db.is_pubkey_allowed(community_b, &pubkey) - .await - .expect("allowlist check B"), - "pubkey added only to A must not be allowed in B" - ); - assert!(db - .has_allowlist_entries(community_a) - .await - .expect("A has entries")); - assert!(!db - .has_allowlist_entries(community_b) - .await - .expect("B has no entries")); - - let listed = db - .list_allowlist(community_a) - .await - .expect("list A allowlist"); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].pubkey, pubkey); - - assert!( - !db.remove_from_allowlist(community_b, &pubkey) - .await - .expect("remove from B is no-op"), - "removing from B must not delete A's row" - ); - assert!(db - .is_pubkey_allowed(community_a, &pubkey) - .await - .expect("A still allowed after B remove")); - assert!(db - .remove_from_allowlist(community_a, &pubkey) - .await - .expect("remove from A")); - assert!(!db - .is_pubkey_allowed(community_a, &pubkey) - .await - .expect("A not allowed after remove")); - } - - /// BUG-5 regression: the `reactions` table is community-scoped - /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a - /// reaction added under community A must be invisible and unremovable from - /// community B — even for the *identical* `(event_id, pubkey, emoji)` shape. - /// Before the fix, `add_reaction` omitted `community_id` (NOT NULL → 500) and - /// every read/remove filtered `event_id` only (latent cross-tenant bleed). - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reactions_are_scoped_to_community() { - let db = setup_db().await; - let community_a = CommunityId::from_uuid(make_community(&db.pool).await); - let community_b = CommunityId::from_uuid(make_community(&db.pool).await); - - // Identical referenced-event shape across both tenants. - let event_id = [0xABu8; 32]; - let event_created_at = Utc::now(); - let pubkey = [7u8; 32]; - let emoji = "👍"; - - // (1) Add succeeds under A (this INSERT 500'd before the fix). - assert!( - db.add_reaction( - community_a, - &event_id, - event_created_at, - &pubkey, - emoji, - None - ) - .await - .expect("add reaction under A"), - "first reaction under A must be inserted" - ); - // Idempotent: re-adding the same active reaction is a no-op. - assert!( - !db.add_reaction( - community_a, - &event_id, - event_created_at, - &pubkey, - emoji, - None - ) - .await - .expect("duplicate reaction under A"), - "active duplicate under A must not re-insert" - ); - - // (2) Visible on A, invisible on B (grouped read path). - let groups_a = db - .get_reactions(community_a, &event_id, event_created_at, 100, None) - .await - .expect("get reactions A"); - assert_eq!(groups_a.len(), 1, "A must see its own reaction group"); - assert_eq!(groups_a[0].emoji, emoji); - assert_eq!(groups_a[0].count, 1); - - let groups_b = db - .get_reactions(community_b, &event_id, event_created_at, 100, None) - .await - .expect("get reactions B"); - assert!( - groups_b.is_empty(), - "B must NOT see A's reaction for the same event shape, got {groups_b:?}" - ); - - // (3) Active-record lookup is scoped: present on A, absent on B. - assert!( - db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("active record A") - .is_some(), - "A's active reaction record must be present" - ); - assert!( - db.get_active_reaction_record(community_b, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("active record B") - .is_none(), - "B must not find A's active reaction record" - ); - - // (4) B can add the identical shape independently (no PK collision). - assert!( - db.add_reaction( - community_b, - &event_id, - event_created_at, - &pubkey, - emoji, - None - ) - .await - .expect("add reaction under B"), - "B must be able to add the same shape as its own scoped row" - ); - - // (5) Removing from B does not touch A's row. - assert!( - db.remove_reaction(community_b, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("remove under B"), - "B remove must affect B's own row" - ); - assert!( - db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("active record A after B remove") - .is_some(), - "A's reaction must survive a B-side removal" - ); - - // (6) A remove affects only A; A's read now empty. - assert!( - db.remove_reaction(community_a, &event_id, event_created_at, &pubkey, emoji) - .await - .expect("remove under A"), - "A remove must affect A's row" - ); - let groups_a_after = db - .get_reactions(community_a, &event_id, event_created_at, 100, None) - .await - .expect("get reactions A after remove"); - assert!( - groups_a_after.is_empty(), - "A's reaction must be gone after A removes it" - ); - } - - // ---- Read-replica routing ------------------------------------------------ - // - // These tests pin the routing contract of `Db::read()` and the two routed - // methods. A second scratch database stands in for the replica; the - // fixtures are deliberately DIVERGENT (rows that exist in only one of the - // two databases) so every assertion observes which pool actually served - // the query instead of trusting the routing code's word for it. - - async fn admin_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) - } - - /// Create a fresh scratch database on the same server and optionally run migrations. - async fn create_scratch_db_through( - admin: &PgPool, - prefix: &str, - target: Option, - ) -> (PgPool, String) { - let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); - sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) - .execute(admin) - .await - .expect("create scratch db"); - let base = admin_url().await; - // Swap the database path segment of the admin URL for the scratch name. - let scratch_url = { - let idx = base.rfind('/').expect("db url has a path segment"); - format!("{}/{}", &base[..idx], name) - }; - let pool = PgPool::connect(&scratch_url) - .await - .expect("connect scratch db"); - match target { - Some(target) => migration::run_migrations_through(&pool, target) - .await - .expect("migrate scratch db through target"), - None => migration::run_migrations(&pool) - .await - .expect("migrate scratch db"), - } - (pool, name) - } - - /// Create a fresh scratch database on the same server and run all migrations. - /// Returns (pool, db_name); callers should `drop_scratch_db` when done. - async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { - create_scratch_db_through(admin, prefix, None).await - } - - async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { - pool.close().await; - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE IF EXISTS {name} WITH (FORCE)" - ))) - .execute(admin) - .await; - } - - /// Insert identical community + channel rows into a database so the same - /// (community, channel) ids resolve in both writer and replica. - async fn seed_community_channel( - pool: &PgPool, - community: Uuid, - channel: Uuid, - author: &nostr::Keys, - ) { - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community) - .bind(format!("replica-routing-{}.example", community.simple())) - .execute(pool) - .await - .expect("insert community"); - crate::channel::create_channel_with_id( - pool, - CommunityId::from_uuid(community), - channel, - &format!("replica-routing-{channel}"), - crate::channel::ChannelType::Stream, - crate::channel::ChannelVisibility::Open, - None, - author.public_key().to_bytes().as_slice(), - None, - ) - .await - .expect("create channel"); - } - - fn signed_event_at(keys: &nostr::Keys, content: &str, secs: u64) -> nostr::Event { - nostr::EventBuilder::new(nostr::Kind::Custom(9), content) - .custom_created_at(nostr::Timestamp::from(secs)) - .sign_with_keys(keys) - .expect("sign event") - } - - async fn insert_top_level(pool: &PgPool, community: Uuid, channel: Uuid, ev: &nostr::Event) { - let ts = - chrono::DateTime::from_timestamp(ev.created_at.as_secs() as i64, 0).expect("valid ts"); - event::insert_event_with_thread_metadata( - pool, - CommunityId::from_uuid(community), - ev, - Some(channel), - Some(event::ThreadMetadataParams { - event_id: ev.id.as_bytes(), - event_created_at: ts, - channel_id: channel, - parent_event_id: None, - parent_event_created_at: None, - root_event_id: None, - root_event_created_at: None, - depth: 0, - broadcast: true, - }), - ) - .await - .expect("insert top-level event"); - } - - async fn insert_thread_reply( - pool: &PgPool, - community: Uuid, - channel: Uuid, - root: &nostr::Event, - reply: &nostr::Event, - ) { - let reply_ts = chrono::DateTime::from_timestamp(reply.created_at.as_secs() as i64, 0) - .expect("valid ts"); - let root_ts = chrono::DateTime::from_timestamp(root.created_at.as_secs() as i64, 0) - .expect("valid ts"); - event::insert_event_with_thread_metadata( - pool, - CommunityId::from_uuid(community), - reply, - Some(channel), - Some(event::ThreadMetadataParams { - event_id: reply.id.as_bytes(), - event_created_at: reply_ts, - channel_id: channel, - parent_event_id: Some(root.id.as_bytes()), - parent_event_created_at: Some(root_ts), - root_event_id: Some(root.id.as_bytes()), - root_event_created_at: Some(root_ts), - depth: 1, - broadcast: false, - }), - ) - .await - .expect("insert reply"); - } - - /// Composite thread cursor: 8-byte BE seconds + raw event id. - fn thread_cursor(reply: &crate::thread::ThreadReply) -> Vec { - let mut cur = reply.created_at.timestamp().to_be_bytes().to_vec(); - cur.extend_from_slice(&reply.event_id); - cur - } - - #[tokio::test] - async fn read_falls_back_to_writer_when_no_replica_configured() { - // Pure wiring test — connect_lazy never touches the network. - let pool = sqlx::PgPool::connect_lazy(TEST_DB_URL).expect("lazy pool"); - let db = Db::from_pool(pool); - assert!(!db.has_read_pool()); - assert!( - std::ptr::eq(db.read(), &db.pool), - "read() must be the writer pool when no replica is configured" - ); - assert!(db.read_pool_stats().is_none()); - } - - #[test] - fn read_budget_zero_disables_and_large_values_clamp_to_staleness() { - assert_eq!(read_budget_from_ms(0), None, "0 = bounded routing off"); - assert_eq!( - read_budget_from_ms(1000), - Some(std::time::Duration::from_millis(1000)) - ); - assert_eq!( - read_budget_from_ms(10_000_000), - Some(replica_fence::FENCE_STALENESS), - "budgets above the staleness gate clamp to it" - ); - } - - /// Truth table for [`RoutePredicate::for_query`]: the strongest sound - /// predicate per query shape, and — the deploy-day default row — that - /// `routing_enabled = false` (BUZZ_REPLICA_READ_MAX_AGE_MS unset) - /// forces `Bounded` even for covered-eligible shapes, so the zero - /// budget fails the new seams closed (Dawn's covered-at-zero-budget - /// catch, design doc rev 5). - #[test] - fn for_query_predicate_truth_table() { - let community = CommunityId::from_uuid(Uuid::new_v4()); - let channel = Uuid::new_v4(); - let until = chrono::Utc::now(); - - let pinned_with_until = { - let mut q = event::EventQuery::for_community(community); - q.channel_id = Some(channel); - q.until = Some(until); - q - }; - let pinned_no_until = { - let mut q = event::EventQuery::for_community(community); - q.channel_id = Some(channel); - q - }; - let unpinned_with_until = { - let mut q = event::EventQuery::for_community(community); - q.until = Some(until); - q - }; - let global_only = { - let mut q = event::EventQuery::for_community(community); - q.global_only = true; - q.until = Some(until); - q - }; - - // Deploy-day default: budget unset ⇒ Bounded regardless of shape. - // The zero budget then fails Bounded closed, so the new seams - // record writer/disabled — merging with no env var set is a no-op. - assert!( - matches!( - RoutePredicate::for_query(&pinned_with_until, false), - RoutePredicate::Bounded - ), - "budget unset must not reach the covered arm even when eligible" - ); - - // Budget set + channel pin + until ⇒ the strongest predicate. - assert!(matches!( - RoutePredicate::for_query(&pinned_with_until, true), - RoutePredicate::BoundedOrCovered { .. } - )); - - // Missing either covered precondition ⇒ Bounded. - assert!(matches!( - RoutePredicate::for_query(&pinned_no_until, true), - RoutePredicate::Bounded - )); - assert!(matches!( - RoutePredicate::for_query(&unpinned_with_until, true), - RoutePredicate::Bounded - )); - // global_only implies `channel_id = None`, so the channel-pin - // precondition fails and no covered arm is possible — `for_query` - // never inspects `global_only` itself; the row holds because - // constructor 1 (channel pin) returns None for an unpinned query. - assert!(matches!( - RoutePredicate::for_query(&global_only, true), - RoutePredicate::Bounded - )); - } - - /// The pre-existing cursor paths are NOT budget-gated: a channel-window - /// cursor page still derives `Covered` with no `routing_enabled` input - /// at all — at B=0 today it routes covered, and that status quo is - /// intentionally unchanged by the `for_query` gate (Max's matrix row: - /// old paths route at budget-unset; only the new seams go dark). - #[test] - fn channel_cursor_predicate_is_not_budget_gated() { - let channel = Uuid::new_v4(); - let cursor = Some((chrono::Utc::now(), vec![1u8; 32])); - assert!(matches!( - RoutePredicate::from_channel_cursor(channel, &cursor), - RoutePredicate::Covered { .. } - )); - // Head fetch (no cursor) is bounded — gated by the budget. - assert!(matches!( - RoutePredicate::from_channel_cursor(channel, &None), - RoutePredicate::Bounded - )); - } - - /// D5 wiring: `read_pool_stats().max` must be the READER pool's own - /// ceiling, not the writer's — `buzz_db_read_pool_active / _max` is the - /// operator's utilisation signal and inheriting the writer's max hides - /// reader saturation by exactly the sizing ratio. Pure wiring test: - /// `connect_lazy` never touches the network, but it does spawn the - /// pool reaper task, which needs a Tokio runtime — hence - /// `#[tokio::test]` despite the test body itself never awaiting. - #[tokio::test] - async fn read_pool_stats_reports_reader_ceiling_not_writer() { - let writer = sqlx::postgres::PgPoolOptions::new() - .max_connections(20) - .connect_lazy(TEST_DB_URL) - .expect("lazy writer pool"); - let reader = sqlx::postgres::PgPoolOptions::new() - .max_connections(40) - .connect_lazy(TEST_DB_URL) - .expect("lazy reader pool"); - let db = Db::from_pools(writer, reader); - assert_eq!(db.pool_stats().max, 20); - assert_eq!( - db.read_pool_stats().expect("read pool configured").max, - 40, - "reader gauge must report the reader's own ceiling" - ); - } - - /// D4 wiring: the reader pool is built lazily with `min_connections(0)` - /// and the short reader acquire timeout — construction must succeed - /// with no replica listening (reader-down at boot must not crash the - /// relay), and `read_max_connections` must honour - /// `DbConfig::read_max_connections` over the writer sizing. - /// `#[tokio::test]` because `connect_lazy` spawns the pool reaper task, - /// which needs a Tokio runtime even though nothing is dialed. - #[tokio::test] - async fn connect_read_pool_is_lazy_and_independently_sized() { - let config = DbConfig { - max_connections: 20, - read_max_connections: Some(7), - ..DbConfig::default() - }; - // Unroutable per RFC 5737 TEST-NET-1: proves nothing is dialed at - // construction time. - let pool = Db::connect_read_pool(&config, "postgres://user:pw@192.0.2.1:5432/none", 7) - .expect("lazy construction must not dial the replica"); - assert_eq!(pool.options().get_max_connections(), 7); - assert_eq!(pool.options().get_min_connections(), 0); - assert_eq!( - pool.options().get_acquire_timeout(), - Db::READER_ACQUIRE_TIMEOUT - ); - } - - /// Channel window: head fetch (no cursor) reads the WRITER; cursor pages - /// read the REPLICA. Divergent fixtures prove which pool served each. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "routing_w").await; - let (replica, rname) = create_scratch_db(&admin, "routing_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - // Shared history (both databases): m1 < m2 < m3. - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - let m3 = signed_event_at(&author, "m3", base + 20); - for pool in [&writer, &replica] { - for ev in [&m1, &m2, &m3] { - insert_top_level(pool, community, channel, ev).await; - } - } - // Lag: the newest event exists only on the writer. - let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); - insert_top_level(&writer, community, channel, &fresh).await; - // Marker: exists only on the "replica" (unphysical for a real replica, - // but it makes replica-served pages unambiguous). - let marker = signed_event_at(&author, "replica-only-marker", base + 5); - insert_top_level(&replica, community, channel, &marker).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - // Open the fence through "now": the fixture's history is far in the - // past, so every cursor falls below the fence and routing is - // eligible. Fence-gating itself is pinned by the fence tests below. - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Head fetch (cursor: None) → writer: sees `fresh`, never `marker`. - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head window"); - let head_contents: Vec = head - .rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect(); - assert_eq!( - head_contents, - vec!["fresh-writer-only".to_string(), "m3".to_string()], - "head fetch must be served by the writer" - ); - - // Cursor page → replica: sees `marker`, never `fresh`. - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - let page2 = db - .get_channel_window(cid, channel, 10, Some(cursor), None) - .await - .expect("cursor window"); - let page2_contents: Vec = page2 - .rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect(); - assert_eq!( - page2_contents, - vec![ - "m2".to_string(), - "replica-only-marker".to_string(), - "m1".to_string() - ], - "cursor page must be served by the replica" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Fail-closed on a mid-request replica failure (Dawn, review of - /// 1b0aa0dfa): a replica-routed page whose query errors *after* the - /// proof (the live shape is a hot-standby recovery conflict — 40001 / - /// 25P02 — cancelling the held snapshot under `max_standby_streaming_delay`) - /// must be re-run on the writer and served, never surfaced as an error - /// the writer could have answered. Degraded capacity, never holes. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn replica_window_failure_falls_back_to_writer() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fb_w").await; - let (replica, rname) = create_scratch_db(&admin, "fb_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - let m3 = signed_event_at(&author, "m3", base + 20); - for pool in [&writer, &replica] { - for ev in [&m1, &m2, &m3] { - insert_top_level(pool, community, channel, ev).await; - } - } - let marker = signed_event_at(&author, "replica-only-marker", base + 5); - insert_top_level(&replica, community, channel, &marker).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - let head = db - .get_channel_window(cid, channel, 1, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - - // Guard against a vacuous pass: the cursor page must actually be - // replica-eligible before we break the replica. - let healthy = db - .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) - .await - .expect("healthy cursor window"); - assert!( - healthy - .rows - .iter() - .any(|r| r.stored_event.event.content == "replica-only-marker"), - "fixture must route the cursor page to the replica while healthy" - ); - - // Break the replica AFTER the proof point: the heartbeat table stays - // intact (the observation succeeds), the page query then fails. - sqlx::query("DROP TABLE events CASCADE") - .execute(&replica) - .await - .expect("drop replica events"); - - let page = db - .get_channel_window(cid, channel, 10, Some(cursor), None) - .await - .expect("replica failure must fall back to the writer, not error"); - let contents: Vec<&str> = page - .rows - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["m2", "m1"], - "fallback page must be the writer's answer (no replica marker)" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// [`replica_window_failure_falls_back_to_writer`] for the thread-replies - /// path: a replica-routed thread page whose query errors after the proof - /// re-runs on the writer instead of surfacing an error. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn replica_thread_failure_falls_back_to_writer() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fbt_w").await; - let (replica, rname) = create_scratch_db(&admin, "fbt_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let root = signed_event_at(&author, "root", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &root).await; - } - let replies: Vec = (1..=3) - .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) - .collect(); - for pool in [&writer, &replica] { - for reply in &replies { - insert_thread_reply(pool, community, channel, &root, reply).await; - } - } - // Replica-only divergent reply between r2 and r3 marks replica serves. - let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); - insert_thread_reply(&replica, community, channel, &root, &ghost).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - let page1 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) - .await - .expect("head page"); - let cur = thread_cursor(page1.last().expect("page 1 non-empty")); - - // Healthy: the full page after r2 is the replica's [ghost]. - let healthy = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("healthy replica page"); - assert_eq!( - healthy[0].stored_event.event.content, "replica-only-ghost", - "fixture must route the cursor page to the replica while healthy" - ); - - sqlx::query("DROP TABLE events CASCADE") - .execute(&replica) - .await - .expect("drop replica events"); - - let page = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("replica failure must fall back to the writer, not error"); - assert_eq!( - page[0].stored_event.event.content, "r3", - "fallback page must be the writer's answer" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Mid-request degradation of the held session (Dawn, review of - /// 1b0aa0dfa): when the proved replica transaction dies between the page - /// and an aux follow-up (stand-in: `pg_terminate_backend` on the reader - /// connection, the same tx-fatal shape as a recovery-conflict cancel), - /// [`ReadSession::query_events`] must re-run the query on the writer and - /// permanently degrade the session instead of surfacing the error. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn read_session_degrades_to_writer_when_replica_connection_dies() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "deg_w").await; - let (replica, rname) = create_scratch_db(&admin, "deg_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - for pool in [&writer, &replica] { - for ev in [&m1, &m2] { - insert_top_level(pool, community, channel, ev).await; - } - } - // Writer-only row proves the degraded aux ran on the writer. - let fresh = signed_event_at(&author, "fresh-writer-only", base + 20); - insert_top_level(&writer, community, channel, &fresh).await; - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - let head = db - .get_channel_window(cid, channel, 1, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - let (_window, mut session) = db - .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) - .await - .expect("routed cursor window"); - assert!( - session.is_replica(), - "fixture must route this page to the replica" - ); - - // Kill the reader's backend out from under the held transaction. - sqlx::query( - "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ - WHERE datname = $1 AND pid <> pg_backend_pid()", - ) - .bind(&rname) - .execute(&admin) - .await - .expect("terminate replica backends"); - - let mut aux = EventQuery::for_community(cid); - aux.channel_id = Some(channel); - let rows = session - .query_events(&aux) - .await - .expect("session must degrade to the writer, not error"); - assert!( - rows.iter() - .any(|se| se.event.content == "fresh-writer-only"), - "degraded aux must be served by the writer" - ); - assert!( - !session.is_replica(), - "the session must be permanently degraded to the writer" - ); - - drop(session); - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request - /// runs inside ONE `REPEATABLE READ, READ ONLY` transaction whose first - /// statement was the heartbeat observation — so a row committed on the - /// replica *after* the proof must be invisible to every follow-up - /// statement in the same request (page, participants, aux). This - /// distinguishes the transaction contract from mere connection reuse: - /// autocommit statements on the same backend advance their snapshot - /// per statement and WOULD see the mid-request row. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn routed_request_holds_one_snapshot_across_page_and_aux() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "snap_w").await; - let (replica, rname) = create_scratch_db(&admin, "snap_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2", base + 10); - for pool in [&writer, &replica] { - for ev in [&m1, &m2] { - insert_top_level(pool, community, channel, ev).await; - } - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Head page on the writer yields the cursor for a replica-routed page. - let head = db - .get_channel_window(cid, channel, 1, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - - // Route the cursor page to the replica and HOLD the session. - let (window, mut session) = db - .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) - .await - .expect("routed cursor window"); - assert!( - session.is_replica(), - "fixture must route this page to the replica" - ); - assert_eq!(window.rows.len(), 1, "page after m2 is [m1]"); - - // Mid-request: a new event commits on the replica (stands in for - // replay advancing between the page and the aux closure). - let mid = signed_event_at(&author, "mid-request-commit", base + 5); - insert_top_level(&replica, community, channel, &mid).await; - - // A fresh autocommit statement on ANOTHER session sees it — the row - // is really there (control for the assertion below). - let mut control = EventQuery::for_community(cid); - control.channel_id = Some(channel); - let visible_elsewhere = event::query_events(&replica, &control) - .await - .expect("control query"); - assert!( - visible_elsewhere - .iter() - .any(|se| se.event.content == "mid-request-commit"), - "control: the mid-request row must be committed and visible to a new snapshot" - ); - - // The held request session must NOT see it: its snapshot was - // anchored by the heartbeat observation, before the commit. - let mut aux = EventQuery::for_community(cid); - aux.channel_id = Some(channel); - let in_request = session.query_events(&aux).await.expect("aux query"); - assert!( - !in_request - .iter() - .any(|se| se.event.content == "mid-request-commit"), - "request transaction must hold the proof-time snapshot; a \ - mid-request commit leaking in means the aux ran outside the \ - request transaction (autocommit connection reuse)" - ); - // Rows from the proof-time snapshot are still served. - assert!( - in_request.iter().any(|se| se.event.content == "m1"), - "proof-time rows must remain visible in the request snapshot" - ); - - drop(session); - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Head gate (Predicate A): with the budget unset, a head fetch reads - /// the writer even over an open fence; with a budget set and a fresh - /// proved entry, the head page is served by the replica session - /// (bounded staleness accepted); with a budget the fence entry exceeds, - /// the head page falls back to the writer. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn head_fetch_routes_by_configured_budget() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "head_w").await; - let (replica, rname) = create_scratch_db(&admin, "head_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let shared = signed_event_at(&author, "shared", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &shared).await; - } - // Divergent heads prove which pool served the fetch. - let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); - insert_top_level(&writer, community, channel, &fresh).await; - let marker = signed_event_at(&author, "replica-only-marker", base + 20); - insert_top_level(&replica, community, channel, &marker).await; - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - let head_contents = |w: &thread::ChannelWindow| -> Vec { - w.rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect() - }; - - // Budget unset (rollout default): head → writer, fence open or not. - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head, gate off"); - assert_eq!( - head_contents(&head), - vec!["fresh-writer-only".to_string(), "shared".to_string()], - "head routing must default off" - ); - - // Budget set, entry fresh (just recorded): head → replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head, gate on"); - assert_eq!( - head_contents(&head), - vec!["replica-only-marker".to_string(), "shared".to_string()], - "a fresh proved entry within budget must serve the head from the replica" - ); - - // Entry older than the budget: head falls back to the writer. - db.fence().close(); - db.fence().force_open_for_tests_at( - chrono::Utc::now(), - std::time::Instant::now() - std::time::Duration::from_secs(10), - ); - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head, entry too old"); - assert_eq!( - head_contents(&head), - vec!["fresh-writer-only".to_string(), "shared".to_string()], - "an over-budget entry must fail the head gate closed" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// End-to-end deploy-default proof for the NEW routed seams: with the - /// budget unset, a covered-eligible query (channel-pinned + `until`) - /// through [`Db::query_events_routed`] is served by the WRITER — the - /// `for_query` gate keeps the covered arm dark (rev 5). With the budget - /// set and a fresh proved entry, the same query routes to the replica. - /// Divergent fixtures prove which pool served each read. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn query_events_routed_defaults_dark_and_routes_covered_when_enabled() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "qer_w").await; - let (replica, rname) = create_scratch_db(&admin, "qer_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let shared = signed_event_at(&author, "shared", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &shared).await; - } - let writer_only = signed_event_at(&author, "writer-only", base + 10); - insert_top_level(&writer, community, channel, &writer_only).await; - let replica_only = signed_event_at(&author, "replica-only", base + 20); - insert_top_level(&replica, community, channel, &replica_only).await; - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Covered-eligible shape: channel-pinned with an `until` upper - // bound below the (now) fence wall. - let q = { - let mut q = EventQuery::for_community(cid); - q.channel_id = Some(channel); - q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); - q - }; - let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { - evs.iter().map(|e| e.event.content.clone()).collect() - }; - - // Deploy default: budget unset ⇒ writer, even though the shape is - // covered-eligible and the fence is open. - let rows = db - .query_events_routed("test_routed", &q) - .await - .expect("routed query, gate off"); - assert!( - contents(&rows).contains("writer-only"), - "budget unset must serve the writer" - ); - assert!( - !contents(&rows).contains("replica-only"), - "budget unset must not reach the replica via the covered arm" - ); - - // Budget set ⇒ the covered arm serves it from the replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let rows = db - .query_events_routed("test_routed", &q) - .await - .expect("routed query, gate on"); - assert!( - contents(&rows).contains("replica-only"), - "budget set + covered-eligible must route to the replica" - ); - assert!(!contents(&rows).contains("writer-only")); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// COUNT is bounded-only (rev 5 deletion-visibility rule): a - /// covered-eligible shape must NOT let a count take the covered arm. - /// With the budget unset the count reads the WRITER even with an open - /// fence; with the budget set and a fresh entry it reads the replica - /// under the bounded arm. Divergent row counts prove the serving pool. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn count_events_routed_is_bounded_only() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "cnt_w").await; - let (replica, rname) = create_scratch_db(&admin, "cnt_r").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - // Writer: 2 rows. Replica: 1 row. - for (i, content) in ["a", "b"].iter().enumerate() { - let ev = signed_event_at(&author, content, base + i as u64); - insert_top_level(&writer, community, channel, &ev).await; - } - let ev = signed_event_at(&author, "c", base); - insert_top_level(&replica, community, channel, &ev).await; - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Covered-eligible shape on purpose: pinned + until. A count must - // ignore that eligibility. - let q = { - let mut q = EventQuery::for_community(cid); - q.channel_id = Some(channel); - q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); - q - }; - - // Budget unset ⇒ bounded arm disabled ⇒ writer. - let n = db - .count_events_routed("test_count", &q) - .await - .expect("count, gate off"); - assert_eq!(n, 2, "budget unset must count on the writer"); - - // Budget set + fresh entry ⇒ bounded arm ⇒ replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let n = db - .count_events_routed("test_count", &q) - .await - .expect("count, gate on"); - assert_eq!(n, 1, "budget set must count on the replica (bounded)"); - - // Entry older than the budget ⇒ bounded fails ⇒ writer. Covered - // would still hold here (upper <= wall) — proving count never - // consults it. - db.fence().close(); - db.fence().force_open_for_tests_at( - chrono::Utc::now(), - std::time::Instant::now() - std::time::Duration::from_secs(10), - ); - let n = db - .count_events_routed("test_count", &q) - .await - .expect("count, entry too old"); - assert_eq!( - n, 2, - "an over-budget entry must fail the count closed to the writer, \ - even when the covered arm would admit the shape" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Routed relay-membership check: budget unset ⇒ writer; budget set + - /// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ - /// writer. Divergent membership rows prove which pool answered. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn is_relay_member_is_bounded_routed_and_fails_closed() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "mem_w").await; - let (replica, rname) = create_scratch_db(&admin, "mem_r").await; - - let community = Uuid::new_v4(); - for pool in [&writer, &replica] { - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(community) - .bind(format!("member-routing-{}.example", community.simple())) - .execute(pool) - .await - .expect("insert community"); - } - let cid = CommunityId::from_uuid(community); - let writer_only = "aa".repeat(32); - let replica_only = "bb".repeat(32); - relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) - .await - .expect("seed writer member"); - relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) - .await - .expect("seed replica member"); - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - - // Budget unset ⇒ bounded arm disabled ⇒ writer. - assert!( - db.is_relay_member(cid, &writer_only) - .await - .expect("gate off"), - "budget unset must answer from the writer" - ); - assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); - - // Budget set + fresh entry ⇒ replica. - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - assert!( - db.is_relay_member(cid, &replica_only) - .await - .expect("gate on"), - "budget set must answer from the replica" - ); - assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); - - // Entry older than the budget ⇒ fail closed to the writer. Close - // first so no prior fresh entry can be the one proved (matches the - // count test; today `force_open_for_tests_at` also clears the ring). - db.fence().close(); - db.fence().force_open_for_tests_at( - chrono::Utc::now(), - std::time::Instant::now() - std::time::Duration::from_secs(10), - ); - assert!( - db.is_relay_member(cid, &writer_only) - .await - .expect("entry too old"), - "an over-budget entry must fail closed to the writer" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Community separation across every routed seam, verified on - /// REPLICA-SERVED reads. - /// - /// The pre-existing feed/event scoping tests prove the shared SQL - /// builders confine rows to one community, but they exercise those - /// builders through the WRITER wrapper. `_on` variants are - /// executor-only refactors, so scoping *should* be identical — this - /// test refuses to take that on faith and re-proves it through the - /// routed executor, on a snapshot the replica actually served. - /// - /// Construction: two communities A and B exist in BOTH databases with - /// the same ids. The replica additionally holds a `replica-only` row in - /// each — divergent fixtures, so any row bearing that content proves - /// the replica (not the writer) served the read. Every assertion - /// requests A and demands B's rows never appear, including B's - /// `replica-only` row, which is the one a leaky predicate would surface. - /// The routed fallback must cost ONE reader acquire budget, even when the - /// Aurora capability cache is cold. - /// - /// Regression test for a stacked-budget bug found at `9fa3c9c0b`: the - /// capability probe used to `acquire()` from the pool itself and return - /// `false` *uncached* on `PoolTimedOut`, so the routed read then spent a - /// SECOND `READER_ACQUIRE_TIMEOUT` inside `begin`. Measured 302ms against - /// a ~150ms documented bound. Boot priming - /// ([`Db::spawn_read_pool_boot_ping`]) hid it only when the boot ping - /// SUCCEEDED — and a reader that is unavailable at boot is exactly the - /// case the bound is specified for, so the two failures are correlated. - /// - /// The fixture reproduces that state deliberately: a size-1 reader whose - /// sole connection is established and then HELD (so every further acquire - /// must time out), with `reader_aurora_identity` asserted cold. It routes - /// through `count_events_routed` rather than calling `proved_reader` - /// directly, because `buzz_db_route_decision` is emitted by `route_read` - /// — a direct call would prove the timing but never emit the label. - /// - /// Timing uses an upper bound of 2x the budget minus a margin: it must - /// fail for two stacked budgets (~300ms) while tolerating scheduler - /// jitter on one (~150ms). Asserting a lower bound too would pin the - /// budget's own value, which `reader_acquire_timeout_is_the_documented_budget` - /// already covers. - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] - async fn routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed, wname) = create_scratch_db(&admin, "one_budget").await; - seed.close().await; - let base = admin_url().await; - let scratch_url = { - let idx = base.rfind('/').expect("db url has a path segment"); - format!("{}/{}", &base[..idx], wname) - }; - - // `Db::new` so the writer arms the floor guard and the reader is the - // real lazy `connect_read_pool` pool (min_connections=0, 150ms - // acquire timeout). Reader is sized 1 so holding one connection - // saturates it. - let mut db = Db::new(&DbConfig { - database_url: scratch_url.clone(), - read_database_url: Some(scratch_url), - max_connections: 4, - read_max_connections: Some(1), - ..DbConfig::default() - }) - .await - .expect("connect armed Db with size-1 lazy reader"); - db.fence().force_open_for_tests(chrono::Utc::now()); - db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); - - let read_pool = db.read_pool.clone().expect("reader pool configured"); - // Establish and hold the reader's only connection: saturated. - let held = read_pool - .acquire() - .await - .expect("establish the reader's sole connection"); - assert_eq!( - db.read_max_connections, 1, - "reader max must report 1 for this fixture to test saturation" - ); - assert_eq!( - read_pool.size(), - 1, - "the sole reader connection is established and held" - ); - // The bug is only observable with the capability cache cold; if a - // future change primes it here, this fixture would silently stop - // discriminating. - assert!( - db.reader_aurora_identity.get().is_none(), - "Aurora capability must be UNPRIMED (post-boot-ping-failure state)" - ); - - let recorder = metrics_util::debugging::DebuggingRecorder::new(); - let snapshotter = recorder.snapshotter(); - let query = EventQuery::for_community(CommunityId::from_uuid(Uuid::new_v4())); - - // The recorder is installed thread-locally, so it must stay installed - // across the `.await` — hence the guard form rather than - // `with_local_recorder`, whose closure cannot host an await. The - // `current_thread` flavor keeps the route decision on this thread; on - // a multi-thread runtime the emit could land on a worker where no - // local recorder is installed and the label assertions would vacuously - // see an empty snapshot. - let start = std::time::Instant::now(); - let count = { - let _guard = metrics::set_default_local_recorder(&recorder); - db.count_events_routed("one_budget_probe", &query).await - } - .expect("writer fallback still answers the read"); - let elapsed = start.elapsed(); - - assert_eq!(count, 0, "writer answered on an empty scratch database"); - assert!( - elapsed < Duration::from_millis(250), - "routed fallback must spend ONE {}ms acquire budget, not two; took {}ms", - Db::READER_ACQUIRE_TIMEOUT.as_millis(), - elapsed.as_millis() - ); - - let reasons: std::collections::HashMap<(String, String), u64> = snapshotter - .snapshot() - .into_vec() - .into_iter() - .filter(|(key, ..)| key.key().name() == "buzz_db_route_decision") - .map(|(key, _, _, value)| { - let metrics_util::debugging::DebugValue::Counter(n) = value else { - panic!("buzz_db_route_decision must be a counter"); - }; - let labels: Vec<_> = key.key().labels().collect(); - let get = |name: &str| { - labels - .iter() - .find(|l| l.key() == name) - .map(|l| l.value().to_owned()) - .unwrap_or_default() - }; - ((get("decision"), get("reason")), n) - }) - .collect(); - - assert_eq!( - reasons.get(&("writer".to_owned(), "reader_acquire_timeout".to_owned())), - Some(&1), - "saturated reader must fall back as writer/reader_acquire_timeout; got {reasons:?}" - ); - // `reader_validation_error` would mean we misclassified a timeout as a - // broken reader, and `pool_busy` is the retired name — neither may - // appear in ANY emitted label. - assert!( - !reasons - .keys() - .any(|(_, reason)| reason == "reader_validation_error" || reason == "pool_busy"), - "no reader_validation_error or retired pool_busy label may be emitted; got {reasons:?}" - ); - - drop(held); - drop_scratch_db(&admin, db.pool.clone(), &wname).await; - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn routed_reads_are_confined_to_the_requested_community() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "sep_w").await; - let (replica, rname) = create_scratch_db(&admin, "sep_r").await; - - let author = nostr::Keys::generate(); - let (comm_a, chan_a) = (Uuid::new_v4(), Uuid::new_v4()); - let (comm_b, chan_b) = (Uuid::new_v4(), Uuid::new_v4()); - for pool in [&writer, &replica] { - seed_community_channel(pool, comm_a, chan_a, &author).await; - seed_community_channel(pool, comm_b, chan_b, &author).await; - } - - // A p-tag mention is what makes a row eligible for the mentions and - // needs-action feeds. Kind 9 satisfies mentions + activity; - // needs-action admits only approval/reminder kinds, so each - // community also gets a kind-46010 row. - let mentioned = nostr::Keys::generate(); - let mentioned_hex = mentioned.public_key().to_hex(); - let mentioned_bytes = mentioned.public_key().to_bytes(); - let tagged_kind = |kind: u16, content: &str, secs: u64| { - nostr::EventBuilder::new(nostr::Kind::Custom(kind), content) - .tags([nostr::Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")]) - .custom_created_at(nostr::Timestamp::from(secs)) - .sign_with_keys(&author) - .expect("sign event") - }; - let tagged = |content: &str, secs: u64| tagged_kind(9, content, secs); - - let base = 1_700_000_000u64; - // Shared rows (both DBs) + replica-only rows (divergence) per community. - let a_shared = tagged("a-shared", base); - let b_shared = tagged("b-shared", base + 1); - for pool in [&writer, &replica] { - insert_top_level(pool, comm_a, chan_a, &a_shared).await; - insert_mentions( - pool, - CommunityId::from_uuid(comm_a), - &a_shared, - Some(chan_a), - ) - .await - .expect("mentions a-shared"); - insert_top_level(pool, comm_b, chan_b, &b_shared).await; - insert_mentions( - pool, - CommunityId::from_uuid(comm_b), - &b_shared, - Some(chan_b), - ) - .await - .expect("mentions b-shared"); - } - let a_replica_only = tagged("a-replica-only", base + 10); - let b_replica_only = tagged("b-replica-only", base + 11); - insert_top_level(&replica, comm_a, chan_a, &a_replica_only).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_a), - &a_replica_only, - Some(chan_a), - ) - .await - .expect("mentions a-replica-only"); - insert_top_level(&replica, comm_b, chan_b, &b_replica_only).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_b), - &b_replica_only, - Some(chan_b), - ) - .await - .expect("mentions b-replica-only"); - - // Needs-action fixtures: approval kind, replica-only in BOTH - // communities, so the assertion below is replica-served on A and - // must still not see B's. - let a_approval = tagged_kind(46010, "a-approval-replica-only", base + 20); - let b_approval = tagged_kind(46010, "b-approval-replica-only", base + 21); - insert_top_level(&replica, comm_a, chan_a, &a_approval).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_a), - &a_approval, - Some(chan_a), - ) - .await - .expect("mentions a-approval"); - insert_top_level(&replica, comm_b, chan_b, &b_approval).await; - insert_mentions( - &replica, - CommunityId::from_uuid(comm_b), - &b_approval, - Some(chan_b), - ) - .await - .expect("mentions b-approval"); - - let mut db = Db::from_pools(writer.clone(), replica.clone()); - db.fence().force_open_for_tests(chrono::Utc::now()); - db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); - let cid_a = CommunityId::from_uuid(comm_a); - - let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { - evs.iter().map(|e| e.event.content.clone()).collect() - }; - // Every routed seam must (a) have been served by the replica — - // proven by a divergent row absent from the writer — and (b) contain - // no row belonging to community B. All B fixtures are named `b-*`, - // so the leak check is a single prefix scan. - let assert_a_only = |rows: &[StoredEvent], marker: &str, seam: &str| { - let got = contents(rows); - assert!( - got.contains(marker), - "{seam}: must be replica-served (divergent row `{marker}` absent from writer); got {got:?}" - ); - assert!( - !got.iter().any(|c| c.starts_with("b-")), - "{seam}: community B rows leaked into a community A read; got {got:?}" - ); - }; - - // 1. Generic query — covered arm (channel-pinned + `until`). - let mut q = EventQuery::for_community(cid_a); - q.channel_id = Some(chan_a); - q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); - let rows = db - .query_events_routed("sep_query", &q) - .await - .expect("routed query"); - assert_a_only(&rows, "a-replica-only", "query_events_routed"); - - // 2. Generic query — bounded arm (no channel pin at all, so a - // missing community predicate could not be masked by the pin). - let unpinned = EventQuery::for_community(cid_a); - let rows = db - .query_events_routed_bounded("sep_query_bounded", &unpinned) - .await - .expect("routed bounded query"); - assert_a_only(&rows, "a-replica-only", "query_events_routed_bounded"); - - // 3. COUNT — bounded-only. Community A holds 3 rows on the replica - // (shared + replica-only + approval) but only 1 on the writer, - // and 3 more exist in community B. Exactly 3 proves the read was - // both replica-served and community-confined. - let count = db - .count_events_routed("sep_count", &unpinned) - .await - .expect("routed count"); - assert_eq!( - count, 3, - "count must see A's three replica rows only — not B's, not the writer's one" - ); - - // 4. By-ID hydration — ids carry no channel pin, and B's ids are - // requested alongside A's. Only A's may hydrate. - let ids: Vec<&[u8]> = vec![ - a_shared.id.as_bytes(), - a_replica_only.id.as_bytes(), - b_shared.id.as_bytes(), - b_replica_only.id.as_bytes(), - ]; - let rows = db - .get_events_by_ids_routed("sep_by_ids", cid_a, &ids) - .await - .expect("routed by-ids"); - assert_a_only(&rows, "a-replica-only", "get_events_by_ids_routed"); - - // 5-7. All three feed builders, each given BOTH channels as - // accessible — so only the community predicate can exclude B. - let both = [chan_a, chan_b]; - let rows = db - .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) - .await - .expect("routed mentions"); - assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed"); - - let rows = db - .query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) - .await - .expect("routed needs action"); - assert_a_only( - &rows, - "a-approval-replica-only", - "query_feed_needs_action_routed", - ); - - let rows = db - .query_feed_activity_routed("sep_feed", cid_a, &both, None, 50) - .await - .expect("routed activity"); - assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed"); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// D4: a LAZY reader pool (connect_lazy, min_connections=0, never yet - /// used) must still let [`Db::spawn_fence_probe`] verify the writer's - /// floor guard and spawn — reader-down or reader-idle at boot must not - /// disable fence probing. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn lazy_reader_pool_still_spawns_fence_probe() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed, wname) = create_scratch_db(&admin, "lazy_w").await; - seed.close().await; - - let writer_url = { - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - format!("{}/{}", &base[..idx], wname) - }; - // `Db::new` (not `from_pools`) so the WRITER pool arms the - // `buzz.created_at_floor` GUC — `spawn_fence_probe` verifies the - // floor guard on a writer connection, and `create_scratch_db`'s - // plain `PgPool::connect` never arms it. The reader is still the - // lazy `connect_read_pool` pool this test is about. - let db = Db::new(&DbConfig { - database_url: writer_url.clone(), - read_database_url: Some(writer_url), - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db with lazy reader"); - - let spawned = db - .spawn_fence_probe() - .await - .expect("floor-guard verification must pass on the migrated writer"); - assert!(spawned, "a configured (lazy) reader must spawn the probe"); - - drop_scratch_db(&admin, db.pool.clone(), &wname).await; - } - - /// Thread replies: head fetch reads the writer; a FULL cursor page is - /// served by the replica; an UNDER-limit cursor page (candidate terminal - /// page) is re-run on the writer so a lagged replica can never truncate - /// the tail into a false EOF. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn thread_replies_cursor_pages_route_to_replica_with_writer_terminal_verification() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "routing_tw").await; - let (replica, rname) = create_scratch_db(&admin, "routing_tr").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let root = signed_event_at(&author, "root", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &root).await; - } - - // Writer holds replies r1..r5; the lagged replica only has r1..r3. - let replies: Vec = (1..=5) - .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) - .collect(); - for reply in &replies { - insert_thread_reply(&writer, community, channel, &root, reply).await; - } - for reply in &replies[..3] { - insert_thread_reply(&replica, community, channel, &root, reply).await; - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - // Open the fence through "now" — fixture history is far in the past. - db.fence().force_open_for_tests(chrono::Utc::now()); - let cid = CommunityId::from_uuid(community); - - // Page 1 (no cursor) → writer. - let page1 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) - .await - .expect("page 1"); - let contents: Vec<&str> = page1 - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!(contents, vec!["r1", "r2"], "head page from writer"); - - // Page 2: replica serves a FULL page (r3 exists there) — but wait: - // replica has r1..r3, page after r2 with limit 2 returns only [r3] - // (under limit) → terminal-verification re-runs on the writer, which - // returns [r3, r4]. A lag-truncated EOF must never surface. - let cur2 = thread_cursor(page1.last().expect("page 1 non-empty")); - let page2 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, Some(&cur2)) - .await - .expect("page 2"); - let contents: Vec<&str> = page2 - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["r3", "r4"], - "under-limit replica page must be re-verified on the writer" - ); - - // Full-page replica serve: with limit 1, the page after r2 is [r3] — - // exactly `limit` rows, so the replica result stands. Prove it came - // from the replica with a replica-only divergent reply. - let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); - insert_thread_reply(&replica, community, channel, &root, &ghost).await; - let page_replica = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) - .await - .expect("full replica page"); - let contents: Vec<&str> = page_replica - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["replica-only-ghost"], - "a full cursor page must be served by the replica" - ); - - // Same query with no replica configured reads the writer and cannot - // see the ghost. - let db_writer_only = Db::from_pool(writer.clone()); - let page_writer = db_writer_only - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) - .await - .expect("writer-only page"); - let contents: Vec<&str> = page_writer - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!(contents, vec!["r3"], "unset replica falls back to writer"); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Channel DESC scrollback, out-of-order commit adversary: the replica is - /// missing a MIDDLE row (`m2`) because a transaction with an older - /// client-signed `created_at` committed late and has not replayed yet. - /// The replica's cursor page would be `[m1]` — silently skipping `m2` - /// forever, since the next cursor advances past it. The fence must route - /// any cursor above it to the writer. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn channel_cursor_above_fence_stays_on_writer_preventing_middle_hole() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fence_cw").await; - let (replica, rname) = create_scratch_db(&admin, "fence_cr").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); - let m2 = signed_event_at(&author, "m2-late-commit", base + 10); - let m3 = signed_event_at(&author, "m3", base + 20); - let m4 = signed_event_at(&author, "m4", base + 30); - for ev in [&m1, &m2, &m3, &m4] { - insert_top_level(&writer, community, channel, ev).await; - } - // Replica replayed everything EXCEPT the late-committed m2. - for ev in [&m1, &m3, &m4] { - insert_top_level(&replica, community, channel, ev).await; - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - let cid = CommunityId::from_uuid(community); - - // Head page (writer): [m4, m3]; cursor lands on m3 (base+20). - let head = db - .get_channel_window(cid, channel, 2, None, None) - .await - .expect("head window"); - let cursor = head.next_cursor.expect("has_more implies next_cursor"); - - // Fence closed → cursor page must come from the writer: m2 present. - let contents = |w: &thread::ChannelWindow| -> Vec { - w.rows - .iter() - .map(|r| r.stored_event.event.content.clone()) - .collect() - }; - let page_closed = db - .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) - .await - .expect("cursor page, fence closed"); - assert_eq!( - contents(&page_closed), - vec!["m2-late-commit".to_string(), "m1".to_string()], - "fence closed: cursor pages route to the writer" - ); - - // Fence open but BELOW the cursor timestamp (covers base+5 only): - // the cursor (base+20) is not covered → writer again. - db.fence().force_open_for_tests( - chrono::DateTime::from_timestamp(base as i64 + 5, 0).expect("ts"), - ); - let page_below = db - .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) - .await - .expect("cursor page, fence below cursor"); - assert_eq!( - contents(&page_below), - vec!["m2-late-commit".to_string(), "m1".to_string()], - "cursor above the fence must stay on the writer" - ); - - // Counterfactual pinning the hazard: were the fence (wrongly) open - // through now, the replica would serve the page WITHOUT m2 — the - // permanent-skip hole this fence exists to prevent. - db.fence().force_open_for_tests(chrono::Utc::now()); - let page_hazard = db - .get_channel_window(cid, channel, 10, Some(cursor), None) - .await - .expect("cursor page, fence wrongly open"); - assert_eq!( - contents(&page_hazard), - vec!["m1".to_string()], - "fixture models the inversion: an over-open fence would skip m2" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Thread ASC pagination, out-of-order commit adversary: the replica - /// holds a FULL page whose newest row (`r4`) has a later key than a - /// not-yet-replayed row (`r3`). The old under-limit check alone would - /// serve `[r4]` and the client cursor would advance past `r3` forever. - /// The fence rule (full AND tail ≤ fence) must send that page to the - /// writer instead. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn thread_full_replica_page_above_fence_is_reverified_on_writer() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "fence_tw").await; - let (replica, rname) = create_scratch_db(&admin, "fence_tr").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; - - let base = 1_700_000_000u64; - let root = signed_event_at(&author, "root", base); - for pool in [&writer, &replica] { - insert_top_level(pool, community, channel, &root).await; - } - let replies: Vec = (1..=4) - .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) - .collect(); - for reply in &replies { - insert_thread_reply(&writer, community, channel, &root, reply).await; - } - // Replica replayed r1, r2, r4 — the late-committed r3 is missing. - for reply in [&replies[0], &replies[1], &replies[3]] { - insert_thread_reply(&replica, community, channel, &root, reply).await; - } - - let db = Db::from_pools(writer.clone(), replica.clone()); - let cid = CommunityId::from_uuid(community); - - // Fence covers r2 (base+20) but not r3/r4. - db.fence().force_open_for_tests( - chrono::DateTime::from_timestamp(base as i64 + 20, 0).expect("ts"), - ); - - // Page after r2 with limit 1: the replica would return the FULL page - // [r4] — but its tail is above the fence, so the writer re-runs it - // and returns [r3]. No skip. - let page1 = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) - .await - .expect("head page"); - let cur = thread_cursor(page1.last().expect("head page non-empty")); - let page = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("cursor page"); - let contents: Vec<&str> = page - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["r3"], - "a full replica page above the fence must be re-run on the writer" - ); - - // Counterfactual: an over-open fence would serve the replica's [r4], - // skipping r3 permanently. - db.fence().force_open_for_tests(chrono::Utc::now()); - let hazard = db - .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) - .await - .expect("hazard page"); - let contents: Vec<&str> = hazard - .iter() - .map(|r| r.stored_event.event.content.as_str()) - .collect(); - assert_eq!( - contents, - vec!["r4"], - "fixture models the inversion: an over-open fence would skip r3" - ); - - drop_scratch_db(&admin, replica, &rname).await; - drop_scratch_db(&admin, writer, &wname).await; - } - - /// Commit-time floor guard (migration 0021), exact held-transaction - /// adversary: a channel-bearing row whose `created_at` is older than the - /// floor at COMMIT time must abort the transaction — the guard runs - /// inside commit processing with `clock_timestamp()`, so holding the - /// transaction open cannot outrun it. channel_id-NULL rows are - /// structurally exempt, and sessions without the GUC are unaffected. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (pool, name) = create_scratch_db(&admin, "floor_guard").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&pool, community, channel, &author).await; - - let insert_raw = |ev: nostr::Event, channel_id: Option| { - let pool = pool.clone(); - async move { - let mut tx = pool.begin().await.expect("begin"); - // Arm the guard for this transaction only (the relay's - // writer pool arms it per connection; tests are explicit). - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") - .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) - .execute(&mut *tx) - .await - .expect("arm guard"); - sqlx::query( - "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, \ - content, sig, received_at, channel_id) \ - VALUES ($1, $2, $3, to_timestamp($4), 9, '[]', $5, $6, NOW(), $7)", - ) - .bind(community) - .bind(ev.id.as_bytes().as_slice()) - .bind(ev.pubkey.to_bytes().as_slice()) - .bind(ev.created_at.as_secs() as f64) - .bind(&ev.content) - .bind(ev.sig.serialize().as_slice()) - .bind(channel_id) - .execute(&mut *tx) - .await - .expect("insert inside tx (guard is deferred to commit)"); - // Hold the transaction "open" past the insert, then commit — - // the deferred guard must still see the stale created_at. - sqlx::query("SELECT pg_sleep(0.05)") - .execute(&mut *tx) - .await - .expect("hold tx"); - tx.commit().await - } - }; - - let now_secs = chrono::Utc::now().timestamp() as u64; - let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; - - // Old channel-bearing row → COMMIT aborts with check_violation. - let old = signed_event_at(&author, "old-held-tx", now_secs - floor - 60); - let err = insert_raw(old, Some(channel)) - .await - .expect_err("below-floor channel row must abort at COMMIT"); - let code = match &err { - sqlx::Error::Database(db_err) => db_err.code().map(|c| c.to_string()), - other => panic!("expected database error, got {other:?}"), - }; - assert_eq!( - code.as_deref(), - Some("23514"), - "guard raises check_violation" - ); - - // Fresh channel-bearing row → commits. - let fresh = signed_event_at(&author, "fresh", now_secs); - insert_raw(fresh, Some(channel)) - .await - .expect("fresh row commits under the armed guard"); - - // Old row WITHOUT a channel (push lease / profile shapes) → - // structurally exempt, commits. - let old_global = signed_event_at(&author, "old-global", now_secs - floor - 60); - insert_raw(old_global, None) - .await - .expect("channel_id-NULL rows are exempt from the floor"); - - // Unarmed session (no GUC) → guard inert; backfills stay possible - // (and must hold the fence closed, per the migration header). - let old_backfill = signed_event_at(&author, "old-backfill", now_secs - floor - 60); - insert_top_level(&pool, community, channel, &old_backfill).await; - - drop_scratch_db(&admin, pool, &name).await; - } - - #[test] - fn writer_pool_safety_hook_is_single_and_composed() { - let source = include_str!("lib.rs"); - let connect_pool = source - .split("async fn connect_pool") - .nth(1) - .and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next()) - .expect("connect_pool source block"); - assert_eq!( - connect_pool.matches(".after_connect(").count(), - 1, - "SQLx replaces after_connect hooks; writer safety must use exactly one" - ); - assert!(connect_pool.contains("buzz.created_at_floor")); - assert!(connect_pool.contains("SHOW transaction_isolation")); - assert!(!connect_pool.contains("arm_floor_guard")); - assert!(!connect_pool.contains("_arm_floor_guard")); - assert!(!connect_pool.contains("allow(unused_variables)")); - - let reader_doc = source - .split("fn connect_read_pool") - .next() - .and_then(|prefix| prefix.rsplit("/// Connect the read-replica").next()) - .expect("reader pool documentation"); - assert!(reader_doc.contains("replica sessions are")); - assert!(reader_doc.contains("read-only")); - assert!(!reader_doc.contains("Db::connect_pool")); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn writer_pool_rejects_non_read_committed_database_default() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed_pool, name) = create_scratch_db(&admin, "writer_isolation").await; - sqlx::query(sqlx::AssertSqlSafe(format!( - "ALTER DATABASE {name} SET default_transaction_isolation = 'repeatable read'" - ))) - .execute(&admin) - .await - .expect("set unsafe database default"); - seed_pool.close().await; - - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - let scratch_url = format!("{}/{}", &base[..idx], name); - let error = Db::new(&DbConfig { - database_url: scratch_url, - max_connections: 1, - min_connections: 1, - acquire_timeout_secs: 1, - ..DbConfig::default() - }) - .await - .expect_err("writer pool must reject pinned-snapshot database defaults"); - assert!( - error.to_string().contains("requires READ COMMITTED") - || error.to_string().contains("pool timed out"), - "unexpected isolation rejection: {error}" - ); - - sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE {name} WITH (FORCE)" - ))) - .execute(&admin) - .await - .expect("drop isolation test database"); - } - - /// The armed writer pool (`Db::new`) must enforce the floor end-to-end - /// through the public insert APIs, and the session GUC must be verifiably - /// set on pooled connections. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn armed_pool_rejects_old_channel_inserts_through_public_api() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed_pool, name) = create_scratch_db(&admin, "floor_pool").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&seed_pool, community, channel, &author).await; - - // Connect a Db the production way: after_connect arms the guard. - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - let scratch_url = format!("{}/{}", &base[..idx], name); - let db = Db::new(&DbConfig { - database_url: scratch_url, - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db"); - let cid = CommunityId::from_uuid(community); - - // Perci nit: assert the effective session value, not the intent. - let effective: String = sqlx::query_scalar("SHOW buzz.created_at_floor") - .fetch_one(&db.pool) - .await - .expect("SHOW guard GUC"); - assert_eq!( - effective, - crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string(), - "writer pool must arm the floor guard on every connection" - ); - let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") - .fetch_one(&db.pool) - .await - .expect("SHOW writer isolation"); - assert_eq!( - isolation, "read committed", - "the same writer after_connect hook must enforce the isolation premise" - ); - - let now_secs = chrono::Utc::now().timestamp() as u64; - let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; - - // insert_event (single INSERT, autocommit): old channel row rejected. - let old = signed_event_at(&author, "old-direct", now_secs - floor - 60); - let err = event::insert_event(&db.pool, cid, &old, Some(channel)) - .await - .expect_err("armed pool must reject below-floor channel inserts"); - assert!( - err.to_string().contains("below the replica-fence floor"), - "unexpected error: {err}" - ); - - // insert_event_with_thread_metadata (multi-statement tx): same. - let old2 = signed_event_at(&author, "old-thread-meta", now_secs - floor - 90); - let ts = chrono::DateTime::from_timestamp(old2.created_at.as_secs() as i64, 0) - .expect("valid ts"); - let err = event::insert_event_with_thread_metadata( - &db.pool, - cid, - &old2, - Some(channel), - Some(event::ThreadMetadataParams { - event_id: old2.id.as_bytes(), - event_created_at: ts, - channel_id: channel, - parent_event_id: None, - parent_event_created_at: None, - root_event_id: None, - root_event_created_at: None, - depth: 0, - broadcast: true, - }), - ) - .await - .expect_err("armed pool must reject below-floor thread-metadata inserts"); - assert!( - err.to_string().contains("below the replica-fence floor"), - "unexpected error: {err}" - ); - - // Fresh events pass through both APIs. - let fresh = signed_event_at(&author, "fresh-direct", now_secs); - event::insert_event(&db.pool, cid, &fresh, Some(channel)) - .await - .expect("fresh insert passes the armed guard"); - - drop_scratch_db(&admin, seed_pool, &name).await; - // db pool still holds connections to the dropped DB; close it. - db.pool.close().await; - } - - /// `spawn_fence_probe` must verify the floor guard before letting the - /// probe run — catalog shape AND observed behavior — and refuse on - /// sabotage. This is the production gate for a relay running with - /// `BUZZ_AUTO_MIGRATE` off: an armed GUC with no enforcing trigger must - /// never yield an open fence. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn fence_probe_refuses_to_start_without_verified_floor_guard() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (seed_pool, wname) = create_scratch_db(&admin, "fence_gate_w").await; - let (replica_pool, rname) = create_scratch_db(&admin, "fence_gate_r").await; - seed_pool.close().await; - replica_pool.close().await; - - let base = admin_url().await; - let idx = base.rfind('/').expect("db url has a path segment"); - let writer_url = format!("{}/{}", &base[..idx], wname); - let replica_url = format!("{}/{}", &base[..idx], rname); - - // Healthy schema: verification passes, probe starts. A SEPARATE Db - // instance, because its background probe legitimately opens its own - // fence (the heartbeat probe is writer-side only) — the refusal - // assertions below must run against a fence whose spawns were all - // refused. - let db_healthy = Db::new(&DbConfig { - database_url: writer_url.clone(), - read_database_url: Some(replica_url.clone()), - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db with replica"); - assert!( - db_healthy - .spawn_fence_probe() - .await - .expect("verification passes"), - "probe must start on a verified schema" - ); - - let db = Db::new(&DbConfig { - database_url: writer_url, - read_database_url: Some(replica_url), - max_connections: 2, - ..DbConfig::default() - }) - .await - .expect("connect armed Db with replica"); - - // Sabotage A: catalog-shaped no-op — same trigger, gutted function - // body. Catalog check alone would pass; behavior check must refuse. - sqlx::query( - "CREATE OR REPLACE FUNCTION events_created_at_floor_guard() RETURNS trigger \ - LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END $$", - ) - .execute(&db.pool) - .await - .expect("gut the guard function"); - let err = db - .spawn_fence_probe() - .await - .expect_err("inert guard body must refuse the probe"); - assert!( - err.to_string().contains("floor guard is inert"), - "unexpected error: {err}" - ); - - // Sabotage B: trigger dropped entirely (the BUZZ_AUTO_MIGRATE=off / - // 0021-unapplied shape). Catalog check must refuse. - sqlx::query("DROP TRIGGER events_created_at_floor ON events") - .execute(&db.pool) - .await - .expect("drop the guard trigger"); - let err = db - .spawn_fence_probe() - .await - .expect_err("missing trigger must refuse the probe"); - assert!( - err.to_string().contains("missing or mis-shaped"), - "unexpected error: {err}" - ); - - // In both refusal states the fence never opened. - assert!( - db.fence().verified_through().is_none(), - "fence must remain closed when verification refuses the probe" - ); - - db_healthy.pool.close().await; - if let Some(rp) = &db_healthy.read_pool { - rp.close().await; - } - db.pool.close().await; - if let Some(rp) = &db.read_pool { - rp.close().await; - } - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE IF EXISTS {wname} WITH (FORCE)" - ))) - .execute(&admin) - .await; - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP DATABASE IF EXISTS {rname} WITH (FORCE)" - ))) - .execute(&admin) - .await; - } - - /// The `UPDATE OF` arm of the floor guard (Perci's second structural - /// hole): an old row legitimately admitted with `channel_id` NULL must - /// not be movable into keyset windows, and a channel row's `created_at` - /// must not be movable below the fence — through raw SQL, at COMMIT. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (pool, name) = create_scratch_db(&admin, "floor_upd").await; - - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); - let channel = Uuid::new_v4(); - seed_community_channel(&pool, community, channel, &author).await; - - let now_secs = chrono::Utc::now().timestamp() as u64; - let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; - - // Seed via unarmed session: one old channel-NULL row, one fresh - // channel row. - let old_null = signed_event_at(&author, "old-null", now_secs - floor - 120); - insert_top_level(&pool, community, channel, &old_null).await; - sqlx::query("UPDATE events SET channel_id = NULL WHERE community_id = $1 AND id = $2") - .bind(community) - .bind(old_null.id.as_bytes().as_slice()) - .execute(&pool) - .await - .expect("detach channel (unarmed seed)"); - let fresh = signed_event_at(&author, "fresh-row", now_secs); - insert_top_level(&pool, community, channel, &fresh).await; - - // Armed transaction, deferred to COMMIT (the production shape). - let run_armed_update = |sql: &'static str, id: Vec, age: Option| { - let pool = pool.clone(); - async move { - let mut tx = pool.begin().await.expect("begin"); - sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") - .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) - .execute(&mut *tx) - .await - .expect("arm guard"); - let q = sqlx::query(sql).bind(community).bind(id); - let q = match age { - Some(a) => q.bind(a as f64), - None => q, - }; - q.execute(&mut *tx) - .await - .expect("update inside tx (deferred)"); - tx.commit().await - } - }; - - // channel-NULL → channel-bearing on an old row: COMMIT must abort. - let err = run_armed_update( - "UPDATE events SET channel_id = community_id WHERE community_id = $1 AND id = $2", - old_null.id.as_bytes().to_vec(), - None, - ) - .await - .expect_err("moving an old channel-NULL row into a channel must abort at COMMIT"); - assert!( - matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), - "unexpected error: {err}" - ); - - // created_at rewrite below the floor on a channel row: COMMIT must abort. - let err = run_armed_update( - "UPDATE events SET created_at = clock_timestamp() - make_interval(secs => $3::double precision) \ - WHERE community_id = $1 AND id = $2", - fresh.id.as_bytes().to_vec(), - Some(floor + 120), - ) - .await - .expect_err("rewriting created_at below the floor must abort at COMMIT"); - assert!( - matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), - "unexpected error: {err}" - ); +pub use event::{EventQuery, DEFAULT_MAX_PAGE_LIMIT}; +pub use reaction::ReactionEventInsertOutcome; +pub use reminder::DueReminder; +pub use usage::UsageMetricsLeader; - drop_scratch_db(&admin, pool, &name).await; - } -} +use buzz_core::CommunityId; diff --git a/crates/buzz-db/src/reaction.rs b/crates/buzz-db/src/reaction.rs deleted file mode 100644 index 9e285051dc1..00000000000 --- a/crates/buzz-db/src/reaction.rs +++ /dev/null @@ -1,418 +0,0 @@ -//! Reaction persistence. -//! -//! One reaction per user per emoji per event. Soft-delete via removed_at. - -use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Postgres, Row, Transaction}; - -use crate::error::Result; -use crate::CommunityId; - -// -- Public structs ----------------------------------------------------------- - -/// A grouped set of reactions for a single emoji on an event. -#[derive(Debug, Clone)] -pub struct ReactionGroup { - /// The emoji character or shortcode used in this reaction group. - pub emoji: String, - /// Total number of active reactions with this emoji. - pub count: i64, - /// Individual users who reacted with this emoji. - pub users: Vec, -} - -/// A single user who reacted with a given emoji. -#[derive(Debug, Clone)] -pub struct ReactionUser { - /// Compressed 33-byte public key of the reacting user. - pub pubkey: Vec, - /// Optional display name resolved from the users table. - pub display_name: Option, - /// Nostr event ID of the kind:7 reaction event (raw bytes), if present. - /// Clients use this to build signed kind:5 deletion events for reaction removal. - pub reaction_event_id: Option>, -} - -/// Bulk reaction entry for embedding in message lists. -#[derive(Debug, Clone)] -pub struct BulkReactionEntry { - /// The event this reaction entry belongs to. - pub event_id: Vec, - /// Partition key timestamp for the event. - pub event_created_at: DateTime, - /// Emoji + count summaries for this event. - pub reactions: Vec, -} - -/// Emoji + count summary (no user list) for bulk fetches. -#[derive(Debug, Clone)] -pub struct ReactionSummary { - /// The emoji character or shortcode. - pub emoji: String, - /// Number of active reactions with this emoji. - pub count: i64, -} - -/// Active reaction row metadata for a specific actor + emoji + target tuple. -#[derive(Debug, Clone)] -pub struct ActiveReactionRecord { - /// Nostr event ID of the reaction event, if this row came from a real kind:7 event. - pub reaction_event_id: Option>, -} - -// -- Write operations --------------------------------------------------------- - -const ADD_REACTION_SQL: &str = r#" - INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET - created_at = NOW(), - removed_at = NULL, - reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id) - WHERE reactions.removed_at IS NOT NULL - "#; - -/// Add (or re-activate) a reaction. -/// -/// Returns `Ok(true)` if the reaction was added or re-activated, `Ok(false)` if -/// the reaction is already active (duplicate, no change made). -/// -/// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where -/// two concurrent adds both see no existing row and then race to INSERT. -pub async fn add_reaction( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: Option<&[u8]>, -) -> Result { - let result = sqlx::query(ADD_REACTION_SQL) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .bind(reaction_event_id) - .execute(pool) - .await?; - - // Three cases: - // (a) New reaction (no existing row): INSERT succeeds → rows_affected = 1 → true. - // (b) Reactivating (row exists, removed_at IS NOT NULL): WHERE matches → UPDATE fires - // → rows_affected = 1 → true. - // (c) Active duplicate (row exists, removed_at IS NULL): WHERE fails → no UPDATE - // → rows_affected = 0 → false. Caller should short-circuit and not store the event. - Ok(result.rows_affected() != 0) -} - -/// Add (or re-activate) a reaction inside an existing transaction. -/// -/// Uses the same `INSERT ... ON CONFLICT DO UPDATE ... WHERE removed_at IS NOT NULL` -/// statement as [`add_reaction`], preserving the new / re-activate / active-duplicate -/// semantics while letting callers atomically couple the reaction row to other writes. -pub(crate) async fn add_reaction_tx( - tx: &mut Transaction<'_, Postgres>, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: Option<&[u8]>, -) -> Result { - let result = sqlx::query(ADD_REACTION_SQL) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .bind(reaction_event_id) - .execute(&mut **tx) - .await?; - - Ok(result.rows_affected() != 0) -} - -/// Soft-delete a reaction by setting `removed_at`. -/// -/// Returns `true` if a row was updated, `false` if not found or already removed. -pub async fn remove_reaction( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, -) -> Result { - let result = sqlx::query( - r#" - UPDATE reactions - SET removed_at = NOW() - WHERE community_id = $1 - AND event_created_at = $2 - AND event_id = $3 - AND pubkey = $4 - AND emoji = $5 - AND removed_at IS NULL - "#, - ) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Soft-delete a reaction by the reaction event's own ID. -/// -/// Returns `true` if a row was updated, `false` if not found or already removed. -pub async fn remove_reaction_by_source_event_id( - pool: &PgPool, - community: CommunityId, - reaction_event_id: &[u8], -) -> Result { - let result = sqlx::query( - r#" - UPDATE reactions - SET removed_at = NOW() - WHERE community_id = $1 - AND reaction_event_id = $2 - AND removed_at IS NULL - "#, - ) - .bind(community.as_uuid()) - .bind(reaction_event_id) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -/// Look up the active reaction row for one actor + emoji + target tuple. -pub async fn get_active_reaction_record( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, -) -> Result> { - let row = sqlx::query( - r#" - SELECT reaction_event_id - FROM reactions - WHERE community_id = $1 - AND event_id = $2 - AND event_created_at = $3 - AND pubkey = $4 - AND emoji = $5 - AND removed_at IS NULL - LIMIT 1 - "#, - ) - .bind(community.as_uuid()) - .bind(event_id) - .bind(event_created_at) - .bind(pubkey) - .bind(emoji) - .fetch_optional(pool) - .await?; - - row.map(|row| -> Result { - Ok(ActiveReactionRecord { - reaction_event_id: row.try_get("reaction_event_id")?, - }) - }) - .transpose() -} - -/// Backfill the source event ID on an active reaction row. -/// -/// Called after the kind:7 event is created and stored, to link the -/// reaction row to its source event. Returns `true` if the row was updated. -pub async fn set_reaction_event_id( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - pubkey: &[u8], - emoji: &str, - reaction_event_id: &[u8], -) -> Result { - let result = sqlx::query( - r#" - UPDATE reactions - SET reaction_event_id = $1 - WHERE community_id = $2 - AND event_created_at = $3 - AND event_id = $4 - AND pubkey = $5 - AND emoji = $6 - AND removed_at IS NULL - "#, - ) - .bind(reaction_event_id) - .bind(community.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(pubkey) - .bind(emoji) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) -} - -// -- Read operations ---------------------------------------------------------- - -/// Get all active reactions for an event, grouped by emoji. -/// -/// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting -/// user pubkeys. Display names are NOT resolved here -- callers should enrich via -/// scoped user lookups if needed. -/// -/// `cursor` is reserved for future keyset pagination (currently unused). -pub async fn get_reactions( - pool: &PgPool, - community: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - limit: u32, - _cursor: Option<&str>, -) -> Result> { - // Two-step query: first get the limited set of distinct emoji groups, - // then fetch all rows for those groups. This ensures `limit` applies to - // emoji groups (the API contract), not raw rows — so one busy emoji - // cannot consume the entire page and hide other groups. - let rows = sqlx::query( - r#" - SELECT r.emoji, r.pubkey, r.reaction_event_id - FROM reactions r - INNER JOIN ( - SELECT DISTINCT emoji - FROM reactions - WHERE community_id = $1 - AND event_id = $2 - AND event_created_at = $3 - AND removed_at IS NULL - ORDER BY emoji - LIMIT $4 - ) g ON g.emoji = r.emoji - WHERE r.community_id = $1 - AND r.event_id = $2 - AND r.event_created_at = $3 - AND r.removed_at IS NULL - ORDER BY r.emoji, r.created_at - "#, - ) - .bind(community.as_uuid()) - .bind(event_id) - .bind(event_created_at) - .bind(limit as i64) - .fetch_all(pool) - .await?; - - // Group individual rows by emoji in Rust. - let mut groups: Vec = Vec::new(); - let mut current_emoji: Option = None; - let mut current_users: Vec = Vec::new(); - - for row in &rows { - let emoji: String = row.try_get("emoji")?; - let pubkey: Vec = row.try_get("pubkey")?; - let reaction_event_id: Option> = row.try_get("reaction_event_id")?; - - if current_emoji.as_ref() != Some(&emoji) { - if let Some(prev_emoji) = current_emoji.take() { - let count = current_users.len() as i64; - groups.push(ReactionGroup { - emoji: prev_emoji, - count, - users: std::mem::take(&mut current_users), - }); - } - current_emoji = Some(emoji); - } - - current_users.push(ReactionUser { - pubkey, - display_name: None, - reaction_event_id, - }); - } - - // Flush the final group. - if let Some(emoji) = current_emoji { - let count = current_users.len() as i64; - groups.push(ReactionGroup { - emoji, - count, - users: current_users, - }); - } - - Ok(groups) -} - -/// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. -/// -/// Returns one [`BulkReactionEntry`] per input pair that has at least one -/// active reaction. Pairs with no reactions are omitted. -pub async fn get_reactions_bulk( - pool: &PgPool, - community: CommunityId, - event_ids: &[(&[u8], DateTime)], -) -> Result> { - if event_ids.is_empty() { - return Ok(Vec::new()); - } - - // Run one query per event. For typical message-list sizes (<=100 events) - // this is acceptable; a single-query approach with dynamic IN clauses over - // composite keys can be added later if needed. - let mut entries = Vec::new(); - - for (event_id, event_created_at) in event_ids { - let rows = sqlx::query( - r#" - SELECT emoji, COUNT(*) AS count - FROM reactions - WHERE community_id = $1 - AND event_id = $2 - AND event_created_at = $3 - AND removed_at IS NULL - GROUP BY emoji - ORDER BY emoji - "#, - ) - .bind(community.as_uuid()) - .bind(*event_id) - .bind(event_created_at) - .fetch_all(pool) - .await?; - - if rows.is_empty() { - continue; - } - - let mut reactions = Vec::with_capacity(rows.len()); - for row in rows { - let emoji: String = row.try_get("emoji")?; - let count: i64 = row.try_get("count")?; - reactions.push(ReactionSummary { emoji, count }); - } - - entries.push(BulkReactionEntry { - event_id: event_id.to_vec(), - event_created_at: *event_created_at, - reactions, - }); - } - - Ok(entries) -} diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/runtime/migration.rs similarity index 99% rename from crates/buzz-db/src/migration.rs rename to crates/buzz-db/src/runtime/migration.rs index 464201adf97..f258fa64411 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -897,7 +897,7 @@ mod tests { assert!(migrations[32].sql.as_str().contains("kind = 30179")); assert!(migrations[32].sql.as_str().contains("search_tsv")); assert!(!migrations[0].sql.as_str().contains("30179")); - assert!(include_str!("../../../schema/schema.sql") + assert!(include_str!("../../../../schema/schema.sql") .contains("kind IN (1059, 30179, 30300, 30350, 30622, 44100, 44101, 44200)")); // Public push-gateway authority is intentionally deployment-global and @@ -1040,7 +1040,7 @@ mod tests { .contains("CREATE INDEX relay_invites_expires_at_idx ON relay_invites (expires_at)")); assert!(!relay_invites.contains("_operator_global_tables")); - let desired_schema = include_str!("../../../schema/schema.sql"); + let desired_schema = include_str!("../../../../schema/schema.sql"); assert!( desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", @@ -1156,7 +1156,7 @@ mod tests { // parameters. Its post-apply reconciliation must restore and verify // both parts of the live heartbeat contract for fresh bootstraps. let pgschema_reconciliation = - include_str!("../../../scripts/reconcile-schema-after-pgschema.sql"); + include_str!("../../../../scripts/reconcile-schema-after-pgschema.sql"); assert!(pgschema_reconciliation .contains("ALTER TABLE replica_heartbeat SET (vacuum_truncate = false)")); assert!(pgschema_reconciliation.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); @@ -1309,7 +1309,7 @@ mod tests { .sql .as_str() .contains("error_code")); - assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); + assert!(include_str!("../../../../schema/schema.sql").contains("error_code TEXT")); } #[test] @@ -1480,7 +1480,7 @@ mod tests { let migrator_run_to = ["MIGRATOR", ".run_to("].concat(); let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let this_file = manifest_dir.join("src/migration.rs"); + let this_file = manifest_dir.join("src/runtime/migration.rs"); let crates_dir = manifest_dir.parent().expect("workspace crates dir"); // The push gateway migrates its own dedicated authority database; it // never holds relay tenant tables, so it is exempt from the relay diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs new file mode 100644 index 00000000000..29eef884024 --- /dev/null +++ b/crates/buzz-db/src/runtime/mod.rs @@ -0,0 +1,1044 @@ +pub mod migration; +pub(crate) mod observability; +pub mod replica_fence; + +use crate::{deletion, event, DbError, EventQuery, Result}; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::postgres::PgPoolOptions; +use sqlx::{PgPool, QueryBuilder}; +use std::time::Duration; +use uuid::Uuid; + +use buzz_core::{CommunityId, StoredEvent}; + +/// Extract p-tag mentions from an event and insert into the `event_mentions` table. +/// +/// This pool-owning wrapper propagates failures to its caller. Replacement writes +/// use the transaction-bound helper below so event storage and mention indexing +/// commit or roll back together. Duplicate inserts are silently skipped with +/// `INSERT ... ON CONFLICT DO NOTHING`. +pub async fn insert_mentions( + pool: &PgPool, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let mut tx = pool.begin().await?; + insert_mentions_in_transaction(&mut tx, community_id, event, channel_id).await?; + tx.commit().await?; + Ok(()) +} + +/// Insert mention rows on the caller's transaction. Replacement writes use +/// this so the authoritative event and its discovery index commit or roll back +/// as one unit. +pub(crate) async fn insert_mentions_in_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, +) -> Result<()> { + let p_tags: Vec<&str> = event + .tags + .iter() + .filter_map(|tag| { + let tag_vec = tag.as_slice(); + if tag_vec.len() >= 2 && tag_vec[0] == "p" { + Some(tag_vec[1].as_str()) + } else { + None + } + }) + .collect(); + + if p_tags.is_empty() { + return Ok(()); + } + + let event_id_bytes = event.id.as_bytes(); + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = DateTime::from_timestamp(created_at_secs, 0) + .ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?; + let kind = event.kind.as_u16() as u32; + + // Validate and normalize pubkeys, logging any malformed ones. + let valid_pubkeys: Vec = p_tags + .into_iter() + .filter(|pk| { + if pk.len() != 64 || !pk.chars().all(|c| c.is_ascii_hexdigit()) { + tracing::debug!( + event_id = %event.id, + invalid_ptag = pk, + "skipping malformed p-tag in insert_mentions" + ); + false + } else { + true + } + }) + .map(|pk| pk.to_ascii_lowercase()) + .collect(); + + if valid_pubkeys.is_empty() { + return Ok(()); + } + + // Multi-row INSERT ... ON CONFLICT DO NOTHING, chunked to stay under + // Postgres's 65,535 bind-parameter statement cap (6 binds per row caps a + // single statement at ~10.9k rows). Relay-signed kind 39002 rosters carry + // one p-tag per channel member and can exceed that. The caller owns the + // transaction so all chunks share its commit boundary. + const MENTION_INSERT_CHUNK_ROWS: usize = 5_000; + for chunk in valid_pubkeys.chunks(MENTION_INSERT_CHUNK_ROWS) { + let mut qb: QueryBuilder = QueryBuilder::new( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", + ); + + qb.push_values(chunk, |mut b, pubkey| { + b.push_bind(community_id.as_uuid()) + .push_bind(pubkey.as_str()) + .push_bind(event_id_bytes.as_slice()) + .push_bind(created_at) + .push_bind(channel_id) + .push_bind(kind as i32); + }); + + qb.push(" ON CONFLICT DO NOTHING"); + + qb.build().execute(&mut **tx).await?; + } + Ok(()) +} + +/// Database handle. Clone is cheap (Arc-backed pool). +#[derive(Clone, Debug)] +pub struct Db { + pub(crate) pool: PgPool, + /// Maximum connections configured for this pool (from [`DbConfig::max_connections`]). + pub(crate) max_connections: u32, + /// Optional read-replica pool (from [`DbConfig::read_database_url`]). + /// + /// `None` means no replica is configured and every read routes to the + /// writer pool — the pre-replica behavior. Only lag-tolerant reads may + /// route here (see [`Db::read`]); locks, transactions, and anything + /// consistency-critical stays on `pool`. + pub(crate) read_pool: Option, + /// Maximum connections configured for the read-replica pool (from + /// [`DbConfig::read_max_connections`], defaulting to the writer's + /// sizing). Kept separately from `max_connections` so + /// [`Db::read_pool_stats`] reports the reader's own ceiling — a + /// utilisation gauge derived from the writer's max would understate + /// reader saturation by exactly the ratio of the two pool sizes. + pub(crate) read_max_connections: u32, + /// Freshness fence gating cursor-page routing to the replica. + /// + /// Starts closed; a background probe ([`replica_fence::run_probe`]) + /// commits heartbeat tokens and retains proof entries. Routing proves + /// coverage per request on the serving reader session; when the ring is + /// empty or stale, every routed read stays on the writer. + pub(crate) fence: std::sync::Arc, + /// Bounded-staleness routing budget `B`: a read routed under + /// [`RoutePredicate::Bounded`] may be served from a proved replica + /// session only when the proved heartbeat entry is at most this old. + /// `None` disables the bounded arm entirely (the rollout default) — + /// bounded-stale read semantics are a product decision, not an + /// invariant, so the gate ships off. + pub(crate) replica_read_max_age: Option, + /// Whether the reader endpoint supports the Aurora PostgreSQL identity + /// function ([`replica_fence::AURORA_IDENTITY_FN`]) — probed + /// once per process on the first routed read (on a plain autocommit + /// checkout, outside any request transaction) and cached. Unset means + /// not yet probed (or the probe hit a transient error and will retry). + /// Shared across `Db` clones. + pub(crate) reader_aurora_identity: std::sync::Arc>, +} + +/// The session that served (or will serve) a routed read, so follow-up +/// queries in the same request (the channel-window aux closure) run on the +/// **same proved snapshot** — a different pooled reader session may sit at a +/// different replay position, and even the same connection advances its +/// snapshot between autocommit statements. +/// +/// `Replica` holds the request's `REPEATABLE READ, READ ONLY` transaction: +/// the heartbeat observation was its first statement, so the snapshot the +/// proof was taken against is exactly the snapshot every follow-up sees. +/// Dropping the session rolls the read-only transaction back and returns +/// the connection to the pool. +/// +/// `Writer` carries the writer pool: follow-ups there are authoritative by +/// construction and need no session pinning. +pub struct ReadSession { + pub(crate) inner: ReadSessionInner, +} + +pub(crate) enum ReadSessionInner { + /// The proved replica request transaction (snapshot-anchored), plus the + /// writer pool so a mid-request replica failure (e.g. a hot-standby + /// recovery conflict cancelling the held snapshot) degrades the session + /// to the writer instead of surfacing an error: degraded capacity, + /// never holes — and never a 500 the writer could have served. + Replica { + tx: sqlx::Transaction<'static, sqlx::Postgres>, + writer: PgPool, + }, + /// The writer pool (cheap clone; Arc-backed). + Writer(PgPool), +} + +impl ReadSession { + /// Query events on this session (see [`Db::query_events`]). + /// + /// If the proved replica transaction fails mid-request, the session + /// permanently degrades to the writer and the query is re-run there. + /// The writer is always at or ahead of any replica replay position, so + /// the degraded follow-up can only observe *more* than the proof-time + /// snapshot, never less — fresher aux rows, the same failure semantics + /// as a request that routed to the writer to begin with. + #[datastore_span(name = "read_session_query_events", system = "postgresql")] + pub async fn query_events(&mut self, q: &EventQuery) -> Result> { + let degraded = match &mut self.inner { + ReadSessionInner::Replica { tx, writer } => { + match event::query_events_on(tx, q).await { + Ok(rows) => return Ok(rows), + Err(e) => { + tracing::warn!( + error = %e, + "replica session query failed mid-request; degrading to writer" + ); + // Deliberately not a `buzz_db_route_decision` event: + // the page's route was already recorded, and the + // offload metric must stay one-event-per-request. + metrics::counter!("buzz_db_read_session_degraded").increment(1); + writer.clone() + } + } + } + ReadSessionInner::Writer(pool) => return event::query_events(pool, q).await, + }; + // Replacing the inner drops the replica transaction (rolling it + // back and returning the reader connection to its pool). + self.inner = ReadSessionInner::Writer(degraded.clone()); + event::query_events(°raded, q).await + } + + /// Whether this session is a proved replica connection (observability). + pub fn is_replica(&self) -> bool { + matches!(self.inner, ReadSessionInner::Replica { .. }) + } +} + +/// Where one routed read is served (see [`Db::route_read`]). +pub(crate) enum RouteDecision { + /// A reader request transaction whose first-statement heartbeat + /// observation proved this fence entry — the page runs inside it. The + /// `&'static str` is the metric reason (`covered`/`fresh`); the caller + /// records the route only once the page is actually served from the + /// replica, so a post-verification writer re-run or a mid-query replica + /// failure emits exactly one `buzz_db_route_decision` event per request + /// (the offload percentage is read straight off `decision="replica"`). + Replica( + sqlx::Transaction<'static, sqlx::Postgres>, + replica_fence::TokenEntry, + &'static str, + ), + /// Fail closed: serve from the writer pool (already recorded). + Writer, +} + +/// The ONLY place [`route_proof::ChannelScoped`] can be constructed. A +/// crate-root tuple struct would be mintable via `ChannelScoped(())` from +/// every descendant module — tuple-struct field privacy is module-scoped — +/// so the token lives in its own module and E0423 enforces the invariant. +pub(crate) mod route_proof { + use uuid::Uuid; + + /// Proof that a query/page can only return rows with + /// `channel_id IS NOT NULL` — the domain of the commit-time floor guard + /// (migration 0021). `channel_ids` (retains channel-NULL rows) and + /// `global_only = false` are explicitly NOT proofs. + /// + /// Each constructor keys off *how* its path proves channel-bearing-ness: + /// a pinned query filter, a bare `Uuid` argument, or a `NOT NULL` column + /// reached through an inner join. Do not add a universal constructor + /// callers reshape their inputs to fit, and never fabricate a throwaway + /// `EventQuery` purely to mint a token — the proof must be the SQL's + /// shape, not "someone assembled a struct". + #[derive(Clone, Copy)] + pub(crate) struct ChannelScoped(()); + + impl ChannelScoped { + /// Constructor 1: the query pins a single channel + /// (`EventQuery.channel_id = Some(_)`, compiled to a + /// `channel_id = $n` predicate). This proof covers BOTH query + /// builders — the SELECT builder (`event::query_events_on`) and the + /// COUNT builder (`event::count_events`) pin identically; if the + /// two ever drift, this comment is a lie and the routed COUNT seam + /// is unsound. + /// Sound under conjunction: any additional clause (e.g. + /// `channel_ids`, which alone retains channel-NULL rows) is ANDed, + /// and `channel_id = ` never matches NULL — the pin strictly + /// narrows and cannot be widened back out to global rows. + pub(crate) fn from_pinned_channel(q: &crate::event::EventQuery) -> Option { + q.channel_id.map(|_| ChannelScoped(())) + } + + /// Constructor 2 (thread pages): the page is an inner JOIN from + /// `thread_metadata` to `events`, and `thread_metadata.channel_id` + /// is `UUID NOT NULL` — every writer that creates a row passes a + /// concrete channel (`ThreadMetadataParams.channel_id: Uuid`, + /// non-Option). Channel-bearing by construction of the join, not by + /// query predicate. + pub(crate) fn from_thread_metadata_join() -> Self { + ChannelScoped(()) + } + + /// Constructor 3 (channel windows): the channel arrives as a bare + /// `Uuid` argument and the SQL binds it unconditionally + /// (`e.channel_id = $2` in `get_channel_window_on`); every served + /// row is channel-bearing. No `EventQuery` exists on this path. + pub(crate) fn from_channel_id(_channel_id: Uuid) -> Self { + ChannelScoped(()) + } + } +} +use route_proof::ChannelScoped; + +/// The predicate one routed read must satisfy (see [`Db::route_read`]). +/// +/// Discipline: no `Default`, no `Deserialize`, stays non-`pub` — any of +/// those re-opens the [`ChannelScoped`] mint. +pub(crate) enum RoutePredicate { + /// Bounded staleness: the proved entry must be within the configured + /// read budget `B` (default off). Bounds TIME — the page misses at most + /// the freshest `B` of writes. Sound for ANY query shape, including + /// global (channel-NULL) rows: it relies only on heartbeat commit order, + /// not the floor guard. + Bounded, + /// Completeness: the proved wall must cover the page's upper bound. + /// Bounds CONTENT — every row at/below `upper` is present, meaningful + /// even when the cursor is hours old, where `B`-freshness says nothing. + /// Sound ONLY on the floor guard's domain (channel-bearing rows), hence + /// the proof token. `upper` is non-optional: the no-upper-bound + /// post-verifying case is [`RoutePredicate::CoveredPostVerified`]. + /// + /// Bounds INSERT-completeness only — "no missing rows", not "no extra + /// rows". Soft deletes are `UPDATE .. SET deleted_at` commits outside + /// the floor guard and never touch `created_at`, so a covered page can + /// briefly serve a row the writer already excludes; deletion visibility + /// is bounded by replication lag under `FENCE_STALENESS` (30s), not by + /// `upper` or `B`. Do not extend the covered arm to a surface that + /// cannot absorb extra rows (this is why the routed COUNT seam is + /// bounded-only). + Covered { + upper: DateTime, + /// Never read — the field exists so constructing this variant + /// requires minting the token through `route_proof`. + #[allow(dead_code)] + proof: ChannelScoped, + }, + /// Forward-walking thread pages: no upper bound is derivable from the + /// cursor; the caller post-verifies the served rows against the proved + /// wall (full page + tail at/below the wall, else re-run on the writer). + /// Only the thread path constructs this — a general routed caller does + /// no post-verification and must never self-certify. + CoveredPostVerified { + #[allow(dead_code)] + proof: ChannelScoped, + }, + /// Either arm admits, covered tried first (it has no budget dependence). + /// For general routed reads that are channel-pinned AND carry an + /// `until` upper bound. + BoundedOrCovered { + upper: DateTime, + /// Never read — see [`RoutePredicate::Covered::proof`]. + #[allow(dead_code)] + proof: ChannelScoped, + }, +} + +impl RoutePredicate { + /// A channel-window request: cursor pages are covered-only — for deep + /// keyset pages only coverage answers "have all rows below the cursor + /// replayed?" — and a head fetch is bounded. The channel id is the + /// bare-`Uuid` proof that the window SQL pins a channel. + pub(crate) fn from_channel_cursor( + channel_id: Uuid, + cursor: &Option<(DateTime, Vec)>, + ) -> Self { + match cursor { + Some((ts, _)) => RoutePredicate::Covered { + upper: *ts, + proof: ChannelScoped::from_channel_id(channel_id), + }, + None => RoutePredicate::Bounded, + } + } + + /// General entry point for the routed query seams: derives the strongest + /// sound predicate from the query shape. Never produces a covered arm + /// without both a channel-scope proof AND a real upper bound. + /// + /// `routing_enabled` is whether `BUZZ_REPLICA_READ_MAX_AGE_MS` is set + /// (non-zero). When it is NOT, this returns `Bounded` — which the zero + /// budget then fails closed — so the new seams are genuinely dark at + /// the deploy default even for channel-pinned queries carrying `until`. + /// Without this gate, `BoundedOrCovered` would take the covered arm + /// (which has no budget dependence) and route on day one with no env + /// var set and no kill switch short of removing the replica URL + /// (Dawn's covered-at-zero-budget catch). The pre-existing cursor + /// paths (`Covered`/`CoveredPostVerified` from channel windows and + /// thread pages) intentionally still route at B=0 — status quo, + /// unchanged. + pub(crate) fn for_query(q: &event::EventQuery, routing_enabled: bool) -> Self { + if !routing_enabled { + return RoutePredicate::Bounded; + } + match (ChannelScoped::from_pinned_channel(q), q.until) { + (Some(proof), Some(upper)) => RoutePredicate::BoundedOrCovered { upper, proof }, + _ => RoutePredicate::Bounded, + } + } +} + +/// Map the configured read budget (`BUZZ_REPLICA_READ_MAX_AGE_MS`) to the +/// runtime gate: `0` disables bounded-staleness routing; anything above the +/// fence staleness gate is clamped to it (an entry older than the staleness +/// gate never routes anyway, so a larger budget would only misrepresent the +/// config). +fn read_budget_from_ms(ms: u64) -> Option { + match ms { + 0 => None, + ms => Some(Duration::from_millis(ms).min(replica_fence::FENCE_STALENESS)), + } +} + +/// Snapshot of Postgres connection pool utilisation. +#[derive(Debug, Clone, Copy)] +pub struct DbPoolStats { + /// Total connections currently in the pool (idle + active). + pub size: u32, + /// Connections available for immediate reuse. + pub idle: u32, + /// Pool ceiling — the `max_connections` value set at construction. + pub max: u32, +} + +/// Configuration for the Postgres connection pool. +#[derive(Debug, Clone)] +pub struct DbConfig { + /// Postgres connection URL (usually sourced from `DATABASE_URL`). + pub database_url: String, + /// Optional read-replica connection URL (usually sourced from + /// `READ_DATABASE_URL`, e.g. an Aurora `cluster-ro-` endpoint). `None` + /// disables replica routing: [`Db::read`] falls back to the writer pool. + pub read_database_url: Option, + /// Maximum number of connections in the pool. + pub max_connections: u32, + /// Maximum connections in the read-replica pool (env + /// `BUZZ_DB_READ_POOL_SIZE`). `None` inherits [`Self::max_connections`]. + pub read_max_connections: Option, + /// Minimum number of idle connections to maintain. + pub min_connections: u32, + /// Seconds to wait when acquiring a connection before timing out. + pub acquire_timeout_secs: u64, + /// Maximum connection lifetime in seconds before recycling. + pub max_lifetime_secs: u64, + /// Seconds a connection may sit idle before being closed. + pub idle_timeout_secs: u64, + /// Replica read budget `B` in milliseconds (bounded arm, env + /// `BUZZ_REPLICA_READ_MAX_AGE_MS`). `0` disables bounded-staleness + /// routing — the rollout default. Values above + /// [`replica_fence::FENCE_STALENESS`] are clamped to it: an entry older + /// than the staleness gate never routes anyway, so a larger budget + /// would only misrepresent the config. + pub replica_read_max_age_ms: u64, +} + +impl Default for DbConfig { + /// Sized for a single relay pod against PG max_connections=100. + /// Staging measured 51 idle + 1 active out of 50 — most connections sat unused. + /// At 20 main + 5 audit = 25/pod, four relay pods fit within the PG limit. + fn default() -> Self { + Self { + database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 + read_database_url: None, + max_connections: 20, + read_max_connections: None, + min_connections: 2, + acquire_timeout_secs: 3, + max_lifetime_secs: 1800, + idle_timeout_secs: 600, + replica_read_max_age_ms: 0, + } + } +} + +impl Db { + /// Creates a new `Db` by connecting a Postgres pool with the given config. + /// + /// When `config.read_database_url` is set, a second pool with the same + /// sizing is connected to it for lag-tolerant reads (see [`Db::read`]). + /// + /// The writer pool arms the commit-time `created_at` floor guard + /// (migration 0021) on every connection by setting the + /// `buzz.created_at_floor` GUC — this is what makes the replica fence + /// proof hold for every insert path that goes through this pool. + pub async fn new(config: &DbConfig) -> Result { + let pool = Self::connect_pool(config, &config.database_url).await?; + let read_max_connections = config + .read_max_connections + .unwrap_or(config.max_connections); + let read_pool = match &config.read_database_url { + Some(url) => Some(Self::connect_read_pool(config, url, read_max_connections)?), + None => None, + }; + let replica_read_max_age = read_budget_from_ms(config.replica_read_max_age_ms); + Ok(Self { + pool, + max_connections: config.max_connections, + read_pool, + read_max_connections, + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + }) + } + + /// Connect the writer pool with all session-level safety premises. + /// + /// SQLx stores one `after_connect` hook, so the floor guard and transaction + /// isolation assertion must remain in this single closure. Registering a + /// second hook replaces the first and silently disarms the floor trigger. + async fn connect_pool(config: &DbConfig, url: &str) -> Result { + let options = PgPoolOptions::new() + .max_connections(config.max_connections) + .min_connections(config.min_connections) + .acquire_timeout(Duration::from_secs(config.acquire_timeout_secs)) + .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) + .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) + .after_connect(|conn, _meta| { + Box::pin(async move { + // `SET` cannot take bind parameters; `set_config` can. + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, false)") + .bind(replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .execute(&mut *conn) + .await?; + let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") + .fetch_one(&mut *conn) + .await?; + if isolation != "read committed" { + return Err(sqlx::Error::Configuration( + format!( + "writer pool requires READ COMMITTED transaction isolation, got {isolation}" + ) + .into(), + )); + } + Ok(()) + }) + }); + Ok(options.connect(url).await?) + } + + /// Reader acquire timeout — deliberately far below the writer's + /// (seconds-denominated) timeout. Failing closed to the writer must be + /// fast: a saturated reader pool that made routed reads wait the full + /// writer-style timeout would add dead latency during exactly the load + /// spike the offload exists for. A miss here surfaces as + /// `writer/reader_acquire_timeout` (see [`Db::proved_reader`] for why + /// the reason names the mechanism rather than a diagnosis). + const READER_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(150); + + /// Connect the read-replica pool **lazily** — no connection is + /// attempted at construction, so a reader that is down at boot cannot + /// crash the relay (it starts all-writer with the fence closed and + /// recovers when the replica returns). + /// + /// `min_connections` is pinned to 0 explicitly: sqlx's lazy pool still + /// spawns an eager background connect task to satisfy a nonzero + /// minimum, which would reintroduce boot-time reader dial attempts (and + /// their log noise) that "lazy" is meant to avoid. With 0, connections + /// are dialed only on first acquire; the ~10-minute reaper never tops + /// the pool back up, which is fine — routed reads re-fill it on demand. + /// + /// No floor guard or writer-isolation assertion: replica sessions are + /// read-only, so the commit-time trigger from migration 0021 never fires + /// here and the write fence that depends on READ COMMITTED is never reached. + fn connect_read_pool(config: &DbConfig, url: &str, max_connections: u32) -> Result { + Ok(PgPoolOptions::new() + .max_connections(max_connections) + .min_connections(0) + .acquire_timeout(Self::READER_ACQUIRE_TIMEOUT) + .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) + .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) + .connect_lazy(url)?) + } + + /// Spawn a one-shot reader reachability probe that only WARNs. + /// + /// With a lazy pool and `min_connections(0)`, nothing dials the replica + /// until the first routed read — so a misconfigured `READ_DATABASE_URL` + /// would otherwise be invisible until traffic arrives and quietly falls + /// back to the writer. This ping is the only boot-time reader-down + /// visibility; it must never gate startup or [`Db::spawn_fence_probe`]. + /// + /// On success it also primes the Aurora identity capability cache + /// ([`Db::reader_aurora_identity`]) on the connection it already holds, + /// so the first routed read doesn't spend a second acquire (up to + /// another [`Db::READER_ACQUIRE_TIMEOUT`]) inside + /// [`Db::reader_aurora_capability_on`]. Prime failure is fine: the routed + /// path re-probes on the connection it already holds, so a failed prime + /// costs a round trip rather than a second acquire budget. + pub fn spawn_read_pool_boot_ping(&self) { + let Some(read_pool) = self.read_pool.clone() else { + return; + }; + let aurora_identity = self.reader_aurora_identity.clone(); + tokio::spawn(async move { + match observability::acquire(&read_pool, observability::PoolRole::Reader).await { + Ok(mut conn) => { + tracing::info!("read replica reachable at boot"); + match replica_fence::reader_supports_aurora_identity(&mut conn).await { + Ok(supported) => { + let _ = aurora_identity.set(supported); + } + Err(e) => tracing::debug!( + error = %e, + "aurora identity boot prime failed; first routed read will probe" + ), + } + } + Err(e) => tracing::warn!( + "read replica unreachable at boot; serving all-writer until it recovers: {e}" + ), + } + }); + } + + /// Creates a `Db` from an existing `PgPool` (useful in tests). + pub fn from_pool(pool: PgPool) -> Self { + Self { + max_connections: pool.options().get_max_connections(), + read_max_connections: pool.options().get_max_connections(), + pool, + read_pool: None, + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + } + } + + /// Creates a `Db` from distinct writer and read pools (useful in tests, + /// where a second database stands in for a lagged replica). + /// + /// The fence starts closed; tests that want cursor pages served by the + /// fake replica must open it via + /// [`replica_fence::ReplicaFence::force_open_for_tests`] (see + /// [`Db::fence`]). + pub fn from_pools(pool: PgPool, read_pool: PgPool) -> Self { + Self { + max_connections: pool.options().get_max_connections(), + read_max_connections: read_pool.options().get_max_connections(), + pool, + read_pool: Some(read_pool), + fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), + } + } + + /// Test hook: set the head-fetch routing budget (Predicate A), which + /// [`Db::from_pools`] leaves disabled. + pub fn set_replica_read_max_age_for_tests(&mut self, budget: Option) { + self.replica_read_max_age = budget; + } + + /// The freshness fence gating replica routing (see [`replica_fence`]). + pub fn fence(&self) -> &std::sync::Arc { + &self.fence + } + + /// Verify the floor guard end-to-end, then spawn the background fence + /// probe. Returns `Ok(false)` when no replica is configured. + /// + /// Ordering matters (Perci, PR #2084 review): this must run **after** + /// the migration decision. On a relay with `BUZZ_AUTO_MIGRATE` off, the + /// writer pool arms the GUC regardless, but if migration 0021 has not + /// been applied there is no trigger enforcing it — and a heartbeat probe + /// would open the fence over an unenforced floor. So the probe is gated + /// on an unconditional two-part verification against the live schema: + /// catalog shape ([`replica_fence::verify_floor_guard_catalog`]) and + /// observed semantics through this exact pool + /// ([`replica_fence::verify_floor_guard_behavior`]). + /// + /// On any verification failure the probe is never spawned and the fence + /// stays closed: every cursor page routes to the writer. The relay keeps + /// serving — degraded capacity, never holes. + pub async fn spawn_fence_probe(&self) -> Result { + if self.read_pool.is_none() { + return Ok(false); + } + replica_fence::verify_floor_guard_catalog(&self.pool).await?; + replica_fence::verify_floor_guard_behavior(&self.pool).await?; + tokio::spawn(replica_fence::run_probe( + self.pool.clone(), + std::sync::Arc::clone(&self.fence), + )); + Ok(true) + } + + /// The pool for lag-tolerant reads: the read replica when configured, + /// otherwise the writer pool. + /// + /// Removed as a public escape hatch (Dawn, review of 1b0aa0dfa): the + /// raw replica pool carries **no fence proof**, which is exactly the + /// bug class the routed-read machinery exists to eliminate. All replica + /// reads must go through [`Db::route_read`]-backed entry points; this + /// remains only for the fence's own plumbing tests. + #[cfg(test)] + fn read(&self) -> &PgPool { + self.read_pool.as_ref().unwrap_or(&self.pool) + } + + /// Whether a distinct read-replica pool is configured. + pub fn has_read_pool(&self) -> bool { + self.read_pool.is_some() + } + + /// Open a reader request transaction and complete the connection-local + /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ + /// ONLY`, then observe the heartbeat token/epoch as the transaction's + /// **first statement** — anchoring the snapshot every follow-up + /// statement (page, participants, aux closure) sees to exactly the + /// snapshot the proof was taken against — and resolve it against the + /// retained ring. Returns the open transaction together with the + /// strongest [`replica_fence::TokenEntry`] its observation supports, or + /// the fail-closed reason for route metrics. + /// + /// `REPEATABLE READ` is the strongest isolation a hot standby supports + /// (`SERIALIZABLE` is writer-only); `READ ONLY` documents intent and + /// rejects accidental writes. Everything but `Ok` fails closed — begin + /// failure, missing heartbeat row (migration not yet replayed there), + /// observation error, epoch mismatch, or a token below every retained + /// entry all route the request to the writer. + async fn proved_reader( + &self, + read_pool: &PgPool, + ) -> std::result::Result< + ( + sqlx::Transaction<'static, sqlx::Postgres>, + replica_fence::TokenEntry, + ), + &'static str, + > { + // One checkout per routed read. The Aurora capability probe and the + // read-only transaction share a single `acquire()` so the request path + // spends exactly one READER_ACQUIRE_TIMEOUT budget. Probing through + // `read_pool` separately would spend a second budget whenever the + // capability is uncached — i.e. after a failed boot ping, which is + // precisely the reader-unavailable case the bound must hold for. + let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { + Ok(conn) => conn, + Err(sqlx::Error::PoolTimedOut) => { + tracing::warn!("reader pool acquire timed out; routing to writer"); + return Err("reader_acquire_timeout"); + } + Err(e) => { + tracing::warn!(error = %e, "reader connection acquire failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + let mut conn = conn; + let aurora = self.reader_aurora_capability_on(&mut conn).await; + let mut tx = match sqlx::Transaction::begin( + conn, + Some(sqlx::SqlStr::from_static( + "BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY", + )), + ) + .await + { + Ok(tx) => tx, + // The acquire miss gets its own reason code: the reader pool's + // short acquire timeout (READER_ACQUIRE_TIMEOUT) makes this the + // fast fail-closed path under load, and + // `buzz_db_route_decision{decision="writer",reason="reader_acquire_timeout"}` + // is the operator's alert signal for a struggling reader pool. + // + // The reason deliberately names the mechanism, not a diagnosis: + // `PoolTimedOut` proves only that no connection was handed out + // within the 150ms budget. That budget includes cold connect + // (TCP+TLS+auth), and sqlx's `size` counts in-flight dials, so + // this fires for slow connection establishment as well as for + // established-connection contention — and neither `size == 0` + // nor `size >= max` recovers the missing causal bit (in-flight + // dials hold a size slot, and a cold burst can push + // `active = size - idle` toward max with zero busy connections). + // Runbook: correlate with `buzz_db_read_pool_active` / `_max` + // and reader connection health/latency; high active suggests + // contention, but this metric alone does not distinguish + // contention from slow connects. Note the gauge is a coarse + // sample (BUZZ_POOL_METRICS_INTERVAL_SECS, default 10s) while + // the event it explains lasts ~150ms — a short burst may fall + // between samples entirely, so absence of elevated active is + // NOT evidence of a cold connect. + Err(sqlx::Error::PoolTimedOut) => { + tracing::warn!("reader pool acquire timed out; routing to writer"); + return Err("reader_acquire_timeout"); + } + Err(e) => { + tracing::warn!(error = %e, "reader transaction begin failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + let obs = match replica_fence::observe_heartbeat(&mut tx, aurora).await { + Ok(Some(observation)) => observation, + Ok(None) => return Err("reader_validation_error"), + Err(e) => { + tracing::warn!(error = %e, "heartbeat observation failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + match self.fence.resolve(obs.token, obs.epoch) { + replica_fence::ResolveOutcome::Proved(entry) => { + tracing::debug!( + token = obs.token, + proved_token = entry.token, + backend = %obs.backend, + "reader snapshot proved fence coverage" + ); + Ok((tx, entry)) + } + replica_fence::ResolveOutcome::EpochMismatch => Err("reader_validation_error"), + replica_fence::ResolveOutcome::TokenBehind => Err("reader_token_behind"), + } + } + + /// Whether the reader endpoint supports the Aurora PostgreSQL identity + /// function ([`replica_fence::AURORA_IDENTITY_FN`]), probed + /// once per process and cached (see [`Db::reader_aurora_identity`]). + /// The probe runs on a plain autocommit checkout — never inside the + /// request transaction, where an undefined-function error would abort + /// it. Probe failure (acquire or transient) degrades to the plain + /// identity tuple for THIS request without caching, so a later request + /// retries; identity is evidence, never a routing gate. + /// Aurora capability on a connection the caller already holds, so the + /// routed path never spends a second acquire budget. + async fn reader_aurora_capability_on( + &self, + conn: &mut sqlx::pool::PoolConnection, + ) -> bool { + if let Some(cached) = self.reader_aurora_identity.get() { + return *cached; + } + match replica_fence::reader_supports_aurora_identity(conn).await { + Ok(supported) => *self.reader_aurora_identity.get_or_init(|| supported), + Err(e) => { + tracing::debug!(error = %e, "aurora identity probe failed; will retry"); + false + } + } + } + + /// Record one route decision (Rev 2 observability): which path, where it + /// went, and why. + pub(crate) fn record_route(path: &'static str, decision: &'static str, reason: &'static str) { + metrics::counter!( + "buzz_db_route_decision", + "path" => path, + "decision" => decision, + "reason" => reason, + ) + .increment(1); + } + + /// Run pending database migrations. + #[datastore_span(name = "migrate", system = "postgresql")] + pub async fn migrate(&self) -> Result<()> { + migration::run_migrations(&self.pool).await + } + + /// Returns `true` if the database is reachable (used by readiness probes). + pub async fn ping(&self) -> bool { + sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() + } + + /// Returns pool utilisation stats for metrics emission. + /// + /// `size` — total connections (idle + active) + /// `idle` — connections available for immediate reuse + /// `max` — pool ceiling set at construction + pub fn pool_stats(&self) -> DbPoolStats { + DbPoolStats { + size: self.pool.size(), + idle: self.pool.num_idle() as u32, + max: self.max_connections, + } + } + + /// Pool utilisation stats for the read-replica pool, when configured. + /// + /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not + /// the writer's: `buzz_db_read_pool_active / buzz_db_read_pool_max` is + /// the operator's utilisation signal for tuning `BUZZ_DB_READ_POOL_SIZE`, + /// and deriving it from the writer's max would misreport saturation by + /// exactly the ratio of the two pool sizes — in the direction that hides + /// the problem. + pub fn read_pool_stats(&self) -> Option { + self.read_pool.as_ref().map(|p| DbPoolStats { + size: p.size(), + idle: p.num_idle() as u32, + max: self.read_max_connections, + }) + } + + /// Begin a database transaction for atomic multi-statement operations. + /// + /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. + /// The transaction holds an owned pool handle, not a borrow. + pub async fn begin_transaction(&self) -> Result> { + let connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + sqlx::Transaction::begin(connection, None) + .await + .map_err(Into::into) + } + + /// Insert an event while holding and validating an admitted serving-write + /// lease under the community ordering lock through commit. + /// + /// External side effects use a durable lease rather than one long-lived DB + /// transaction. Their final database mutation presents that exact lease so + /// it may finish during quiescing without admitting any new serving work. + pub async fn insert_event_with_serving_write_guard( + &self, + lease: &deletion::ServingWriteLease, + event: &nostr::Event, + channel_id: Option, + ) -> Result<(StoredEvent, bool)> { + let community_id = lease.community_id; + let kind_u16 = event.kind.as_u16(); + let kind_u32 = u32::from(kind_u16); + if kind_u32 == buzz_core::kind::KIND_AUTH { + return Err(DbError::AuthEventRejected); + } + if buzz_core::kind::is_ephemeral(kind_u32) { + return Err(DbError::EphemeralEventRejected(kind_u16)); + } + + let mut tx = self.pool.begin().await?; + self.deletion_store() + .guard_transaction_with_serving_lease(&mut tx, lease) + .await?; + let result = event::insert_event_with_thread_metadata_tx( + &mut tx, + community_id, + event, + channel_id, + None, + ) + .await?; + tx.commit().await?; + if result.1 { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) + } + + /// Shared route decision for one read: evaluate the predicate against a + /// proved reader session and record the decision. Fail closed to the + /// writer everywhere. + pub(crate) async fn route_read( + &self, + path: &'static str, + predicate: RoutePredicate, + ) -> RouteDecision { + let Some(read_pool) = &self.read_pool else { + Self::record_route(path, "writer", "disabled"); + return RouteDecision::Writer; + }; + // Cheap prechecks on the shared ring before spending a reader + // checkout; the connection-local observation still has to prove it. + let Some(newest) = self.fence.newest() else { + Self::record_route(path, "writer", "uninitialized"); + return RouteDecision::Writer; + }; + // Precheck helpers against the newest shared entry: if the newest + // cannot satisfy an arm, no proved (older-or-equal) entry can. + let bounded_precheck = + |budget: &Option| -> std::result::Result<(), &'static str> { + match budget { + Some(budget) if newest.committed_at.elapsed() <= *budget => Ok(()), + Some(_) => Err("stale"), + None => Err("disabled"), + } + }; + let covered_precheck = |upper: &DateTime| -> std::result::Result<(), &'static str> { + if *upper <= newest.fence_wall { + Ok(()) + } else { + Err("stale") + } + }; + let precheck = match &predicate { + RoutePredicate::Bounded => bounded_precheck(&self.replica_read_max_age), + RoutePredicate::Covered { upper, .. } => covered_precheck(upper), + // No upper bound: the caller post-verifies served rows. + RoutePredicate::CoveredPostVerified { .. } => Ok(()), + // Covered first (no budget dependence), else bounded. + RoutePredicate::BoundedOrCovered { upper, .. } => { + covered_precheck(upper).or_else(|_| bounded_precheck(&self.replica_read_max_age)) + } + }; + if let Err(reason) = precheck { + Self::record_route(path, "writer", reason); + return RouteDecision::Writer; + } + match self.proved_reader(read_pool).await { + Ok((tx, entry)) => { + // Re-evaluate against the entry the session actually proved + // (it may be older than the shared newest). + let bounded_holds = || { + self.replica_read_max_age + .is_some_and(|budget| entry.committed_at.elapsed() <= budget) + }; + let verdict: Option<&'static str> = match &predicate { + RoutePredicate::Bounded => bounded_holds().then_some("fresh"), + RoutePredicate::Covered { upper, .. } => { + (*upper <= entry.fence_wall).then_some("covered") + } + // No upper bound: the caller post-verifies the served + // rows against the proved wall. + RoutePredicate::CoveredPostVerified { .. } => Some("covered"), + RoutePredicate::BoundedOrCovered { upper, .. } => { + if *upper <= entry.fence_wall { + Some("covered") + } else { + bounded_holds().then_some("fresh") + } + } + }; + match verdict { + Some(reason) => RouteDecision::Replica(tx, entry, reason), + None => { + // The session proves an older entry than the + // predicate needs (replication lag) — fail closed. + Self::record_route(path, "writer", "stale"); + RouteDecision::Writer + } + } + } + Err(reason) => { + Self::record_route(path, "writer", reason); + RouteDecision::Writer + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-db/src/observability.rs b/crates/buzz-db/src/runtime/observability.rs similarity index 100% rename from crates/buzz-db/src/observability.rs rename to crates/buzz-db/src/runtime/observability.rs diff --git a/crates/buzz-db/src/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs similarity index 99% rename from crates/buzz-db/src/replica_fence.rs rename to crates/buzz-db/src/runtime/replica_fence.rs index 83322bea141..cf9b46ddd8b 100644 --- a/crates/buzz-db/src/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -19,13 +19,13 @@ //! observes `token >= M` on its own connection has, by WAL/storage replay //! order, also replayed every commit that preceded M's commit; every //! transaction then partitions into exactly three buckets: -//! (a) finished before the activity scan — its commit precedes `M`'s -//! commit, so the replica session has replayed it; -//! (b) open at the activity scan — represented by `xact_start`, so it is -//! bounded by the `oldest_xact_start` term; -//! (c) started after the activity scan — its deferred floor guard runs -//! after `S`, so it cannot commit a row with -//! `created_at < S - floor`. +//! (a) finished before the activity scan — its commit precedes `M`'s +//! commit, so the replica session has replayed it; +//! (b) open at the activity scan — represented by `xact_start`, so it is +//! bounded by the `oldest_xact_start` term; +//! (c) started after the activity scan — its deferred floor guard runs +//! after `S`, so it cannot commit a row with +//! `created_at < S - floor`. //! There is no fourth bucket. Each committed token `M` therefore proves a //! **fence wall** of `min(oldest_xact_start, S) - floor - clock_margin`: //! every channel-window row with `created_at <= fence_wall(M)` is present diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs new file mode 100644 index 00000000000..ecdc983a4ac --- /dev/null +++ b/crates/buzz-db/src/runtime/tests.rs @@ -0,0 +1,2542 @@ +use super::*; +use crate::{relay_members, thread}; +use buzz_core::CommunityId; +use sqlx::PgPool; +use uuid::Uuid; + +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + +async fn setup_db() -> Db { + let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) +} + +async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn database_guard_covers_legacy_writer_and_nip09_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let d_tag = format!("read-state:{}", "b".repeat(32)); + let tags = vec![ + Tag::parse(["d", d_tag.as_str()]).expect("d tag"), + Tag::parse(["t", "read-state"]).expect("t tag"), + ]; + let base = Timestamp::now().as_secs(); + let a = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "A") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base)) + .sign_with_keys(&keys) + .expect("sign A"); + let x = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "X") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + 1)) + .sign_with_keys(&keys) + .expect("sign X"); + let b = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "B") + .tags(tags.clone()) + .custom_created_at(Timestamp::from(base + 2)) + .sign_with_keys(&keys) + .expect("sign B"); + let c = EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_READ_STATE as u16), "C") + .tags(tags) + .custom_created_at(Timestamp::from(base + 3)) + .sign_with_keys(&keys) + .expect("sign C"); + + async fn legacy_insert( + pool: &PgPool, + community: CommunityId, + event: &nostr::Event, + d_tag: &str, + ) -> std::result::Result { + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, d_tag) \ + VALUES ($1, $2, $3, to_timestamp($4), $5, $6, $7, $8, NOW(), $9) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(event.pubkey.to_bytes()) + .bind(event.created_at.as_secs() as f64) + .bind(buzz_core::kind::KIND_READ_STATE as i32) + .bind(serde_json::to_value(&event.tags).expect("serialize tags")) + .bind(&event.content) + .bind(event.sig.serialize().as_slice()) + .bind(d_tag) + .execute(pool) + .await + } + + legacy_insert(&db.pool, community, &a, &d_tag) + .await + .expect("legacy insert A"); + let duplicate = legacy_insert(&db.pool, community, &a, &d_tag) + .await + .expect("legacy duplicate A remains idempotent"); + assert_eq!(duplicate.rows_affected(), 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("c".repeat(64)) + .bind(a.id.as_bytes().as_slice()) + .bind(a.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert live mention"); + + // Emulate the pre-PR replacement path after migration 0007: soft-delete + // the live row, then insert B without any application watermark write. + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .execute(&db.pool) + .await + .expect("legacy soft-delete A"); + let mentions_after_delete: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(a.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count mentions after delete"); + assert_eq!(mentions_after_delete, 0); + + let stale_mention = sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("d".repeat(64)) + .bind(a.id.as_bytes().as_slice()) + .bind(a.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("stale post-commit mention is skipped"); + assert_eq!(stale_mention.rows_affected(), 0); + + legacy_insert(&db.pool, community, &b, &d_tag) + .await + .expect("legacy insert B"); + let duplicate_b = legacy_insert(&db.pool, community, &b, &d_tag) + .await + .expect("live duplicate B is skipped"); + assert_eq!(duplicate_b.rows_affected(), 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("e".repeat(64)) + .bind(b.id.as_bytes().as_slice()) + .bind(b.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert B mention"); + + // Exercise the new Rust hard-delete path independently. An in-flight + // mention holds KEY SHARE on B, so replacement by C must block, then + // complete after the mention commits and remove both B and its mention. + let mut rust_mention_tx = db + .pool + .begin() + .await + .expect("begin Rust mention transaction"); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind("e".repeat(64)) + .bind(b.id.as_bytes().as_slice()) + .bind(b.created_at.as_secs() as f64) + .execute(&mut *rust_mention_tx) + .await + .expect("hold B live-event key-share lock"); + + let replace_db = db.clone(); + let replace_d_tag = d_tag.clone(); + let replace_c = c.clone(); + let replace_task = tokio::spawn(async move { + replace_db + .replace_parameterized_event(community, &replace_c, &replace_d_tag, None) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !replace_task.is_finished(), + "Rust hard delete should wait for mention lock" + ); + rust_mention_tx + .commit() + .await + .expect("release Rust mention lock"); + let replaced = tokio::time::timeout(std::time::Duration::from_secs(2), replace_task) + .await + .expect("Rust hard delete deadlocked with mention insert") + .expect("replacement task panicked") + .expect("replace B with C"); + assert!(replaced.1, "C must replace B"); + let b_mentions: i64 = sqlx::query_scalar( + "SELECT count(*) FROM event_mentions WHERE community_id=$1 AND event_id=$2", + ) + .bind(community.as_uuid()) + .bind(b.id.as_bytes().as_slice()) + .fetch_one(&db.pool) + .await + .expect("count B mentions after Rust replacement"); + assert_eq!(b_mentions, 0); + + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078)", + ) + .bind(community.as_uuid()) + .bind("f".repeat(64)) + .bind(c.id.as_bytes().as_slice()) + .bind(c.created_at.as_secs() as f64) + .execute(&db.pool) + .await + .expect("insert C mention"); + + // Exercise legacy UPDATE-trigger deletion with the same barrier. While + // deletion waits on C's KEY SHARE lock, an exact replay must already be + // a zero-row trigger no-op; it must not wait for deletion or resurrect C. + let mut legacy_mention_tx = db + .pool + .begin() + .await + .expect("begin legacy mention transaction"); + sqlx::query( + "INSERT INTO event_mentions \ + (community_id, pubkey_hex, event_id, event_created_at, event_kind) \ + VALUES ($1, $2, $3, to_timestamp($4), 30078) ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind("f".repeat(64)) + .bind(c.id.as_bytes().as_slice()) + .bind(c.created_at.as_secs() as f64) + .execute(&mut *legacy_mention_tx) + .await + .expect("hold C live-event key-share lock"); + + let delete_pool = db.pool.clone(); + let delete_pubkey = keys.public_key().to_bytes(); + let delete_d_tag = d_tag.clone(); + let delete_task = tokio::spawn(async move { + sqlx::query( + "UPDATE events SET deleted_at=NOW() \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(delete_pubkey) + .bind(delete_d_tag) + .execute(&delete_pool) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert!( + !delete_task.is_finished(), + "legacy delete should wait for mention lock" + ); + + let replay_while_delete_waits = legacy_insert(&db.pool, community, &c, &d_tag) + .await + .expect("concurrent exact C replay is skipped"); + assert_eq!(replay_while_delete_waits.rows_affected(), 0); + + legacy_mention_tx + .commit() + .await + .expect("release legacy mention lock"); + tokio::time::timeout(std::time::Duration::from_secs(2), delete_task) + .await + .expect("legacy delete deadlocked with mention insert") + .expect("delete task panicked") + .expect("legacy NIP-09 delete C"); + + let payloads: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count retained payloads"); + assert_eq!( + payloads, 0, + "legacy soft deletes must not retain NIP-RS payloads" + ); + + // Opposite commit order: deletion has committed before exact replay. + // Equality remains an observable zero-row no-op, never a resurrection. + let replay_c = legacy_insert(&db.pool, community, &c, &d_tag) + .await + .expect("post-delete exact C replay is skipped"); + assert_eq!(replay_c.rows_affected(), 0); + let payloads_after_exact_replay: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("count payloads after exact replay"); + assert_eq!(payloads_after_exact_replay, 0); + + let replay = legacy_insert(&db.pool, community, &x, &d_tag).await; + assert!( + replay.is_err(), + "database guard must reject A < X < C replay" + ); + + let watermark: (chrono::DateTime, Vec) = sqlx::query_as( + "SELECT created_at, event_id FROM parameterized_event_watermarks \ + WHERE community_id=$1 AND kind=30078 AND pubkey=$2 AND d_tag=$3", + ) + .bind(community.as_uuid()) + .bind(keys.public_key().to_bytes()) + .bind(&d_tag) + .fetch_one(&db.pool) + .await + .expect("read C watermark"); + assert_eq!(watermark.0.timestamp(), base as i64 + 3); + assert_eq!(watermark.1, c.id.as_bytes().as_slice()); +} + +// ---- Read-replica routing ------------------------------------------------ +// +// These tests pin the routing contract of `Db::read()` and the two routed +// methods. A second scratch database stands in for the replica; the +// fixtures are deliberately DIVERGENT (rows that exist in only one of the +// two databases) so every assertion observes which pool actually served +// the query instead of trusting the routing code's word for it. + +async fn admin_url() -> String { + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) +} + +/// Create a fresh scratch database on the same server and optionally run migrations. +async fn create_scratch_db_through( + admin: &PgPool, + prefix: &str, + target: Option, +) -> (PgPool, String) { + let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .expect("create scratch db"); + let base = admin_url().await; + // Swap the database path segment of the admin URL for the scratch name. + let scratch_url = { + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], name) + }; + let pool = PgPool::connect(&scratch_url) + .await + .expect("connect scratch db"); + match target { + Some(target) => migration::run_migrations_through(&pool, target) + .await + .expect("migrate scratch db through target"), + None => migration::run_migrations(&pool) + .await + .expect("migrate scratch db"), + } + (pool, name) +} + +/// Create a fresh scratch database on the same server and run all migrations. +/// Returns (pool, db_name); callers should `drop_scratch_db` when done. +async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + create_scratch_db_through(admin, prefix, None).await +} + +async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; +} + +/// Insert identical community + channel rows into a database so the same +/// (community, channel) ids resolve in both writer and replica. +async fn seed_community_channel( + pool: &PgPool, + community: Uuid, + channel: Uuid, + author: &nostr::Keys, +) { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("replica-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + crate::channel::create_channel_with_id( + pool, + CommunityId::from_uuid(community), + channel, + &format!("replica-routing-{channel}"), + crate::channel::ChannelType::Stream, + crate::channel::ChannelVisibility::Open, + None, + author.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create channel"); +} + +fn signed_event_at(keys: &nostr::Keys, content: &str, secs: u64) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(9), content) + .custom_created_at(nostr::Timestamp::from(secs)) + .sign_with_keys(keys) + .expect("sign event") +} + +async fn insert_top_level(pool: &PgPool, community: Uuid, channel: Uuid, ev: &nostr::Event) { + let ts = chrono::DateTime::from_timestamp(ev.created_at.as_secs() as i64, 0).expect("valid ts"); + event::insert_event_with_thread_metadata( + pool, + CommunityId::from_uuid(community), + ev, + Some(channel), + Some(event::ThreadMetadataParams { + event_id: ev.id.as_bytes(), + event_created_at: ts, + channel_id: channel, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: true, + }), + ) + .await + .expect("insert top-level event"); +} + +async fn insert_thread_reply( + pool: &PgPool, + community: Uuid, + channel: Uuid, + root: &nostr::Event, + reply: &nostr::Event, +) { + let reply_ts = + chrono::DateTime::from_timestamp(reply.created_at.as_secs() as i64, 0).expect("valid ts"); + let root_ts = + chrono::DateTime::from_timestamp(root.created_at.as_secs() as i64, 0).expect("valid ts"); + event::insert_event_with_thread_metadata( + pool, + CommunityId::from_uuid(community), + reply, + Some(channel), + Some(event::ThreadMetadataParams { + event_id: reply.id.as_bytes(), + event_created_at: reply_ts, + channel_id: channel, + parent_event_id: Some(root.id.as_bytes()), + parent_event_created_at: Some(root_ts), + root_event_id: Some(root.id.as_bytes()), + root_event_created_at: Some(root_ts), + depth: 1, + broadcast: false, + }), + ) + .await + .expect("insert reply"); +} + +/// Composite thread cursor: 8-byte BE seconds + raw event id. +fn thread_cursor(reply: &crate::thread::ThreadReply) -> Vec { + let mut cur = reply.created_at.timestamp().to_be_bytes().to_vec(); + cur.extend_from_slice(&reply.event_id); + cur +} + +#[tokio::test] +async fn read_falls_back_to_writer_when_no_replica_configured() { + // Pure wiring test — connect_lazy never touches the network. + let pool = sqlx::PgPool::connect_lazy(TEST_DB_URL).expect("lazy pool"); + let db = Db::from_pool(pool); + assert!(!db.has_read_pool()); + assert!( + std::ptr::eq(db.read(), &db.pool), + "read() must be the writer pool when no replica is configured" + ); + assert!(db.read_pool_stats().is_none()); +} + +#[test] +fn read_budget_zero_disables_and_large_values_clamp_to_staleness() { + assert_eq!(read_budget_from_ms(0), None, "0 = bounded routing off"); + assert_eq!( + read_budget_from_ms(1000), + Some(std::time::Duration::from_millis(1000)) + ); + assert_eq!( + read_budget_from_ms(10_000_000), + Some(replica_fence::FENCE_STALENESS), + "budgets above the staleness gate clamp to it" + ); +} + +/// Truth table for [`RoutePredicate::for_query`]: the strongest sound +/// predicate per query shape, and — the deploy-day default row — that +/// `routing_enabled = false` (BUZZ_REPLICA_READ_MAX_AGE_MS unset) +/// forces `Bounded` even for covered-eligible shapes, so the zero +/// budget fails the new seams closed (Dawn's covered-at-zero-budget +/// catch, design doc rev 5). +#[test] +fn for_query_predicate_truth_table() { + let community = CommunityId::from_uuid(Uuid::new_v4()); + let channel = Uuid::new_v4(); + let until = chrono::Utc::now(); + + let pinned_with_until = { + let mut q = event::EventQuery::for_community(community); + q.channel_id = Some(channel); + q.until = Some(until); + q + }; + let pinned_no_until = { + let mut q = event::EventQuery::for_community(community); + q.channel_id = Some(channel); + q + }; + let unpinned_with_until = { + let mut q = event::EventQuery::for_community(community); + q.until = Some(until); + q + }; + let global_only = { + let mut q = event::EventQuery::for_community(community); + q.global_only = true; + q.until = Some(until); + q + }; + + // Deploy-day default: budget unset ⇒ Bounded regardless of shape. + // The zero budget then fails Bounded closed, so the new seams + // record writer/disabled — merging with no env var set is a no-op. + assert!( + matches!( + RoutePredicate::for_query(&pinned_with_until, false), + RoutePredicate::Bounded + ), + "budget unset must not reach the covered arm even when eligible" + ); + + // Budget set + channel pin + until ⇒ the strongest predicate. + assert!(matches!( + RoutePredicate::for_query(&pinned_with_until, true), + RoutePredicate::BoundedOrCovered { .. } + )); + + // Missing either covered precondition ⇒ Bounded. + assert!(matches!( + RoutePredicate::for_query(&pinned_no_until, true), + RoutePredicate::Bounded + )); + assert!(matches!( + RoutePredicate::for_query(&unpinned_with_until, true), + RoutePredicate::Bounded + )); + // global_only implies `channel_id = None`, so the channel-pin + // precondition fails and no covered arm is possible — `for_query` + // never inspects `global_only` itself; the row holds because + // constructor 1 (channel pin) returns None for an unpinned query. + assert!(matches!( + RoutePredicate::for_query(&global_only, true), + RoutePredicate::Bounded + )); +} + +/// The pre-existing cursor paths are NOT budget-gated: a channel-window +/// cursor page still derives `Covered` with no `routing_enabled` input +/// at all — at B=0 today it routes covered, and that status quo is +/// intentionally unchanged by the `for_query` gate (Max's matrix row: +/// old paths route at budget-unset; only the new seams go dark). +#[test] +fn channel_cursor_predicate_is_not_budget_gated() { + let channel = Uuid::new_v4(); + let cursor = Some((chrono::Utc::now(), vec![1u8; 32])); + assert!(matches!( + RoutePredicate::from_channel_cursor(channel, &cursor), + RoutePredicate::Covered { .. } + )); + // Head fetch (no cursor) is bounded — gated by the budget. + assert!(matches!( + RoutePredicate::from_channel_cursor(channel, &None), + RoutePredicate::Bounded + )); +} + +/// D5 wiring: `read_pool_stats().max` must be the READER pool's own +/// ceiling, not the writer's — `buzz_db_read_pool_active / _max` is the +/// operator's utilisation signal and inheriting the writer's max hides +/// reader saturation by exactly the sizing ratio. Pure wiring test: +/// `connect_lazy` never touches the network, but it does spawn the +/// pool reaper task, which needs a Tokio runtime — hence +/// `#[tokio::test]` despite the test body itself never awaiting. +#[tokio::test] +async fn read_pool_stats_reports_reader_ceiling_not_writer() { + let writer = sqlx::postgres::PgPoolOptions::new() + .max_connections(20) + .connect_lazy(TEST_DB_URL) + .expect("lazy writer pool"); + let reader = sqlx::postgres::PgPoolOptions::new() + .max_connections(40) + .connect_lazy(TEST_DB_URL) + .expect("lazy reader pool"); + let db = Db::from_pools(writer, reader); + assert_eq!(db.pool_stats().max, 20); + assert_eq!( + db.read_pool_stats().expect("read pool configured").max, + 40, + "reader gauge must report the reader's own ceiling" + ); +} + +/// D4 wiring: the reader pool is built lazily with `min_connections(0)` +/// and the short reader acquire timeout — construction must succeed +/// with no replica listening (reader-down at boot must not crash the +/// relay), and `read_max_connections` must honour +/// `DbConfig::read_max_connections` over the writer sizing. +/// `#[tokio::test]` because `connect_lazy` spawns the pool reaper task, +/// which needs a Tokio runtime even though nothing is dialed. +#[tokio::test] +async fn connect_read_pool_is_lazy_and_independently_sized() { + let config = DbConfig { + max_connections: 20, + read_max_connections: Some(7), + ..DbConfig::default() + }; + // Unroutable per RFC 5737 TEST-NET-1: proves nothing is dialed at + // construction time. + let pool = Db::connect_read_pool(&config, "postgres://user:pw@192.0.2.1:5432/none", 7) + .expect("lazy construction must not dial the replica"); + assert_eq!(pool.options().get_max_connections(), 7); + assert_eq!(pool.options().get_min_connections(), 0); + assert_eq!( + pool.options().get_acquire_timeout(), + Db::READER_ACQUIRE_TIMEOUT + ); +} + +/// Channel window: head fetch (no cursor) reads the WRITER; cursor pages +/// read the REPLICA. Divergent fixtures prove which pool served each. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "routing_w").await; + let (replica, rname) = create_scratch_db(&admin, "routing_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + // Shared history (both databases): m1 < m2 < m3. + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + for pool in [&writer, &replica] { + for ev in [&m1, &m2, &m3] { + insert_top_level(pool, community, channel, ev).await; + } + } + // Lag: the newest event exists only on the writer. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); + insert_top_level(&writer, community, channel, &fresh).await; + // Marker: exists only on the "replica" (unphysical for a real replica, + // but it makes replica-served pages unambiguous). + let marker = signed_event_at(&author, "replica-only-marker", base + 5); + insert_top_level(&replica, community, channel, &marker).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + // Open the fence through "now": the fixture's history is far in the + // past, so every cursor falls below the fence and routing is + // eligible. Fence-gating itself is pinned by the fence tests below. + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Head fetch (cursor: None) → writer: sees `fresh`, never `marker`. + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head window"); + let head_contents: Vec = head + .rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect(); + assert_eq!( + head_contents, + vec!["fresh-writer-only".to_string(), "m3".to_string()], + "head fetch must be served by the writer" + ); + + // Cursor page → replica: sees `marker`, never `fresh`. + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + let page2 = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("cursor window"); + let page2_contents: Vec = page2 + .rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect(); + assert_eq!( + page2_contents, + vec![ + "m2".to_string(), + "replica-only-marker".to_string(), + "m1".to_string() + ], + "cursor page must be served by the replica" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Fail-closed on a mid-request replica failure (Dawn, review of +/// 1b0aa0dfa): a replica-routed page whose query errors *after* the +/// proof (the live shape is a hot-standby recovery conflict — 40001 / +/// 25P02 — cancelling the held snapshot under `max_standby_streaming_delay`) +/// must be re-run on the writer and served, never surfaced as an error +/// the writer could have answered. Degraded capacity, never holes. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_window_failure_falls_back_to_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fb_w").await; + let (replica, rname) = create_scratch_db(&admin, "fb_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + for pool in [&writer, &replica] { + for ev in [&m1, &m2, &m3] { + insert_top_level(pool, community, channel, ev).await; + } + } + let marker = signed_event_at(&author, "replica-only-marker", base + 5); + insert_top_level(&replica, community, channel, &marker).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Guard against a vacuous pass: the cursor page must actually be + // replica-eligible before we break the replica. + let healthy = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("healthy cursor window"); + assert!( + healthy + .rows + .iter() + .any(|r| r.stored_event.event.content == "replica-only-marker"), + "fixture must route the cursor page to the replica while healthy" + ); + + // Break the replica AFTER the proof point: the heartbeat table stays + // intact (the observation succeeds), the page query then fails. + sqlx::query("DROP TABLE events CASCADE") + .execute(&replica) + .await + .expect("drop replica events"); + + let page = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("replica failure must fall back to the writer, not error"); + let contents: Vec<&str> = page + .rows + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["m2", "m1"], + "fallback page must be the writer's answer (no replica marker)" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// [`replica_window_failure_falls_back_to_writer`] for the thread-replies +/// path: a replica-routed thread page whose query errors after the proof +/// re-runs on the writer instead of surfacing an error. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn replica_thread_failure_falls_back_to_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fbt_w").await; + let (replica, rname) = create_scratch_db(&admin, "fbt_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + let replies: Vec = (1..=3) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for pool in [&writer, &replica] { + for reply in &replies { + insert_thread_reply(pool, community, channel, &root, reply).await; + } + } + // Replica-only divergent reply between r2 and r3 marks replica serves. + let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); + insert_thread_reply(&replica, community, channel, &root, &ghost).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("head page"); + let cur = thread_cursor(page1.last().expect("page 1 non-empty")); + + // Healthy: the full page after r2 is the replica's [ghost]. + let healthy = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("healthy replica page"); + assert_eq!( + healthy[0].stored_event.event.content, "replica-only-ghost", + "fixture must route the cursor page to the replica while healthy" + ); + + sqlx::query("DROP TABLE events CASCADE") + .execute(&replica) + .await + .expect("drop replica events"); + + let page = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("replica failure must fall back to the writer, not error"); + assert_eq!( + page[0].stored_event.event.content, "r3", + "fallback page must be the writer's answer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Mid-request degradation of the held session (Dawn, review of +/// 1b0aa0dfa): when the proved replica transaction dies between the page +/// and an aux follow-up (stand-in: `pg_terminate_backend` on the reader +/// connection, the same tx-fatal shape as a recovery-conflict cancel), +/// [`ReadSession::query_events`] must re-run the query on the writer and +/// permanently degrade the session instead of surfacing the error. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn read_session_degrades_to_writer_when_replica_connection_dies() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "deg_w").await; + let (replica, rname) = create_scratch_db(&admin, "deg_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + for pool in [&writer, &replica] { + for ev in [&m1, &m2] { + insert_top_level(pool, community, channel, ev).await; + } + } + // Writer-only row proves the degraded aux ran on the writer. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 20); + insert_top_level(&writer, community, channel, &fresh).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + let (_window, mut session) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("routed cursor window"); + assert!( + session.is_replica(), + "fixture must route this page to the replica" + ); + + // Kill the reader's backend out from under the held transaction. + sqlx::query( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ + WHERE datname = $1 AND pid <> pg_backend_pid()", + ) + .bind(&rname) + .execute(&admin) + .await + .expect("terminate replica backends"); + + let mut aux = EventQuery::for_community(cid); + aux.channel_id = Some(channel); + let rows = session + .query_events(&aux) + .await + .expect("session must degrade to the writer, not error"); + assert!( + rows.iter() + .any(|se| se.event.content == "fresh-writer-only"), + "degraded aux must be served by the writer" + ); + assert!( + !session.is_replica(), + "the session must be permanently degraded to the writer" + ); + + drop(session); + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request +/// runs inside ONE `REPEATABLE READ, READ ONLY` transaction whose first +/// statement was the heartbeat observation — so a row committed on the +/// replica *after* the proof must be invisible to every follow-up +/// statement in the same request (page, participants, aux). This +/// distinguishes the transaction contract from mere connection reuse: +/// autocommit statements on the same backend advance their snapshot +/// per statement and WOULD see the mid-request row. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn routed_request_holds_one_snapshot_across_page_and_aux() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "snap_w").await; + let (replica, rname) = create_scratch_db(&admin, "snap_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + for pool in [&writer, &replica] { + for ev in [&m1, &m2] { + insert_top_level(pool, community, channel, ev).await; + } + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Head page on the writer yields the cursor for a replica-routed page. + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Route the cursor page to the replica and HOLD the session. + let (window, mut session) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("routed cursor window"); + assert!( + session.is_replica(), + "fixture must route this page to the replica" + ); + assert_eq!(window.rows.len(), 1, "page after m2 is [m1]"); + + // Mid-request: a new event commits on the replica (stands in for + // replay advancing between the page and the aux closure). + let mid = signed_event_at(&author, "mid-request-commit", base + 5); + insert_top_level(&replica, community, channel, &mid).await; + + // A fresh autocommit statement on ANOTHER session sees it — the row + // is really there (control for the assertion below). + let mut control = EventQuery::for_community(cid); + control.channel_id = Some(channel); + let visible_elsewhere = event::query_events(&replica, &control) + .await + .expect("control query"); + assert!( + visible_elsewhere + .iter() + .any(|se| se.event.content == "mid-request-commit"), + "control: the mid-request row must be committed and visible to a new snapshot" + ); + + // The held request session must NOT see it: its snapshot was + // anchored by the heartbeat observation, before the commit. + let mut aux = EventQuery::for_community(cid); + aux.channel_id = Some(channel); + let in_request = session.query_events(&aux).await.expect("aux query"); + assert!( + !in_request + .iter() + .any(|se| se.event.content == "mid-request-commit"), + "request transaction must hold the proof-time snapshot; a \ + mid-request commit leaking in means the aux ran outside the \ + request transaction (autocommit connection reuse)" + ); + // Rows from the proof-time snapshot are still served. + assert!( + in_request.iter().any(|se| se.event.content == "m1"), + "proof-time rows must remain visible in the request snapshot" + ); + + drop(session); + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Head gate (Predicate A): with the budget unset, a head fetch reads +/// the writer even over an open fence; with a budget set and a fresh +/// proved entry, the head page is served by the replica session +/// (bounded staleness accepted); with a budget the fence entry exceeds, +/// the head page falls back to the writer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn head_fetch_routes_by_configured_budget() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "head_w").await; + let (replica, rname) = create_scratch_db(&admin, "head_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let shared = signed_event_at(&author, "shared", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &shared).await; + } + // Divergent heads prove which pool served the fetch. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); + insert_top_level(&writer, community, channel, &fresh).await; + let marker = signed_event_at(&author, "replica-only-marker", base + 20); + insert_top_level(&replica, community, channel, &marker).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + let head_contents = |w: &thread::ChannelWindow| -> Vec { + w.rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect() + }; + + // Budget unset (rollout default): head → writer, fence open or not. + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, gate off"); + assert_eq!( + head_contents(&head), + vec!["fresh-writer-only".to_string(), "shared".to_string()], + "head routing must default off" + ); + + // Budget set, entry fresh (just recorded): head → replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, gate on"); + assert_eq!( + head_contents(&head), + vec!["replica-only-marker".to_string(), "shared".to_string()], + "a fresh proved entry within budget must serve the head from the replica" + ); + + // Entry older than the budget: head falls back to the writer. + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, entry too old"); + assert_eq!( + head_contents(&head), + vec!["fresh-writer-only".to_string(), "shared".to_string()], + "an over-budget entry must fail the head gate closed" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// End-to-end deploy-default proof for the NEW routed seams: with the +/// budget unset, a covered-eligible query (channel-pinned + `until`) +/// through [`Db::query_events_routed`] is served by the WRITER — the +/// `for_query` gate keeps the covered arm dark (rev 5). With the budget +/// set and a fresh proved entry, the same query routes to the replica. +/// Divergent fixtures prove which pool served each read. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn query_events_routed_defaults_dark_and_routes_covered_when_enabled() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "qer_w").await; + let (replica, rname) = create_scratch_db(&admin, "qer_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let shared = signed_event_at(&author, "shared", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &shared).await; + } + let writer_only = signed_event_at(&author, "writer-only", base + 10); + insert_top_level(&writer, community, channel, &writer_only).await; + let replica_only = signed_event_at(&author, "replica-only", base + 20); + insert_top_level(&replica, community, channel, &replica_only).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Covered-eligible shape: channel-pinned with an `until` upper + // bound below the (now) fence wall. + let q = { + let mut q = EventQuery::for_community(cid); + q.channel_id = Some(channel); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + q + }; + let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { + evs.iter().map(|e| e.event.content.clone()).collect() + }; + + // Deploy default: budget unset ⇒ writer, even though the shape is + // covered-eligible and the fence is open. + let rows = db + .query_events_routed("test_routed", &q) + .await + .expect("routed query, gate off"); + assert!( + contents(&rows).contains("writer-only"), + "budget unset must serve the writer" + ); + assert!( + !contents(&rows).contains("replica-only"), + "budget unset must not reach the replica via the covered arm" + ); + + // Budget set ⇒ the covered arm serves it from the replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let rows = db + .query_events_routed("test_routed", &q) + .await + .expect("routed query, gate on"); + assert!( + contents(&rows).contains("replica-only"), + "budget set + covered-eligible must route to the replica" + ); + assert!(!contents(&rows).contains("writer-only")); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// COUNT is bounded-only (rev 5 deletion-visibility rule): a +/// covered-eligible shape must NOT let a count take the covered arm. +/// With the budget unset the count reads the WRITER even with an open +/// fence; with the budget set and a fresh entry it reads the replica +/// under the bounded arm. Divergent row counts prove the serving pool. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn count_events_routed_is_bounded_only() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "cnt_w").await; + let (replica, rname) = create_scratch_db(&admin, "cnt_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + // Writer: 2 rows. Replica: 1 row. + for (i, content) in ["a", "b"].iter().enumerate() { + let ev = signed_event_at(&author, content, base + i as u64); + insert_top_level(&writer, community, channel, &ev).await; + } + let ev = signed_event_at(&author, "c", base); + insert_top_level(&replica, community, channel, &ev).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Covered-eligible shape on purpose: pinned + until. A count must + // ignore that eligibility. + let q = { + let mut q = EventQuery::for_community(cid); + q.channel_id = Some(channel); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + q + }; + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, gate off"); + assert_eq!(n, 2, "budget unset must count on the writer"); + + // Budget set + fresh entry ⇒ bounded arm ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, gate on"); + assert_eq!(n, 1, "budget set must count on the replica (bounded)"); + + // Entry older than the budget ⇒ bounded fails ⇒ writer. Covered + // would still hold here (upper <= wall) — proving count never + // consults it. + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, entry too old"); + assert_eq!( + n, 2, + "an over-budget entry must fail the count closed to the writer, \ + even when the covered arm would admit the shape" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Routed relay-membership check: budget unset ⇒ writer; budget set + +/// fresh proved entry ⇒ replica (bounded arm); over-budget entry ⇒ +/// writer. Divergent membership rows prove which pool answered. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn is_relay_member_is_bounded_routed_and_fails_closed() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "mem_w").await; + let (replica, rname) = create_scratch_db(&admin, "mem_r").await; + + let community = Uuid::new_v4(); + for pool in [&writer, &replica] { + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community) + .bind(format!("member-routing-{}.example", community.simple())) + .execute(pool) + .await + .expect("insert community"); + } + let cid = CommunityId::from_uuid(community); + let writer_only = "aa".repeat(32); + let replica_only = "bb".repeat(32); + relay_members::add_relay_member(&writer, cid, &writer_only, "member", None) + .await + .expect("seed writer member"); + relay_members::add_relay_member(&replica, cid, &replica_only, "member", None) + .await + .expect("seed replica member"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("gate off"), + "budget unset must answer from the writer" + ); + assert!(!db.is_relay_member(cid, &replica_only).await.unwrap()); + + // Budget set + fresh entry ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + assert!( + db.is_relay_member(cid, &replica_only) + .await + .expect("gate on"), + "budget set must answer from the replica" + ); + assert!(!db.is_relay_member(cid, &writer_only).await.unwrap()); + + // Entry older than the budget ⇒ fail closed to the writer. Close + // first so no prior fresh entry can be the one proved (matches the + // count test; today `force_open_for_tests_at` also clears the ring). + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + assert!( + db.is_relay_member(cid, &writer_only) + .await + .expect("entry too old"), + "an over-budget entry must fail closed to the writer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Community separation across every routed seam, verified on +/// REPLICA-SERVED reads. +/// +/// The pre-existing feed/event scoping tests prove the shared SQL +/// builders confine rows to one community, but they exercise those +/// builders through the WRITER wrapper. `_on` variants are +/// executor-only refactors, so scoping *should* be identical — this +/// test refuses to take that on faith and re-proves it through the +/// routed executor, on a snapshot the replica actually served. +/// +/// Construction: two communities A and B exist in BOTH databases with +/// the same ids. The replica additionally holds a `replica-only` row in +/// each — divergent fixtures, so any row bearing that content proves +/// the replica (not the writer) served the read. Every assertion +/// requests A and demands B's rows never appear, including B's +/// `replica-only` row, which is the one a leaky predicate would surface. +/// The routed fallback must cost ONE reader acquire budget, even when the +/// Aurora capability cache is cold. +/// +/// Regression test for a stacked-budget bug found at `9fa3c9c0b`: the +/// capability probe used to `acquire()` from the pool itself and return +/// `false` *uncached* on `PoolTimedOut`, so the routed read then spent a +/// SECOND `READER_ACQUIRE_TIMEOUT` inside `begin`. Measured 302ms against +/// a ~150ms documented bound. Boot priming +/// ([`Db::spawn_read_pool_boot_ping`]) hid it only when the boot ping +/// SUCCEEDED — and a reader that is unavailable at boot is exactly the +/// case the bound is specified for, so the two failures are correlated. +/// +/// The fixture reproduces that state deliberately: a size-1 reader whose +/// sole connection is established and then HELD (so every further acquire +/// must time out), with `reader_aurora_identity` asserted cold. It routes +/// through `count_events_routed` rather than calling `proved_reader` +/// directly, because `buzz_db_route_decision` is emitted by `route_read` +/// — a direct call would prove the timing but never emit the label. +/// +/// Timing uses an upper bound of 2x the budget minus a margin: it must +/// fail for two stacked budgets (~300ms) while tolerating scheduler +/// jitter on one (~150ms). Asserting a lower bound too would pin the +/// budget's own value, which `reader_acquire_timeout_is_the_documented_budget` +/// already covers. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires Postgres"] +async fn routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed, wname) = create_scratch_db(&admin, "one_budget").await; + seed.close().await; + let base = admin_url().await; + let scratch_url = { + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], wname) + }; + + // `Db::new` so the writer arms the floor guard and the reader is the + // real lazy `connect_read_pool` pool (min_connections=0, 150ms + // acquire timeout). Reader is sized 1 so holding one connection + // saturates it. + let mut db = Db::new(&DbConfig { + database_url: scratch_url.clone(), + read_database_url: Some(scratch_url), + max_connections: 4, + read_max_connections: Some(1), + ..DbConfig::default() + }) + .await + .expect("connect armed Db with size-1 lazy reader"); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); + + let read_pool = db.read_pool.clone().expect("reader pool configured"); + // Establish and hold the reader's only connection: saturated. + let held = read_pool + .acquire() + .await + .expect("establish the reader's sole connection"); + assert_eq!( + db.read_max_connections, 1, + "reader max must report 1 for this fixture to test saturation" + ); + assert_eq!( + read_pool.size(), + 1, + "the sole reader connection is established and held" + ); + // The bug is only observable with the capability cache cold; if a + // future change primes it here, this fixture would silently stop + // discriminating. + assert!( + db.reader_aurora_identity.get().is_none(), + "Aurora capability must be UNPRIMED (post-boot-ping-failure state)" + ); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let query = EventQuery::for_community(CommunityId::from_uuid(Uuid::new_v4())); + + // The recorder is installed thread-locally, so it must stay installed + // across the `.await` — hence the guard form rather than + // `with_local_recorder`, whose closure cannot host an await. The + // `current_thread` flavor keeps the route decision on this thread; on + // a multi-thread runtime the emit could land on a worker where no + // local recorder is installed and the label assertions would vacuously + // see an empty snapshot. + let start = std::time::Instant::now(); + let count = { + let _guard = metrics::set_default_local_recorder(&recorder); + db.count_events_routed("one_budget_probe", &query).await + } + .expect("writer fallback still answers the read"); + let elapsed = start.elapsed(); + + assert_eq!(count, 0, "writer answered on an empty scratch database"); + assert!( + elapsed < Duration::from_millis(250), + "routed fallback must spend ONE {}ms acquire budget, not two; took {}ms", + Db::READER_ACQUIRE_TIMEOUT.as_millis(), + elapsed.as_millis() + ); + + let reasons: std::collections::HashMap<(String, String), u64> = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_route_decision") + .map(|(key, _, _, value)| { + let metrics_util::debugging::DebugValue::Counter(n) = value else { + panic!("buzz_db_route_decision must be a counter"); + }; + let labels: Vec<_> = key.key().labels().collect(); + let get = |name: &str| { + labels + .iter() + .find(|l| l.key() == name) + .map(|l| l.value().to_owned()) + .unwrap_or_default() + }; + ((get("decision"), get("reason")), n) + }) + .collect(); + + assert_eq!( + reasons.get(&("writer".to_owned(), "reader_acquire_timeout".to_owned())), + Some(&1), + "saturated reader must fall back as writer/reader_acquire_timeout; got {reasons:?}" + ); + // `reader_validation_error` would mean we misclassified a timeout as a + // broken reader, and `pool_busy` is the retired name — neither may + // appear in ANY emitted label. + assert!( + !reasons + .keys() + .any(|(_, reason)| reason == "reader_validation_error" || reason == "pool_busy"), + "no reader_validation_error or retired pool_busy label may be emitted; got {reasons:?}" + ); + + drop(held); + drop_scratch_db(&admin, db.pool.clone(), &wname).await; +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn routed_reads_are_confined_to_the_requested_community() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "sep_w").await; + let (replica, rname) = create_scratch_db(&admin, "sep_r").await; + + let author = nostr::Keys::generate(); + let (comm_a, chan_a) = (Uuid::new_v4(), Uuid::new_v4()); + let (comm_b, chan_b) = (Uuid::new_v4(), Uuid::new_v4()); + for pool in [&writer, &replica] { + seed_community_channel(pool, comm_a, chan_a, &author).await; + seed_community_channel(pool, comm_b, chan_b, &author).await; + } + + // A p-tag mention is what makes a row eligible for the mentions and + // needs-action feeds. Kind 9 satisfies mentions + activity; + // needs-action admits only approval/reminder kinds, so each + // community also gets a kind-46010 row. + let mentioned = nostr::Keys::generate(); + let mentioned_hex = mentioned.public_key().to_hex(); + let mentioned_bytes = mentioned.public_key().to_bytes(); + let tagged_kind = |kind: u16, content: &str, secs: u64| { + nostr::EventBuilder::new(nostr::Kind::Custom(kind), content) + .tags([nostr::Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")]) + .custom_created_at(nostr::Timestamp::from(secs)) + .sign_with_keys(&author) + .expect("sign event") + }; + let tagged = |content: &str, secs: u64| tagged_kind(9, content, secs); + + let base = 1_700_000_000u64; + // Shared rows (both DBs) + replica-only rows (divergence) per community. + let a_shared = tagged("a-shared", base); + let b_shared = tagged("b-shared", base + 1); + for pool in [&writer, &replica] { + insert_top_level(pool, comm_a, chan_a, &a_shared).await; + insert_mentions( + pool, + CommunityId::from_uuid(comm_a), + &a_shared, + Some(chan_a), + ) + .await + .expect("mentions a-shared"); + insert_top_level(pool, comm_b, chan_b, &b_shared).await; + insert_mentions( + pool, + CommunityId::from_uuid(comm_b), + &b_shared, + Some(chan_b), + ) + .await + .expect("mentions b-shared"); + } + let a_replica_only = tagged("a-replica-only", base + 10); + let b_replica_only = tagged("b-replica-only", base + 11); + insert_top_level(&replica, comm_a, chan_a, &a_replica_only).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_a), + &a_replica_only, + Some(chan_a), + ) + .await + .expect("mentions a-replica-only"); + insert_top_level(&replica, comm_b, chan_b, &b_replica_only).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_b), + &b_replica_only, + Some(chan_b), + ) + .await + .expect("mentions b-replica-only"); + + // Needs-action fixtures: approval kind, replica-only in BOTH + // communities, so the assertion below is replica-served on A and + // must still not see B's. + let a_approval = tagged_kind(46010, "a-approval-replica-only", base + 20); + let b_approval = tagged_kind(46010, "b-approval-replica-only", base + 21); + insert_top_level(&replica, comm_a, chan_a, &a_approval).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_a), + &a_approval, + Some(chan_a), + ) + .await + .expect("mentions a-approval"); + insert_top_level(&replica, comm_b, chan_b, &b_approval).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_b), + &b_approval, + Some(chan_b), + ) + .await + .expect("mentions b-approval"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let cid_a = CommunityId::from_uuid(comm_a); + + let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { + evs.iter().map(|e| e.event.content.clone()).collect() + }; + // Every routed seam must (a) have been served by the replica — + // proven by a divergent row absent from the writer — and (b) contain + // no row belonging to community B. All B fixtures are named `b-*`, + // so the leak check is a single prefix scan. + let assert_a_only = |rows: &[StoredEvent], marker: &str, seam: &str| { + let got = contents(rows); + assert!( + got.contains(marker), + "{seam}: must be replica-served (divergent row `{marker}` absent from writer); got {got:?}" + ); + assert!( + !got.iter().any(|c| c.starts_with("b-")), + "{seam}: community B rows leaked into a community A read; got {got:?}" + ); + }; + + // 1. Generic query — covered arm (channel-pinned + `until`). + let mut q = EventQuery::for_community(cid_a); + q.channel_id = Some(chan_a); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + let rows = db + .query_events_routed("sep_query", &q) + .await + .expect("routed query"); + assert_a_only(&rows, "a-replica-only", "query_events_routed"); + + // 2. Generic query — bounded arm (no channel pin at all, so a + // missing community predicate could not be masked by the pin). + let unpinned = EventQuery::for_community(cid_a); + let rows = db + .query_events_routed_bounded("sep_query_bounded", &unpinned) + .await + .expect("routed bounded query"); + assert_a_only(&rows, "a-replica-only", "query_events_routed_bounded"); + + // 3. COUNT — bounded-only. Community A holds 3 rows on the replica + // (shared + replica-only + approval) but only 1 on the writer, + // and 3 more exist in community B. Exactly 3 proves the read was + // both replica-served and community-confined. + let count = db + .count_events_routed("sep_count", &unpinned) + .await + .expect("routed count"); + assert_eq!( + count, 3, + "count must see A's three replica rows only — not B's, not the writer's one" + ); + + // 4. By-ID hydration — ids carry no channel pin, and B's ids are + // requested alongside A's. Only A's may hydrate. + let ids: Vec<&[u8]> = vec![ + a_shared.id.as_bytes(), + a_replica_only.id.as_bytes(), + b_shared.id.as_bytes(), + b_replica_only.id.as_bytes(), + ]; + let rows = db + .get_events_by_ids_routed("sep_by_ids", cid_a, &ids) + .await + .expect("routed by-ids"); + assert_a_only(&rows, "a-replica-only", "get_events_by_ids_routed"); + + // 5-7. All three feed builders, each given BOTH channels as + // accessible — so only the community predicate can exclude B. + let both = [chan_a, chan_b]; + let rows = db + .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .await + .expect("routed mentions"); + assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed"); + + let rows = db + .query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .await + .expect("routed needs action"); + assert_a_only( + &rows, + "a-approval-replica-only", + "query_feed_needs_action_routed", + ); + + let rows = db + .query_feed_activity_routed("sep_feed", cid_a, &both, None, 50) + .await + .expect("routed activity"); + assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed"); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// D4: a LAZY reader pool (connect_lazy, min_connections=0, never yet +/// used) must still let [`Db::spawn_fence_probe`] verify the writer's +/// floor guard and spawn — reader-down or reader-idle at boot must not +/// disable fence probing. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn lazy_reader_pool_still_spawns_fence_probe() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed, wname) = create_scratch_db(&admin, "lazy_w").await; + seed.close().await; + + let writer_url = { + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], wname) + }; + // `Db::new` (not `from_pools`) so the WRITER pool arms the + // `buzz.created_at_floor` GUC — `spawn_fence_probe` verifies the + // floor guard on a writer connection, and `create_scratch_db`'s + // plain `PgPool::connect` never arms it. The reader is still the + // lazy `connect_read_pool` pool this test is about. + let db = Db::new(&DbConfig { + database_url: writer_url.clone(), + read_database_url: Some(writer_url), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with lazy reader"); + + let spawned = db + .spawn_fence_probe() + .await + .expect("floor-guard verification must pass on the migrated writer"); + assert!(spawned, "a configured (lazy) reader must spawn the probe"); + + drop_scratch_db(&admin, db.pool.clone(), &wname).await; +} + +/// Thread replies: head fetch reads the writer; a FULL cursor page is +/// served by the replica; an UNDER-limit cursor page (candidate terminal +/// page) is re-run on the writer so a lagged replica can never truncate +/// the tail into a false EOF. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_replies_cursor_pages_route_to_replica_with_writer_terminal_verification() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "routing_tw").await; + let (replica, rname) = create_scratch_db(&admin, "routing_tr").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + + // Writer holds replies r1..r5; the lagged replica only has r1..r3. + let replies: Vec = (1..=5) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for reply in &replies { + insert_thread_reply(&writer, community, channel, &root, reply).await; + } + for reply in &replies[..3] { + insert_thread_reply(&replica, community, channel, &root, reply).await; + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + // Open the fence through "now" — fixture history is far in the past. + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Page 1 (no cursor) → writer. + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("page 1"); + let contents: Vec<&str> = page1 + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!(contents, vec!["r1", "r2"], "head page from writer"); + + // Page 2: replica serves a FULL page (r3 exists there) — but wait: + // replica has r1..r3, page after r2 with limit 2 returns only [r3] + // (under limit) → terminal-verification re-runs on the writer, which + // returns [r3, r4]. A lag-truncated EOF must never surface. + let cur2 = thread_cursor(page1.last().expect("page 1 non-empty")); + let page2 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, Some(&cur2)) + .await + .expect("page 2"); + let contents: Vec<&str> = page2 + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["r3", "r4"], + "under-limit replica page must be re-verified on the writer" + ); + + // Full-page replica serve: with limit 1, the page after r2 is [r3] — + // exactly `limit` rows, so the replica result stands. Prove it came + // from the replica with a replica-only divergent reply. + let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); + insert_thread_reply(&replica, community, channel, &root, &ghost).await; + let page_replica = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) + .await + .expect("full replica page"); + let contents: Vec<&str> = page_replica + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["replica-only-ghost"], + "a full cursor page must be served by the replica" + ); + + // Same query with no replica configured reads the writer and cannot + // see the ghost. + let db_writer_only = Db::from_pool(writer.clone()); + let page_writer = db_writer_only + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur2)) + .await + .expect("writer-only page"); + let contents: Vec<&str> = page_writer + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!(contents, vec!["r3"], "unset replica falls back to writer"); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Channel DESC scrollback, out-of-order commit adversary: the replica is +/// missing a MIDDLE row (`m2`) because a transaction with an older +/// client-signed `created_at` committed late and has not replayed yet. +/// The replica's cursor page would be `[m1]` — silently skipping `m2` +/// forever, since the next cursor advances past it. The fence must route +/// any cursor above it to the writer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_cursor_above_fence_stays_on_writer_preventing_middle_hole() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fence_cw").await; + let (replica, rname) = create_scratch_db(&admin, "fence_cr").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2-late-commit", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + let m4 = signed_event_at(&author, "m4", base + 30); + for ev in [&m1, &m2, &m3, &m4] { + insert_top_level(&writer, community, channel, ev).await; + } + // Replica replayed everything EXCEPT the late-committed m2. + for ev in [&m1, &m3, &m4] { + insert_top_level(&replica, community, channel, ev).await; + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + let cid = CommunityId::from_uuid(community); + + // Head page (writer): [m4, m3]; cursor lands on m3 (base+20). + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Fence closed → cursor page must come from the writer: m2 present. + let contents = |w: &thread::ChannelWindow| -> Vec { + w.rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect() + }; + let page_closed = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("cursor page, fence closed"); + assert_eq!( + contents(&page_closed), + vec!["m2-late-commit".to_string(), "m1".to_string()], + "fence closed: cursor pages route to the writer" + ); + + // Fence open but BELOW the cursor timestamp (covers base+5 only): + // the cursor (base+20) is not covered → writer again. + db.fence() + .force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 5, 0).expect("ts")); + let page_below = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("cursor page, fence below cursor"); + assert_eq!( + contents(&page_below), + vec!["m2-late-commit".to_string(), "m1".to_string()], + "cursor above the fence must stay on the writer" + ); + + // Counterfactual pinning the hazard: were the fence (wrongly) open + // through now, the replica would serve the page WITHOUT m2 — the + // permanent-skip hole this fence exists to prevent. + db.fence().force_open_for_tests(chrono::Utc::now()); + let page_hazard = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("cursor page, fence wrongly open"); + assert_eq!( + contents(&page_hazard), + vec!["m1".to_string()], + "fixture models the inversion: an over-open fence would skip m2" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Thread ASC pagination, out-of-order commit adversary: the replica +/// holds a FULL page whose newest row (`r4`) has a later key than a +/// not-yet-replayed row (`r3`). The old under-limit check alone would +/// serve `[r4]` and the client cursor would advance past `r3` forever. +/// The fence rule (full AND tail ≤ fence) must send that page to the +/// writer instead. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_full_replica_page_above_fence_is_reverified_on_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fence_tw").await; + let (replica, rname) = create_scratch_db(&admin, "fence_tr").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + let replies: Vec = (1..=4) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for reply in &replies { + insert_thread_reply(&writer, community, channel, &root, reply).await; + } + // Replica replayed r1, r2, r4 — the late-committed r3 is missing. + for reply in [&replies[0], &replies[1], &replies[3]] { + insert_thread_reply(&replica, community, channel, &root, reply).await; + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + let cid = CommunityId::from_uuid(community); + + // Fence covers r2 (base+20) but not r3/r4. + db.fence() + .force_open_for_tests(chrono::DateTime::from_timestamp(base as i64 + 20, 0).expect("ts")); + + // Page after r2 with limit 1: the replica would return the FULL page + // [r4] — but its tail is above the fence, so the writer re-runs it + // and returns [r3]. No skip. + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("head page"); + let cur = thread_cursor(page1.last().expect("head page non-empty")); + let page = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("cursor page"); + let contents: Vec<&str> = page + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["r3"], + "a full replica page above the fence must be re-run on the writer" + ); + + // Counterfactual: an over-open fence would serve the replica's [r4], + // skipping r3 permanently. + db.fence().force_open_for_tests(chrono::Utc::now()); + let hazard = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("hazard page"); + let contents: Vec<&str> = hazard + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["r4"], + "fixture models the inversion: an over-open fence would skip r3" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; +} + +/// Commit-time floor guard (migration 0021), exact held-transaction +/// adversary: a channel-bearing row whose `created_at` is older than the +/// floor at COMMIT time must abort the transaction — the guard runs +/// inside commit processing with `clock_timestamp()`, so holding the +/// transaction open cannot outrun it. channel_id-NULL rows are +/// structurally exempt, and sessions without the GUC are unaffected. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn created_at_floor_guard_aborts_old_channel_rows_at_commit() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, name) = create_scratch_db(&admin, "floor_guard").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&pool, community, channel, &author).await; + + let insert_raw = |ev: nostr::Event, channel_id: Option| { + let pool = pool.clone(); + async move { + let mut tx = pool.begin().await.expect("begin"); + // Arm the guard for this transaction only (the relay's + // writer pool arms it per connection; tests are explicit). + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") + .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .execute(&mut *tx) + .await + .expect("arm guard"); + sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, \ + content, sig, received_at, channel_id) \ + VALUES ($1, $2, $3, to_timestamp($4), 9, '[]', $5, $6, NOW(), $7)", + ) + .bind(community) + .bind(ev.id.as_bytes().as_slice()) + .bind(ev.pubkey.to_bytes().as_slice()) + .bind(ev.created_at.as_secs() as f64) + .bind(&ev.content) + .bind(ev.sig.serialize().as_slice()) + .bind(channel_id) + .execute(&mut *tx) + .await + .expect("insert inside tx (guard is deferred to commit)"); + // Hold the transaction "open" past the insert, then commit — + // the deferred guard must still see the stale created_at. + sqlx::query("SELECT pg_sleep(0.05)") + .execute(&mut *tx) + .await + .expect("hold tx"); + tx.commit().await + } + }; + + let now_secs = chrono::Utc::now().timestamp() as u64; + let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; + + // Old channel-bearing row → COMMIT aborts with check_violation. + let old = signed_event_at(&author, "old-held-tx", now_secs - floor - 60); + let err = insert_raw(old, Some(channel)) + .await + .expect_err("below-floor channel row must abort at COMMIT"); + let code = match &err { + sqlx::Error::Database(db_err) => db_err.code().map(|c| c.to_string()), + other => panic!("expected database error, got {other:?}"), + }; + assert_eq!( + code.as_deref(), + Some("23514"), + "guard raises check_violation" + ); + + // Fresh channel-bearing row → commits. + let fresh = signed_event_at(&author, "fresh", now_secs); + insert_raw(fresh, Some(channel)) + .await + .expect("fresh row commits under the armed guard"); + + // Old row WITHOUT a channel (push lease / profile shapes) → + // structurally exempt, commits. + let old_global = signed_event_at(&author, "old-global", now_secs - floor - 60); + insert_raw(old_global, None) + .await + .expect("channel_id-NULL rows are exempt from the floor"); + + // Unarmed session (no GUC) → guard inert; backfills stay possible + // (and must hold the fence closed, per the migration header). + let old_backfill = signed_event_at(&author, "old-backfill", now_secs - floor - 60); + insert_top_level(&pool, community, channel, &old_backfill).await; + + drop_scratch_db(&admin, pool, &name).await; +} + +#[test] +fn writer_pool_safety_hook_is_single_and_composed() { + let source = include_str!("mod.rs"); + let connect_pool = source + .split("async fn connect_pool") + .nth(1) + .and_then(|tail| tail.split("const READER_ACQUIRE_TIMEOUT").next()) + .expect("connect_pool source block"); + assert_eq!( + connect_pool.matches(".after_connect(").count(), + 1, + "SQLx replaces after_connect hooks; writer safety must use exactly one" + ); + assert!(connect_pool.contains("buzz.created_at_floor")); + assert!(connect_pool.contains("SHOW transaction_isolation")); + assert!(!connect_pool.contains("arm_floor_guard")); + assert!(!connect_pool.contains("_arm_floor_guard")); + assert!(!connect_pool.contains("allow(unused_variables)")); + + let reader_doc = source + .split("fn connect_read_pool") + .next() + .and_then(|prefix| prefix.rsplit("/// Connect the read-replica").next()) + .expect("reader pool documentation"); + assert!(reader_doc.contains("replica sessions are")); + assert!(reader_doc.contains("read-only")); + assert!(!reader_doc.contains("Db::connect_pool")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn writer_pool_rejects_non_read_committed_database_default() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, name) = create_scratch_db(&admin, "writer_isolation").await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "ALTER DATABASE {name} SET default_transaction_isolation = 'repeatable read'" + ))) + .execute(&admin) + .await + .expect("set unsafe database default"); + seed_pool.close().await; + + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let error = Db::new(&DbConfig { + database_url: scratch_url, + max_connections: 1, + min_connections: 1, + acquire_timeout_secs: 1, + ..DbConfig::default() + }) + .await + .expect_err("writer pool must reject pinned-snapshot database defaults"); + assert!( + error.to_string().contains("requires READ COMMITTED") + || error.to_string().contains("pool timed out"), + "unexpected isolation rejection: {error}" + ); + + sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE {name} WITH (FORCE)" + ))) + .execute(&admin) + .await + .expect("drop isolation test database"); +} + +/// The armed writer pool (`Db::new`) must enforce the floor end-to-end +/// through the public insert APIs, and the session GUC must be verifiably +/// set on pooled connections. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn armed_pool_rejects_old_channel_inserts_through_public_api() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, name) = create_scratch_db(&admin, "floor_pool").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&seed_pool, community, channel, &author).await; + + // Connect a Db the production way: after_connect arms the guard. + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let db = Db::new(&DbConfig { + database_url: scratch_url, + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db"); + let cid = CommunityId::from_uuid(community); + + // Perci nit: assert the effective session value, not the intent. + let effective: String = sqlx::query_scalar("SHOW buzz.created_at_floor") + .fetch_one(&db.pool) + .await + .expect("SHOW guard GUC"); + assert_eq!( + effective, + crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string(), + "writer pool must arm the floor guard on every connection" + ); + let isolation: String = sqlx::query_scalar("SHOW transaction_isolation") + .fetch_one(&db.pool) + .await + .expect("SHOW writer isolation"); + assert_eq!( + isolation, "read committed", + "the same writer after_connect hook must enforce the isolation premise" + ); + + let now_secs = chrono::Utc::now().timestamp() as u64; + let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; + + // insert_event (single INSERT, autocommit): old channel row rejected. + let old = signed_event_at(&author, "old-direct", now_secs - floor - 60); + let err = event::insert_event(&db.pool, cid, &old, Some(channel)) + .await + .expect_err("armed pool must reject below-floor channel inserts"); + assert!( + err.to_string().contains("below the replica-fence floor"), + "unexpected error: {err}" + ); + + // insert_event_with_thread_metadata (multi-statement tx): same. + let old2 = signed_event_at(&author, "old-thread-meta", now_secs - floor - 90); + let ts = + chrono::DateTime::from_timestamp(old2.created_at.as_secs() as i64, 0).expect("valid ts"); + let err = event::insert_event_with_thread_metadata( + &db.pool, + cid, + &old2, + Some(channel), + Some(event::ThreadMetadataParams { + event_id: old2.id.as_bytes(), + event_created_at: ts, + channel_id: channel, + parent_event_id: None, + parent_event_created_at: None, + root_event_id: None, + root_event_created_at: None, + depth: 0, + broadcast: true, + }), + ) + .await + .expect_err("armed pool must reject below-floor thread-metadata inserts"); + assert!( + err.to_string().contains("below the replica-fence floor"), + "unexpected error: {err}" + ); + + // Fresh events pass through both APIs. + let fresh = signed_event_at(&author, "fresh-direct", now_secs); + event::insert_event(&db.pool, cid, &fresh, Some(channel)) + .await + .expect("fresh insert passes the armed guard"); + + drop_scratch_db(&admin, seed_pool, &name).await; + // db pool still holds connections to the dropped DB; close it. + db.pool.close().await; +} + +/// `spawn_fence_probe` must verify the floor guard before letting the +/// probe run — catalog shape AND observed behavior — and refuse on +/// sabotage. This is the production gate for a relay running with +/// `BUZZ_AUTO_MIGRATE` off: an armed GUC with no enforcing trigger must +/// never yield an open fence. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn fence_probe_refuses_to_start_without_verified_floor_guard() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed_pool, wname) = create_scratch_db(&admin, "fence_gate_w").await; + let (replica_pool, rname) = create_scratch_db(&admin, "fence_gate_r").await; + seed_pool.close().await; + replica_pool.close().await; + + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + let writer_url = format!("{}/{}", &base[..idx], wname); + let replica_url = format!("{}/{}", &base[..idx], rname); + + // Healthy schema: verification passes, probe starts. A SEPARATE Db + // instance, because its background probe legitimately opens its own + // fence (the heartbeat probe is writer-side only) — the refusal + // assertions below must run against a fence whose spawns were all + // refused. + let db_healthy = Db::new(&DbConfig { + database_url: writer_url.clone(), + read_database_url: Some(replica_url.clone()), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with replica"); + assert!( + db_healthy + .spawn_fence_probe() + .await + .expect("verification passes"), + "probe must start on a verified schema" + ); + + let db = Db::new(&DbConfig { + database_url: writer_url, + read_database_url: Some(replica_url), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with replica"); + + // Sabotage A: catalog-shaped no-op — same trigger, gutted function + // body. Catalog check alone would pass; behavior check must refuse. + sqlx::query( + "CREATE OR REPLACE FUNCTION events_created_at_floor_guard() RETURNS trigger \ + LANGUAGE plpgsql AS $$ BEGIN RETURN NULL; END $$", + ) + .execute(&db.pool) + .await + .expect("gut the guard function"); + let err = db + .spawn_fence_probe() + .await + .expect_err("inert guard body must refuse the probe"); + assert!( + err.to_string().contains("floor guard is inert"), + "unexpected error: {err}" + ); + + // Sabotage B: trigger dropped entirely (the BUZZ_AUTO_MIGRATE=off / + // 0021-unapplied shape). Catalog check must refuse. + sqlx::query("DROP TRIGGER events_created_at_floor ON events") + .execute(&db.pool) + .await + .expect("drop the guard trigger"); + let err = db + .spawn_fence_probe() + .await + .expect_err("missing trigger must refuse the probe"); + assert!( + err.to_string().contains("missing or mis-shaped"), + "unexpected error: {err}" + ); + + // In both refusal states the fence never opened. + assert!( + db.fence().verified_through().is_none(), + "fence must remain closed when verification refuses the probe" + ); + + db_healthy.pool.close().await; + if let Some(rp) = &db_healthy.read_pool { + rp.close().await; + } + db.pool.close().await; + if let Some(rp) = &db.read_pool { + rp.close().await; + } + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {wname} WITH (FORCE)" + ))) + .execute(&admin) + .await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {rname} WITH (FORCE)" + ))) + .execute(&admin) + .await; +} + +/// The `UPDATE OF` arm of the floor guard (Perci's second structural +/// hole): an old row legitimately admitted with `channel_id` NULL must +/// not be movable into keyset windows, and a channel row's `created_at` +/// must not be movable below the fence — through raw SQL, at COMMIT. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn floor_guard_blocks_updates_that_move_rows_below_the_fence() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (pool, name) = create_scratch_db(&admin, "floor_upd").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&pool, community, channel, &author).await; + + let now_secs = chrono::Utc::now().timestamp() as u64; + let floor = crate::replica_fence::CREATED_AT_FLOOR_SECS as u64; + + // Seed via unarmed session: one old channel-NULL row, one fresh + // channel row. + let old_null = signed_event_at(&author, "old-null", now_secs - floor - 120); + insert_top_level(&pool, community, channel, &old_null).await; + sqlx::query("UPDATE events SET channel_id = NULL WHERE community_id = $1 AND id = $2") + .bind(community) + .bind(old_null.id.as_bytes().as_slice()) + .execute(&pool) + .await + .expect("detach channel (unarmed seed)"); + let fresh = signed_event_at(&author, "fresh-row", now_secs); + insert_top_level(&pool, community, channel, &fresh).await; + + // Armed transaction, deferred to COMMIT (the production shape). + let run_armed_update = |sql: &'static str, id: Vec, age: Option| { + let pool = pool.clone(); + async move { + let mut tx = pool.begin().await.expect("begin"); + sqlx::query("SELECT set_config('buzz.created_at_floor', $1, true)") + .bind(crate::replica_fence::CREATED_AT_FLOOR_SECS.to_string()) + .execute(&mut *tx) + .await + .expect("arm guard"); + let q = sqlx::query(sql).bind(community).bind(id); + let q = match age { + Some(a) => q.bind(a as f64), + None => q, + }; + q.execute(&mut *tx) + .await + .expect("update inside tx (deferred)"); + tx.commit().await + } + }; + + // channel-NULL → channel-bearing on an old row: COMMIT must abort. + let err = run_armed_update( + "UPDATE events SET channel_id = community_id WHERE community_id = $1 AND id = $2", + old_null.id.as_bytes().to_vec(), + None, + ) + .await + .expect_err("moving an old channel-NULL row into a channel must abort at COMMIT"); + assert!( + matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), + "unexpected error: {err}" + ); + + // created_at rewrite below the floor on a channel row: COMMIT must abort. + let err = run_armed_update( + "UPDATE events SET created_at = clock_timestamp() - make_interval(secs => $3::double precision) \ + WHERE community_id = $1 AND id = $2", + fresh.id.as_bytes().to_vec(), + Some(floor + 120), + ) + .await + .expect_err("rewriting created_at below the floor must abort at COMMIT"); + assert!( + matches!(&err, sqlx::Error::Database(e) if e.code().as_deref() == Some("23514")), + "unexpected error: {err}" + ); + + drop_scratch_db(&admin, pool, &name).await; +} diff --git a/crates/buzz-db/src/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs similarity index 94% rename from crates/buzz-db/src/admin_moderation.rs rename to crates/buzz-db/src/store/admin_moderation.rs index 3dc8bd94c80..f38231787bf 100644 --- a/crates/buzz-db/src/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -4,12 +4,14 @@ //! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in //! [`crate::moderation`] tenant-fenced. +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; +use crate::Db; /// Maximum rows accepted by one admin query. pub const MAX_PAGE_SIZE: i64 = 200; @@ -404,11 +406,59 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result { }) } +impl Db { + /// List reports for the deployment-global read-only admin plane. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "admin_list_reports", system = "postgresql")] + pub async fn admin_list_reports( + &self, + community_id: Option, + status: Option<&str>, + report_type: Option<&str>, + target_kind: Option<&str>, + after: Option>, + before: Option>, + cursor: Option<(DateTime, Uuid)>, + limit: i64, + ) -> Result> { + list_reports( + &self.pool, + community_id, + status, + report_type, + target_kind, + after, + before, + cursor, + limit, + ) + .await + } + + /// Fetch one report for the deployment-global read-only admin plane. + #[datastore_span(name = "admin_get_report", system = "postgresql")] + pub async fn admin_get_report(&self, id: Uuid) -> Result> { + get_report(&self.pool, id).await + } + + /// List feedback for the deployment-global read-only admin plane. + #[datastore_span(name = "admin_list_feedback", system = "postgresql")] + pub async fn admin_list_feedback(&self, limit: i64) -> Result> { + list_feedback(&self.pool, limit).await + } + + /// Fetch one feedback submission for the deployment-global admin plane. + #[datastore_span(name = "admin_get_feedback", system = "postgresql")] + pub async fn admin_get_feedback(&self, id: Uuid) -> Result> { + get_feedback(&self.pool, id).await + } +} + #[cfg(test)] mod tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs new file mode 100644 index 00000000000..6b213d5cce8 --- /dev/null +++ b/crates/buzz-db/src/store/allowlist.rs @@ -0,0 +1,209 @@ +//! Community-scoped authentication allowlist persistence. +//! +//! This store is distinct from NIP-43 relay membership. Membership backfill +//! orchestration remains with the relay-membership invariant owner. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::Row; + +use crate::error::Result; +use crate::Db; + +/// An entry in the pubkey allowlist. +#[derive(Debug, Clone)] +pub struct AllowlistEntry { + /// The allowed pubkey. + pub pubkey: Vec, + /// Who added this entry. + pub added_by: Vec, + /// When the entry was added. + pub added_at: DateTime, + /// Optional note. + pub note: Option, +} + +impl Db { + /// Check if a pubkey is in the allowlist for `community`. + #[datastore_span(name = "is_pubkey_allowed", system = "postgresql")] + pub async fn is_pubkey_allowed(&self, community: CommunityId, pubkey: &[u8]) -> Result { + let row = sqlx::query( + "SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_one(&self.pool) + .await?; + let cnt: i64 = row.try_get("cnt")?; + Ok(cnt > 0) + } + + /// Check if the community allowlist has any entries (i.e. is enforcement active). + #[datastore_span(name = "has_allowlist_entries", system = "postgresql")] + pub async fn has_allowlist_entries(&self, community: CommunityId) -> Result { + let row = + sqlx::query("SELECT COUNT(*) as cnt FROM pubkey_allowlist WHERE community_id = $1") + .bind(community.as_uuid()) + .fetch_one(&self.pool) + .await?; + let cnt: i64 = row.try_get("cnt")?; + Ok(cnt > 0) + } + + /// Add a pubkey to the community allowlist. + #[datastore_span(name = "add_to_allowlist", system = "postgresql")] + pub async fn add_to_allowlist( + &self, + community: CommunityId, + pubkey: &[u8], + added_by: &[u8], + note: Option<&str>, + ) -> Result { + let result = sqlx::query( + "INSERT INTO pubkey_allowlist (community_id, pubkey, added_by, note) VALUES ($1, $2, $3, $4) \ + ON CONFLICT DO NOTHING", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(added_by) + .bind(note) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// Remove a pubkey from the community allowlist. + #[datastore_span(name = "remove_from_allowlist", system = "postgresql")] + pub async fn remove_from_allowlist( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + let result = + sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&self.pool) + .await?; + Ok(result.rows_affected() > 0) + } + + /// List all pubkeys in the community allowlist. + #[datastore_span(name = "list_allowlist", system = "postgresql")] + pub async fn list_allowlist(&self, community: CommunityId) -> Result> { + let rows = sqlx::query( + "SELECT pubkey, added_by, added_at, note FROM pubkey_allowlist WHERE community_id = $1 ORDER BY added_at DESC", + ) + .bind(community.as_uuid()) + .fetch_all(&self.pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + out.push(AllowlistEntry { + pubkey: row.try_get("pubkey")?, + added_by: row.try_get("added_by")?, + added_at: row.try_get("added_at")?, + note: row.try_get("note")?, + }); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + + async fn setup_db() -> Db { + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn allowlist_is_scoped_to_community() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let pubkey = [7u8; 32]; + let added_by = [9u8; 32]; + + assert!(db + .add_to_allowlist(community_a, &pubkey, &added_by, Some("a-only")) + .await + .expect("add allowlist row")); + assert!(!db + .add_to_allowlist(community_a, &pubkey, &added_by, Some("duplicate")) + .await + .expect("duplicate allowlist row is idempotent")); + + assert!( + db.is_pubkey_allowed(community_a, &pubkey) + .await + .expect("allowlist check A"), + "pubkey added to A must be allowed in A" + ); + assert!( + !db.is_pubkey_allowed(community_b, &pubkey) + .await + .expect("allowlist check B"), + "pubkey added only to A must not be allowed in B" + ); + assert!(db + .has_allowlist_entries(community_a) + .await + .expect("A has entries")); + assert!(!db + .has_allowlist_entries(community_b) + .await + .expect("B has no entries")); + + let listed = db + .list_allowlist(community_a) + .await + .expect("list A allowlist"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].pubkey, pubkey); + + assert!( + !db.remove_from_allowlist(community_b, &pubkey) + .await + .expect("remove from B is no-op"), + "removing from B must not delete A's row" + ); + assert!(db + .is_pubkey_allowed(community_a, &pubkey) + .await + .expect("A still allowed after B remove")); + assert!(db + .remove_from_allowlist(community_a, &pubkey) + .await + .expect("remove from A")); + assert!(!db + .is_pubkey_allowed(community_a, &pubkey) + .await + .expect("A not allowed after remove")); + } +} diff --git a/crates/buzz-db/src/api_token.rs b/crates/buzz-db/src/store/api_token.rs similarity index 66% rename from crates/buzz-db/src/api_token.rs rename to crates/buzz-db/src/store/api_token.rs index 50821743d27..ec380d9e5e7 100644 --- a/crates/buzz-db/src/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -5,6 +5,9 @@ use sqlx::{PgPool, Row}; use uuid::Uuid; use crate::error::{DbError, Result}; +use crate::Db; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; /// Create a new API token record. The caller is responsible for generating /// the raw token and computing its SHA-256 hash. @@ -324,6 +327,284 @@ pub async fn revoke_all_tokens( Ok(result.rows_affected()) } +/// Token summary returned by [`Db::list_active_tokens`]. +#[derive(Debug, Clone)] +pub struct TokenSummary { + /// Unique token identifier. + pub id: Uuid, + /// Human-readable token name. + pub name: String, + /// Compressed public key bytes of the token owner. + pub owner_pubkey: Vec, + /// Permission scopes granted to this token. + pub scopes: Vec, + /// When the token was created. + pub created_at: DateTime, + /// Optional expiry timestamp; `None` means no expiry. + pub expires_at: Option>, +} + +/// A full API token record. +#[derive(Debug, Clone)] +pub struct ApiTokenRecord { + /// Unique token identifier. + pub id: Uuid, + /// SHA-256 hash of the raw token value. + pub token_hash: Vec, + /// Compressed public key bytes of the token owner. + pub owner_pubkey: Vec, + /// Human-readable token name. + pub name: String, + /// Permission scopes granted to this token. + pub scopes: Vec, + /// Optional channel ID restrictions. + pub channel_ids: Option>, + /// When the token was created. + pub created_at: DateTime, + /// Optional expiry timestamp. + pub expires_at: Option>, + /// When the token was last used. + pub last_used_at: Option>, + /// When the token was revoked. + pub revoked_at: Option>, +} + +fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { + let id: Uuid = row.try_get("id")?; + + let scopes_json: serde_json::Value = row.try_get("scopes")?; + let scopes: Vec = serde_json::from_value(scopes_json) + .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; + + let channel_ids: Option> = { + let raw: Option = row.try_get("channel_ids")?; + match raw { + None => None, + Some(v) => { + let strings: Vec = serde_json::from_value(v) + .map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?; + let uuids: std::result::Result, _> = + strings.iter().map(|s| s.parse::()).collect(); + Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?) + } + } + }; + + Ok(ApiTokenRecord { + id, + token_hash: row.try_get("token_hash")?, + owner_pubkey: row.try_get("owner_pubkey")?, + name: row.try_get("name")?, + scopes, + channel_ids, + created_at: row.try_get("created_at")?, + expires_at: row.try_get("expires_at")?, + last_used_at: row.try_get("last_used_at")?, + revoked_at: row.try_get("revoked_at")?, + }) +} + +impl Db { + /// Create a new API token record. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "create_api_token", system = "postgresql")] + pub async fn create_api_token( + &self, + community_id: CommunityId, + token_hash: &[u8], + owner_pubkey: &[u8], + name: &str, + scopes: &[String], + channel_ids: Option<&[Uuid]>, + expires_at: Option>, + ) -> Result { + create_api_token( + &self.pool, + *community_id.as_uuid(), + token_hash, + owner_pubkey, + name, + scopes, + channel_ids, + expires_at, + ) + .await + } + + /// Atomic conditional INSERT with 10-token limit (per (community, owner)). + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "create_api_token_if_under_limit", system = "postgresql")] + pub async fn create_api_token_if_under_limit( + &self, + community_id: CommunityId, + token_hash: &[u8], + owner_pubkey: &[u8], + name: &str, + scopes: &[String], + channel_ids: Option<&[Uuid]>, + expires_at: Option>, + ) -> Result> { + create_api_token_if_under_limit( + &self.pool, + *community_id.as_uuid(), + token_hash, + owner_pubkey, + name, + scopes, + channel_ids, + expires_at, + ) + .await + } + + /// Look up an active (non-revoked) API token by its SHA-256 hash, + /// scoped to the request's community. + /// + /// See [`get_api_token_by_hash_including_revoked`] for the + /// row-44 conformance rationale — the `(community_id, token_hash)` key + /// is enforced both by the storage UNIQUE index and by this WHERE clause. + #[datastore_span(name = "get_api_token_by_hash", system = "postgresql")] + pub async fn get_api_token_by_hash( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids, + created_at, expires_at, last_used_at, revoked_at + FROM api_tokens + WHERE community_id = $1 AND token_hash = $2 AND revoked_at IS NULL + "#, + ) + .bind(community_id.as_uuid()) + .bind(hash) + .fetch_optional(&self.pool) + .await?; + + match row { + None => Ok(None), + Some(r) => parse_api_token_row(r).map(Some), + } + } + + /// Look up an API token by hash, including revoked, scoped to community. + #[datastore_span( + name = "get_api_token_by_hash_including_revoked", + system = "postgresql" + )] + pub async fn get_api_token_by_hash_including_revoked( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result> { + get_api_token_by_hash_including_revoked(&self.pool, *community_id.as_uuid(), hash).await + } + + /// Record a token usage (update `last_used_at`), scoped to community. + #[datastore_span(name = "touch_api_token", system = "postgresql")] + pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { + sqlx::query( + "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", + ) + .bind(community_id.as_uuid()) + .bind(hash) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Alias for [`Self::touch_api_token`]. + pub async fn update_token_last_used( + &self, + community_id: CommunityId, + hash: &[u8], + ) -> Result<()> { + self.touch_api_token(community_id, hash).await + } + + /// List all active (non-revoked) tokens in a community, newest first. + #[datastore_span(name = "list_active_tokens", system = "postgresql")] + pub async fn list_active_tokens(&self, community_id: CommunityId) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, name, owner_pubkey, scopes, created_at, expires_at + FROM api_tokens + WHERE community_id = $1 AND revoked_at IS NULL + ORDER BY created_at DESC + LIMIT 1000 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_all(&self.pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + let id: Uuid = row.try_get("id")?; + let scopes_json: serde_json::Value = row.try_get("scopes")?; + let scopes: Vec = serde_json::from_value(scopes_json) + .map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?; + + out.push(TokenSummary { + id, + name: row.try_get("name")?, + owner_pubkey: row.try_get("owner_pubkey")?, + scopes, + created_at: row.try_get("created_at")?, + expires_at: row.try_get("expires_at")?, + }); + } + Ok(out) + } + + /// List all tokens for a (community, owner) pair (including revoked). + #[datastore_span(name = "list_tokens_by_owner", system = "postgresql")] + pub async fn list_tokens_by_owner( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + list_tokens_by_owner(&self.pool, *community_id.as_uuid(), pubkey).await + } + + /// Revoke a single token by ID, scoped to (community, owner). + #[datastore_span(name = "revoke_token", system = "postgresql")] + pub async fn revoke_token( + &self, + community_id: CommunityId, + id: Uuid, + owner_pubkey: &[u8], + revoked_by: &[u8], + ) -> Result { + revoke_token( + &self.pool, + *community_id.as_uuid(), + id, + owner_pubkey, + revoked_by, + ) + .await + } + + /// Revoke all active tokens for a (community, owner) pair. + #[datastore_span(name = "revoke_all_tokens", system = "postgresql")] + pub async fn revoke_all_tokens( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + revoked_by: &[u8], + ) -> Result { + revoke_all_tokens( + &self.pool, + *community_id.as_uuid(), + owner_pubkey, + revoked_by, + ) + .await + } +} + #[cfg(test)] mod tests { //! Row-44 conformance: API token lookups MUST be keyed on @@ -344,7 +625,7 @@ mod tests { use crate::{ApiTokenRecord, Db}; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_db() -> Db { let pool = PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs similarity index 80% rename from crates/buzz-db/src/archived_identities.rs rename to crates/buzz-db/src/store/archived_identities.rs index 941c0fc7358..810c8c0aa1f 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -6,10 +6,12 @@ //! All pubkey and event ID values are lowercase hex strings. use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; +use crate::Db; /// A single archived identity record. #[derive(Debug, Clone)] @@ -124,11 +126,60 @@ fn row_to_archived_identity( }) } +impl Db { + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. + #[datastore_span(name = "is_archived", system = "postgresql")] + pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { + is_archived(&self.pool, community_id, pubkey).await + } + + /// Archives an identity in `community_id`. Returns `true` if inserted, + /// `false` if already archived. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "archive", system = "postgresql")] + pub async fn archive( + &self, + community_id: CommunityId, + pubkey: &str, + consent_path: &str, + actor: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + request_event_id: &str, + ) -> Result { + archive( + &self.pool, + community_id, + pubkey, + consent_path, + actor, + reason, + replaced_by, + request_event_id, + ) + .await + } + + /// Unarchives an identity from `community_id`. Returns `true` if deleted, + /// `false` if absent. + #[datastore_span(name = "unarchive", system = "postgresql")] + pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { + unarchive(&self.pool, community_id, pubkey).await + } + + /// Returns all identities archived in `community_id`, ordered by archive + /// time ascending. + #[datastore_span(name = "list_archived", system = "postgresql")] + pub async fn list_archived(&self, community_id: CommunityId) -> Result> { + list_archived(&self.pool, community_id).await + } +} + #[cfg(test)] mod tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/store/channel.rs similarity index 100% rename from crates/buzz-db/src/channel.rs rename to crates/buzz-db/src/store/channel.rs diff --git a/crates/buzz-db/src/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs similarity index 99% rename from crates/buzz-db/src/channel_members.rs rename to crates/buzz-db/src/store/channel_members.rs index 912f7dcf689..f0fd3332acd 100644 --- a/crates/buzz-db/src/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -2944,7 +2944,7 @@ mod tests { .connect(&scratch_url) .await .expect("connect desired-schema scratch db"); - sqlx::raw_sql(include_str!("../../../schema/schema.sql")) + sqlx::raw_sql(include_str!("../../../../schema/schema.sql")) .execute(&pool) .await .expect("apply desired-state schema"); diff --git a/crates/buzz-db/src/community.rs b/crates/buzz-db/src/store/community.rs similarity index 99% rename from crates/buzz-db/src/community.rs rename to crates/buzz-db/src/store/community.rs index 5df896a3500..5e8462345bb 100644 --- a/crates/buzz-db/src/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -602,7 +602,7 @@ mod tests { #[test] fn community_implementation_tests_and_spans_have_single_owners() { let community_source = include_str!("community.rs"); - let lib_source = include_str!("lib.rs"); + let lib_source = include_str!("../lib.rs"); let operations = [ "lookup_community_by_host", "is_community_active", diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/store/deletion.rs similarity index 99% rename from crates/buzz-db/src/deletion.rs rename to crates/buzz-db/src/store/deletion.rs index 98a039d62f3..c7fcdc09f66 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -17,6 +17,7 @@ use sqlx::{AssertSqlSafe, PgConnection, PgPool, Postgres, Row, Transaction}; use uuid::Uuid; use crate::error::{DbError, Result}; +use crate::Db; /// Default PostgreSQL lease duration for one claimed deletion request. pub const DEFAULT_LEASE_DURATION: Duration = Duration::from_secs(60); @@ -627,6 +628,23 @@ pub struct DeletionStore { pool: PgPool, } +impl Db { + /// Validate the minimum deletion fence catalog required by serving paths. + pub async fn validate_deletion_serving_catalog(&self) -> Result<()> { + self.deletion_store().validate_serving_catalog().await + } + + /// Validate the exact live community-deletion tenant catalog for destruction. + pub async fn validate_deletion_catalog(&self) -> Result<()> { + self.deletion_store().validate_catalog().await + } + + /// Return the shared durable whole-community deletion adapter. + pub fn deletion_store(&self) -> DeletionStore { + DeletionStore::new(self.pool.clone()) + } +} + impl DeletionStore { /// Construct from the writer pool used by [`crate::Db`]. pub(crate) fn new(pool: PgPool) -> Self { diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/store/dm.rs similarity index 85% rename from crates/buzz-db/src/dm.rs rename to crates/buzz-db/src/store/dm.rs index 89e15c70260..89e4a0e5221 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -10,7 +10,9 @@ use uuid::Uuid; use crate::channel::ChannelRecord; use crate::error::{DbError, Result}; +use crate::Db; use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; // -- Public structs ----------------------------------------------------------- @@ -514,6 +516,89 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { }) } +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Find an existing DM by its participant hash. + #[datastore_span(name = "find_dm_by_participants", system = "postgresql")] + pub async fn find_dm_by_participants( + &self, + community_id: CommunityId, + participant_hash: &[u8], + ) -> Result> { + crate::dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await + } + + /// Create or return an existing DM channel. + #[datastore_span(name = "create_dm", system = "postgresql")] + pub async fn create_dm( + &self, + community_id: CommunityId, + participants: &[&[u8]], + created_by: &[u8], + ) -> Result { + crate::dm::create_dm(&self.pool, community_id, participants, created_by).await + } + + /// List all DMs for a user. + #[datastore_span(name = "list_dms_for_user", system = "postgresql")] + pub async fn list_dms_for_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + limit: u32, + cursor: Option, + ) -> Result> { + crate::dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await + } + + /// Open or retrieve a DM for the given participants. + #[datastore_span(name = "open_dm", system = "postgresql")] + pub async fn open_dm( + &self, + community_id: CommunityId, + pubkeys: &[&[u8]], + created_by: &[u8], + ) -> Result<(ChannelRecord, bool)> { + crate::dm::open_dm(&self.pool, community_id, pubkeys, created_by).await + } + + /// Hide a DM channel for a specific user. + /// + /// The DM is not deleted — it can be restored by opening a new DM with + /// the same participants. + #[datastore_span(name = "hide_dm", system = "postgresql")] + pub async fn hide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + crate::dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await + } + + /// Unhide a DM channel for a specific user. + #[datastore_span(name = "unhide_dm", system = "postgresql")] + pub async fn unhide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + crate::dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await + } + + /// List the channel IDs of all DMs the given user currently has hidden. + #[datastore_span(name = "list_hidden_dms", system = "postgresql")] + pub async fn list_hidden_dms( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + crate::dm::list_hidden_dms(&self.pool, community_id, pubkey).await + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/store/event.rs similarity index 73% rename from crates/buzz-db/src/event.rs rename to crates/buzz-db/src/store/event.rs index 136bcce26b5..60e6b05ef9b 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -14,8 +14,16 @@ use buzz_core::kind::{ KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; +use buzz_datastore_tracing::datastore_span; use crate::error::{DbError, Result}; +use crate::Db; + +// Compatibility exports preserve the pre-extraction public event-store paths. +pub use crate::reminder::{ + claim_due_reminder, claim_due_reminder_with_stamp, query_due_reminders, release_due_reminder, + DueReminder, +}; /// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is /// unset — the effective ceiling on any client-requested `limit`. @@ -140,21 +148,7 @@ impl EventQuery { } } -/// Result of atomically inserting a kind:7 reaction event and its reaction row. -#[derive(Debug)] -pub enum ReactionEventInsertOutcome { - /// Target event was absent in this community, or was soft-deleted. No writes committed. - TargetMissing, - /// The active `(target, actor, emoji)` reaction already exists. No event was stored. - Duplicate, - /// Reaction row and event transaction committed. - Inserted { - /// Stored reaction event. - stored_event: Box, - /// Whether the event row itself was newly inserted. - was_inserted: bool, - }, -} +pub use crate::reaction::{insert_reaction_event_with_thread_metadata, ReactionEventInsertOutcome}; /// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers; /// anything beyond this is either a bug or abuse. @@ -1354,238 +1348,395 @@ pub async fn insert_event_with_thread_metadata( Ok(result) } -/// Atomically insert a kind:7 reaction event and its reaction row. -/// -/// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, -/// check `rows_affected`, then insert the kind:7 event. Active duplicates return -/// before event insertion so duplicate reactions never store a duplicate kind:7. -#[allow(clippy::too_many_arguments)] -pub async fn insert_reaction_event_with_thread_metadata( - pool: &PgPool, - community_id: CommunityId, - reaction_event: &Event, - channel_id: Option, - thread_meta: Option>, - target_event_id: &[u8], - actor_pubkey: &[u8], - emoji: &str, -) -> Result { - let mut tx = pool.begin().await?; - - let target_row = sqlx::query( - "SELECT created_at FROM events \ - WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ - ORDER BY created_at DESC LIMIT 1", - ) - .bind(community_id.as_uuid()) - .bind(target_event_id) - .fetch_optional(&mut *tx) - .await?; +impl Db { + /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. + #[datastore_span(name = "insert_event", system = "postgresql")] + pub async fn insert_event( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + ) -> Result<(StoredEvent, bool)> { + let result = + crate::event::insert_event(&self.pool, community_id, event, channel_id).await?; + if result.1 { + if let Err(e) = + crate::insert_mentions(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) + } - let Some(target_row) = target_row else { - tx.rollback().await?; - return Ok(ReactionEventInsertOutcome::TargetMissing); - }; - let target_created_at: DateTime = target_row.get("created_at"); - - // Preserve add_reaction's exact new / re-activate / active-duplicate semantics. - let reaction_inserted = crate::reaction::add_reaction_tx( - &mut tx, - community_id, - target_event_id, - target_created_at, - actor_pubkey, - emoji, - Some(reaction_event.id.as_bytes()), - ) - .await?; + /// Queries events matching the given filter parameters. + /// + /// Always reads from the WRITER pool. If the result influences a write + /// or a permission decision, this is the method to call. Display-path + /// callers that tolerate bounded staleness should use + /// [`Db::query_events_routed`] instead — converting a caller is an + /// explicit, per-callsite decision, never a change to this method. + #[datastore_span(name = "query_events", system = "postgresql")] + pub async fn query_events(&self, q: &EventQuery) -> Result> { + crate::event::query_events(&self.pool, q).await + } + + /// [`Db::query_events`] with replica routing — the opt-in fast path for + /// display reads. + /// + /// Rule of thumb: **if the result influences a write or a permission, + /// it reads from the writer** — do not convert such a caller to this + /// method. Every new caller must be added to the caller-classification + /// table in `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md`. + /// + /// Routing derives the strongest sound predicate from the query shape + /// ([`crate::RoutePredicate::for_query`]): a channel-pinned query with an + /// `until` upper bound may be served covered (provably complete below + /// the fence wall); anything else is bounded-staleness only. The whole + /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when + /// unset, even covered-eligible queries stay on the writer, so merging + /// this seam is a true no-op until the budget is configured. Every + /// failure fails closed to the writer. + #[datastore_span(name = "query_events_routed", system = "postgresql")] + pub async fn query_events_routed( + &self, + path: &'static str, + q: &EventQuery, + ) -> Result> { + let predicate = crate::RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); + match self.route_read(path, predicate).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::query_events_on(&mut tx, q).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + // Mid-query replica failure: fail closed to the + // writer rather than surfacing a routed error. + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::query_events(&self.pool, q).await + } + } + } + crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + } + } - if !reaction_inserted { - tx.rollback().await?; - return Ok(ReactionEventInsertOutcome::Duplicate); + /// [`Db::query_events_routed`] restricted to the BOUNDED arm — for + /// reads whose result feeds a COUNT rather than a displayed page. + /// + /// The covered arm bounds insert-completeness only; stale deletions can + /// briefly inflate the result set (see [`crate::RoutePredicate::Covered`]). A + /// display page absorbs that per-row; a number derived from the rows + /// does not. Same classification-table requirement as + /// [`Db::query_events_routed`]. + #[datastore_span(name = "query_events_routed_bounded", system = "postgresql")] + pub async fn query_events_routed_bounded( + &self, + path: &'static str, + q: &EventQuery, + ) -> Result> { + match self.route_read(path, crate::RoutePredicate::Bounded).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::query_events_on(&mut tx, q).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::query_events(&self.pool, q).await + } + } + } + crate::RouteDecision::Writer => crate::event::query_events(&self.pool, q).await, + } } - let (stored_event, was_inserted) = insert_event_with_thread_metadata_tx( - &mut tx, - community_id, - reaction_event, - channel_id, - thread_meta, - ) - .await?; + /// Count events matching the given query (NIP-45 COUNT support). + /// + /// Always reads from the WRITER pool — see [`Db::query_events`] for the + /// writer-vs-routed rule. + #[datastore_span(name = "count_events", system = "postgresql")] + pub async fn count_events(&self, q: &EventQuery) -> Result { + crate::event::count_events(&self.pool, q).await + } - tx.commit().await?; + /// [`Db::count_events`] with replica routing — same contract, rules, + /// and classification-table requirement as [`Db::query_events_routed`]. + /// + /// Counts route on the BOUNDED arm only, never covered: the covered + /// arm bounds insert-completeness but not deletion visibility (soft + /// deletes are UPDATEs outside the floor guard), and a count has no + /// downstream per-row re-filter to absorb extra rows — a silently + /// inflated number for up to `FENCE_STALENESS` is a different product + /// statement than a page briefly showing a deleted row. `Bounded` ties + /// the error to the accepted budget `B`. + #[datastore_span(name = "count_events_routed", system = "postgresql")] + pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { + match self.route_read(path, crate::RoutePredicate::Bounded).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::count_events_on(&mut tx, q).await { + Ok(count) => { + Self::record_route(path, "replica", reason); + Ok(count) + } + Err(e) => { + tracing::warn!(path, "replica count failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::count_events(&self.pool, q).await + } + } + } + crate::RouteDecision::Writer => crate::event::count_events(&self.pool, q).await, + } + } - Ok(ReactionEventInsertOutcome::Inserted { - stored_event: Box::new(stored_event), - was_inserted, - }) -} + /// Return whether a creator-signed huddle-start event links a parent + /// channel to an ephemeral huddle channel. + #[datastore_span(name = "huddle_started_link_exists", system = "postgresql")] + pub async fn huddle_started_link_exists( + &self, + community_id: CommunityId, + parent_channel_id: Uuid, + ephemeral_channel_id: Uuid, + creator_pubkey: &[u8], + ) -> Result { + crate::event::huddle_started_link_exists( + &self.pool, + community_id, + parent_channel_id, + ephemeral_channel_id, + creator_pubkey, + ) + .await + } -/// A due reminder row returned by [`query_due_reminders`]. -#[derive(Debug)] -pub struct DueReminder { - /// Server-resolved community this reminder row belongs to. - pub community_id: CommunityId, - /// Normalized host mapped to that community. - pub host: String, - /// The event's raw ID bytes. - pub id: Vec, - /// The event's pubkey bytes. - pub pubkey: Vec, - /// The event's `created_at` timestamp. - pub created_at: DateTime, - /// The event's kind (always 30300). - pub kind: i32, - /// The event's JSONB tags. - pub tags: serde_json::Value, - /// The event's encrypted content. - pub content: String, - /// The event's signature bytes. - pub sig: Vec, - /// The channel ID (always None for reminders — global events). - pub channel_id: Option, -} + /// Fetch the latest replaceable event for a (kind, pubkey) pair. + /// + /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. + /// This matches the write path in [`replace_addressable_event`] and handles + /// historical duplicate survivors correctly. + #[datastore_span(name = "get_latest_global_replaceable", system = "postgresql")] + pub async fn get_latest_global_replaceable( + &self, + community_id: CommunityId, + kind: i32, + pubkey_bytes: &[u8], + ) -> Result> { + crate::event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes) + .await + } -/// Query due reminders: latest-per-address `kind:30300` rows where -/// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`. -/// -/// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16 -/// ordering (`created_at DESC, id ASC`). -pub async fn query_due_reminders( - pool: &PgPool, - now_secs: i64, - batch_limit: i64, -) -> Result> { - let kind_i32 = KIND_EVENT_REMINDER as i32; - let rows = sqlx::query( - r#" - SELECT DISTINCT ON (e.community_id, e.pubkey, e.d_tag) - e.community_id, c.host, e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.channel_id - FROM events AS e - JOIN communities AS c ON c.id = e.community_id - WHERE e.kind = $1 - AND e.not_before IS NOT NULL - AND e.not_before <= $2 - AND e.deleted_at IS NULL - AND e.delivered_at IS NULL - AND c.archived_at IS NULL - ORDER BY e.community_id, e.pubkey, e.d_tag, e.created_at DESC, e.id ASC - LIMIT $3 - "#, - ) - .bind(kind_i32) - .bind(now_secs) - .bind(batch_limit) - .fetch_all(pool) - .await?; + /// Fetches a single non-deleted event by its raw ID bytes. + /// + /// Returns `None` if the event does not exist or has been soft-deleted. + #[datastore_span(name = "get_event_by_id", system = "postgresql")] + pub async fn get_event_by_id( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id(&self.pool, community_id, id_bytes).await + } + + /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. + #[datastore_span(name = "get_event_by_id_including_deleted", system = "postgresql")] + pub async fn get_event_by_id_including_deleted( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + crate::event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await + } + + /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. + #[datastore_span(name = "soft_delete_event", system = "postgresql")] + pub async fn soft_delete_event( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result { + crate::event::soft_delete_event(&self.pool, community_id, event_id).await + } + + /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` + /// when it is not newer than the deletion request. + /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; + /// `deletion_created_at_secs` is the deletion event's `created_at`. + #[datastore_span(name = "soft_delete_by_coordinate", system = "postgresql")] + pub async fn soft_delete_by_coordinate( + &self, + community_id: CommunityId, + kind: i32, + pubkey: &[u8], + d_tag: &str, + deletion_created_at_secs: i64, + ) -> Result { + crate::event::soft_delete_by_coordinate( + &self.pool, + community_id, + kind, + pubkey, + d_tag, + deletion_created_at_secs, + ) + .await + } - let results = rows - .into_iter() - .map(|row| DueReminder { - community_id: CommunityId::from_uuid(row.get("community_id")), - host: row.get("host"), - id: row.get("id"), - pubkey: row.get("pubkey"), - created_at: row.get("created_at"), - kind: row.get("kind"), - tags: row.get("tags"), - content: row.get("content"), - sig: row.get("sig"), - channel_id: row.get("channel_id"), - }) - .collect(); + /// Atomically soft-delete an event and decrement thread reply counters. + #[datastore_span(name = "soft_delete_event_and_update_thread", system = "postgresql")] + pub async fn soft_delete_event_and_update_thread( + &self, + community_id: CommunityId, + event_id: &[u8], + parent_event_id: Option<&[u8]>, + root_event_id: Option<&[u8]>, + ) -> Result { + crate::event::soft_delete_event_and_update_thread( + &self.pool, + community_id, + event_id, + parent_event_id, + root_event_id, + ) + .await + } - Ok(results) -} + /// Returns the most recent `created_at` for a channel. + #[datastore_span(name = "get_last_message_at", system = "postgresql")] + pub async fn get_last_message_at( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result>> { + crate::event::get_last_message_at(&self.pool, community_id, channel_id).await + } -/// Atomically claim a due reminder for delivery. Returns `Some(id)` if this -/// caller won the claim (set `delivered_at`), or `None` if another pod already -/// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod -/// idempotency. -pub async fn claim_due_reminder( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, -) -> Result { - claim_due_reminder_with_stamp( - pool, - community_id, - event_id, - event_created_at, - Utc::now().timestamp(), - ) - .await -} + /// Bulk-fetch the most recent `created_at` for a set of channel IDs. + #[datastore_span(name = "get_last_message_at_bulk", system = "postgresql")] + pub async fn get_last_message_at_bulk( + &self, + community_id: CommunityId, + channel_ids: &[Uuid], + ) -> Result>> { + crate::event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await + } -/// Atomically claim a due reminder using a caller-supplied delivery stamp. -/// -/// The same stamp should be passed to [`release_due_reminder`] if the publish -/// side effect fails, so rollback can compare-and-clear only this pod's claim. -/// -/// Scoped by `community_id`: `events` is keyed `(community_id, created_at, id)`, -/// and the same Nostr event id (hence the same `id`/`created_at` pair) is -/// allowed across communities. Without the community predicate a claim for -/// `A/X` would also mark `B/X` delivered. The caller already holds the owning -/// community on the `DueReminder` row. -pub async fn claim_due_reminder_with_stamp( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - delivery_stamp: i64, -) -> Result { - let result = sqlx::query( - r#" - UPDATE events - SET delivered_at = $1 - WHERE community_id = $2 AND created_at = $3 AND id = $4 AND delivered_at IS NULL - "#, - ) - .bind(delivery_stamp) - .bind(community_id.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .execute(pool) - .await?; + /// Batch-fetch non-deleted events by their raw IDs. + #[datastore_span(name = "get_events_by_ids", system = "postgresql")] + pub async fn get_events_by_ids( + &self, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + crate::event::get_events_by_ids(&self.pool, community_id, ids).await + } - Ok(result.rows_affected() > 0) -} + /// [`Db::get_events_by_ids`] with replica routing — same contract and + /// classification-table requirement as [`Db::query_events_routed`]. + /// + /// By-id fetches route on the BOUNDED arm only: an id list carries no + /// channel pin, so no fence floor can prove insert-completeness — the + /// covered arm is structurally unavailable. Used for FTS hit hydration, + /// where a missing row degrades to a skipped search hit downstream. + #[datastore_span(name = "get_events_by_ids_routed", system = "postgresql")] + pub async fn get_events_by_ids_routed( + &self, + path: &'static str, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + match self.route_read(path, crate::RoutePredicate::Bounded).await { + crate::RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::event::get_events_by_ids_on(&mut tx, community_id, ids).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::event::get_events_by_ids(&self.pool, community_id, ids).await + } + } + } + crate::RouteDecision::Writer => { + crate::event::get_events_by_ids(&self.pool, community_id, ids).await + } + } + } -/// Release a previously claimed reminder when publish fails. -/// -/// The `delivery_stamp` must be the exact value written by the claiming pod; -/// that compare-and-clear prevents one pod from rolling back another pod's -/// later claim after a retry/race. -/// -/// Scoped by `community_id` for the same reason as the claim: a release for -/// `A/X` must not clear `B/X` even when their `id`/`created_at`/stamp coincide. -pub async fn release_due_reminder( - pool: &PgPool, - community_id: CommunityId, - event_id: &[u8], - event_created_at: DateTime, - delivery_stamp: i64, -) -> Result { - let result = sqlx::query( - r#" - UPDATE events - SET delivered_at = NULL - WHERE community_id = $1 - AND created_at = $2 - AND id = $3 - AND delivered_at = $4 - "#, - ) - .bind(community_id.as_uuid()) - .bind(event_created_at) - .bind(event_id) - .bind(delivery_stamp) - .execute(pool) - .await?; + /// Atomically insert an event AND its thread metadata in a single transaction. + #[datastore_span(name = "insert_event_with_thread_metadata", system = "postgresql")] + pub async fn insert_event_with_thread_metadata( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + thread_meta: Option>, + ) -> Result<(StoredEvent, bool)> { + let result = crate::event::insert_event_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + ) + .await?; + if result.1 { + if let Err(e) = + crate::insert_mentions(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(result) + } - Ok(result.rows_affected() == 1) + /// Backfill `d_tag` for existing NIP-33 events (kind 30000–39999) that have `d_tag IS NULL`. + /// + /// Idempotent — safe to call on every startup. No-ops when all rows are already populated. + /// Runs a single UPDATE touching only NIP-33 rows with NULL d_tag. + #[datastore_span(name = "backfill_d_tags", system = "postgresql")] + pub async fn backfill_d_tags(&self) -> Result { + let result = sqlx::query( + "UPDATE events \ + SET d_tag = COALESCE( \ + (SELECT elem->>1 FROM jsonb_array_elements(tags) AS elem \ + WHERE elem->>0 = 'd' LIMIT 1), \ + '' \ + ) \ + WHERE kind BETWEEN 30000 AND 39999 AND d_tag IS NULL \ + AND community_write_allowed(community_id)", + ) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + + /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. + #[datastore_span(name = "soft_delete_discovery_events", system = "postgresql")] + pub async fn soft_delete_discovery_events( + &self, + community_id: CommunityId, + channel_id: Uuid, + relay_pubkey: &[u8], + ) -> Result { + let result = sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .bind(relay_pubkey) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } } #[cfg(test)] @@ -2073,298 +2224,6 @@ mod tests { .expect("sign text event") } - fn make_reaction_event(keys: &Keys, target_id_hex: &str, emoji: &str) -> nostr::Event { - let nonce = Uuid::new_v4().to_string(); - EventBuilder::new(Kind::Custom(7), emoji) - .tags(vec![ - Tag::parse(["e", target_id_hex]).expect("reaction e tag"), - Tag::parse(["nonce", nonce.as_str()]).expect("nonce tag"), - ]) - .sign_with_keys(keys) - .expect("sign reaction event") - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_stores_wrapped_max_shortcode() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("long custom emoji target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let emoji = format!(":{}:", "a".repeat(64)); - let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); - let outcome = insert_reaction_event_with_thread_metadata( - &pool, - community, - &reaction, - None, - None, - target.id.as_bytes(), - &actor.public_key().to_bytes(), - &emoji, - ) - .await - .expect("store wrapped 64-character shortcode"); - - assert!(matches!( - outcome, - ReactionEventInsertOutcome::Inserted { - was_inserted: true, - .. - } - )); - assert_eq!(emoji.chars().count(), 66); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("reaction target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let target_hex = target.id.to_hex(); - let first = make_reaction_event(&actor, &target_hex, "👍"); - let second = make_reaction_event(&actor, &target_hex, "👍"); - - let first_outcome = insert_reaction_event_with_thread_metadata( - &pool, - community, - &first, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("first reaction insert"); - assert!(matches!( - first_outcome, - ReactionEventInsertOutcome::Inserted { - was_inserted: true, - .. - } - )); - - let duplicate = insert_reaction_event_with_thread_metadata( - &pool, - community, - &second, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("duplicate reaction insert"); - assert!(matches!(duplicate, ReactionEventInsertOutcome::Duplicate)); - - let duplicate_event = get_event_by_id(&pool, community, second.id.as_bytes()) - .await - .expect("lookup duplicate reaction event"); - assert!( - duplicate_event.is_none(), - "active duplicate reaction must short-circuit before storing kind:7 event" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_cross_community_target_rejected() { - let pool = setup_pool().await; - let community_a = CommunityId::from_uuid(make_test_community(&pool).await); - let community_b = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("community A target only"); - insert_event(&pool, community_a, &target, None) - .await - .expect("insert target in A"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let reaction = make_reaction_event(&actor, &target.id.to_hex(), "👍"); - - let outcome = insert_reaction_event_with_thread_metadata( - &pool, - community_b, - &reaction, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("cross-community reaction attempt"); - assert!(matches!(outcome, ReactionEventInsertOutcome::TargetMissing)); - - assert!( - get_event_by_id(&pool, community_b, reaction.id.as_bytes()) - .await - .expect("lookup B reaction event") - .is_none(), - "reaction event must not store when target exists only in another community" - ); - assert!( - crate::reaction::get_active_reaction_record( - &pool, - community_b, - target.id.as_bytes(), - DateTime::from_timestamp(target.created_at.as_secs() as i64, 0).unwrap(), - &actor_pubkey, - "👍", - ) - .await - .expect("lookup B reaction row") - .is_none(), - "reaction row must not be inserted for cross-community target miss" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_event_insert_failure_rolls_back_reaction() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("rollback target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let target_hex = target.id.to_hex(); - let bad_reaction = EventBuilder::new(Kind::Custom(20000), "👍") - .tags(vec![ - Tag::parse(["e", target_hex.as_str()]).expect("reaction e tag") - ]) - .sign_with_keys(&actor) - .expect("sign ephemeral reaction-shaped event"); - let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) - .expect("target timestamp"); - - let err = insert_reaction_event_with_thread_metadata( - &pool, - community, - &bad_reaction, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect_err("ephemeral event insert must fail after reaction upsert attempt"); - assert!(matches!(err, DbError::EphemeralEventRejected(20000))); - - assert!( - crate::reaction::get_active_reaction_record( - &pool, - community, - target.id.as_bytes(), - target_created_at, - &actor_pubkey, - "👍", - ) - .await - .expect("lookup reaction row after rollback") - .is_none(), - "transaction rollback must remove the reaction row when event insert fails" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reaction_single_tx_reactivates_soft_deleted_reaction() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let target = make_text_event("reactivation target"); - insert_event(&pool, community, &target, None) - .await - .expect("insert target"); - - let actor = Keys::generate(); - let actor_pubkey = actor.public_key().to_bytes(); - let target_hex = target.id.to_hex(); - let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) - .expect("target timestamp"); - let first = make_reaction_event(&actor, &target_hex, "👍"); - let second = make_reaction_event(&actor, &target_hex, "👍"); - - assert!(matches!( - insert_reaction_event_with_thread_metadata( - &pool, - community, - &first, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("first reaction insert"), - ReactionEventInsertOutcome::Inserted { .. } - )); - assert!(crate::reaction::remove_reaction( - &pool, - community, - target.id.as_bytes(), - target_created_at, - &actor_pubkey, - "👍", - ) - .await - .expect("soft delete reaction")); - - let outcome = insert_reaction_event_with_thread_metadata( - &pool, - community, - &second, - None, - None, - target.id.as_bytes(), - &actor_pubkey, - "👍", - ) - .await - .expect("reactivate reaction"); - assert!(matches!( - outcome, - ReactionEventInsertOutcome::Inserted { - was_inserted: true, - .. - } - )); - - let active = crate::reaction::get_active_reaction_record( - &pool, - community, - target.id.as_bytes(), - target_created_at, - &actor_pubkey, - "👍", - ) - .await - .expect("active record after reactivation") - .expect("reaction active after reactivation"); - assert_eq!( - active.reaction_event_id.as_deref(), - Some(second.id.as_bytes().as_slice()), - "reactivation through the tx path must preserve add_reaction's source-id update semantics" - ); - } - #[test] fn extract_d_tag_from_nip33_event() { let event = make_event_with_kind_and_tags( @@ -2495,240 +2354,70 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn query_due_reminders_returns_row_community_and_host_per_tenant() { - let pool = setup_pool().await; - let community_a_uuid = make_test_community(&pool).await; - let community_b_uuid = make_test_community(&pool).await; - let community_a = CommunityId::from_uuid(community_a_uuid); - let community_b = CommunityId::from_uuid(community_b_uuid); - let host_a: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") - .bind(community_a_uuid) - .fetch_one(&pool) - .await - .expect("load host A"); - let host_b: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") - .bind(community_b_uuid) - .fetch_one(&pool) - .await - .expect("load host B"); - - let not_before = Utc::now().timestamp() - 1; - let keys_a = Keys::generate(); - let keys_b = Keys::generate(); - let event_a = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "a") - .tags([ - Tag::parse(["d", "due-reminder-scope-a"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys_a) - .expect("sign A"); - let event_b = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "b") - .tags([ - Tag::parse(["d", "due-reminder-scope-b"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys_b) - .expect("sign B"); - - insert_event(&pool, community_a, &event_a, None) - .await - .expect("insert A"); - insert_event(&pool, community_b, &event_b, None) - .await - .expect("insert B"); - - let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) - .await - .expect("query due reminders"); - - assert!(due.iter().any(|row| { - row.id == event_a.id.as_bytes() && row.community_id == community_a && row.host == host_a - })); - assert!(due.iter().any(|row| { - row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b - })); - } + async fn coordinate_delete_spares_head_newer_than_the_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; - /// Two pods race to claim the same due reminder: exactly one wins. The - /// scheduler publishes only on a winning claim (`Ok(true)`) and `continue`s - /// on the loser (`Ok(false)`), so a single winning claim *is* the proof of - /// exactly one publish side effect across N pods. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let not_before = Utc::now().timestamp() - 1; + let db = Db::from_pool(setup_pool().await); + let community = CommunityId::from_uuid(make_test_community(&db.pool).await); let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") - .tags([ - Tag::parse(["d", "due-reminder-claim-race"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .expect("sign reminder"); - insert_event(&pool, community, &event, None) - .await - .expect("insert reminder"); - - let id = event.id.as_bytes().to_vec(); - let created_at = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); - - // Two pods, two distinct per-attempt stamps, same reminder. - let stamp_p1: i64 = 0x1111_1111_1111_1111; - let stamp_p2: i64 = 0x2222_2222_2222_2222; - let won_p1 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p1) - .await - .expect("p1 claim"); - let won_p2 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p2) - .await - .expect("p2 claim"); - - assert!( - won_p1 ^ won_p2, - "exactly one pod must win the claim (p1={won_p1}, p2={won_p2}) — \ - the loser never reaches the publish side effect" - ); - } + let kind = buzz_core::kind::KIND_PROJECT as i32; + let d_tag = "stale-tombstone-project"; + let pubkey = keys.public_key().to_bytes().to_vec(); + let base = Timestamp::now().as_secs(); + + let version = |content: &str, offset: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign project version") + }; + + for (content, offset) in [("v1", 0), ("v2", 100)] { + assert!( + db.replace_parameterized_event(community, &version(content, offset), d_tag, None) + .await + .expect("store project version") + .1 + ); + } - /// A failed publish releases the claim so the reminder is redeliverable, - /// and the compare-and-clear stamp guard prevents one pod from rolling back - /// another pod's claim. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn release_due_reminder_rolls_back_only_the_matching_stamp() { - let pool = setup_pool().await; - let community = CommunityId::from_uuid(make_test_community(&pool).await); - let not_before = Utc::now().timestamp() - 1; - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") - .tags([ - Tag::parse(["d", "due-reminder-release"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .expect("sign reminder"); - insert_event(&pool, community, &event, None) + // Tombstone timestamped between V1 and V2: it authorizes deleting V1, + // never the newer head that replaced it. + let stale_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) .await - .expect("insert reminder"); - - let id = event.id.as_bytes().to_vec(); - let created_at = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); - let stamp: i64 = 0x3333_3333_3333_3333; - + .expect("stale coordinate delete"); assert!( - claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) - .await - .expect("claim"), - "first claim wins" + !stale_deleted, + "a tombstone older than the live head must delete nothing" ); - // A release with the *wrong* stamp must be a no-op (does not clear - // another pod's claim). - assert!( - !release_due_reminder(&pool, community, &id, created_at, stamp ^ 0xFFFF) - .await - .expect("wrong-stamp release"), - "release with a non-matching stamp must not clear the claim" - ); - assert!( - !claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) - .await - .expect("re-claim after no-op release"), - "reminder must still be claimed after a no-op release" - ); - - // The matching-stamp release rolls the claim back; the reminder is - // redeliverable and a subsequent claim wins again. - assert!( - release_due_reminder(&pool, community, &id, created_at, stamp) - .await - .expect("matching-stamp release"), - "release with the claiming stamp must clear the claim" - ); - assert!( - claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) - .await - .expect("re-claim after release"), - "released reminder must be reclaimable for retry" + let live_content: Option = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(kind) + .bind(&pubkey) + .bind(d_tag) + .fetch_optional(&db.pool) + .await + .expect("read live head"); + assert_eq!( + live_content.as_deref(), + Some("v2"), + "the newer head must survive a stale tombstone" ); - } - /// Cross-community confinement: the same Nostr reminder event (identical - /// `id` and `created_at`) inserted into communities A and B must claim and - /// release independently. A claim/release for `A/X` must never touch `B/X`. - /// - /// This is the primitive the scheduler's exactly-once-publish proof rests - /// on: `events` is keyed `(community_id, created_at, id)`, so without the - /// community predicate a claim for A would mark B delivered (suppressing - /// B's reminder) and a matching-stamp release for A would clear B. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn reminder_claim_and_release_are_confined_to_their_community() { - let pool = setup_pool().await; - let community_a = CommunityId::from_uuid(make_test_community(&pool).await); - let community_b = CommunityId::from_uuid(make_test_community(&pool).await); - - // One signed event, inserted into both communities — same id/created_at. - let not_before = Utc::now().timestamp() - 1; - let keys = Keys::generate(); - let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") - .tags([ - Tag::parse(["d", "due-reminder-cross-community"]).unwrap(), - Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), - ]) - .sign_with_keys(&keys) - .expect("sign reminder"); - insert_event(&pool, community_a, &event, None) - .await - .expect("insert A/X"); - insert_event(&pool, community_b, &event, None) + // A tombstone at or after the head's own timestamp still deletes it. + let current_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) .await - .expect("insert B/X"); - - let id = event.id.as_bytes().to_vec(); - let created_at = event.created_at.as_secs() as i64; - let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); - let stamp: i64 = 0x4444_4444_4444_4444; - - // Claim A/X. B/X must remain claimable — A's claim did not mark B. + .expect("current coordinate delete"); assert!( - claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) - .await - .expect("claim A"), - "A/X claim wins" - ); - assert!( - claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) - .await - .expect("claim B"), - "B/X must still be claimable after A/X is claimed — \ - a claim for A must not mark B delivered" - ); - - // Both are now claimed under the same stamp. A matching-stamp release - // for A/X must clear only A/X; B/X must stay claimed. - assert!( - release_due_reminder(&pool, community_a, &id, created_at, stamp) - .await - .expect("release A"), - "A/X release with the claiming stamp clears A/X" - ); - assert!( - !claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) - .await - .expect("re-claim B after A release"), - "B/X must remain claimed after A/X is released — \ - a release for A must not clear B" - ); - // And A/X is genuinely redeliverable (the release was real, not a no-op). - assert!( - claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) - .await - .expect("re-claim A after release"), - "A/X must be reclaimable after its own release" + current_deleted, + "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" ); } diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/store/feed.rs similarity index 79% rename from crates/buzz-db/src/feed.rs rename to crates/buzz-db/src/store/feed.rs index 6900e2061c5..01e4fef32be 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -28,6 +28,7 @@ /// before the query is issued so the SQL `LIMIT` clause always reflects this cap. pub const FEED_MAX_LIMIT: i64 = 100; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::postgres::PgRow; use sqlx::{PgPool, QueryBuilder}; @@ -41,8 +42,8 @@ use buzz_core::kind::{ }; use buzz_core::{CommunityId, StoredEvent}; -use crate::error::Result; use crate::event::row_to_stored_event; +use crate::{error::Result, Db, RouteDecision, RoutePredicate}; /// Column list shared by every feed subquery that aliases the `events` table as `e`. const EVENT_COLS: &str = @@ -303,6 +304,235 @@ pub(crate) async fn query_activity_on( collect_stored_events(rows) } +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Find events that @mention the given pubkey. + #[datastore_span(name = "query_feed_mentions", system = "postgresql")] + pub async fn query_feed_mentions( + &self, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + crate::feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + + /// [`Db::query_feed_mentions`] with replica routing — same contract and + /// classification-table requirement as [`Db::query_events_routed`]. + /// + /// Feed queries route on the BOUNDED arm only: the `accessible_channel_ids` + /// parameter admits community-global rows alongside channel rows, so no + /// single channel's fence floor can prove completeness — the covered arm + /// is structurally unavailable, not merely unchosen. + #[datastore_span(name = "query_feed_mentions_routed", system = "postgresql")] + pub async fn query_feed_mentions_routed( + &self, + path: &'static str, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_mentions_on( + &mut tx, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + }, + RouteDecision::Writer => { + crate::feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + + /// Find events that require action from the given pubkey. + #[datastore_span(name = "query_feed_needs_action", system = "postgresql")] + pub async fn query_feed_needs_action( + &self, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + crate::feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + + /// [`Db::query_feed_needs_action`] with replica routing — BOUNDED arm + /// only; see [`Db::query_feed_mentions_routed`] for why the covered arm + /// is structurally unavailable to feed queries. + #[datastore_span(name = "query_feed_needs_action_routed", system = "postgresql")] + pub async fn query_feed_needs_action_routed( + &self, + path: &'static str, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::feed::query_needs_action_on( + &mut tx, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + RouteDecision::Writer => { + crate::feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + + /// Find recent activity across accessible channels. + #[datastore_span(name = "query_feed_activity", system = "postgresql")] + pub async fn query_feed_activity( + &self, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + crate::feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) + .await + } + + /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; + /// see [`Db::query_feed_mentions_routed`] for why the covered arm is + /// structurally unavailable to feed queries. + #[datastore_span(name = "query_feed_activity_routed", system = "postgresql")] + pub async fn query_feed_activity_routed( + &self, + path: &'static str, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => match crate::feed::query_activity_on( + &mut tx, + community, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + crate::feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + limit, + ) + .await + } + }, + RouteDecision::Writer => { + crate::feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] @@ -904,8 +1134,19 @@ mod tests { // 11,000 rows x 6 binds = 66,000 > 65,535: overflows a single statement. let mention_count = 11_000usize; + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role) \ + SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member' \ + FROM generate_series(1, $3) n", + ) + .bind(community.as_uuid()) + .bind(channel) + .bind(mention_count as i64) + .execute(&pool) + .await + .expect("insert canonical roster members"); let tags: Vec = (1..=mention_count) - .map(|n| Tag::parse(["p", &format!("{n:064x}")]).expect("p tag")) + .map(|n| Tag::parse(["p", &format!("{n:064x}"), "", "member"]).expect("p tag")) .collect(); let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await; diff --git a/crates/buzz-db/src/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs similarity index 87% rename from crates/buzz-db/src/git_repo.rs rename to crates/buzz-db/src/store/git_repo.rs index c1e47c0f8cc..5afea1e4fda 100644 --- a/crates/buzz-db/src/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -16,10 +16,11 @@ //! idempotent re-announce (same owner) from a collision (different owner), and //! backs the per-pubkey quota via `COUNT`. +use buzz_datastore_tracing::datastore_span; use sqlx::{PgPool, Row as _}; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, Db}; /// Outcome of a name-reservation attempt. /// @@ -179,12 +180,62 @@ pub async fn release_repo_name( Ok(result.rows_affected()) } +impl Db { + /// Return the current owner of git repo name `repo_id` in `community`, or + /// `None` if unreserved. See [`repo_name_owner`]. + #[datastore_span(name = "repo_name_owner", system = "postgresql")] + pub async fn repo_name_owner( + &self, + community: CommunityId, + repo_id: &str, + ) -> Result> { + repo_name_owner(&self.pool, community, repo_id).await + } + + /// Reserve a git repo name for `owner_pubkey` in `community` (NIP-34). + /// + /// See [`reserve_repo_name`] for the outcome semantics. The per-pubkey + /// quota is enforced by the caller against `count_repos_for_owner`. + #[datastore_span(name = "reserve_repo_name", system = "postgresql")] + pub async fn reserve_repo_name( + &self, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result { + reserve_repo_name(&self.pool, community, repo_id, owner_pubkey).await + } + + /// Count git repos reserved by `owner_pubkey` in `community` (quota check). + #[datastore_span(name = "count_repos_for_owner", system = "postgresql")] + pub async fn count_repos_for_owner( + &self, + community: CommunityId, + owner_pubkey: &str, + ) -> Result { + count_repos_for_owner(&self.pool, community, owner_pubkey).await + } + + /// Release a git repo name reservation held by `owner_pubkey` (rollback). + /// + /// Returns the number of rows removed (0 or 1). See [`release_repo_name`]. + #[datastore_span(name = "release_repo_name", system = "postgresql")] + pub async fn release_repo_name( + &self, + community: CommunityId, + repo_id: &str, + owner_pubkey: &str, + ) -> Result { + release_repo_name(&self.pool, community, repo_id, owner_pubkey).await + } +} + #[cfg(test)] mod tests { use super::*; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs new file mode 100644 index 00000000000..1fa1273eb0f --- /dev/null +++ b/crates/buzz-db/src/store/mod.rs @@ -0,0 +1,56 @@ +//! Domain-owned persistence implementations. + +/// Explicit deployment-global admin report reads. +pub mod admin_moderation; +/// Community-scoped authentication allowlist persistence. +pub mod allowlist; +/// API token storage and lookup. +pub mod api_token; +/// Relay-scoped archived identity persistence (NIP-IA). +pub mod archived_identities; +/// Channel lifecycle and metadata persistence. +pub mod channel; +/// Channel membership and roster persistence. +pub mod channel_members; +/// Community lifecycle and host-map persistence. +pub mod community; +/// Durable whole-community deletion lifecycle and PostgreSQL adapter. +pub mod deletion; +/// Direct message channel persistence. +pub mod dm; +/// Event storage and retrieval. +pub mod event; +/// Home feed queries. +pub mod feed; +/// Git repository name registry (NIP-34 kind:30617). +pub mod git_repo; +/// Community moderation: reports, bans/timeouts, audit actions. +pub mod moderation; +/// Monthly table partition management. +pub mod partition; +/// Buzz product-feedback sidecar persistence. +pub mod product_feedback; +/// Community-scoped push lease and durable wake-outbox persistence. +pub mod push; +/// Reaction persistence. +pub mod reaction; +/// HTTP report-resolution enforcement state machine persistence. +pub mod relay_admin_actions; +/// Use-limited relay invite persistence (v2 opaque tokens). +pub mod relay_invite; +/// Relay-level membership persistence (NIP-43). +pub mod relay_members; +/// Deployment-global relay operator/moderator roster persistence. +pub mod relay_operators; +/// Event-reminder delivery query, claim, and release persistence. +pub mod reminder; +/// Replaceable-event persistence and coordinate locking. +pub mod replaceable; +/// Thread metadata persistence. +pub mod thread; +/// Per-community usage rollup queries for Prometheus gauges. +pub mod usage; +/// User profile persistence. +pub mod user; +/// Workflow, run, and approval persistence. +pub mod workflow; diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/store/moderation.rs similarity index 85% rename from crates/buzz-db/src/moderation.rs rename to crates/buzz-db/src/store/moderation.rs index 7146886e3e8..5ac7c93af9a 100644 --- a/crates/buzz-db/src/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -14,12 +14,13 @@ //! Lane ownership: L1 (Max). Signatures below are the contract; changes go //! through the integration thread. +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, Db}; /// What a report points at. Exactly one target class per report row. #[derive(Debug, Clone, PartialEq, Eq)] @@ -651,13 +652,174 @@ fn row_to_action(row: sqlx::postgres::PgRow) -> Result { }) } +impl Db { + /// Insert a tenant-scoped NIP-56 report row, idempotent by report event id. + #[datastore_span(name = "insert_moderation_report", system = "postgresql")] + pub async fn insert_moderation_report( + &self, + community: CommunityId, + report: NewReport<'_>, + ) -> Result { + insert_report(&self.pool, community, report).await + } + + /// List moderation reports for a community, newest first. + #[datastore_span(name = "list_moderation_reports", system = "postgresql")] + pub async fn list_moderation_reports( + &self, + community: CommunityId, + status: Option<&str>, + limit: i64, + ) -> Result> { + list_reports(&self.pool, community, status, limit).await + } + + /// Fetch one moderation report by row id. + #[datastore_span(name = "get_moderation_report", system = "postgresql")] + pub async fn get_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + ) -> Result> { + get_report(&self.pool, community, report_id).await + } + + /// Fetch one moderation report by signed NIP-56 report event id. + #[datastore_span(name = "get_moderation_report_by_event", system = "postgresql")] + pub async fn get_moderation_report_by_event( + &self, + community: CommunityId, + report_event_id: &[u8], + ) -> Result> { + get_report_by_event(&self.pool, community, report_event_id).await + } + + /// Resolve, dismiss, or escalate an open moderation report. + #[datastore_span(name = "resolve_moderation_report", system = "postgresql")] + pub async fn resolve_moderation_report( + &self, + community: CommunityId, + report_id: Uuid, + status: &str, + resolved_by: &[u8], + action_id: Option, + ) -> Result { + resolve_report( + &self.pool, + community, + report_id, + status, + resolved_by, + action_id, + ) + .await + } + + /// Upsert a community ban for a member pubkey. + #[datastore_span(name = "ban_community_member", system = "postgresql")] + pub async fn ban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + reason: Option<&str>, + expires_at: Option>, + ) -> Result<()> { + ban_member(&self.pool, community, pubkey, actor, reason, expires_at).await + } + + /// Lift a community ban for a member pubkey. + #[datastore_span(name = "unban_community_member", system = "postgresql")] + pub async fn unban_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + unban_member(&self.pool, community, pubkey, actor).await + } + + /// Upsert a community timeout/write-block for a member pubkey. + #[datastore_span(name = "timeout_community_member", system = "postgresql")] + pub async fn timeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + muted_until: DateTime, + reason: Option<&str>, + ) -> Result<()> { + timeout_member(&self.pool, community, pubkey, actor, muted_until, reason).await + } + + /// Clear a community timeout/write-block for a member pubkey. + #[datastore_span(name = "untimeout_community_member", system = "postgresql")] + pub async fn untimeout_community_member( + &self, + community: CommunityId, + pubkey: &[u8], + actor: &[u8], + ) -> Result { + untimeout_member(&self.pool, community, pubkey, actor).await + } + + /// Fetch the active ban/timeout restriction state for enforcement hot paths. + #[datastore_span(name = "moderation_restriction_state", system = "postgresql")] + pub async fn moderation_restriction_state( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result { + restriction_state(&self.pool, community, pubkey).await + } + + /// Fetch the full ban/timeout row for a member pubkey. + #[datastore_span(name = "get_community_ban", system = "postgresql")] + pub async fn get_community_ban( + &self, + community: CommunityId, + pubkey: &[u8], + ) -> Result> { + get_ban(&self.pool, community, pubkey).await + } + + /// List currently restricted members in a community. + #[datastore_span(name = "list_community_restrictions", system = "postgresql")] + pub async fn list_community_restrictions( + &self, + community: CommunityId, + ) -> Result> { + list_restricted(&self.pool, community).await + } + + /// Insert a moderation audit action row. + #[datastore_span(name = "insert_moderation_action", system = "postgresql")] + pub async fn insert_moderation_action( + &self, + community: CommunityId, + action: NewAction<'_>, + ) -> Result { + insert_action(&self.pool, community, action).await + } + + /// List moderation audit action rows, newest first. + #[datastore_span(name = "list_moderation_actions", system = "postgresql")] + pub async fn list_moderation_actions( + &self, + community: CommunityId, + limit: i64, + ) -> Result> { + list_actions(&self.pool, community, limit).await + } +} + #[cfg(test)] mod tests { use super::*; use chrono::Duration; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/partition.rs b/crates/buzz-db/src/store/partition.rs similarity index 94% rename from crates/buzz-db/src/partition.rs rename to crates/buzz-db/src/store/partition.rs index b3803f1b34c..ba252f71f4a 100644 --- a/crates/buzz-db/src/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -2,11 +2,13 @@ //! //! Call `ensure_future_partitions` on startup and monthly via cron. +use buzz_datastore_tracing::datastore_span; use chrono::{Datelike, TimeZone, Utc}; use sqlx::{PgPool, Row}; use tracing::info; use crate::error::{DbError, Result}; +use crate::Db; /// Tables that may be partition-managed. Allowlist prevents DDL injection. const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"]; @@ -55,6 +57,14 @@ pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Resul Ok(()) } +impl Db { + /// Ensures monthly partitions exist for the next N months. + #[datastore_span(name = "ensure_future_partitions", system = "postgresql")] + pub async fn ensure_future_partitions(&self, months_ahead: u32) -> Result<()> { + ensure_future_partitions(&self.pool, months_ahead).await + } +} + /// Validate that a partition suffix is digits and underscores only. fn validate_partition_suffix(suffix: &str) -> bool { !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit() || c == '_') diff --git a/crates/buzz-db/src/product_feedback.rs b/crates/buzz-db/src/store/product_feedback.rs similarity index 89% rename from crates/buzz-db/src/product_feedback.rs rename to crates/buzz-db/src/store/product_feedback.rs index 1a9f45e62b3..8a0ef36bea5 100644 --- a/crates/buzz-db/src/product_feedback.rs +++ b/crates/buzz-db/src/store/product_feedback.rs @@ -3,12 +3,13 @@ //! Feedback retains its source [`CommunityId`] as provenance, but is not a //! community moderation concern and is never inserted into the events table. +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::{PgPool, Row as _}; use uuid::Uuid; -use crate::{error::Result, CommunityId}; +use crate::{error::Result, CommunityId, Db}; /// Validated fields from an accepted product-feedback event. #[derive(Debug, Clone)] @@ -117,6 +118,24 @@ pub async fn list(pool: &PgPool, limit: i64) -> Result, + ) -> Result { + insert(&self.pool, community, feedback).await + } + + /// List product feedback across the deployment, newest first. + #[datastore_span(name = "list_product_feedback", system = "postgresql")] + pub async fn list_product_feedback(&self, limit: i64) -> Result> { + list(&self.pool, limit).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/store/push.rs similarity index 93% rename from crates/buzz-db/src/push.rs rename to crates/buzz-db/src/store/push.rs index 3aa6cd9b3fe..fc94843a9c2 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -11,6 +11,8 @@ use sqlx::{PgPool, Row as _}; use uuid::Uuid; use crate::error::Result; +use crate::Db; +use buzz_datastore_tracing::datastore_span; /// Namespace for the per-community push-gate advisory lock. Must match the /// key built inside the `enqueue_push_match_job` trigger (migration 0023): @@ -1278,6 +1280,177 @@ fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result { }) } +impl Db { + /// Exclusively claim a batch of due matcher jobs from one community. + #[datastore_span(name = "claim_due_push_match_batch", system = "postgresql")] + pub async fn claim_due_push_match_batch( + &self, + limit: i64, + lease_until: DateTime, + ) -> Result> { + crate::push::claim_due_match_batch(&self.pool, limit, lease_until).await + } + + /// Load active endpoint-enabled leases eligible for push matching. + #[datastore_span(name = "active_push_match_leases", system = "postgresql")] + pub async fn active_push_match_leases( + &self, + community: CommunityId, + ) -> Result> { + crate::push::active_match_leases(&self.pool, community).await + } + + /// Complete matcher jobs from one claimed batch while the fence holds. + #[datastore_span(name = "complete_push_match_batch", system = "postgresql")] + pub async fn complete_push_match_batch( + &self, + community: CommunityId, + claim_id: uuid::Uuid, + event_ids: &[Vec], + ) -> Result { + crate::push::complete_match_batch(&self.pool, community, claim_id, event_ids).await + } + + /// Release fenced matcher claims from one batch for retry. + #[datastore_span(name = "retry_push_match_batch", system = "postgresql")] + pub async fn retry_push_match_batch( + &self, + community: CommunityId, + claim_id: uuid::Uuid, + event_ids: &[Vec], + next: DateTime, + ) -> Result { + crate::push::retry_match_batch(&self.pool, community, claim_id, event_ids, next).await + } + + /// Delete exhausted matcher jobs (periodic sweep, off the claim path). + #[datastore_span(name = "reap_exhausted_push_matches", system = "postgresql")] + pub async fn reap_exhausted_push_matches(&self) -> Result { + crate::push::reap_exhausted_matches(&self.pool).await + } + + /// Idempotently enqueue a wake for a matched lease and event. + #[datastore_span(name = "enqueue_push_wake", system = "postgresql")] + pub async fn enqueue_push_wake( + &self, + community: CommunityId, + author: &[u8], + installation_id: &str, + wake: crate::push::NewWake<'_>, + ) -> Result { + crate::push::enqueue_wake(&self.pool, community, author, installation_id, wake).await + } + + /// Set-wise [`Self::enqueue_push_wake`]: one transaction per batch. + #[datastore_span(name = "enqueue_push_wakes", system = "postgresql")] + pub async fn enqueue_push_wakes( + &self, + community: CommunityId, + requests: &[crate::push::WakeRequest], + ) -> Result> { + crate::push::enqueue_wakes(&self.pool, community, requests).await + } + + /// Exclusively claim due wake jobs for one community. + #[datastore_span(name = "claim_due_push_wakes", system = "postgresql")] + pub async fn claim_due_push_wakes( + &self, + community: CommunityId, + limit: i64, + lease_until: DateTime, + ) -> Result> { + crate::push::claim_due_wakes(&self.pool, community, limit, lease_until).await + } + + /// Revalidate a wake's claim, source event, and current lease before send. + #[datastore_span(name = "revalidate_push_wake", system = "postgresql")] + pub async fn revalidate_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + crate::push::revalidate_wake_for_send(&self.pool, community, id, claim_id).await + } + + /// Mark a fenced wake claim delivered. + #[datastore_span(name = "complete_push_wake", system = "postgresql")] + pub async fn complete_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + crate::push::complete_wake(&self.pool, community, id, claim_id).await + } + + /// Release a fenced wake claim for retry at the supplied time. + #[datastore_span(name = "retry_push_wake", system = "postgresql")] + pub async fn retry_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + next: DateTime, + ) -> Result { + crate::push::retry_wake(&self.pool, community, id, claim_id, next).await + } + + /// Mark a fenced wake claim terminally failed. + #[datastore_span(name = "fail_push_wake", system = "postgresql")] + pub async fn fail_push_wake( + &self, + community: CommunityId, + id: Uuid, + claim_id: Uuid, + ) -> Result { + crate::push::fail_wake(&self.pool, community, id, claim_id).await + } + + /// Disable an endpoint only if the specified lease generation is current. + #[datastore_span(name = "disable_push_endpoint", system = "postgresql")] + pub async fn disable_push_endpoint( + &self, + community: CommunityId, + author: &[u8], + installation_id: &str, + generation: i64, + ) -> Result { + crate::push::disable_endpoint_generation( + &self.pool, + community, + author, + installation_id, + generation, + ) + .await + } + + /// Atomically persist a validated kind:30350 event and its effective lease. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "accept_push_lease_event", system = "postgresql")] + pub async fn accept_push_lease_event( + &self, + community: CommunityId, + event: &nostr::Event, + installation_id: &str, + version: crate::push::LeaseVersion<'_>, + active: Option>, + max_active_leases: i64, + ) -> Result { + crate::push::accept_lease_event( + &self.pool, + community, + event, + installation_id, + version, + active, + max_active_leases, + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs new file mode 100644 index 00000000000..1f14adf176d --- /dev/null +++ b/crates/buzz-db/src/store/reaction.rs @@ -0,0 +1,1149 @@ +//! Reaction persistence. +//! +//! One reaction per user per emoji per event. Soft-delete via removed_at. + +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use nostr::Event; +use sqlx::{PgPool, Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::{ + error::Result, + event::{insert_event_with_thread_metadata_tx, ThreadMetadataParams}, + Db, +}; +use buzz_core::{CommunityId, StoredEvent}; + +// -- Public structs ----------------------------------------------------------- + +/// Result of atomically inserting a kind:7 reaction event and its reaction row. +#[derive(Debug)] +pub enum ReactionEventInsertOutcome { + /// Target event was absent in this community, or was soft-deleted. No writes committed. + TargetMissing, + /// The active `(target, actor, emoji)` reaction already exists. No event was stored. + Duplicate, + /// Reaction row and event transaction committed. + Inserted { + /// Stored reaction event. + stored_event: Box, + /// Whether the event row itself was newly inserted. + was_inserted: bool, + }, +} + +/// A grouped set of reactions for a single emoji on an event. +#[derive(Debug, Clone)] +pub struct ReactionGroup { + /// The emoji character or shortcode used in this reaction group. + pub emoji: String, + /// Total number of active reactions with this emoji. + pub count: i64, + /// Individual users who reacted with this emoji. + pub users: Vec, +} + +/// A single user who reacted with a given emoji. +#[derive(Debug, Clone)] +pub struct ReactionUser { + /// Compressed 33-byte public key of the reacting user. + pub pubkey: Vec, + /// Optional display name resolved from the users table. + pub display_name: Option, + /// Nostr event ID of the kind:7 reaction event (raw bytes), if present. + /// Clients use this to build signed kind:5 deletion events for reaction removal. + pub reaction_event_id: Option>, +} + +/// Bulk reaction entry for embedding in message lists. +#[derive(Debug, Clone)] +pub struct BulkReactionEntry { + /// The event this reaction entry belongs to. + pub event_id: Vec, + /// Partition key timestamp for the event. + pub event_created_at: DateTime, + /// Emoji + count summaries for this event. + pub reactions: Vec, +} + +/// Emoji + count summary (no user list) for bulk fetches. +#[derive(Debug, Clone)] +pub struct ReactionSummary { + /// The emoji character or shortcode. + pub emoji: String, + /// Number of active reactions with this emoji. + pub count: i64, +} + +/// Active reaction row metadata for a specific actor + emoji + target tuple. +#[derive(Debug, Clone)] +pub struct ActiveReactionRecord { + /// Nostr event ID of the reaction event, if this row came from a real kind:7 event. + pub reaction_event_id: Option>, +} + +// -- Write operations --------------------------------------------------------- + +const ADD_REACTION_SQL: &str = r#" + INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET + created_at = NOW(), + removed_at = NULL, + reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id) + WHERE reactions.removed_at IS NOT NULL + "#; + +/// Add (or re-activate) a reaction. +/// +/// Returns `Ok(true)` if the reaction was added or re-activated, `Ok(false)` if +/// the reaction is already active (duplicate, no change made). +/// +/// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where +/// two concurrent adds both see no existing row and then race to INSERT. +pub async fn add_reaction( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: Option<&[u8]>, +) -> Result { + let result = sqlx::query(ADD_REACTION_SQL) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .bind(reaction_event_id) + .execute(pool) + .await?; + + // Three cases: + // (a) New reaction (no existing row): INSERT succeeds → rows_affected = 1 → true. + // (b) Reactivating (row exists, removed_at IS NOT NULL): WHERE matches → UPDATE fires + // → rows_affected = 1 → true. + // (c) Active duplicate (row exists, removed_at IS NULL): WHERE fails → no UPDATE + // → rows_affected = 0 → false. Caller should short-circuit and not store the event. + Ok(result.rows_affected() != 0) +} + +/// Add (or re-activate) a reaction inside an existing transaction. +/// +/// Uses the same `INSERT ... ON CONFLICT DO UPDATE ... WHERE removed_at IS NOT NULL` +/// statement as [`add_reaction`], preserving the new / re-activate / active-duplicate +/// semantics while letting callers atomically couple the reaction row to other writes. +pub(crate) async fn add_reaction_tx( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: Option<&[u8]>, +) -> Result { + let result = sqlx::query(ADD_REACTION_SQL) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .bind(reaction_event_id) + .execute(&mut **tx) + .await?; + + Ok(result.rows_affected() != 0) +} + +/// Atomically insert a kind:7 reaction event and its reaction row. +/// +/// Ordering is load-bearing: resolve target, upsert/reactivate the reaction row, +/// check `rows_affected`, then insert the kind:7 event. Active duplicates return +/// before event insertion so duplicate reactions never store a duplicate kind:7. +#[allow(clippy::too_many_arguments)] +pub async fn insert_reaction_event_with_thread_metadata( + pool: &PgPool, + community_id: CommunityId, + reaction_event: &Event, + channel_id: Option, + thread_meta: Option>, + target_event_id: &[u8], + actor_pubkey: &[u8], + emoji: &str, +) -> Result { + let mut tx = pool.begin().await?; + + let target_row = sqlx::query( + "SELECT created_at FROM events \ + WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(target_event_id) + .fetch_optional(&mut *tx) + .await?; + + let Some(target_row) = target_row else { + tx.rollback().await?; + return Ok(ReactionEventInsertOutcome::TargetMissing); + }; + let target_created_at: DateTime = target_row.get("created_at"); + + // Preserve add_reaction's exact new / re-activate / active-duplicate semantics. + let reaction_inserted = add_reaction_tx( + &mut tx, + community_id, + target_event_id, + target_created_at, + actor_pubkey, + emoji, + Some(reaction_event.id.as_bytes()), + ) + .await?; + + if !reaction_inserted { + tx.rollback().await?; + return Ok(ReactionEventInsertOutcome::Duplicate); + } + + let (stored_event, was_inserted) = insert_event_with_thread_metadata_tx( + &mut tx, + community_id, + reaction_event, + channel_id, + thread_meta, + ) + .await?; + + tx.commit().await?; + + Ok(ReactionEventInsertOutcome::Inserted { + stored_event: Box::new(stored_event), + was_inserted, + }) +} + +/// Soft-delete a reaction by setting `removed_at`. +/// +/// Returns `true` if a row was updated, `false` if not found or already removed. +pub async fn remove_reaction( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, +) -> Result { + let result = sqlx::query( + r#" + UPDATE reactions + SET removed_at = NOW() + WHERE community_id = $1 + AND event_created_at = $2 + AND event_id = $3 + AND pubkey = $4 + AND emoji = $5 + AND removed_at IS NULL + "#, + ) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Soft-delete a reaction by the reaction event's own ID. +/// +/// Returns `true` if a row was updated, `false` if not found or already removed. +pub async fn remove_reaction_by_source_event_id( + pool: &PgPool, + community: CommunityId, + reaction_event_id: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE reactions + SET removed_at = NOW() + WHERE community_id = $1 + AND reaction_event_id = $2 + AND removed_at IS NULL + "#, + ) + .bind(community.as_uuid()) + .bind(reaction_event_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Look up the active reaction row for one actor + emoji + target tuple. +pub async fn get_active_reaction_record( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, +) -> Result> { + let row = sqlx::query( + r#" + SELECT reaction_event_id + FROM reactions + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND pubkey = $4 + AND emoji = $5 + AND removed_at IS NULL + LIMIT 1 + "#, + ) + .bind(community.as_uuid()) + .bind(event_id) + .bind(event_created_at) + .bind(pubkey) + .bind(emoji) + .fetch_optional(pool) + .await?; + + row.map(|row| -> Result { + Ok(ActiveReactionRecord { + reaction_event_id: row.try_get("reaction_event_id")?, + }) + }) + .transpose() +} + +/// Backfill the source event ID on an active reaction row. +/// +/// Called after the kind:7 event is created and stored, to link the +/// reaction row to its source event. Returns `true` if the row was updated. +pub async fn set_reaction_event_id( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: &[u8], +) -> Result { + let result = sqlx::query( + r#" + UPDATE reactions + SET reaction_event_id = $1 + WHERE community_id = $2 + AND event_created_at = $3 + AND event_id = $4 + AND pubkey = $5 + AND emoji = $6 + AND removed_at IS NULL + "#, + ) + .bind(reaction_event_id) + .bind(community.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(pubkey) + .bind(emoji) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// -- Read operations ---------------------------------------------------------- + +/// Get all active reactions for an event, grouped by emoji. +/// +/// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting +/// user pubkeys. Display names are NOT resolved here -- callers should enrich via +/// scoped user lookups if needed. +/// +/// `cursor` is reserved for future keyset pagination (currently unused). +pub async fn get_reactions( + pool: &PgPool, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + limit: u32, + _cursor: Option<&str>, +) -> Result> { + // Two-step query: first get the limited set of distinct emoji groups, + // then fetch all rows for those groups. This ensures `limit` applies to + // emoji groups (the API contract), not raw rows — so one busy emoji + // cannot consume the entire page and hide other groups. + let rows = sqlx::query( + r#" + SELECT r.emoji, r.pubkey, r.reaction_event_id + FROM reactions r + INNER JOIN ( + SELECT DISTINCT emoji + FROM reactions + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND removed_at IS NULL + ORDER BY emoji + LIMIT $4 + ) g ON g.emoji = r.emoji + WHERE r.community_id = $1 + AND r.event_id = $2 + AND r.event_created_at = $3 + AND r.removed_at IS NULL + ORDER BY r.emoji, r.created_at + "#, + ) + .bind(community.as_uuid()) + .bind(event_id) + .bind(event_created_at) + .bind(limit as i64) + .fetch_all(pool) + .await?; + + // Group individual rows by emoji in Rust. + let mut groups: Vec = Vec::new(); + let mut current_emoji: Option = None; + let mut current_users: Vec = Vec::new(); + + for row in &rows { + let emoji: String = row.try_get("emoji")?; + let pubkey: Vec = row.try_get("pubkey")?; + let reaction_event_id: Option> = row.try_get("reaction_event_id")?; + + if current_emoji.as_ref() != Some(&emoji) { + if let Some(prev_emoji) = current_emoji.take() { + let count = current_users.len() as i64; + groups.push(ReactionGroup { + emoji: prev_emoji, + count, + users: std::mem::take(&mut current_users), + }); + } + current_emoji = Some(emoji); + } + + current_users.push(ReactionUser { + pubkey, + display_name: None, + reaction_event_id, + }); + } + + // Flush the final group. + if let Some(emoji) = current_emoji { + let count = current_users.len() as i64; + groups.push(ReactionGroup { + emoji, + count, + users: current_users, + }); + } + + Ok(groups) +} + +/// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. +/// +/// Returns one [`BulkReactionEntry`] per input pair that has at least one +/// active reaction. Pairs with no reactions are omitted. +pub async fn get_reactions_bulk( + pool: &PgPool, + community: CommunityId, + event_ids: &[(&[u8], DateTime)], +) -> Result> { + if event_ids.is_empty() { + return Ok(Vec::new()); + } + + // Run one query per event. For typical message-list sizes (<=100 events) + // this is acceptable; a single-query approach with dynamic IN clauses over + // composite keys can be added later if needed. + let mut entries = Vec::new(); + + for (event_id, event_created_at) in event_ids { + let rows = sqlx::query( + r#" + SELECT emoji, COUNT(*) AS count + FROM reactions + WHERE community_id = $1 + AND event_id = $2 + AND event_created_at = $3 + AND removed_at IS NULL + GROUP BY emoji + ORDER BY emoji + "#, + ) + .bind(community.as_uuid()) + .bind(*event_id) + .bind(event_created_at) + .fetch_all(pool) + .await?; + + if rows.is_empty() { + continue; + } + + let mut reactions = Vec::with_capacity(rows.len()); + for row in rows { + let emoji: String = row.try_get("emoji")?; + let count: i64 = row.try_get("count")?; + reactions.push(ReactionSummary { emoji, count }); + } + + entries.push(BulkReactionEntry { + event_id: event_id.to_vec(), + event_created_at: *event_created_at, + reactions, + }); + } + + Ok(entries) +} + +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Atomically insert a kind:7 reaction event and its reaction row. + #[allow(clippy::too_many_arguments)] + #[datastore_span( + name = "insert_reaction_event_with_thread_metadata", + system = "postgresql" + )] + pub async fn insert_reaction_event_with_thread_metadata( + &self, + community_id: CommunityId, + event: &nostr::Event, + channel_id: Option, + thread_meta: Option>, + target_event_id: &[u8], + actor_pubkey: &[u8], + emoji: &str, + ) -> Result { + let outcome = crate::reaction::insert_reaction_event_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + target_event_id, + actor_pubkey, + emoji, + ) + .await?; + if let ReactionEventInsertOutcome::Inserted { + was_inserted: true, .. + } = &outcome + { + if let Err(e) = + crate::insert_mentions(&self.pool, community_id, event, channel_id).await + { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + Ok(outcome) + } + + /// Add (or re-activate) a reaction. + #[datastore_span(name = "add_reaction", system = "postgresql")] + pub async fn add_reaction( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: Option<&[u8]>, + ) -> Result { + crate::reaction::add_reaction( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + reaction_event_id, + ) + .await + } + + /// Soft-delete a reaction. + #[datastore_span(name = "remove_reaction", system = "postgresql")] + pub async fn remove_reaction( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + ) -> Result { + crate::reaction::remove_reaction( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await + } + + /// Soft-delete a reaction by its source event ID. + #[datastore_span(name = "remove_reaction_by_source_event_id", system = "postgresql")] + pub async fn remove_reaction_by_source_event_id( + &self, + community: CommunityId, + reaction_event_id: &[u8], + ) -> Result { + crate::reaction::remove_reaction_by_source_event_id( + &self.pool, + community, + reaction_event_id, + ) + .await + } + + /// Look up the active reaction row for one actor + emoji + target tuple. + #[datastore_span(name = "get_active_reaction_record", system = "postgresql")] + pub async fn get_active_reaction_record( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + ) -> Result> { + crate::reaction::get_active_reaction_record( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + ) + .await + } + + /// Backfill the source event ID on an active reaction row. + #[datastore_span(name = "set_reaction_event_id", system = "postgresql")] + pub async fn set_reaction_event_id( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + pubkey: &[u8], + emoji: &str, + reaction_event_id: &[u8], + ) -> Result { + crate::reaction::set_reaction_event_id( + &self.pool, + community, + event_id, + event_created_at, + pubkey, + emoji, + reaction_event_id, + ) + .await + } + + /// Get all active reactions for an event, grouped by emoji. + #[datastore_span(name = "get_reactions", system = "postgresql")] + pub async fn get_reactions( + &self, + community: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + limit: u32, + cursor: Option<&str>, + ) -> Result> { + crate::reaction::get_reactions( + &self.pool, + community, + event_id, + event_created_at, + limit, + cursor, + ) + .await + } + + /// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs. + #[datastore_span(name = "get_reactions_bulk", system = "postgresql")] + pub async fn get_reactions_bulk( + &self, + community: CommunityId, + event_ids: &[(&[u8], DateTime)], + ) -> Result> { + crate::reaction::get_reactions_bulk(&self.pool, community, event_ids).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + error::DbError, + event::{get_event_by_id, insert_event}, + }; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("reaction-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + fn make_text_event(content: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(9), content) + .sign_with_keys(&Keys::generate()) + .expect("sign text event") + } + + fn make_reaction_event(keys: &Keys, target_id_hex: &str, emoji: &str) -> nostr::Event { + let nonce = Uuid::new_v4().to_string(); + EventBuilder::new(Kind::Custom(7), emoji) + .tags(vec![ + Tag::parse(["e", target_id_hex]).expect("reaction e tag"), + Tag::parse(["nonce", nonce.as_str()]).expect("nonce tag"), + ]) + .sign_with_keys(keys) + .expect("sign reaction event") + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_stores_wrapped_max_shortcode() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("long custom emoji target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let emoji = format!(":{}:", "a".repeat(64)); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &reaction, + None, + None, + target.id.as_bytes(), + &actor.public_key().to_bytes(), + &emoji, + ) + .await + .expect("store wrapped 64-character shortcode"); + + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + assert_eq!(emoji.chars().count(), 66); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("reaction target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let target_hex = target.id.to_hex(); + let first = make_reaction_event(&actor, &target_hex, "👍"); + let second = make_reaction_event(&actor, &target_hex, "👍"); + + let first_outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &first, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("first reaction insert"); + assert!(matches!( + first_outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + + let duplicate = insert_reaction_event_with_thread_metadata( + &pool, + community, + &second, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("duplicate reaction insert"); + assert!(matches!(duplicate, ReactionEventInsertOutcome::Duplicate)); + + let duplicate_event = get_event_by_id(&pool, community, second.id.as_bytes()) + .await + .expect("lookup duplicate reaction event"); + assert!( + duplicate_event.is_none(), + "active duplicate reaction must short-circuit before storing kind:7 event" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_cross_community_target_rejected() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("community A target only"); + insert_event(&pool, community_a, &target, None) + .await + .expect("insert target in A"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), "👍"); + + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community_b, + &reaction, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("cross-community reaction attempt"); + assert!(matches!(outcome, ReactionEventInsertOutcome::TargetMissing)); + + assert!( + get_event_by_id(&pool, community_b, reaction.id.as_bytes()) + .await + .expect("lookup B reaction event") + .is_none(), + "reaction event must not store when target exists only in another community" + ); + assert!( + crate::reaction::get_active_reaction_record( + &pool, + community_b, + target.id.as_bytes(), + DateTime::from_timestamp(target.created_at.as_secs() as i64, 0).unwrap(), + &actor_pubkey, + "👍", + ) + .await + .expect("lookup B reaction row") + .is_none(), + "reaction row must not be inserted for cross-community target miss" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_event_insert_failure_rolls_back_reaction() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("rollback target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let target_hex = target.id.to_hex(); + let bad_reaction = EventBuilder::new(Kind::Custom(20000), "👍") + .tags(vec![ + Tag::parse(["e", target_hex.as_str()]).expect("reaction e tag") + ]) + .sign_with_keys(&actor) + .expect("sign ephemeral reaction-shaped event"); + let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) + .expect("target timestamp"); + + let err = insert_reaction_event_with_thread_metadata( + &pool, + community, + &bad_reaction, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect_err("ephemeral event insert must fail after reaction upsert attempt"); + assert!(matches!(err, DbError::EphemeralEventRejected(20000))); + + assert!( + crate::reaction::get_active_reaction_record( + &pool, + community, + target.id.as_bytes(), + target_created_at, + &actor_pubkey, + "👍", + ) + .await + .expect("lookup reaction row after rollback") + .is_none(), + "transaction rollback must remove the reaction row when event insert fails" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_reactivates_soft_deleted_reaction() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("reactivation target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let actor_pubkey = actor.public_key().to_bytes(); + let target_hex = target.id.to_hex(); + let target_created_at = DateTime::from_timestamp(target.created_at.as_secs() as i64, 0) + .expect("target timestamp"); + let first = make_reaction_event(&actor, &target_hex, "👍"); + let second = make_reaction_event(&actor, &target_hex, "👍"); + + assert!(matches!( + insert_reaction_event_with_thread_metadata( + &pool, + community, + &first, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("first reaction insert"), + ReactionEventInsertOutcome::Inserted { .. } + )); + assert!(crate::reaction::remove_reaction( + &pool, + community, + target.id.as_bytes(), + target_created_at, + &actor_pubkey, + "👍", + ) + .await + .expect("soft delete reaction")); + + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &second, + None, + None, + target.id.as_bytes(), + &actor_pubkey, + "👍", + ) + .await + .expect("reactivate reaction"); + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + + let active = crate::reaction::get_active_reaction_record( + &pool, + community, + target.id.as_bytes(), + target_created_at, + &actor_pubkey, + "👍", + ) + .await + .expect("active record after reactivation") + .expect("reaction active after reactivation"); + assert_eq!( + active.reaction_event_id.as_deref(), + Some(second.id.as_bytes().as_slice()), + "reactivation through the tx path must preserve add_reaction's source-id update semantics" + ); + } + + /// BUG-5 regression: the `reactions` table is community-scoped + /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a + /// reaction added under community A must be invisible and unremovable from + /// community B — even for the *identical* `(event_id, pubkey, emoji)` shape. + /// Before the fix, `add_reaction` omitted `community_id` (NOT NULL → 500) and + /// every read/remove filtered `event_id` only (latent cross-tenant bleed). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reactions_are_scoped_to_community() { + let pool = setup_pool().await; + let db = Db::from_pool(pool.clone()); + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + + // Identical referenced-event shape across both tenants. + let event_id = [0xABu8; 32]; + let event_created_at = Utc::now(); + let pubkey = [7u8; 32]; + let emoji = "👍"; + + // (1) Add succeeds under A (this INSERT 500'd before the fix). + assert!( + db.add_reaction( + community_a, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("add reaction under A"), + "first reaction under A must be inserted" + ); + // Idempotent: re-adding the same active reaction is a no-op. + assert!( + !db.add_reaction( + community_a, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("duplicate reaction under A"), + "active duplicate under A must not re-insert" + ); + + // (2) Visible on A, invisible on B (grouped read path). + let groups_a = db + .get_reactions(community_a, &event_id, event_created_at, 100, None) + .await + .expect("get reactions A"); + assert_eq!(groups_a.len(), 1, "A must see its own reaction group"); + assert_eq!(groups_a[0].emoji, emoji); + assert_eq!(groups_a[0].count, 1); + + let groups_b = db + .get_reactions(community_b, &event_id, event_created_at, 100, None) + .await + .expect("get reactions B"); + assert!( + groups_b.is_empty(), + "B must NOT see A's reaction for the same event shape, got {groups_b:?}" + ); + + // (3) Active-record lookup is scoped: present on A, absent on B. + assert!( + db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record A") + .is_some(), + "A's active reaction record must be present" + ); + assert!( + db.get_active_reaction_record(community_b, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record B") + .is_none(), + "B must not find A's active reaction record" + ); + + // (4) B can add the identical shape independently (no PK collision). + assert!( + db.add_reaction( + community_b, + &event_id, + event_created_at, + &pubkey, + emoji, + None + ) + .await + .expect("add reaction under B"), + "B must be able to add the same shape as its own scoped row" + ); + + // (5) Removing from B does not touch A's row. + assert!( + db.remove_reaction(community_b, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("remove under B"), + "B remove must affect B's own row" + ); + assert!( + db.get_active_reaction_record(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("active record A after B remove") + .is_some(), + "A's reaction must survive a B-side removal" + ); + + // (6) A remove affects only A; A's read now empty. + assert!( + db.remove_reaction(community_a, &event_id, event_created_at, &pubkey, emoji) + .await + .expect("remove under A"), + "A remove must affect A's row" + ); + let groups_a_after = db + .get_reactions(community_a, &event_id, event_created_at, 100, None) + .await + .expect("get reactions A after remove"); + assert!( + groups_a_after.is_empty(), + "A's reaction must be gone after A removes it" + ); + } +} diff --git a/crates/buzz-db/src/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs similarity index 89% rename from crates/buzz-db/src/relay_admin_actions.rs rename to crates/buzz-db/src/store/relay_admin_actions.rs index 9835c526247..438543da583 100644 --- a/crates/buzz-db/src/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -10,6 +10,7 @@ //! //! Lane ownership: relay admin API (Duncan). +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use uuid::Uuid; @@ -1654,6 +1655,393 @@ fn row_to_outbox_claimed(row: sqlx::postgres::PgRow) -> Result { }) } +impl crate::Db { + /// Atomic decision-only report closure: CAS open→terminal + audit row in one transaction. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "resolve_report_decision_atomic", system = "postgresql")] + pub async fn resolve_report_decision_atomic( + &self, + community_id: CommunityId, + report_id: uuid::Uuid, + terminal_status: &str, + audit_action: &str, + actor_pubkey: &[u8], + actor_authority: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + channel_id: Option, + reason: Option<&str>, + ) -> Result { + resolve_report_decision_atomic( + &self.pool, + community_id, + report_id, + terminal_status, + audit_action, + actor_pubkey, + actor_authority, + target_pubkey, + target_event_id, + channel_id, + reason, + ) + .await + } + + /// Attempt to claim a report for HTTP enforcement (CAS open → processing). + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "claim_report_for_enforcement", system = "postgresql")] + pub async fn claim_report_for_enforcement( + &self, + community_id: CommunityId, + report_id: uuid::Uuid, + request_id: uuid::Uuid, + actor_pubkey: &[u8], + actor_role: &str, + action: &str, + reason: Option<&str>, + timeout_until: Option>, + audit_action: &str, + actor_authority: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + channel_id: Option, + ) -> Result { + claim_report( + &self.pool, + community_id, + report_id, + request_id, + actor_pubkey, + actor_role, + action, + reason, + timeout_until, + audit_action, + actor_authority, + target_pubkey, + target_event_id, + channel_id, + ) + .await + } + + /// Advance an action from 'pending' to 'enforcing'. + #[datastore_span(name = "begin_enforcing_action", system = "postgresql")] + pub async fn begin_enforcing_action(&self, action_id: uuid::Uuid) -> Result { + begin_enforcing(&self.pool, action_id).await + } + + /// Commit the core mutation step (advance step_marker to 'mutation_committed'). + #[datastore_span(name = "commit_action_mutation_step", system = "postgresql")] + pub async fn commit_action_mutation_step(&self, action_id: uuid::Uuid) -> Result { + commit_mutation_step(&self.pool, action_id).await + } + + /// Finalize enforcement: action → succeeded, report → terminal status, + /// and enqueue outbox delivery rows atomically. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "finalize_action_success", system = "postgresql")] + pub async fn finalize_action_success( + &self, + action_id: uuid::Uuid, + community_id: CommunityId, + report_id: uuid::Uuid, + terminal_status: &str, + actor_pubkey: &[u8], + action_name: &str, + target_pubkey: Option<&[u8]>, + target_event_id: Option<&[u8]>, + channel_id: Option, + reason: Option<&str>, + timeout_until: Option>, + ) -> Result { + finalize_success( + &self.pool, + action_id, + community_id, + report_id, + terminal_status, + actor_pubkey, + action_name, + target_pubkey, + target_event_id, + channel_id, + reason, + timeout_until, + ) + .await + } + + /// Atomically execute a ban mutation and commit the step marker. + /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. + #[datastore_span(name = "execute_ban_with_marker", system = "postgresql")] + pub async fn execute_ban_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + reason: Option<&str>, + ) -> Result { + execute_ban_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + target_pubkey, + actor_pubkey, + reason, + ) + .await + } + + /// Atomically execute a timeout mutation and commit the step marker. + /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "execute_timeout_with_marker", system = "postgresql")] + pub async fn execute_timeout_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + until: chrono::DateTime, + reason: Option<&str>, + ) -> Result { + execute_timeout_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + target_pubkey, + actor_pubkey, + until, + reason, + ) + .await + } + + /// Atomically execute a kick mutation and commit the step marker. + /// Returns `Removed` (member was present), `AlreadyGone` (absent before this action), + /// or `AlreadyMarked` (marker already committed by another driver or lease lost). + #[datastore_span(name = "execute_kick_with_marker", system = "postgresql")] + pub async fn execute_kick_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + channel_id: uuid::Uuid, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + execute_kick_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + channel_id, + target_pubkey, + actor_pubkey, + ) + .await + } + + /// Atomically execute a soft-delete mutation and commit the step marker. + /// Returns `true` if this driver committed the marker, `false` if already marked or lease lost. + #[datastore_span(name = "execute_delete_with_marker", system = "postgresql")] + pub async fn execute_delete_with_marker( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + community_id: CommunityId, + target_event_id: &[u8], + parent_event_id: Option<&[u8]>, + root_event_id: Option<&[u8]>, + ) -> Result { + execute_delete_with_marker( + &self.pool, + action_id, + lease_token, + community_id, + target_event_id, + parent_event_id, + root_event_id, + ) + .await + } + + /// Acquire the action mutation lease (prevents concurrent double-mutation). + #[datastore_span(name = "acquire_admin_action_lease", system = "postgresql")] + pub async fn acquire_admin_action_lease( + &self, + action_id: uuid::Uuid, + lease_until: chrono::DateTime, + ) -> Result { + acquire_action_lease(&self.pool, action_id, lease_until).await + } + + /// Release the action mutation lease. No-op if caller no longer holds the token. + #[datastore_span(name = "release_admin_action_lease", system = "postgresql")] + pub async fn release_admin_action_lease( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + ) -> Result<()> { + release_action_lease(&self.pool, action_id, lease_token).await + } + + /// Claim a batch of stranded `relay_admin_actions` for the action recovery worker. + #[datastore_span(name = "claim_stranded_admin_action_batch", system = "postgresql")] + pub async fn claim_stranded_admin_action_batch( + &self, + worker_id: &str, + lease_until: chrono::DateTime, + batch_size: i64, + ) -> Result> { + claim_stranded_action_batch(&self.pool, worker_id, lease_until, batch_size).await + } + + /// Record a pre-mutation enforcement failure (keeps report in 'processing'). + #[datastore_span(name = "record_action_failure", system = "postgresql")] + pub async fn record_action_failure( + &self, + action_id: uuid::Uuid, + lease_token: uuid::Uuid, + error: &str, + ) -> Result { + record_failure(&self.pool, action_id, lease_token, error).await + } + + /// Cancel a pre-mutation failed action (returns report to 'open'), + /// attributing the cancel to `cancelled_by`. + #[datastore_span(name = "cancel_admin_action", system = "postgresql")] + pub async fn cancel_admin_action( + &self, + action_id: uuid::Uuid, + community_id: CommunityId, + report_id: uuid::Uuid, + cancelled_by: &[u8], + ) -> Result { + cancel_action(&self.pool, action_id, community_id, report_id, cancelled_by).await + } + + /// Reopen a terminal report (resolved|dismissed|escalated → open) with a + /// durable `reopen` audit row, keyed idempotent on `request_id`. + #[datastore_span(name = "reopen_report", system = "postgresql")] + pub async fn reopen_report( + &self, + community_id: CommunityId, + report_id: uuid::Uuid, + request_id: uuid::Uuid, + actor_pubkey: &[u8], + actor_role: &str, + reason: Option<&str>, + ) -> Result { + reopen_report( + &self.pool, + community_id, + report_id, + request_id, + actor_pubkey, + actor_role, + reason, + ) + .await + } + + /// Fetch an action record by ID. + #[datastore_span(name = "get_admin_action", system = "postgresql")] + pub async fn get_admin_action( + &self, + action_id: uuid::Uuid, + ) -> Result> { + get_action(&self.pool, action_id).await + } + + /// Enqueue an outbox artifact/notice delivery command. + #[datastore_span(name = "enqueue_admin_outbox", system = "postgresql")] + pub async fn enqueue_admin_outbox( + &self, + action_id: uuid::Uuid, + task_type: &str, + payload: serde_json::Value, + dedup_key: &str, + ) -> Result<()> { + enqueue_outbox(&self.pool, action_id, task_type, payload, dedup_key).await + } + + /// Mark an outbox record as delivered, fenced by the claim token. + /// Returns `true` if updated, `false` if ownership was already lost. + #[datastore_span(name = "mark_admin_outbox_delivered", system = "postgresql")] + pub async fn mark_admin_outbox_delivered( + &self, + outbox_id: uuid::Uuid, + claim_token: uuid::Uuid, + ) -> Result { + mark_outbox_delivered(&self.pool, outbox_id, claim_token).await + } + + /// Mark an outbox record as failed, fenced by the claim token. + /// Returns `true` if updated, `false` if ownership was already lost. + #[datastore_span(name = "fail_admin_outbox_row", system = "postgresql")] + pub async fn fail_admin_outbox_row( + &self, + outbox_id: uuid::Uuid, + claim_token: uuid::Uuid, + error: &str, + ) -> Result { + fail_outbox_row(&self.pool, outbox_id, claim_token, error).await + } + + /// Claim a batch of pending outbox rows for the given worker pod. + #[datastore_span(name = "claim_pending_admin_outbox_batch", system = "postgresql")] + pub async fn claim_pending_admin_outbox_batch( + &self, + worker_id: &str, + lease_until: chrono::DateTime, + batch_size: i64, + ) -> Result> { + claim_pending_outbox_batch(&self.pool, worker_id, lease_until, batch_size).await + } + + /// List pending outbox records for an action. + #[datastore_span(name = "list_pending_admin_outbox", system = "postgresql")] + pub async fn list_pending_admin_outbox( + &self, + action_id: uuid::Uuid, + ) -> Result> { + list_pending_outbox(&self.pool, action_id).await + } + + /// Deployment-authority kick: remove a member without requiring tenant owner/admin actor. + #[datastore_span(name = "deploy_kick_member", system = "postgresql")] + pub async fn deploy_kick_member( + &self, + community_id: CommunityId, + channel_id: uuid::Uuid, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + deploy_kick_member( + &self.pool, + community_id, + channel_id, + target_pubkey, + actor_pubkey, + ) + .await + } + + /// Update product_feedback status (operator-managed lifecycle). + #[datastore_span(name = "update_feedback_status", system = "postgresql")] + pub async fn update_feedback_status(&self, id: uuid::Uuid, status: &str) -> Result { + update_feedback_status(&self.pool, id, status).await + } +} + #[cfg(test)] mod tests { use super::*; @@ -1661,7 +2049,7 @@ mod tests { use sqlx::PgPool; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let url = diff --git a/crates/buzz-db/src/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs similarity index 94% rename from crates/buzz-db/src/relay_invite.rs rename to crates/buzz-db/src/store/relay_invite.rs index 14331b022f5..1424829933f 100644 --- a/crates/buzz-db/src/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -21,11 +21,12 @@ use buzz_core::invite::{ encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_SECRET_LEN, }; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; use crate::error::Result; -use crate::CommunityId; +use crate::{CommunityId, Db}; /// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are /// typed variants so the relay layer can map them to distinct HTTP responses @@ -380,6 +381,53 @@ pub async fn claim_relay_invite( }) } +impl Db { + /// Mints a v2 use-limited relay invite. The plaintext code is returned + /// exactly once; only its SHA-256 hash is persisted. + /// + /// `max_uses` is `None` for unlimited or `Some(1..=10000)`. + /// `ttl_secs` must be in the shared invite lifetime range. + #[datastore_span(name = "mint_relay_invite", system = "postgresql")] + pub async fn mint_relay_invite( + &self, + community: CommunityId, + created_by: &str, + ttl_secs: u64, + max_uses: Option, + ) -> Result { + mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await + } + + /// Delete one bounded batch of invites expired before `cutoff`. + #[datastore_span(name = "reap_expired_relay_invites", system = "postgresql")] + pub async fn reap_expired_relay_invites(&self, cutoff: DateTime) -> Result { + reap_expired_relay_invites(&self.pool, cutoff).await + } + + /// Atomically claims a v2 relay invite. The full redemption (membership + /// insert, policy evidence, use_count increment) runs in one PostgreSQL + /// transaction with `FOR UPDATE` on the invite row. + /// + /// `token_hash` is the SHA-256 of the presented v2 code (32 bytes). + #[datastore_span(name = "claim_relay_invite", system = "postgresql")] + pub async fn claim_relay_invite( + &self, + community: CommunityId, + token_hash: &[u8; 32], + claimer_pubkey: &str, + policy_version: Option<&str>, + ) -> Result { + claim_relay_invite( + &self.pool, + community, + token_hash, + claimer_pubkey, + policy_version, + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs similarity index 71% rename from crates/buzz-db/src/relay_members.rs rename to crates/buzz-db/src/store/relay_members.rs index 3cb86e8a437..0a20b011ebd 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -6,11 +6,14 @@ //! community B (NIP-43 admission confinement). `pubkey` values are 64-char //! lowercase hex strings. +use buzz_core::StoredEvent; +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; +use uuid::Uuid; -use crate::error::Result; -use crate::CommunityId; +use crate::error::{DbError, Result}; +use crate::{observability, replaceable, CommunityId, Db, RouteDecision, RoutePredicate}; /// A single relay member record. #[derive(Debug, Clone)] @@ -609,6 +612,361 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R Ok(result.rows_affected()) } +impl Db { + /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. + /// + /// Replica-routed on the bounded arm — the one PERMISSION read routed by + /// explicit product decision (bounded-stale membership beats the 10s + /// cache it replaced). Admits and revokes may lag by at most the budget + /// `B`; everything else fails closed to the writer, exactly like + /// [`Db::query_events_routed_bounded`]. Not precedent for routing other + /// permission reads. + #[datastore_span(name = "is_relay_member", system = "postgresql")] + pub async fn is_relay_member(&self, community: CommunityId, pubkey: &str) -> Result { + let path = "relay_membership"; + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match is_relay_member_on(&mut tx, community, pubkey).await { + Ok(is_member) => { + Self::record_route(path, "replica", reason); + Ok(is_member) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + is_relay_member(&self.pool, community, pubkey).await + } + } + } + RouteDecision::Writer => is_relay_member(&self.pool, community, pubkey).await, + } + } + + /// Returns the relay member record for `pubkey` in `community`, or `None` if not found. + #[datastore_span(name = "get_relay_member", system = "postgresql")] + pub async fn get_relay_member( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result> { + get_relay_member(&self.pool, community, pubkey).await + } + + /// Returns all relay members of `community` ordered by `created_at` ascending. + #[datastore_span(name = "list_relay_members", system = "postgresql")] + pub async fn list_relay_members(&self, community: CommunityId) -> Result> { + list_relay_members(&self.pool, community).await + } + + /// Adds a new relay member to `community`. + /// + /// Returns `true` if the row was actually inserted, `false` if the pubkey + /// already existed in `community` (idempotent — `ON CONFLICT DO NOTHING`). + #[datastore_span(name = "add_relay_member", system = "postgresql")] + pub async fn add_relay_member( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + added_by: Option<&str>, + ) -> Result { + add_relay_member(&self.pool, community, pubkey, role, added_by).await + } + + /// Claims relay membership via an invite and atomically persists the + /// accepted policy version when a policy is configured. + #[datastore_span(name = "claim_relay_membership", system = "postgresql")] + pub async fn claim_relay_membership( + &self, + community: CommunityId, + pubkey: &str, + role: &str, + policy_version: Option<&str>, + ) -> Result { + claim_relay_membership(&self.pool, community, pubkey, role, policy_version).await + } + + /// Returns whether a member has persisted acceptance evidence for a policy version. + #[datastore_span(name = "has_join_policy_acceptance", system = "postgresql")] + pub async fn has_join_policy_acceptance( + &self, + community: CommunityId, + pubkey: &str, + policy_version: &str, + ) -> Result { + has_join_policy_acceptance(&self.pool, community, pubkey, policy_version).await + } + + /// Removes a relay member from `community` atomically, refusing to delete the owner. + #[datastore_span(name = "remove_relay_member", system = "postgresql")] + pub async fn remove_relay_member( + &self, + community: CommunityId, + pubkey: &str, + ) -> Result { + remove_relay_member(&self.pool, community, pubkey).await + } + + /// Removes a relay member from `community` only if their current role matches `expected_role`. + /// + /// Atomic conditional delete — eliminates the TOCTOU race between a + /// prior role read and the delete. See [`remove_relay_member_if_role`]. + #[datastore_span(name = "remove_relay_member_if_role", system = "postgresql")] + pub async fn remove_relay_member_if_role( + &self, + community: CommunityId, + pubkey: &str, + expected_role: &str, + ) -> Result { + remove_relay_member_if_role(&self.pool, community, pubkey, expected_role).await + } + + /// Updates the role of an existing relay member in `community`. Returns `true` if updated. + #[datastore_span(name = "update_relay_member_role", system = "postgresql")] + pub async fn update_relay_member_role( + &self, + community: CommunityId, + pubkey: &str, + new_role: &str, + ) -> Result { + update_relay_member_role(&self.pool, community, pubkey, new_role).await + } + + /// Ensures the owner pubkey exists with role `"owner"` in `community`. Called at startup. + #[datastore_span(name = "bootstrap_owner", system = "postgresql")] + pub async fn bootstrap_owner(&self, community: CommunityId, owner_pubkey: &str) -> Result<()> { + bootstrap_owner(&self.pool, community, owner_pubkey).await + } + + /// Returns `true` if any member of `community` holds the `admin` or + /// `owner` role. + #[datastore_span(name = "has_admin_or_owner", system = "postgresql")] + pub async fn has_admin_or_owner(&self, community: CommunityId) -> Result { + has_admin_or_owner(&self.pool, community).await + } + + /// Atomically transfers ownership of `community` to `new_owner_pubkey`, + /// demoting the previous owner(s) to `member`. Verifies + /// `expected_owner_pubkey` matches the current owner inside the same + /// transaction to prevent stale-owner races. + #[datastore_span(name = "transfer_ownership", system = "postgresql")] + pub async fn transfer_ownership( + &self, + community: CommunityId, + new_owner_pubkey: &str, + expected_owner_pubkey: &str, + ) -> Result { + transfer_ownership( + &self.pool, + community, + new_owner_pubkey, + expected_owner_pubkey, + ) + .await + } + + /// Migrates existing `pubkey_allowlist` entries into `relay_members` for `community`. + /// + /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows + /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. + #[datastore_span(name = "backfill_from_allowlist", system = "postgresql")] + pub async fn backfill_from_allowlist(&self, community: CommunityId) -> Result { + backfill_from_allowlist(&self.pool, community).await + } + + /// Returns whether the relay-authored NIP-43 snapshot is absent or differs + /// from the canonical membership rows for `community_id`. + /// + /// Snapshot and canonical rows are compared directly rather than by + /// timestamp: relay membership events use whole-second Nostr timestamps, + /// and multiple mutations within one second must still be repaired. + #[datastore_span( + name = "nip43_membership_snapshot_needs_reconciliation", + system = "postgresql" + )] + pub async fn nip43_membership_snapshot_needs_reconciliation( + &self, + community_id: CommunityId, + relay_pubkey: &nostr::PublicKey, + ) -> Result { + let snapshot = self + .query_events(&crate::event::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32]), + pubkey: Some(relay_pubkey.to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..crate::event::EventQuery::for_community(community_id) + }) + .await? + .into_iter() + .next(); + let members = self.list_relay_members(community_id).await?; + + let Some(snapshot) = snapshot else { + return Ok(true); + }; + let mut snapshot_members = snapshot + .event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("member") && parts.len() >= 3) + .then(|| (parts[1].to_ascii_lowercase(), parts[2].clone())) + }) + .collect::>(); + let mut canonical_members = members + .into_iter() + .map(|member| (member.pubkey.to_ascii_lowercase(), member.role)) + .collect::>(); + snapshot_members.sort_unstable(); + canonical_members.sort_unstable(); + + Ok(snapshot_members != canonical_members) + } + + /// Atomically publish a NIP-43 membership snapshot under a single + /// transaction-scoped advisory lock. + /// + /// This method acquires the per-community snapshot lock, reads the + /// current membership, builds the event, and replaces the prior snapshot + /// — all inside one transaction on one database connection. This + /// prevents the stale-snapshot race where a concurrent publication reads + /// older state and overwrites a newer snapshot by arrival order. + #[datastore_span(name = "publish_nip43_membership_locked", system = "postgresql")] + pub async fn publish_nip43_membership_locked( + &self, + community_id: CommunityId, + relay_keypair: &nostr::Keys, + ) -> Result<(StoredEvent, bool, usize)> { + use nostr::{EventBuilder, Kind, Tag}; + + let kind_i32 = buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST as i32; + let pubkey_bytes = relay_keypair.public_key().to_bytes(); + + let lock_key = replaceable::event_replacement_lock_key( + community_id, + kind_i32, + pubkey_bytes.as_slice(), + None, + ); + + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::PublishNip43MembershipLocked, + ) + .await?; + let (event, received_at, was_inserted, member_count) = transaction_timer + .observe(async { + + // Acquire the per-community snapshot lock BEFORE reading members. + // This serializes the entire read-build-write cycle: a concurrent + // publication will block here until our transaction commits, then + // read the updated membership state. + observability::observe_advisory_lock( + observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; + + // Read current members inside the locked transaction. + let rows = sqlx::query( + "SELECT pubkey, role FROM relay_members \ + WHERE community_id = $1 ORDER BY created_at ASC", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut *tx) + .await?; + + let member_count = rows.len(); + + // Build the NIP-43 event from the locked member rows. + let mut tags: Vec = Vec::with_capacity(member_count + 1); + // NIP-70 protected-event marker. + tags.push(Tag::parse(["-"]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build '-' tag: {e}")) + })?); + for row in &rows { + let pubkey: String = row.try_get("pubkey")?; + let role: String = row.try_get("role")?; + tags.push(Tag::parse(["member", &pubkey, &role]).map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to build member tag: {e}")) + })?); + } + + let event = EventBuilder::new(Kind::Custom(kind_i32 as u16), "") + .tags(tags) + .sign_with_keys(relay_keypair) + .map_err(|e| { + crate::error::DbError::InvalidData(format!("failed to sign kind:13534: {e}")) + })?; + + let created_at_secs = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(created_at_secs))?; + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags)?; + let received_at = chrono::Utc::now(); + let d_tag = crate::event::extract_d_tag(&event); + + // Soft-delete prior snapshots — unconditional, the relay is authoritative. + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NULL \ + AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(kind_i32) + .bind(pubkey_bytes.as_slice()) + .execute(&mut *tx) + .await?; + + let insert_result = sqlx::query( + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind::>(None) + .bind(d_tag.as_deref()) + .execute(&mut *tx) + .await?; + + let was_inserted = insert_result.rows_affected() > 0; + if was_inserted { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok::<_, DbError>((event, received_at, was_inserted, member_count)) + }) + .await?; + + if was_inserted { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } + } + + Ok(( + StoredEvent::with_received_at(event, received_at, None, was_inserted), + was_inserted, + member_count, + )) + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/buzz-db/src/relay_operators.rs b/crates/buzz-db/src/store/relay_operators.rs similarity index 93% rename from crates/buzz-db/src/relay_operators.rs rename to crates/buzz-db/src/store/relay_operators.rs index b9beb68b026..3670a2f142b 100644 --- a/crates/buzz-db/src/relay_operators.rs +++ b/crates/buzz-db/src/store/relay_operators.rs @@ -9,6 +9,7 @@ //! //! Lane ownership: relay admin API (Duncan). +use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Postgres, Row as _, Transaction}; @@ -278,6 +279,52 @@ pub async fn list(pool: &PgPool) -> Result> { .map_err(crate::error::DbError::from) } +impl crate::Db { + /// Fetch one relay operator/moderator row by pubkey (32-byte binary). + #[datastore_span(name = "get_relay_operator", system = "postgresql")] + pub async fn get_relay_operator(&self, pubkey: &[u8]) -> Result> { + get(&self.pool, pubkey).await + } + + /// List all relay operator/moderator rows ordered by creation time. + #[datastore_span(name = "list_relay_operators", system = "postgresql")] + pub async fn list_relay_operators(&self) -> Result> { + list(&self.pool).await + } + + /// Insert or update a relay operator/moderator row (upsert by pubkey). + /// + /// `config_operator_exists` is the caller's request-time snapshot of + /// whether a config-backed operator is effective; a demotion that would + /// leave no effective operator is rejected with [`DbError::LastOperator`]. + #[datastore_span(name = "upsert_relay_operator", system = "postgresql")] + pub async fn upsert_relay_operator( + &self, + pubkey: &[u8], + role: &str, + added_by: &[u8], + config_operator_exists: bool, + ) -> Result<()> { + upsert(&self.pool, pubkey, role, added_by, config_operator_exists).await + } + + /// Remove a relay operator/moderator row. Returns `true` if deleted. + /// Records the revocation in the append-only audit trail; `actor` is the + /// authenticated operator performing the removal. `config_operator_exists` + /// is the caller's request-time snapshot of whether a config-backed + /// operator is effective; deleting the sole effective operator is rejected + /// with [`DbError::LastOperator`]. + #[datastore_span(name = "remove_relay_operator", system = "postgresql")] + pub async fn remove_relay_operator( + &self, + pubkey: &[u8], + actor: &[u8], + config_operator_exists: bool, + ) -> Result { + remove(&self.pool, pubkey, actor, config_operator_exists).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/buzz-db/src/store/reminder.rs b/crates/buzz-db/src/store/reminder.rs new file mode 100644 index 00000000000..20f503f4008 --- /dev/null +++ b/crates/buzz-db/src/store/reminder.rs @@ -0,0 +1,509 @@ +//! Event-reminder delivery query, claim, and release persistence. + +use buzz_core::kind::KIND_EVENT_REMINDER; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::error::Result; +use crate::Db; + +/// A due reminder row returned by [`query_due_reminders`]. +#[derive(Debug)] +pub struct DueReminder { + /// Server-resolved community this reminder row belongs to. + pub community_id: CommunityId, + /// Normalized host mapped to that community. + pub host: String, + /// The event's raw ID bytes. + pub id: Vec, + /// The event's pubkey bytes. + pub pubkey: Vec, + /// The event's `created_at` timestamp. + pub created_at: DateTime, + /// The event's kind (always 30300). + pub kind: i32, + /// The event's JSONB tags. + pub tags: serde_json::Value, + /// The event's encrypted content. + pub content: String, + /// The event's signature bytes. + pub sig: Vec, + /// The channel ID (always None for reminders — global events). + pub channel_id: Option, +} + +/// Query due reminders: latest-per-address `kind:30300` rows where +/// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`. +/// +/// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16 +/// ordering (`created_at DESC, id ASC`). +pub async fn query_due_reminders( + pool: &PgPool, + now_secs: i64, + batch_limit: i64, +) -> Result> { + let kind_i32 = KIND_EVENT_REMINDER as i32; + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (e.community_id, e.pubkey, e.d_tag) + e.community_id, c.host, e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.channel_id + FROM events AS e + JOIN communities AS c ON c.id = e.community_id + WHERE e.kind = $1 + AND e.not_before IS NOT NULL + AND e.not_before <= $2 + AND e.deleted_at IS NULL + AND e.delivered_at IS NULL + AND c.archived_at IS NULL + ORDER BY e.community_id, e.pubkey, e.d_tag, e.created_at DESC, e.id ASC + LIMIT $3 + "#, + ) + .bind(kind_i32) + .bind(now_secs) + .bind(batch_limit) + .fetch_all(pool) + .await?; + + let results = rows + .into_iter() + .map(|row| DueReminder { + community_id: CommunityId::from_uuid(row.get("community_id")), + host: row.get("host"), + id: row.get("id"), + pubkey: row.get("pubkey"), + created_at: row.get("created_at"), + kind: row.get("kind"), + tags: row.get("tags"), + content: row.get("content"), + sig: row.get("sig"), + channel_id: row.get("channel_id"), + }) + .collect(); + + Ok(results) +} + +/// Atomically claim a due reminder for delivery. Returns `Some(id)` if this +/// caller won the claim (set `delivered_at`), or `None` if another pod already +/// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod +/// idempotency. +pub async fn claim_due_reminder( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, +) -> Result { + claim_due_reminder_with_stamp( + pool, + community_id, + event_id, + event_created_at, + Utc::now().timestamp(), + ) + .await +} + +/// Atomically claim a due reminder using a caller-supplied delivery stamp. +/// +/// The same stamp should be passed to [`release_due_reminder`] if the publish +/// side effect fails, so rollback can compare-and-clear only this pod's claim. +/// +/// Scoped by `community_id`: `events` is keyed `(community_id, created_at, id)`, +/// and the same Nostr event id (hence the same `id`/`created_at` pair) is +/// allowed across communities. Without the community predicate a claim for +/// `A/X` would also mark `B/X` delivered. The caller already holds the owning +/// community on the `DueReminder` row. +pub async fn claim_due_reminder_with_stamp( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = $1 + WHERE community_id = $2 AND created_at = $3 AND id = $4 AND delivered_at IS NULL + "#, + ) + .bind(delivery_stamp) + .bind(community_id.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +/// Release a previously claimed reminder when publish fails. +/// +/// The `delivery_stamp` must be the exact value written by the claiming pod; +/// that compare-and-clear prevents one pod from rolling back another pod's +/// later claim after a retry/race. +/// +/// Scoped by `community_id` for the same reason as the claim: a release for +/// `A/X` must not clear `B/X` even when their `id`/`created_at`/stamp coincide. +pub async fn release_due_reminder( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = NULL + WHERE community_id = $1 + AND created_at = $2 + AND id = $3 + AND delivered_at = $4 + "#, + ) + .bind(community_id.as_uuid()) + .bind(event_created_at) + .bind(event_id) + .bind(delivery_stamp) + .execute(pool) + .await?; + + Ok(result.rows_affected() == 1) +} + +impl Db { + /// Query due reminders ready for delivery. + #[datastore_span(name = "query_due_reminders", system = "postgresql")] + pub async fn query_due_reminders( + &self, + now_secs: i64, + batch_limit: i64, + ) -> Result> { + crate::reminder::query_due_reminders(&self.pool, now_secs, batch_limit).await + } + + /// Atomically claim a due reminder for delivery (cross-pod dedup). + #[datastore_span(name = "claim_due_reminder", system = "postgresql")] + pub async fn claim_due_reminder( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + ) -> Result { + crate::reminder::claim_due_reminder(&self.pool, community_id, event_id, event_created_at) + .await + } + + /// Atomically claim a due reminder using a caller-supplied delivery stamp. + #[datastore_span(name = "claim_due_reminder_with_stamp", system = "postgresql")] + pub async fn claim_due_reminder_with_stamp( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + crate::reminder::claim_due_reminder_with_stamp( + &self.pool, + community_id, + event_id, + event_created_at, + delivery_stamp, + ) + .await + } + + /// Release a claimed due reminder after a publish failure. + #[datastore_span(name = "release_due_reminder", system = "postgresql")] + pub async fn release_due_reminder( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + crate::reminder::release_due_reminder( + &self.pool, + community_id, + event_id, + event_created_at, + delivery_stamp, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::insert_event; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("event-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_due_reminders_returns_row_community_and_host_per_tenant() { + let pool = setup_pool().await; + let community_a_uuid = make_test_community(&pool).await; + let community_b_uuid = make_test_community(&pool).await; + let community_a = CommunityId::from_uuid(community_a_uuid); + let community_b = CommunityId::from_uuid(community_b_uuid); + let host_a: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_a_uuid) + .fetch_one(&pool) + .await + .expect("load host A"); + let host_b: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_b_uuid) + .fetch_one(&pool) + .await + .expect("load host B"); + + let not_before = Utc::now().timestamp() - 1; + let keys_a = Keys::generate(); + let keys_b = Keys::generate(); + let event_a = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "a") + .tags([ + Tag::parse(["d", "due-reminder-scope-a"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_a) + .expect("sign A"); + let event_b = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "b") + .tags([ + Tag::parse(["d", "due-reminder-scope-b"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_b) + .expect("sign B"); + + insert_event(&pool, community_a, &event_a, None) + .await + .expect("insert A"); + insert_event(&pool, community_b, &event_b, None) + .await + .expect("insert B"); + + let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) + .await + .expect("query due reminders"); + + assert!(due.iter().any(|row| { + row.id == event_a.id.as_bytes() && row.community_id == community_a && row.host == host_a + })); + assert!(due.iter().any(|row| { + row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b + })); + } + + /// Two pods race to claim the same due reminder: exactly one wins. The + /// scheduler publishes only on a winning claim (`Ok(true)`) and `continue`s + /// on the loser (`Ok(false)`), so a single winning claim *is* the proof of + /// exactly one publish side effect across N pods. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_due_reminder_is_won_by_exactly_one_of_two_racing_pods() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-claim-race"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community, &event, None) + .await + .expect("insert reminder"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + + // Two pods, two distinct per-attempt stamps, same reminder. + let stamp_p1: i64 = 0x1111_1111_1111_1111; + let stamp_p2: i64 = 0x2222_2222_2222_2222; + let won_p1 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p1) + .await + .expect("p1 claim"); + let won_p2 = claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp_p2) + .await + .expect("p2 claim"); + + assert!( + won_p1 ^ won_p2, + "exactly one pod must win the claim (p1={won_p1}, p2={won_p2}) — \ + the loser never reaches the publish side effect" + ); + } + + /// A failed publish releases the claim so the reminder is redeliverable, + /// and the compare-and-clear stamp guard prevents one pod from rolling back + /// another pod's claim. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn release_due_reminder_rolls_back_only_the_matching_stamp() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-release"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community, &event, None) + .await + .expect("insert reminder"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + let stamp: i64 = 0x3333_3333_3333_3333; + + assert!( + claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("claim"), + "first claim wins" + ); + + // A release with the *wrong* stamp must be a no-op (does not clear + // another pod's claim). + assert!( + !release_due_reminder(&pool, community, &id, created_at, stamp ^ 0xFFFF) + .await + .expect("wrong-stamp release"), + "release with a non-matching stamp must not clear the claim" + ); + assert!( + !claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("re-claim after no-op release"), + "reminder must still be claimed after a no-op release" + ); + + // The matching-stamp release rolls the claim back; the reminder is + // redeliverable and a subsequent claim wins again. + assert!( + release_due_reminder(&pool, community, &id, created_at, stamp) + .await + .expect("matching-stamp release"), + "release with the claiming stamp must clear the claim" + ); + assert!( + claim_due_reminder_with_stamp(&pool, community, &id, created_at, stamp) + .await + .expect("re-claim after release"), + "released reminder must be reclaimable for retry" + ); + } + + /// Cross-community confinement: the same Nostr reminder event (identical + /// `id` and `created_at`) inserted into communities A and B must claim and + /// release independently. A claim/release for `A/X` must never touch `B/X`. + /// + /// This is the primitive the scheduler's exactly-once-publish proof rests + /// on: `events` is keyed `(community_id, created_at, id)`, so without the + /// community predicate a claim for A would mark B delivered (suppressing + /// B's reminder) and a matching-stamp release for A would clear B. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reminder_claim_and_release_are_confined_to_their_community() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + + // One signed event, inserted into both communities — same id/created_at. + let not_before = Utc::now().timestamp() - 1; + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "due") + .tags([ + Tag::parse(["d", "due-reminder-cross-community"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .expect("sign reminder"); + insert_event(&pool, community_a, &event, None) + .await + .expect("insert A/X"); + insert_event(&pool, community_b, &event, None) + .await + .expect("insert B/X"); + + let id = event.id.as_bytes().to_vec(); + let created_at = event.created_at.as_secs() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at, 0).expect("created_at"); + let stamp: i64 = 0x4444_4444_4444_4444; + + // Claim A/X. B/X must remain claimable — A's claim did not mark B. + assert!( + claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) + .await + .expect("claim A"), + "A/X claim wins" + ); + assert!( + claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) + .await + .expect("claim B"), + "B/X must still be claimable after A/X is claimed — \ + a claim for A must not mark B delivered" + ); + + // Both are now claimed under the same stamp. A matching-stamp release + // for A/X must clear only A/X; B/X must stay claimed. + assert!( + release_due_reminder(&pool, community_a, &id, created_at, stamp) + .await + .expect("release A"), + "A/X release with the claiming stamp clears A/X" + ); + assert!( + !claim_due_reminder_with_stamp(&pool, community_b, &id, created_at, stamp) + .await + .expect("re-claim B after A release"), + "B/X must remain claimed after A/X is released — \ + a release for A must not clear B" + ); + // And A/X is genuinely redeliverable (the release was real, not a no-op). + assert!( + claim_due_reminder_with_stamp(&pool, community_a, &id, created_at, stamp) + .await + .expect("re-claim A after release"), + "A/X must be reclaimable after its own release" + ); + } +} diff --git a/crates/buzz-db/src/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs similarity index 100% rename from crates/buzz-db/src/replaceable.rs rename to crates/buzz-db/src/store/replaceable.rs diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/store/thread.rs similarity index 84% rename from crates/buzz-db/src/thread.rs rename to crates/buzz-db/src/store/thread.rs index 007677e2581..d7a2d239eff 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -9,9 +9,14 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use uuid::Uuid; +use buzz_datastore_tracing::datastore_span; + use buzz_core::CommunityId; -use crate::{error::Result, event::row_to_stored_event}; +use crate::{ + error::Result, event::row_to_stored_event, route_proof::ChannelScoped, Db, ReadSession, + ReadSessionInner, RouteDecision, RoutePredicate, +}; // -- Structs ------------------------------------------------------------------ @@ -856,6 +861,296 @@ pub async fn get_thread_metadata_by_event( })) } +// -- Db API ------------------------------------------------------------------- + +impl Db { + /// Insert thread metadata. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "insert_thread_metadata", system = "postgresql")] + pub async fn insert_thread_metadata( + &self, + community_id: CommunityId, + event_id: &[u8], + event_created_at: DateTime, + channel_id: Uuid, + parent_event_id: Option<&[u8]>, + parent_event_created_at: Option>, + root_event_id: Option<&[u8]>, + root_event_created_at: Option>, + depth: i32, + broadcast: bool, + ) -> Result<()> { + crate::thread::insert_thread_metadata( + &self.pool, + community_id, + event_id, + event_created_at, + channel_id, + parent_event_id, + parent_event_created_at, + root_event_id, + root_event_created_at, + depth, + broadcast, + ) + .await + } + + /// Fetch replies under a root event. + /// + /// Routing mirrors [`Db::get_channel_window_with_session`]: a head + /// fetch (`cursor: None`) is Predicate A (bounded staleness, gated by + /// the default-off head budget); cursor pages are Predicate B + /// (completeness). Thread pagination walks **forward** from oldest to + /// newest, so a cursor carries no upper bound — instead the served page + /// is post-verified against the wall the serving session proved: + /// + /// - an under-`limit` page is a candidate terminal page — the client + /// treats it as EOF, so it is re-run on the writer to keep the EOF + /// decision authoritative (a lagged replica could truncate the tail); + /// - a full page whose newest row exceeds the proved fence wall could + /// straddle a row the session has not replayed (commit order is not + /// `created_at` order), so it is also re-run on the writer. Only a + /// full page that sits entirely at or below the proved wall is served + /// from the replica. + /// + /// A head fetch routed under Predicate A skips the re-run: bounded + /// staleness (missing at most the freshest budget-window of replies) is + /// exactly the semantic the head gate accepts. + #[datastore_span(name = "get_thread_replies", system = "postgresql")] + pub async fn get_thread_replies( + &self, + community_id: CommunityId, + root_event_id: &[u8], + depth_limit: Option, + limit: u32, + cursor: Option<&[u8]>, + ) -> Result> { + let (path, predicate): (&'static str, RoutePredicate) = match cursor { + Some(_) => ( + "thread_cursor", + RoutePredicate::CoveredPostVerified { + proof: ChannelScoped::from_thread_metadata_join(), + }, + ), + None => ("thread_head", RoutePredicate::Bounded), + }; + if let RouteDecision::Replica(mut tx, entry, reason) = + self.route_read(path, predicate).await + { + match crate::thread::get_thread_replies_on( + &mut tx, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await + { + Ok(replies) => { + if cursor.is_none() { + // Predicate A: bounded-stale head page, served as proved. + Self::record_route(path, "replica", reason); + return Ok(replies); + } + let full = replies.len() >= limit as usize; + let below_fence = replies + .last() + .is_some_and(|tail| tail.created_at <= entry.fence_wall); + if full && below_fence { + Self::record_route(path, "replica", reason); + return Ok(replies); + } + // Candidate terminal page, or page reaching above the + // proved wall — verify against the writer. Recorded as + // the request's ONLY route event: the replica leg was + // discarded, so counting it would overstate offload. + Self::record_route("thread_eof", "writer", "stale"); + } + Err(e) => { + // Mid-request replica failure (e.g. a hot-standby + // recovery conflict) fails closed to the writer. + tracing::warn!( + error = %e, + path, + "replica thread query failed; re-running on writer" + ); + Self::record_route(path, "writer", "replica_error"); + } + } + } + crate::thread::get_thread_replies( + &self.pool, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await + } + + /// Fetch aggregated thread stats. + #[datastore_span(name = "get_thread_summary", system = "postgresql")] + pub async fn get_thread_summary( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result> { + crate::thread::get_thread_summary(&self.pool, community_id, event_id).await + } + + /// One channel window: top-level rows + summaries + server `has_more`. + /// + /// Convenience wrapper over [`Db::get_channel_window_with_session`] for + /// callers with no follow-up queries; the serving session is released. + pub async fn get_channel_window( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: u32, + cursor: Option<(DateTime, Vec)>, + kind_filter: Option<&[u32]>, + ) -> Result { + self.get_channel_window_with_session(community_id, channel_id, limit, cursor, kind_filter) + .await + .map(|(window, _session)| window) + } + + /// [`Db::get_channel_window`], additionally returning the session that + /// served the page so request-scoped follow-ups (the aux closure) run on + /// the same proved connection. + /// + /// Routing: + /// + /// - **Cursor page** (Predicate B — completeness): scrolls *backward* + /// into history bounded above by the cursor timestamp (`created_at < + /// ts`, or `= ts` with the id tiebreak), so it may be served by a + /// replica session when one is configured AND that session **proves** + /// coverage of the cursor timestamp: the heartbeat token/epoch is + /// observed on the exact connection that will serve the page and + /// resolved against the fence's retained ring ([`crate::replica_fence`]). + /// - **Head fetch** (Predicate A — bounded staleness): served by a + /// proved replica session only when the head gate is configured + /// ([`crate::DbConfig::replica_read_max_age_ms`], default off) and the + /// proved entry is within the budget. This trades a bounded staleness + /// window (budget plus probe cadence) on the GET leg for writer + /// offload. NOTE: enabling the budget also breaks read-your-own-writes + /// on the GET leg; the client-side WS `since`-overlap union intended + /// to cover fresh events has NOT shipped yet — do not enable + /// `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` until it has, proven by a + /// post-then-immediately-refetch test. + /// + /// Every failure fails closed to the writer and is recorded in + /// `buzz_db_route_decision`. + #[datastore_span(name = "get_channel_window", system = "postgresql")] + pub async fn get_channel_window_with_session( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: u32, + cursor: Option<(DateTime, Vec)>, + kind_filter: Option<&[u32]>, + ) -> Result<(crate::thread::ChannelWindow, ReadSession)> { + let path: &'static str = if cursor.is_some() { + "channel_cursor" + } else { + "channel_head" + }; + match self + .route_read( + path, + RoutePredicate::from_channel_cursor(channel_id, &cursor), + ) + .await + { + RouteDecision::Replica(mut tx, _entry, reason) => { + match crate::thread::get_channel_window_on( + &mut tx, + community_id, + channel_id, + limit, + cursor.clone(), + kind_filter, + ) + .await + { + Ok(window) => { + Self::record_route(path, "replica", reason); + return Ok(( + window, + ReadSession { + inner: ReadSessionInner::Replica { + tx, + writer: self.pool.clone(), + }, + }, + )); + } + Err(e) => { + // A mid-request replica failure (e.g. a hot-standby + // recovery conflict cancelling the held snapshot) + // fails closed to the writer: a stale-but-served + // page, never an error the writer could have + // answered. Dropping `tx` rolls the reader + // transaction back. + tracing::warn!( + error = %e, + path, + "replica window query failed; re-running on writer" + ); + Self::record_route(path, "writer", "replica_error"); + } + } + } + RouteDecision::Writer => {} + } + let window = crate::thread::get_channel_window( + &self.pool, + community_id, + channel_id, + limit, + cursor, + kind_filter, + ) + .await?; + Ok(( + window, + ReadSession { + inner: ReadSessionInner::Writer(self.pool.clone()), + }, + )) + } + + /// Look up a single thread_metadata row by event_id. + #[datastore_span(name = "get_thread_metadata_by_event", system = "postgresql")] + pub async fn get_thread_metadata_by_event( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result> { + crate::thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await + } + + /// Decrement reply counts. + #[datastore_span(name = "decrement_reply_count", system = "postgresql")] + pub async fn decrement_reply_count( + &self, + community_id: CommunityId, + parent_event_id: &[u8], + root_event_id: Option<&[u8]>, + ) -> Result<()> { + crate::thread::decrement_reply_count( + &self.pool, + community_id, + parent_event_id, + root_event_id, + ) + .await + } +} + #[cfg(test)] mod tests { use super::*; @@ -865,7 +1160,7 @@ mod tests { }; use nostr::{EventBuilder, Keys, Kind}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/usage.rs b/crates/buzz-db/src/store/usage.rs similarity index 74% rename from crates/buzz-db/src/usage.rs rename to crates/buzz-db/src/store/usage.rs index f009dc6e056..97235f0b26e 100644 --- a/crates/buzz-db/src/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -12,10 +12,35 @@ //! Returned structs are plain data; the caller (relay poller) maps them //! to Prometheus labels and calls `metrics::gauge!(...).set(...)`. -use crate::error::Result; -use sqlx::PgPool; +use buzz_datastore_tracing::datastore_span; +use sqlx::postgres::PgConnection; +use sqlx::{Connection as _, PgPool}; use uuid::Uuid; +use crate::error::Result; +use crate::{observability, Db}; + +/// Owns the detached Postgres session holding the relay usage-metrics advisory lock. +/// +/// The connection deliberately does not return to the main pool: session advisory +/// locks must remain bound to this exact physical connection, and the poller +/// pings it before each leader-only collection tick. +pub struct UsageMetricsLeader { + connection: PgConnection, +} + +impl UsageMetricsLeader { + /// Returns whether the lock-owning session is still reachable. + /// + /// Bounded to 5 seconds — a blackholed connection (no RST) would otherwise + /// stall the entire poller tick until the OS TCP timeout. + pub async fn is_live(&mut self) -> bool { + tokio::time::timeout(std::time::Duration::from_secs(5), self.connection.ping()) + .await + .is_ok_and(|r| r.is_ok()) + } +} + /// Total number of communities registered on this relay. pub async fn community_count(pool: &PgPool) -> Result { let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM communities") @@ -354,14 +379,111 @@ pub async fn community_hosts(pool: &PgPool) -> Result> { .collect()) } +impl Db { + /// Try to acquire the detached session advisory lock for relay usage metrics. + /// + /// The returned guard owns the exact connection that acquired the lock. It is + /// detached from the shared pool so a stable leader neither returns a locked + /// session to other callers nor permanently consumes a pool slot. Dropping the + /// guard closes the connection and releases the session-scoped lock. + #[datastore_span(name = "try_lock_usage_metrics", system = "postgresql")] + pub async fn try_lock_usage_metrics( + &self, + lock_key: i64, + ) -> Result> { + let mut connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") + .bind(lock_key) + .fetch_one(&mut *connection) + .await?; + if acquired { + Ok(Some(UsageMetricsLeader { + connection: connection.detach(), + })) + } else { + Ok(None) + } + } + + /// Return total number of communities on this relay. + #[datastore_span(name = "usage_community_count", system = "postgresql")] + pub async fn usage_community_count(&self) -> Result { + community_count(&self.pool).await + } + + /// Return per-community user counts split by human/agent. + #[datastore_span(name = "usage_user_counts", system = "postgresql")] + pub async fn usage_user_counts(&self) -> Result> { + user_counts(&self.pool).await + } + + /// Return per-community channel counts by type. + #[datastore_span(name = "usage_channel_counts", system = "postgresql")] + pub async fn usage_channel_counts(&self) -> Result> { + channel_counts(&self.pool).await + } + + /// Return per-community kind=9 message counts. + #[datastore_span(name = "usage_message_counts", system = "postgresql")] + pub async fn usage_message_counts(&self) -> Result> { + message_counts(&self.pool).await + } + + /// Return per-community relay-member counts by role. + #[datastore_span(name = "usage_relay_member_counts", system = "postgresql")] + pub async fn usage_relay_member_counts(&self) -> Result> { + relay_member_counts(&self.pool).await + } + + /// Return per-community workflow counts by status. + #[datastore_span(name = "usage_workflow_counts", system = "postgresql")] + pub async fn usage_workflow_counts(&self) -> Result> { + workflow_counts(&self.pool).await + } + + /// Return per-community git-repo counts. + #[datastore_span(name = "usage_git_repo_counts", system = "postgresql")] + pub async fn usage_git_repo_counts(&self) -> Result> { + git_repo_counts(&self.pool).await + } + + /// Return per-community distinct active-user counts for a given SQL interval. + /// + /// `interval_sql` must be a trusted literal such as `"1 day"` or `"7 days"`. + #[datastore_span(name = "usage_active_user_counts", system = "postgresql")] + pub async fn usage_active_user_counts( + &self, + interval_sql: &'static str, + ) -> Result> { + active_user_counts(&self.pool, interval_sql).await + } + + /// Return per-community active-channel counts for a given SQL interval. + #[datastore_span(name = "usage_active_channel_counts", system = "postgresql")] + pub async fn usage_active_channel_counts( + &self, + interval_sql: &'static str, + ) -> Result> { + active_channel_counts(&self.pool, interval_sql).await + } + + /// Return all community id → host mappings. + #[datastore_span(name = "usage_community_hosts", system = "postgresql")] + pub async fn usage_community_hosts(&self) -> Result> { + community_hosts(&self.pool).await + } +} + #[cfg(test)] mod tests { use super::*; use buzz_core::CommunityId; use nostr::Keys; + use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn get_pool() -> PgPool { PgPool::connect(TEST_DB_URL) @@ -369,6 +491,84 @@ mod tests { .expect("connect to test DB") } + async fn create_scratch_db(admin: &PgPool, prefix: &str) -> (PgPool, String) { + let name = format!("{}_{}", prefix, Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(admin) + .await + .expect("create scratch db"); + let base = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let idx = base.rfind('/').expect("db url has a path segment"); + let scratch_url = format!("{}/{}", &base[..idx], name); + let pool = PgPool::connect(&scratch_url) + .await + .expect("connect scratch db"); + crate::migration::run_migrations(&pool) + .await + .expect("migrate scratch db"); + (pool, name) + } + + async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn test_usage_metrics_lock_has_single_owner_and_releases_on_drop() { + // Use a private scratch database — not the shared TEST_DATABASE_URL. + // Postgres advisory locks are per-database; hardcoding the production + // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB + // races any live buzz-relay on the same database (see #3619). + let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&admin_url) + .await + .expect("connect admin to create scratch db"); + let (pool, scratch_name) = create_scratch_db(&admin, "usage_metrics_lock").await; + let first = Db::from_pool(pool.clone()); + let second = Db::from_pool(pool.clone()); + // Same key as production (`buzz-relay` USAGE_METRICS_LOCK_KEY) — safe here + // because the scratch DB is empty of other holders. + let key = 0x4255_5A5A_4D45_5452; + + let mut leader = first + .try_lock_usage_metrics(key) + .await + .expect("first lock attempt") + .expect("first database handle becomes leader"); + assert!(leader.is_live().await, "lock owner remains reachable"); + assert!( + second + .try_lock_usage_metrics(key) + .await + .expect("second lock attempt") + .is_none(), + "another session cannot become leader while the guard exists" + ); + + drop(leader); + assert!( + second + .try_lock_usage_metrics(key) + .await + .expect("lock attempt after leader drop") + .is_some(), + "dropping the detached session releases its advisory lock" + ); + + // Release any remaining session state before DROP DATABASE. + drop(first); + drop(second); + drop_scratch_db(&admin, pool, &scratch_name).await; + } + fn random_pubkey() -> Vec { Keys::generate().public_key().to_bytes().to_vec() } diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/store/user.rs similarity index 84% rename from crates/buzz-db/src/user.rs rename to crates/buzz-db/src/store/user.rs index 066fb5f5c04..140a722a21b 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -1,7 +1,9 @@ //! User CRUD operations. use crate::error::Result; +use crate::Db; use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; use sqlx::PgPool; use sqlx::Row; @@ -398,13 +400,124 @@ pub async fn set_channel_add_policy( Ok(()) } +impl Db { + /// Ensure a user record exists (upsert). + /// + /// Returns `true` if a new row was inserted (first time), `false` if it + /// already existed. Callers use the `true` return to increment + /// `buzz_users_created_total`. + #[datastore_span(name = "ensure_user", system = "postgresql")] + pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result { + crate::user::ensure_user(&self.pool, community_id, pubkey).await + } + + /// Get a single user record by pubkey. + #[datastore_span(name = "get_user", system = "postgresql")] + pub async fn get_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + crate::user::get_user(&self.pool, community_id, pubkey).await + } + + /// Update a user's profile fields. + #[datastore_span(name = "update_user_profile", system = "postgresql")] + pub async fn update_user_profile( + &self, + community_id: CommunityId, + pubkey: &[u8], + display_name: Option<&str>, + avatar_url: Option<&str>, + about: Option<&str>, + nip05_handle: Option<&str>, + ) -> Result<()> { + crate::user::update_user_profile( + &self.pool, + community_id, + pubkey, + display_name, + avatar_url, + about, + nip05_handle, + ) + .await + } + + /// Look up a user by NIP-05 handle. + #[datastore_span(name = "get_user_by_nip05", system = "postgresql")] + pub async fn get_user_by_nip05( + &self, + community_id: CommunityId, + local_part: &str, + domain: &str, + ) -> Result> { + crate::user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await + } + + /// Search users by display name, NIP-05 handle, or pubkey prefix. + #[datastore_span(name = "search_users", system = "postgresql")] + pub async fn search_users( + &self, + community_id: CommunityId, + query: &str, + limit: u32, + ) -> Result> { + crate::user::search_users(&self.pool, community_id, query, limit).await + } + + /// Atomically set agent owner — only if no owner is currently assigned. + /// Returns Ok(true) if set, Ok(false) if an owner already exists. + #[datastore_span(name = "set_agent_owner", system = "postgresql")] + pub async fn set_agent_owner( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + ) -> Result { + crate::user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await + } + + /// Get the channel_add_policy and agent_owner_pubkey for a user. + #[datastore_span(name = "get_agent_channel_policy", system = "postgresql")] + pub async fn get_agent_channel_policy( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result>)>> { + crate::user::get_agent_channel_policy(&self.pool, community_id, pubkey).await + } + + /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. + #[datastore_span(name = "is_agent_owner", system = "postgresql")] + pub async fn is_agent_owner( + &self, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + crate::user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await + } + + /// Set the channel_add_policy for a user. + #[datastore_span(name = "set_channel_add_policy", system = "postgresql")] + pub async fn set_channel_add_policy( + &self, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, + ) -> Result<()> { + crate::user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await + } +} + #[cfg(test)] mod tests { use super::*; use crate::Db; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_db() -> Db { let pool = PgPool::connect(TEST_DB_URL) diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/store/workflow.rs similarity index 85% rename from crates/buzz-db/src/workflow.rs rename to crates/buzz-db/src/store/workflow.rs index e970e978aaf..0ae1b623764 100644 --- a/crates/buzz-db/src/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -18,6 +18,8 @@ use uuid::Uuid; use buzz_core::CommunityId; use crate::error::{DbError, Result}; +use crate::Db; +use buzz_datastore_tracing::datastore_span; // -- Token hashing ------------------------------------------------------------ @@ -1266,6 +1268,421 @@ pub async fn find_by_owner_and_name( } } +// -- Run and approval Db API -------------------------------------------------- + +impl Db { + /// Create a new workflow run. + #[datastore_span(name = "create_workflow_run", system = "postgresql")] + pub async fn create_workflow_run( + &self, + community_id: CommunityId, + workflow_id: Uuid, + trigger_event_id: Option<&[u8]>, + trigger_context: Option<&serde_json::Value>, + ) -> Result { + crate::workflow::create_workflow_run( + &self.pool, + community_id, + workflow_id, + trigger_event_id, + trigger_context, + ) + .await + } + + /// Fetch a single workflow run, scoped to its community. + #[datastore_span(name = "get_workflow_run", system = "postgresql")] + pub async fn get_workflow_run( + &self, + community_id: CommunityId, + id: Uuid, + ) -> Result { + crate::workflow::get_workflow_run(&self.pool, community_id, id).await + } + + /// List runs for a workflow. + #[datastore_span(name = "list_workflow_runs", system = "postgresql")] + pub async fn list_workflow_runs( + &self, + community_id: CommunityId, + workflow_id: Uuid, + limit: i64, + ) -> Result> { + crate::workflow::list_workflow_runs(&self.pool, community_id, workflow_id, limit).await + } + + /// List one keyset-paginated page of workflow runs. + #[datastore_span(name = "list_workflow_runs_page", system = "postgresql")] + pub async fn list_workflow_runs_page( + &self, + community_id: CommunityId, + workflow_id: Uuid, + before: Option>, + before_id: Option, + limit: i64, + ) -> Result> { + crate::workflow::list_workflow_runs_page( + &self.pool, + community_id, + workflow_id, + before, + before_id, + limit, + ) + .await + } + + /// Update a workflow run's status. + #[datastore_span(name = "update_workflow_run", system = "postgresql")] + pub async fn update_workflow_run( + &self, + community_id: CommunityId, + id: Uuid, + status: crate::workflow::RunStatus, + current_step: i32, + trace: &serde_json::Value, + failure: Option>, + ) -> Result<()> { + crate::workflow::update_workflow_run( + &self.pool, + community_id, + id, + status, + current_step, + trace, + failure, + ) + .await + } + + /// Create an approval request. + #[datastore_span(name = "create_approval", system = "postgresql")] + pub async fn create_approval( + &self, + params: crate::workflow::CreateApprovalParams<'_>, + ) -> Result<()> { + crate::workflow::create_approval(&self.pool, params).await + } + + /// Fetch an approval by raw token. + #[datastore_span(name = "get_approval", system = "postgresql")] + pub async fn get_approval( + &self, + community_id: CommunityId, + token: &str, + ) -> Result { + crate::workflow::get_approval(&self.pool, community_id, token).await + } + + /// Fetch an approval by its already-hashed token (no re-hashing). + #[datastore_span(name = "get_approval_by_stored_hash", system = "postgresql")] + pub async fn get_approval_by_stored_hash( + &self, + community_id: CommunityId, + token_hash: &[u8], + ) -> Result { + crate::workflow::get_approval_by_stored_hash(&self.pool, community_id, token_hash).await + } + + /// Fetch all approvals for a workflow run. + #[datastore_span(name = "get_run_approvals", system = "postgresql")] + pub async fn get_run_approvals( + &self, + community_id: CommunityId, + workflow_id: uuid::Uuid, + run_id: uuid::Uuid, + ) -> Result> { + crate::workflow::get_run_approvals(&self.pool, community_id, workflow_id, run_id).await + } + + /// Update an approval's status. + #[datastore_span(name = "update_approval", system = "postgresql")] + pub async fn update_approval( + &self, + community_id: CommunityId, + token: &str, + status: crate::workflow::ApprovalStatus, + approver_pubkey: Option<&[u8]>, + note: Option<&str>, + ) -> Result { + crate::workflow::update_approval( + &self.pool, + community_id, + token, + status, + approver_pubkey, + note, + ) + .await + } + + /// Update an approval by its already-hashed token (no re-hashing). + #[datastore_span(name = "update_approval_by_stored_hash", system = "postgresql")] + pub async fn update_approval_by_stored_hash( + &self, + community_id: CommunityId, + token_hash: &[u8], + status: crate::workflow::ApprovalStatus, + approver_pubkey: Option<&[u8]>, + note: Option<&str>, + ) -> Result { + crate::workflow::update_approval_by_stored_hash( + &self.pool, + community_id, + token_hash, + status, + approver_pubkey, + note, + ) + .await + } +} + +// -- Workflow lifecycle Db API ------------------------------------------------ + +impl Db { + /// Create a new workflow. + #[datastore_span(name = "create_workflow", system = "postgresql")] + pub async fn create_workflow( + &self, + community_id: CommunityId, + channel_id: Option, + owner_pubkey: &[u8], + name: &str, + definition_json: &str, + definition_hash: &[u8], + ) -> Result { + crate::workflow::create_workflow( + &self.pool, + community_id, + channel_id, + owner_pubkey, + name, + definition_json, + definition_hash, + ) + .await + } + + /// Insert or update a workflow using its NIP-33 `d`-tag UUID. + #[allow(clippy::too_many_arguments)] + #[datastore_span(name = "upsert_workflow", system = "postgresql")] + pub async fn upsert_workflow( + &self, + community_id: CommunityId, + id: Uuid, + channel_id: Option, + owner_pubkey: &[u8], + name: &str, + definition_json: &str, + definition_hash: &[u8], + ) -> Result<()> { + crate::workflow::upsert_workflow( + &self.pool, + community_id, + id, + channel_id, + owner_pubkey, + name, + definition_json, + definition_hash, + ) + .await + } + + /// Fetch a single workflow by ID, scoped to its community. + #[datastore_span(name = "get_workflow", system = "postgresql")] + pub async fn get_workflow( + &self, + community_id: CommunityId, + id: Uuid, + ) -> Result { + crate::workflow::get_workflow(&self.pool, community_id, id).await + } + + /// List workflows for a channel. + #[datastore_span(name = "list_channel_workflows", system = "postgresql")] + pub async fn list_channel_workflows( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: Option, + offset: Option, + ) -> Result> { + crate::workflow::list_channel_workflows(&self.pool, community_id, channel_id, limit, offset) + .await + } + + /// List active, enabled workflows for a channel. + #[datastore_span(name = "list_enabled_channel_workflows", system = "postgresql")] + pub async fn list_enabled_channel_workflows( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + crate::workflow::list_enabled_channel_workflows(&self.pool, community_id, channel_id).await + } + + /// List all active, enabled schedule-triggered workflows. + #[datastore_span(name = "list_all_enabled_workflows", system = "postgresql")] + pub async fn list_all_enabled_workflows(&self) -> Result> { + crate::workflow::list_all_enabled_workflows(&self.pool).await + } + + /// Claim a scheduled workflow fire for an authoritative schedule instant. + /// + /// Returns `Some` only for the first pod to claim `(community_id, + /// workflow_id, scheduled_for)`; all other pods must skip creating a run. + /// `community_id` is server provenance (the workflow row's own community + /// from the scheduler scan), never client-supplied — `workflows` is keyed + /// `(community_id, id)`, so the claim must bind both to avoid fanning + /// across communities that share the workflow UUID. + #[datastore_span(name = "claim_scheduled_workflow_fire", system = "postgresql")] + pub async fn claim_scheduled_workflow_fire( + &self, + community_id: CommunityId, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + ) -> Result> { + crate::workflow::claim_scheduled_workflow_fire( + &self.pool, + community_id, + workflow_id, + scheduled_for, + ) + .await + } + + /// Fetch the latest claimed schedule instant for interval trigger anchoring. + #[datastore_span(name = "latest_scheduled_workflow_fire", system = "postgresql")] + pub async fn latest_scheduled_workflow_fire( + &self, + community_id: CommunityId, + workflow_id: Uuid, + ) -> Result>> { + crate::workflow::latest_scheduled_workflow_fire(&self.pool, community_id, workflow_id).await + } + + /// Attach the workflow run id created from a won scheduled-fire claim. + #[datastore_span(name = "attach_scheduled_workflow_run", system = "postgresql")] + pub async fn attach_scheduled_workflow_run( + &self, + community_id: CommunityId, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + workflow_run_id: Uuid, + ) -> Result { + crate::workflow::attach_scheduled_workflow_run( + &self.pool, + community_id, + workflow_id, + scheduled_for, + workflow_run_id, + ) + .await + } + + /// Delete old scheduled workflow fire claims before a retention cutoff. + #[datastore_span(name = "prune_scheduled_workflow_fires_before", system = "postgresql")] + pub async fn prune_scheduled_workflow_fires_before( + &self, + older_than: chrono::DateTime, + ) -> Result { + crate::workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await + } + + /// Update a workflow's name, definition, and hash. + #[datastore_span(name = "update_workflow", system = "postgresql")] + pub async fn update_workflow( + &self, + community_id: CommunityId, + id: Uuid, + name: &str, + definition_json: &str, + definition_hash: &[u8], + ) -> Result<()> { + crate::workflow::update_workflow( + &self.pool, + community_id, + id, + name, + definition_json, + definition_hash, + ) + .await + } + + /// Update a workflow's status. + #[datastore_span(name = "update_workflow_status", system = "postgresql")] + pub async fn update_workflow_status( + &self, + community_id: CommunityId, + id: Uuid, + status: crate::workflow::WorkflowStatus, + ) -> Result<()> { + crate::workflow::update_workflow_status(&self.pool, community_id, id, status).await + } + + /// Enable or disable a workflow. + #[datastore_span(name = "set_workflow_enabled", system = "postgresql")] + pub async fn set_workflow_enabled( + &self, + community_id: CommunityId, + id: Uuid, + enabled: bool, + ) -> Result<()> { + crate::workflow::set_workflow_enabled(&self.pool, community_id, id, enabled).await + } + + /// Disable all of an owner's workflows in a channel (SEC-006, on + /// membership loss). Returns the number of workflows disabled. + #[datastore_span(name = "disable_workflows_for_owner_in_channel", system = "postgresql")] + pub async fn disable_workflows_for_owner_in_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + ) -> Result { + crate::workflow::disable_workflows_for_owner_in_channel( + &self.pool, + community_id, + channel_id, + owner_pubkey, + ) + .await + } + + /// Delete a workflow and all its runs/approvals. + #[datastore_span(name = "delete_workflow", system = "postgresql")] + pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { + crate::workflow::delete_workflow(&self.pool, community_id, id).await + } + + /// Delete a workflow only when it belongs to the provided owner. + /// Returns the deleted workflow's `channel_id`. + #[datastore_span(name = "delete_workflow_for_owner", system = "postgresql")] + pub async fn delete_workflow_for_owner( + &self, + community_id: CommunityId, + id: Uuid, + owner_pubkey: &[u8], + ) -> Result> { + crate::workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await + } + + /// Find a workflow by owner pubkey and name within a community. Used for + /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). + #[datastore_span(name = "find_workflow_by_owner_and_name", system = "postgresql")] + pub async fn find_workflow_by_owner_and_name( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + name: &str, + ) -> Result> { + crate::workflow::find_by_owner_and_name(&self.pool, community_id, owner_pubkey, name).await + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] @@ -1774,7 +2191,7 @@ mod tests { use crate::user::ensure_user; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 724a9b47ed8..9e37186009f 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -1,6 +1,6 @@ #[test] fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { - let implementation = include_str!("../src/observability.rs"); + let implementation = include_str!("../src/runtime/observability.rs"); let datastore_macro = include_str!("../../buzz-datastore-tracing/src/lib.rs"); let instrumentation = format!("{implementation}\n{datastore_macro}"); @@ -38,3 +38,44 @@ fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { // The runtime tracing-layer assertion covers field names because a source // search would also match ordinary local variables such as `record_error`. } + +#[test] +fn relay_admin_db_wrappers_have_exactly_one_datastore_span() { + for (domain, source) in [ + ( + "relay_admin_actions", + include_str!("../src/store/relay_admin_actions.rs"), + ), + ( + "relay_operators", + include_str!("../src/store/relay_operators.rs"), + ), + ] { + let db_impl = source + .split_once("impl crate::Db {") + .unwrap_or_else(|| panic!("{domain} must own its Db wrappers")) + .1 + .split_once("\n#[cfg(test)]") + .unwrap_or_else(|| panic!("{domain} Db wrappers must precede focused tests")) + .0; + let mut pending_spans = 0; + let mut methods = 0; + + for line in db_impl.lines() { + if line.contains("#[datastore_span(") { + pending_spans += 1; + } + if line.trim_start().starts_with("pub async fn ") { + assert_eq!(pending_spans, 1, "{domain} wrapper `{line}` span count"); + pending_spans = 0; + methods += 1; + } + } + + assert!(methods > 0, "{domain} must own public Db wrappers"); + assert_eq!( + pending_spans, 0, + "{domain} has an unattached datastore span" + ); + } +} diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 8ecffdf04de..7c7d96f44c1 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -107,7 +107,7 @@ function numericList(source, pattern) { test("channel replay lookback stays coupled to relay and DB source constants", async () => { const [ingest, fence] = await Promise.all([ readFile("../crates/buzz-relay/src/handlers/ingest.rs", "utf8"), - readFile("../crates/buzz-db/src/replica_fence.rs", "utf8"), + readFile("../crates/buzz-db/src/runtime/replica_fence.rs", "utf8"), ]); assert.match( ingest, From c432a111ca9ddd31a85e1312d5995f8b92191b82 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 28 Aug 2026 14:15:09 -0700 Subject: [PATCH 094/101] feat(mobile): push notifications MVP (#6269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR implements MVP, iOS-only, [NIP-PL](https://github.com/block/buzz/blob/8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8/docs/nips/NIP-PL.md)-compliant push notifications. A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any message that appears in the in-app Notifications tab. ## Enrollment flow The first time the client first connects to a relay with `BUZZ_PUSH_ENABLED`: ```mermaid sequenceDiagram autonumber participant App as Buzz iOS app participant iOS participant Relay as Buzz relay participant Attest as Apple App Attest participant Gateway as Push gateway App->>Relay: Fetch NIP-11 push capability Relay-->>App: Push profile, current relay public key, and limits par App->>iOS: Request notification permission iOS-->>App: Permission result and App->>iOS: Register for remote notifications iOS-->>App: Device token end App->>Gateway: Request installation challenge Gateway-->>App: Single-use challenge App->>Attest: Attest installation transcript Attest-->>App: Attestation proof App->>Gateway: Enroll device token and proof Gateway-->>App: Installation handle App->>Gateway: Request delegation challenge Gateway-->>App: Single-use challenge App->>Attest: Assert relay-key delegation Attest-->>App: Assertion App->>Gateway: Create delegation Gateway-->>App: Opaque endpoint grant App->>Relay: Publish encrypted push lease and filters Relay-->>App: Lease acknowledged ``` ## Push-time flow When a notification-eligible event is received by the relay: ```mermaid %%{init: { "sequence": { "actorMargin": 20, "width": 110, "messageMargin": 18, "diagramMarginX": 8, "wrap": true } }}%% sequenceDiagram autonumber participant Relay as Buzz relay participant Gateway as Push gateway participant APNs as Apple Push
Notification service participant iOS participant NSE as Notification service
extension Relay->>Gateway: POST /v1/deliveries/apns
opaque endpoint grant, request ID, expiry, NIP-98 authorization Gateway->>APNs: POST /3/device/{device-token}
topic, request ID, expiry, constant mutable-content payload APNs-->>Gateway: 200 OK: request accepted Gateway-->>Relay: 200 OK: accepted status APNs-->>iOS: Notification: constant reconnect alert
mutable-content = 1 iOS->>NSE: Invoke extension
original notification content NSE->>Relay: POST /query: subscription filters, limit 10
NIP-98 authorization Relay-->>NSE: 200 OK: signed Nostr events
kinds 9, 40002, 45001, or 45003 NSE->>iOS: Complete notification: title, body, subtitle
thread ID, exact-message target ``` relay → push gateway → APNs -> NSE -> Notification Center ## Known limitations The APNs wake payload is intentionally constant and opaque: it contains no originating community or message identifier, in keeping with the implemented NIP-PL privacy design. The Notification Service Extension must therefore reconnect to the relay and resolve eligible messages after each wake. Around overlapping wakes, timing boundaries, or resolution windows, notification presentation may occasionally omit an expected message or display a message more than once. This best-effort behavior is deliberately accepted for the current implementation and will be measured during the internal rollout to determine whether the user experience is acceptable before any broader deployment; the implementation does not claim exactly-once presentation. ## Validation Live end-to-end hardware validation used an internal remotely hosted development relay and push gateway, the APNs sandbox, and a physical iPhone 12 mini: - A second real Buzz client published a uniquely marked message through the hosted relay. - The relay matched the message and sent the constant opaque wake through the hosted gateway. The gateway made an actual APNs request; no `simctl push` or simulated notification was used. - The iPhone received the notification on its lock screen. The Notification Service Extension reconnected to the relay, fetched the event, verified its ID and signature, and replaced the placeholder content with the real notification title and body. - After the app populated its shared presentation cache, a final marked notification visibly showed the sender display name, sender avatar, and hashtag-prefixed channel name. - Tapping a lock-screen notification opened Buzz and exercised the notification-response path and navigated to the corresponding message. Final validation with a dogfood-signed artifact and production App Attest/APNs configuration remains a release step. ## Independent pre-reviews - **First pass:** [Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585) found missing transient retries, executor-key rotation suppression, duplicate installation renewal, and an unauthenticated challenge write amplifier. These were resolved by [retry-safe bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62) and [authenticated renewal plus a cross-replica quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd). [sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962) found delegation generation burning and an edited applied migration, resolved by [exact-generation revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d) and a [forward-only migration](https://github.com/block/buzz/pull/6269/changes/956c1d099). [k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1) found no blockers. - **Exact-head re-review:** [Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68), [sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849), and [k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb) independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also revalidated every remediation and the endpoint-specific App Attest enrollment bound. --------- Signed-off-by: Tom Brow Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: Codex Co-authored-by: Jordan Mecom --- .env.example | 6 + .github/workflows/ci.yml | 14 + .intersect/sadscan.yaml | 11 + Cargo.lock | 164 -- Justfile | 7 +- crates/buzz-db/src/runtime/migration.rs | 25 +- crates/buzz-db/src/store/push.rs | 25 +- crates/buzz-push-gateway/Cargo.toml | 3 +- .../migrations/0002_application_profiles.sql | 18 + .../0003_challenge_issuance_quota.sql | 4 + .../migrations/0004_dogfood_only_profile.sql | 16 + crates/buzz-push-gateway/src/apns.rs | 365 ++--- crates/buzz-push-gateway/src/app_attest.rs | 213 ++- crates/buzz-push-gateway/src/authority.rs | 349 ++++- crates/buzz-push-gateway/src/config.rs | 166 ++- crates/buzz-push-gateway/src/grant.rs | 2 +- crates/buzz-push-gateway/src/http.rs | 369 ++++- crates/buzz-push-gateway/src/main.rs | 31 +- crates/buzz-push-gateway/src/metrics.rs | 22 +- crates/buzz-push-gateway/src/model.rs | 13 +- crates/buzz-push-gateway/src/postgres.rs | 250 +++- .../tests/fixtures/apns-test-cert-only.pem | 11 + .../fixtures/apns-test-encrypted-identity.pem | 19 + .../tests/fixtures/apns-test-identity.pem | 16 + .../tests/fixtures/apns-test-key-only.pem | 5 + .../apns-test-mismatched-identity.pem | 16 + .../tests/fixtures/app-attest-good.json | 9 + .../fixtures/app-attest-wrong-aaguid.json | 9 + .../tests/fixtures/app-attest-wrong-root.json | 9 + .../fixtures/apple-app-attestation-root.pem | 14 + .../tests/vectors/app_attest_transcripts.json | 48 + crates/buzz-relay/src/config.rs | 61 +- crates/buzz-relay/src/handlers/push_lease.rs | 92 +- crates/buzz-relay/src/main.rs | 11 +- crates/buzz-relay/src/nip11.rs | 12 +- crates/buzz-relay/src/push_runtime.rs | 132 +- .../templates/deployment.yaml | 15 +- .../templates/prometheusrule.yaml | 6 +- .../tests/release-contract.sh | 55 +- .../charts/buzz-push-gateway/tests/render.sh | 165 ++- .../buzz-push-gateway/values-production.yaml | 4 +- .../buzz-push-gateway/values.schema.json | 77 +- deploy/charts/buzz-push-gateway/values.yaml | 16 +- docs/nips/NIP-PL.md | 28 +- docs/push-gateway-deployment.md | 131 +- migrations/0040_push_message_kinds.sql | 24 + mobile/.env.json.example | 1 + mobile/README.md | 53 +- mobile/ios/.gitignore | 1 + mobile/ios/BuzzPushKit/Package.swift | 27 + .../BuzzPushKit/APNsRegistrationBuffer.swift | 40 + .../BuzzCommunicationNotification.swift | 154 ++ .../BuzzDevPushEnrollmentDriver.swift | 995 +++++++++++++ .../BuzzPushNavigationTarget.swift | 85 ++ .../BuzzPushNotificationResolver.swift | 647 ++++++++ .../BuzzPushPendingEnrollmentRecord.swift | 45 + .../BuzzPushPresentationCache.swift | 788 ++++++++++ .../BuzzPushKit/BuzzPushTranscript.swift | 227 +++ .../Sources/BuzzPushKit/NostrHTTPAuth.swift | 143 ++ .../Sources/BuzzPushKit/PushLease.swift | 143 ++ .../APNsRegistrationBufferTests.swift | 29 + .../BuzzDevPushEnrollmentDriverTests.swift | 1313 +++++++++++++++++ .../BuzzPushConversationResolverTests.swift | 266 ++++ .../BuzzPushNavigationTargetTests.swift | 77 + .../BuzzPushNotificationResolverTests.swift | 852 +++++++++++ .../BuzzPushPresentationCacheTests.swift | 748 ++++++++++ .../BuzzPushTranscriptTests.swift | 159 ++ .../Tests/BuzzPushKitTests/Fixtures | 1 + .../BuzzPushKitTests/NostrHTTPAuthTests.swift | 76 + .../BuzzPushKitTests/PushLeaseTests.swift | 84 ++ mobile/ios/Flutter/Debug.xcconfig | 16 +- mobile/ios/Flutter/Release.xcconfig | 10 +- mobile/ios/NotificationService/Info.plist | 35 + .../NotificationService.entitlements | 14 + .../NotificationService.swift | 139 ++ mobile/ios/Runner.xcodeproj/project.pbxproj | 210 ++- .../xcshareddata/swiftpm/Package.resolved | 15 + mobile/ios/Runner/AppDelegate.swift | 305 +++- mobile/ios/Runner/Info.plist | 8 + .../ios/Runner/PushEndpointGrantStore.swift | 154 ++ mobile/ios/Runner/PushNativeState.swift | 80 + mobile/ios/Runner/PushSnapshotBridge.swift | 271 ++++ mobile/ios/Runner/Runner.entitlements | 20 + .../BuzzCommunicationNotificationTests.swift | 282 ++++ mobile/ios/RunnerTests/RunnerTests.swift | 9 + mobile/lib/app.dart | 7 + .../channels/channel_member_snapshots.dart | 37 + .../features/channels/channels_provider.dart | 51 +- .../channels/deep_link_dispatcher.dart | 36 + .../lib/features/settings/settings_page.dart | 4 + .../settings_page/connection_section.dart | 13 +- .../settings_page/notifications_section.dart | 79 + mobile/lib/main.dart | 10 +- mobile/lib/shared/auth/auth_provider.dart | 37 +- mobile/lib/shared/community/community.dart | 89 +- .../shared/community/community_provider.dart | 433 +++++- mobile/lib/shared/deeplink/deep_link.dart | 11 +- .../deeplink/pending_deep_link_provider.dart | 62 +- .../shared/profile/user_cache_provider.dart | 6 + mobile/lib/shared/push/dev_push_lease.dart | 658 +++++++++ mobile/lib/shared/push/push_bootstrap.dart | 395 +++++ mobile/lib/shared/push/push_bridge.dart | 326 ++++ .../push/push_lease_revocation_outbox.dart | 553 +++++++ .../shared/push/push_presentation_cache.dart | 240 +++ .../push/push_relay_capability_provider.dart | 61 + mobile/lib/shared/push/push_snapshot.dart | 53 + mobile/lib/shared/push/push_subscription.dart | 579 ++++++++ .../push/push_subscription_provider.dart | 62 + mobile/lib/shared/relay/media_image.dart | 9 + mobile/lib/shared/relay/relay_provider.dart | 7 +- .../lib/shared/relay/signed_event_relay.dart | 73 + mobile/lib/shared/widgets/avatar_image.dart | 51 +- .../channels/deep_link_dispatcher_test.dart | 81 + .../features/settings/settings_page_test.dart | 150 ++ .../test/shared/auth/auth_provider_test.dart | 166 ++- .../community/community_provider_test.dart | 369 ++++- .../community/community_storage_test.dart | 78 + .../test/shared/community/community_test.dart | 30 + .../pending_deep_link_provider_test.dart | 49 + .../test/shared/push/dev_push_lease_test.dart | 436 ++++++ .../test/shared/push/push_bootstrap_test.dart | 204 +++ mobile/test/shared/push/push_bridge_test.dart | 417 ++++++ .../push_lease_revocation_outbox_test.dart | 331 +++++ .../push/push_presentation_cache_test.dart | 144 ++ .../push_relay_capability_provider_test.dart | 73 + .../test/shared/push/push_snapshot_test.dart | 23 + .../push/push_subscription_provider_test.dart | 66 + .../shared/push/push_subscription_test.dart | 154 ++ .../shared/widgets/avatar_image_test.dart | 10 + schema/schema.sql | 2 +- scripts/mobile-worktree-clean.sh | 12 +- scripts/mobile-worktree-overrides.sh | 2 +- scripts/test-mobile-worktree-overrides.sh | 16 +- 133 files changed, 17470 insertions(+), 865 deletions(-) create mode 100644 crates/buzz-push-gateway/migrations/0002_application_profiles.sql create mode 100644 crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql create mode 100644 crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-good.json create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json create mode 100644 crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json create mode 100644 crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem create mode 100644 crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json create mode 100644 migrations/0040_push_message_kinds.sql create mode 100644 mobile/ios/BuzzPushKit/Package.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift create mode 120000 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift create mode 100644 mobile/ios/NotificationService/Info.plist create mode 100644 mobile/ios/NotificationService/NotificationService.entitlements create mode 100644 mobile/ios/NotificationService/NotificationService.swift create mode 100644 mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 mobile/ios/Runner/PushEndpointGrantStore.swift create mode 100644 mobile/ios/Runner/PushNativeState.swift create mode 100644 mobile/ios/Runner/PushSnapshotBridge.swift create mode 100644 mobile/ios/Runner/Runner.entitlements create mode 100644 mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift create mode 100644 mobile/lib/features/channels/channel_member_snapshots.dart create mode 100644 mobile/lib/features/settings/settings_page/notifications_section.dart create mode 100644 mobile/lib/shared/push/dev_push_lease.dart create mode 100644 mobile/lib/shared/push/push_bootstrap.dart create mode 100644 mobile/lib/shared/push/push_bridge.dart create mode 100644 mobile/lib/shared/push/push_lease_revocation_outbox.dart create mode 100644 mobile/lib/shared/push/push_presentation_cache.dart create mode 100644 mobile/lib/shared/push/push_relay_capability_provider.dart create mode 100644 mobile/lib/shared/push/push_snapshot.dart create mode 100644 mobile/lib/shared/push/push_subscription.dart create mode 100644 mobile/lib/shared/push/push_subscription_provider.dart create mode 100644 mobile/test/shared/deeplink/pending_deep_link_provider_test.dart create mode 100644 mobile/test/shared/push/dev_push_lease_test.dart create mode 100644 mobile/test/shared/push/push_bootstrap_test.dart create mode 100644 mobile/test/shared/push/push_bridge_test.dart create mode 100644 mobile/test/shared/push/push_lease_revocation_outbox_test.dart create mode 100644 mobile/test/shared/push/push_presentation_cache_test.dart create mode 100644 mobile/test/shared/push/push_relay_capability_provider_test.dart create mode 100644 mobile/test/shared/push/push_snapshot_test.dart create mode 100644 mobile/test/shared/push/push_subscription_provider_test.dart create mode 100644 mobile/test/shared/push/push_subscription_test.dart diff --git a/.env.example b/.env.example index 42f403e7a59..02e907b8cfd 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,12 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# NIP-PL mobile push is an explicit deployment opt-in. A gateway URL alone +# never enables it. When enabled and the URL is absent, the canonical +# https://push.buzz.xyz/v1/deliveries/apns endpoint is used. +BUZZ_PUSH_ENABLED=false +# BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns + # ----------------------------------------------------------------------------- # Admin Dashboard (private moderation surface) # ----------------------------------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eb63944e41..25a59c32432 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1007,6 +1007,20 @@ jobs: - name: Build Android debug APK run: just mobile-build-android + mobile-swift: + name: Mobile Swift + runs-on: macos-latest + timeout-minutes: 10 + needs: [changes] + if: needs.changes.outputs.mobile == 'true' + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Build + run: swift build --package-path mobile/ios/BuzzPushKit + - name: Build release + run: swift build -c release --package-path mobile/ios/BuzzPushKit + - name: Test + run: swift test --package-path mobile/ios/BuzzPushKit security: name: Security runs-on: ubuntu-latest diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml index a321714bcb5..77710ae0d98 100644 --- a/.intersect/sadscan.yaml +++ b/.intersect/sadscan.yaml @@ -2,3 +2,14 @@ exclude_rules_for_files: sq.pii.cc.visa: - Cargo.lock + # Self-signed test fixture generated solely to exercise reqwest identity parsing. + kingfisher.privkey.2: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" + np.pem.1: + - "*apns-test-identity.pem" + - "*apns-test-key-only.pem" + - "*apns-test-encrypted-identity.pem" + - "*apns-test-mismatched-identity.pem" diff --git a/Cargo.lock b/Cargo.lock index d436016fd62..9544a63b899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1238,7 +1238,6 @@ dependencies = [ "metrics-exporter-prometheus", "minicbor", "nostr 0.44.7", - "p256", "proptest", "rand 0.10.1", "reqwest 0.13.4", @@ -1919,12 +1918,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -2077,22 +2070,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -2110,9 +2087,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] @@ -2180,7 +2155,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -2626,21 +2600,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der", - "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature 3.0.0", - "spki", - "zeroize", -] - [[package]] name = "ed25519" version = "3.0.0" @@ -2677,27 +2636,6 @@ dependencies = [ "serde", ] -[[package]] -name = "elliptic-curve" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core 0.10.1", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "embedded-io" version = "0.4.0" @@ -2900,16 +2838,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3334,17 +3262,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" version = "0.4.16" @@ -3687,9 +3604,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -6622,19 +6537,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.0", -] - [[package]] name = "palette" version = "0.7.6" @@ -7119,33 +7021,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint", - "crypto-common 0.2.2", - "ff", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -8049,16 +7924,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint", - "hmac 0.13.0", -] - [[package]] name = "ring" version = "0.17.14" @@ -8418,20 +8283,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - [[package]] name = "secp256k1" version = "0.29.1" @@ -8864,10 +8715,6 @@ name = "signature" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] [[package]] name = "simd-adler32" @@ -11562,17 +11409,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "wnaf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" -dependencies = [ - "ff", - "group", - "hybrid-array", -] - [[package]] name = "writeable" version = "0.6.3" diff --git a/Justfile b/Justfile index 6da2bb8b483..32d83355e1c 100644 --- a/Justfile +++ b/Justfile @@ -326,9 +326,10 @@ test-unit: cargo nextest run -p buzz-voice --lib cargo nextest run -p buzz-cli # buzz-db migrator/lint tests: pure SQL-parsing unit tests (no infra). - # They guard the embedded-migrator invariant (exactly the consolidated - # 0001; cutover/backfill stays an operator script, not startup state) - # and the tenant-scoping lints. The Postgres-backed buzz-db tests are + # They guard the embedded-migrator invariant (the complete checked-in + # additive migration set; legacy cutover/backfill remains an operator + # script, not startup state) and the tenant-scoping lints. The + # Postgres-backed buzz-db tests are # #[ignore]d, so --lib runs only the infra-free set. Without this gate a # stray file in migrations/ or a broken lint ships green. cargo nextest run -p buzz-db --lib diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index f258fa64411..66251563cbd 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -1,8 +1,9 @@ //! Embedded SQLx migrations for Buzz. //! -//! Fresh deployments apply the checked-in SQL files under `migrations/`. The -//! multi-tenant rewrite owns a clean consolidated `0001`; legacy single-tenant -//! cutover/backfill is a separate operator script, not startup migration state. +//! Fresh deployments apply the checked-in additive SQL files under +//! `migrations/`. The multi-tenant rewrite begins from a clean consolidated +//! `0001`; legacy single-tenant cutover/backfill is a separate operator script, +//! not startup migration state. use std::future::Future; @@ -689,7 +690,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 39); + assert_eq!(migrations.len(), 40); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1312,6 +1313,22 @@ mod tests { assert!(include_str!("../../../../schema/schema.sql").contains("error_code TEXT")); } + #[test] + fn push_match_trigger_is_narrowed_to_message_kinds_additively() { + let mut migrations: Vec<_> = MIGRATOR.iter().collect(); + migrations.sort_by_key(|migration| migration.version); + + assert_eq!(migrations[39].version, 40); + let sql = migrations[39].sql.as_str(); + assert!(sql.contains("CREATE OR REPLACE FUNCTION enqueue_push_match_job")); + assert!(sql.contains("NEW.kind IN (9, 40002, 45001, 45003)")); + assert!(!sql.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + + let desired_schema = include_str!("../../../../schema/schema.sql"); + assert!(desired_schema.contains("NEW.kind IN (9, 40002, 45001, 45003)")); + assert!(!desired_schema.contains("NEW.kind IN (7, 9, 1059, 40007, 46010)")); + } + #[test] fn migration_lint_detects_tables_missing_community_id_by_default() { let sql = r#" diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index fc94843a9c2..9133b82e716 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -59,7 +59,7 @@ async fn backfill_push_match_jobs( "INSERT INTO push_match_queue (community_id, event_id) \ SELECT community_id, id FROM events \ WHERE community_id = $1 \ - AND kind IN (7, 9, 1059, 40007, 46010) \ + AND kind IN (9, 40002, 45001, 45003) \ AND deleted_at IS NULL \ AND received_at > now() - make_interval(secs => $2) \ ON CONFLICT DO NOTHING", @@ -162,6 +162,8 @@ pub struct ClaimedWake { pub class: String, /// Delivery deadline, in Unix seconds. pub expires_at: i64, + /// Time this durable wake entered the relay outbox. + pub queued_at: DateTime, /// Attempt number, starting at one for the first claim. pub attempt: i32, } @@ -236,11 +238,17 @@ pub async fn accept_lease_event( address_lock.extend_from_slice(community.as_uuid().as_bytes()); address_lock.extend_from_slice(author); address_lock.extend_from_slice(installation_id.as_bytes()); - let address_lock = i64::from_le_bytes(Sha256::digest(&address_lock)[..8].try_into().unwrap()); + let address_digest = Sha256::digest(&address_lock); + let mut address_lock_bytes = [0_u8; 8]; + address_lock_bytes.copy_from_slice(&address_digest[..8]); + let address_lock = i64::from_le_bytes(address_lock_bytes); let mut author_lock = Vec::with_capacity(16 + author.len()); author_lock.extend_from_slice(community.as_uuid().as_bytes()); author_lock.extend_from_slice(author); - let author_lock = i64::from_le_bytes(Sha256::digest(&author_lock)[..8].try_into().unwrap()); + let author_digest = Sha256::digest(&author_lock); + let mut author_lock_bytes = [0_u8; 8]; + author_lock_bytes.copy_from_slice(&author_digest[..8]); + let author_lock = i64::from_le_bytes(author_lock_bytes); crate::observability::observe_advisory_lock( crate::observability::LockType::PushGate, sqlx::query("SELECT pg_advisory_xact_lock($1)") @@ -616,10 +624,10 @@ pub async fn enqueue_wake( }], ) .await?; - Ok(outcomes + outcomes .into_iter() .next() - .expect("one outcome per request")) + .ok_or_else(|| crate::DbError::InvalidData("missing wake enqueue outcome".into())) } /// Set-wise counterpart of [`enqueue_wake`]: one transaction and a constant @@ -1085,7 +1093,8 @@ pub async fn claim_due_wakes( AND l.endpoint_hash = o.endpoint_hash RETURNING o.community_id, o.id, o.claim_id, o.event_id, c.channel_id, o.author, o.installation_id, o.lease_generation, - l.endpoint_grant, o.class, o.expires_at, o.attempts + l.endpoint_grant, o.class, o.expires_at, o.created_at AS queued_at, + o.attempts "#, ) .bind(community.as_uuid()) @@ -1113,7 +1122,8 @@ pub async fn revalidate_wake_for_send( r#" SELECT o.community_id, o.id, o.claim_id, o.event_id, e.channel_id, o.author, o.installation_id, o.lease_generation, - l.endpoint_grant, o.class, o.expires_at, o.attempts + l.endpoint_grant, o.class, o.expires_at, o.created_at AS queued_at, + o.attempts FROM push_wake_outbox o JOIN push_leases l ON l.community_id = o.community_id @@ -1276,6 +1286,7 @@ fn row_to_claimed_wake(row: sqlx::postgres::PgRow) -> Result { endpoint_grant: row.try_get("endpoint_grant")?, class: row.try_get("class")?, expires_at: row.try_get("expires_at")?, + queued_at: row.try_get("queued_at")?, attempt: row.try_get("attempts")?, }) } diff --git a/crates/buzz-push-gateway/Cargo.toml b/crates/buzz-push-gateway/Cargo.toml index aec3c43b026..06376c02dc5 100644 --- a/crates/buzz-push-gateway/Cargo.toml +++ b/crates/buzz-push-gateway/Cargo.toml @@ -29,9 +29,8 @@ getrandom = "0.4" metrics = { workspace = true } metrics-exporter-prometheus = { workspace = true } nostr = { workspace = true } -p256 = { version = "0.14", features = ["ecdsa", "pem", "pkcs8"] } rand = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["http2"] } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } diff --git a/crates/buzz-push-gateway/migrations/0002_application_profiles.sql b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql new file mode 100644 index 00000000000..45be402dc07 --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0002_application_profiles.sql @@ -0,0 +1,18 @@ +-- The original profile names encoded APNs transport environment, not a +-- verified application identity. They therefore cannot be mapped safely to +-- either closed bundle profile. Retire the pre-profile demo authority and let +-- clients re-attest under the exact server-owned application profile. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id FROM push_gateway_installations + WHERE app_profile IN ('buzz-ios-production', 'buzz-ios-sandbox') +); + +DELETE FROM push_gateway_installations +WHERE app_profile IN ('buzz-ios-production', 'buzz-ios-sandbox'); + +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_check; +ALTER TABLE push_gateway_installations + ADD CONSTRAINT push_gateway_installations_app_profile_check + CHECK (app_profile IN ('buzz-ios-dogfood', 'buzz-ios-app-store')); diff --git a/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql b/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql new file mode 100644 index 00000000000..cc8222f6c6d --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0003_challenge_issuance_quota.sql @@ -0,0 +1,4 @@ +-- The unauthenticated challenge route applies a deployment-global rolling +-- issuance quota. Keep its count query bounded as challenge volume grows. +CREATE INDEX push_gateway_challenges_created_at + ON push_gateway_challenges (created_at); diff --git a/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql b/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql new file mode 100644 index 00000000000..2274219d6ef --- /dev/null +++ b/crates/buzz-push-gateway/migrations/0004_dogfood_only_profile.sql @@ -0,0 +1,16 @@ +-- The internal MVP now exposes only the dogfood application profile. Retire +-- dormant App Store authority before narrowing the server-owned registry. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id FROM push_gateway_installations + WHERE app_profile = 'buzz-ios-app-store' +); + +DELETE FROM push_gateway_installations +WHERE app_profile = 'buzz-ios-app-store'; + +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_check; +ALTER TABLE push_gateway_installations + ADD CONSTRAINT push_gateway_installations_app_profile_check + CHECK (app_profile = 'buzz-ios-dogfood'); diff --git a/crates/buzz-push-gateway/src/apns.rs b/crates/buzz-push-gateway/src/apns.rs index 8f6f1820001..f8f19486c13 100644 --- a/crates/buzz-push-gateway/src/apns.rs +++ b/crates/buzz-push-gateway/src/apns.rs @@ -1,21 +1,13 @@ //! APNs envelope construction, endpoint encryption, and response classification. -use std::{sync::Mutex, time::Duration}; +use std::time::Duration; use async_trait::async_trait; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use p256::{ - ecdsa::{signature::Signer, Signature, SigningKey}, - pkcs8::DecodePrivateKey, -}; -use reqwest::{ - header::{AUTHORIZATION, CONTENT_TYPE}, - StatusCode, -}; +use reqwest::{header::CONTENT_TYPE, StatusCode}; use serde::Deserialize; use thiserror::Error; -use crate::model::{AppProfile, APNS_RECONNECT_PAYLOAD}; +use crate::{config::ApnsEnvironment, model::APNS_RECONNECT_PAYLOAD}; /// Sanitized delivery outcome. Raw provider bodies never cross this boundary. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,8 +24,6 @@ pub enum DeliveryOutcome { /// Retry-After delay in seconds, clamped by the transport. retry_after_seconds: Option, }, - /// Refresh the cached provider JWT, then retry once within normal attempt bounds. - RefreshCredential, /// Provider credential/profile configuration is unhealthy; do not invalidate endpoints. ConfigurationFault, /// The locally-generated request is permanently invalid. @@ -47,12 +37,13 @@ pub fn classify(code: u16, reason: Option<&str>, timestamp: Option) -> Deli (410, Some("Unregistered")) => DeliveryOutcome::InvalidEndpoint { unregistered_at: timestamp, }, + // Both reasons are ambiguous with deployment profile mistakes: APNs + // uses BadDeviceToken for environment mismatches and + // DeviceTokenNotForTopic for topic mismatches. Only Unregistered + // crosses the permanent endpoint-invalidation boundary. (400, Some("BadDeviceToken" | "DeviceTokenNotForTopic")) => { - DeliveryOutcome::InvalidEndpoint { - unregistered_at: None, - } + DeliveryOutcome::ConfigurationFault } - (403, Some("ExpiredProviderToken")) => DeliveryOutcome::RefreshCredential, (403, _) | (429, Some("TooManyProviderTokenUpdates")) => { DeliveryOutcome::ConfigurationFault } @@ -85,110 +76,84 @@ pub struct DeliveryAttempt { #[async_trait] pub trait PushTransport: Send + Sync { /// Send one durable job. - async fn send( - &self, - attempt: DeliveryAttempt, - profile: AppProfile, - endpoint: &str, - ) -> DeliveryOutcome; - /// Discard a cached credential after APNs reports expiry. - fn refresh_credential(&self) {} + async fn send(&self, attempt: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome; } -struct CachedJwt { - token: String, - issued_at: i64, -} - -/// Direct HTTP/2 APNs transport using a cached ES256 provider token. +/// Direct HTTP/2 APNs transport using a client certificate identity. pub struct ApnsTransport { client: reqwest::Client, - signing_key: SigningKey, - key_id: String, - team_id: String, topic: String, - production_base_url: String, - sandbox_base_url: String, - cached_jwt: Mutex>, + base_url: String, } impl ApnsTransport { - /// Build a reusable APNs client from an Apple `.p8` private key. - pub fn token(p8: &[u8], key_id: &str, team_id: &str, topic: String) -> Result { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build() - .map_err(|_| ApnsError::Client)?; - Self::token_with_client( - p8, - key_id, - team_id, - topic, - client, - "https://api.push.apple.com".to_owned(), - "https://api.sandbox.push.apple.com".to_owned(), - ) + /// Build a reusable APNs client from a combined PEM private key and certificate. + pub fn certificate( + identity_pem: &[u8], + topic: String, + environment: ApnsEnvironment, + ) -> Result { + let base_url = match environment { + ApnsEnvironment::Production => "https://api.push.apple.com", + ApnsEnvironment::Sandbox => "https://api.sandbox.push.apple.com", + }; + Self::certificate_with_base_url(identity_pem, topic, base_url.to_owned()) } - fn token_with_client( - p8: &[u8], - key_id: &str, - team_id: &str, + fn certificate_with_base_url( + identity_pem: &[u8], topic: String, - client: reqwest::Client, - production_base_url: String, - sandbox_base_url: String, + base_url: String, ) -> Result { - let pem = std::str::from_utf8(p8).map_err(|_| ApnsError::Credential)?; - let signing_key = SigningKey::from_pkcs8_pem(pem).map_err(|_| ApnsError::Credential)?; + let identity = + reqwest::Identity::from_pem(identity_pem).map_err(|_| ApnsError::Credential)?; + let client = reqwest::Client::builder() + // APNs requires HTTP/2. This no-op method reference is intentionally + // feature-gated so removing reqwest's `http2` feature fails the build. + .http2_keep_alive_while_idle(false) + .identity(identity) + .timeout(Duration::from_secs(15)) + // Identity validation completes while the TLS client is built, so a + // malformed or mismatched certificate/key pair is a credential error. + .build() + .map_err(|_| ApnsError::Credential)?; Ok(Self { client, - signing_key, - key_id: key_id.to_owned(), - team_id: team_id.to_owned(), topic, - production_base_url, - sandbox_base_url, - cached_jwt: Mutex::new(None), + base_url, }) } - fn jwt(&self, now: i64) -> Result { - let mut cached = self.cached_jwt.lock().map_err(|_| ApnsError::Credential)?; - if let Some(jwt) = cached.as_ref().filter(|jwt| now - jwt.issued_at < 50 * 60) { - return Ok(jwt.token.clone()); - } - let header = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"alg":"ES256","kid":self.key_id})) - .map_err(|_| ApnsError::Credential)?, - ); - let claims = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&serde_json::json!({"iss":self.team_id,"iat":now})) - .map_err(|_| ApnsError::Credential)?, - ); - let signing_input = format!("{header}.{claims}"); - let signature: Signature = self.signing_key.sign(signing_input.as_bytes()); - let token = format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature.to_bytes()) - ); - *cached = Some(CachedJwt { - token: token.clone(), - issued_at: now, - }); - Ok(token) + fn request(&self, attempt: DeliveryAttempt, endpoint: &str) -> reqwest::RequestBuilder { + self.client + .post(format!("{}/3/device/{endpoint}", self.base_url)) + .header(CONTENT_TYPE, "application/json") + .header("apns-id", attempt.request_id.to_string()) + .header("apns-topic", &self.topic) + .header("apns-push-type", "alert") + .header("apns-priority", "10") + .header("apns-expiration", attempt.expires_at.to_string()) + // This is the only APNs application body in the program. It is a + // byte constant, not a serialization of the relay request, grant, + // endpoint, headers, route, provider response, or any generic JSON map. + .body(APNS_RECONNECT_PAYLOAD) + } + + async fn send_response( + &self, + attempt: DeliveryAttempt, + endpoint: &str, + ) -> Result { + self.request(attempt, endpoint).send().await } } /// APNs transport setup failure. It intentionally carries no credential material. #[derive(Debug, Error)] pub enum ApnsError { - /// Invalid provider key material. + /// Invalid client certificate identity material. #[error("invalid APNs credential")] Credential, - /// HTTP client setup failed. - #[error("failed to construct APNs client")] - Client, } #[derive(Deserialize)] @@ -199,38 +164,9 @@ struct ApnsErrorBody { #[async_trait] impl PushTransport for ApnsTransport { - async fn send( - &self, - attempt: DeliveryAttempt, - profile: AppProfile, - endpoint: &str, - ) -> DeliveryOutcome { - // This is the only APNs application body in the program. It is a - // byte constant, not a serialization of the relay request, grant, - // endpoint, headers, route, provider response, or any generic JSON map. - let body = APNS_RECONNECT_PAYLOAD; - let now = chrono::Utc::now().timestamp(); - let token = match self.jwt(now) { - Ok(token) => token, - Err(_) => return DeliveryOutcome::ConfigurationFault, - }; - let base_url = match profile { - AppProfile::BuzzIosProduction => &self.production_base_url, - AppProfile::BuzzIosSandbox => &self.sandbox_base_url, - }; - let response = self - .client - .post(format!("{base_url}/3/device/{endpoint}")) - .header(AUTHORIZATION, format!("bearer {token}")) - .header(CONTENT_TYPE, "application/json") - .header("apns-id", attempt.request_id.to_string()) - .header("apns-topic", &self.topic) - .header("apns-push-type", "alert") - .header("apns-priority", "10") - .header("apns-expiration", attempt.expires_at.to_string()) - .body(body) - .send() - .await; + async fn send(&self, attempt: DeliveryAttempt, endpoint: &str) -> DeliveryOutcome { + crate::metrics::record_apns_send_attempt(); + let response = self.send_response(attempt, endpoint).await; let response = match response { Ok(response) => response, Err(_) => { @@ -262,63 +198,66 @@ impl PushTransport for ApnsTransport { outcome => outcome, } } - - fn refresh_credential(&self) { - if let Ok(mut cached) = self.cached_jwt.lock() { - *cached = None; - } - } } #[cfg(test)] mod tests { use super::*; - use axum::{body::Bytes, extract::State, http::StatusCode, routing::post, Router}; - use p256::pkcs8::{EncodePrivateKey, LineEnding}; - use std::sync::Arc; + use axum::{ + body::Bytes, + extract::State, + http::{HeaderMap, StatusCode}, + routing::post, + Router, + }; + use std::sync::{Arc, Mutex}; + + // Self-signed test-only identity material. None of these are Apple credentials. + const TEST_IDENTITY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-identity.pem"); + const TEST_CERT_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-cert-only.pem"); + const TEST_KEY_ONLY_PEM: &[u8] = include_bytes!("../tests/fixtures/apns-test-key-only.pem"); + const TEST_ENCRYPTED_IDENTITY_PEM: &[u8] = + include_bytes!("../tests/fixtures/apns-test-encrypted-identity.pem"); + const TEST_MISMATCHED_IDENTITY_PEM: &[u8] = + include_bytes!("../tests/fixtures/apns-test-mismatched-identity.pem"); + + #[derive(Default)] + struct CapturedRequest { + headers: HeaderMap, + body: Vec, + } - async fn capture_body( - State(bodies): State>>>>, + async fn capture_request( + State(requests): State>>>, + headers: HeaderMap, body: Bytes, ) -> StatusCode { - bodies.lock().unwrap().push(body.to_vec()); + requests.lock().unwrap().push(CapturedRequest { + headers, + body: body.to_vec(), + }); StatusCode::OK } + #[tokio::test] - async fn real_outbound_http_body_is_the_exact_constant_for_every_attempt() { - let bodies = Arc::new(Mutex::new(Vec::new())); + async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() { + let requests = Arc::new(Mutex::new(Vec::new())); let app = Router::new() - .route("/3/device/{endpoint}", post(capture_body)) - .with_state(bodies.clone()); + .route("/3/device/{endpoint}", post(capture_request)) + .with_state(requests.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let signing_key = SigningKey::from_slice(&[7; 32]).unwrap(); - let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap(); - let transport = ApnsTransport::token_with_client( - pem.as_bytes(), - "kid", - "team", + let transport = ApnsTransport::certificate_with_base_url( + TEST_IDENTITY_PEM, "app.topic".to_owned(), - reqwest::Client::new(), - base_url.clone(), base_url, ) .unwrap(); - for (request_id, expires_at, profile, endpoint) in [ - ( - uuid::Uuid::nil(), - 1, - AppProfile::BuzzIosProduction, - "00".repeat(32), - ), - ( - uuid::Uuid::max(), - i64::MAX, - AppProfile::BuzzIosSandbox, - "ff".repeat(32), - ), + for (request_id, expires_at, endpoint) in [ + (uuid::Uuid::nil(), 1, "00".repeat(32)), + (uuid::Uuid::max(), i64::MAX, "ff".repeat(32)), ] { assert_eq!( transport @@ -327,18 +266,94 @@ mod tests { request_id, expires_at, }, - profile, &endpoint, ) .await, DeliveryOutcome::Accepted ); } - let captured = bodies.lock().unwrap(); + let captured = requests.lock().unwrap(); assert_eq!(captured.len(), 2); assert!(captured .iter() - .all(|body| body.as_slice() == APNS_RECONNECT_PAYLOAD)); + .all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD)); + assert!(captured + .iter() + .all(|request| !request.headers.contains_key(reqwest::header::AUTHORIZATION))); + assert!(captured.iter().all(|request| request + .headers + .get("apns-topic") + .is_some_and(|topic| topic == "app.topic"))); + } + + #[tokio::test] + #[ignore = "requires the exported dogfood Apple Push Services PEM"] + async fn live_sandbox_probe_reports_literal_status_and_body() { + let cert_path = std::env::var("BUZZ_PUSH_LIVE_APNS_CERT_PATH") + .expect("set BUZZ_PUSH_LIVE_APNS_CERT_PATH to the dogfood identity PEM"); + let topic = std::env::var("BUZZ_PUSH_LIVE_APNS_TOPIC") + .expect("set BUZZ_PUSH_LIVE_APNS_TOPIC to the dogfood bundle id"); + let identity = std::fs::read(cert_path).unwrap(); + let transport = + ApnsTransport::certificate(&identity, topic, ApnsEnvironment::Sandbox).unwrap(); + let response = transport + .send_response( + DeliveryAttempt { + request_id: uuid::Uuid::nil(), + expires_at: chrono::Utc::now().timestamp() + 60, + }, + &"00".repeat(32), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.text().await.unwrap(); + eprintln!("live APNs response: status={status}, body={body}"); + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(body, r#"{"reason":"BadDeviceToken"}"#); + } + + #[test] + fn empty_certificate_identity_fails_as_a_credential_error() { + assert_credential_error(b""); + } + + #[test] + fn malformed_certificate_identity_fails_as_a_credential_error() { + assert_credential_error(b"not a PEM identity"); + } + + #[test] + fn certificate_without_private_key_fails_as_a_credential_error() { + assert_credential_error(TEST_CERT_ONLY_PEM); + } + + #[test] + fn private_key_without_certificate_fails_as_a_credential_error() { + assert_credential_error(TEST_KEY_ONLY_PEM); + } + + #[test] + fn encrypted_private_key_fails_as_a_credential_error() { + assert_credential_error(TEST_ENCRYPTED_IDENTITY_PEM); + } + + #[test] + fn mismatched_private_key_fails_as_a_credential_error() { + // reqwest parses both PEM blocks, then rejects the mismatched pair while + // building the TLS client. This locks the ClientBuilder error mapping. + assert_credential_error(TEST_MISMATCHED_IDENTITY_PEM); + } + + fn assert_credential_error(identity_pem: &[u8]) { + assert!(matches!( + ApnsTransport::certificate( + identity_pem, + "app.topic".to_owned(), + ApnsEnvironment::Production, + ), + Err(ApnsError::Credential) + )); } #[test] @@ -349,10 +364,18 @@ mod tests { unregistered_at: Some(7) } ); - assert_eq!( - classify(403, Some("InvalidProviderToken"), None), - DeliveryOutcome::ConfigurationFault - ); + for reason in ["InvalidProviderToken", "ExpiredProviderToken"] { + assert_eq!( + classify(403, Some(reason), None), + DeliveryOutcome::ConfigurationFault + ); + } + for reason in ["BadDeviceToken", "DeviceTokenNotForTopic"] { + assert_eq!( + classify(400, Some(reason), None), + DeliveryOutcome::ConfigurationFault + ); + } assert_eq!( classify(429, Some("TooManyRequests"), None), DeliveryOutcome::Retry { diff --git a/crates/buzz-push-gateway/src/app_attest.rs b/crates/buzz-push-gateway/src/app_attest.rs index ebb1fc56bc0..df655e23d88 100644 --- a/crates/buzz-push-gateway/src/app_attest.rs +++ b/crates/buzz-push-gateway/src/app_attest.rs @@ -6,7 +6,6 @@ use byteorder::{BigEndian, ByteOrder}; use sha2::{Digest, Sha256}; use thiserror::Error; -const MAX_ATTESTATION_BYTES: usize = 16 * 1024; const MAX_ASSERTION_BYTES: usize = 1024; const APPLE_APP_ATTEST_ROOT_PEM_SHA256: [u8; 32] = [ 0xc7, 0x78, 0xd0, 0x9a, 0xc3, 0x41, 0xf7, 0xfd, 0x9f, 0x8f, 0x3b, 0x19, 0xe2, 0xb8, 0x15, 0xaf, @@ -57,7 +56,7 @@ impl AppAttestVerifier { let cbor = STANDARD .decode(attestation_b64) .map_err(|_| AppAttestError::Invalid)?; - if cbor.is_empty() || cbor.len() > MAX_ATTESTATION_BYTES { + if cbor.is_empty() || cbor.len() > crate::model::MAX_APP_ATTESTATION_BYTES { return Err(AppAttestError::Invalid); } let challenge = std::str::from_utf8(client_data).map_err(|_| AppAttestError::Invalid)?; @@ -138,3 +137,213 @@ fn assertion_counter(cbor: &[u8]) -> Result { .ok_or(AppAttestError::Invalid)?; Ok(BigEndian::read_u32(&auth[33..37])) } + +#[cfg(test)] +mod tests { + use super::*; + use appattest::error::AppAttestError as DependencyAppAttestError; + use serde::Deserialize; + + const GOOD_FIXTURE_JSON: &str = include_str!("../tests/fixtures/app-attest-good.json"); + const WRONG_AAGUID_FIXTURE_JSON: &str = + include_str!("../tests/fixtures/app-attest-wrong-aaguid.json"); + const WRONG_ROOT_FIXTURE_JSON: &str = + include_str!("../tests/fixtures/app-attest-wrong-root.json"); + const APPLE_ROOT_CERT_PEM: &[u8] = + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem"); + + #[derive(Deserialize)] + struct Fixture { + description: String, + app_id: String, + challenge: String, + aaguid: String, + attestation_b64: String, + key_id_b64: String, + root_cert_pem: String, + } + + fn fixture(json: &str) -> Fixture { + let fixture: Fixture = serde_json::from_str(json).expect("valid App Attest fixture JSON"); + assert!(!fixture.description.is_empty()); + fixture + } + + fn verifier(app_id: &str, root_cert_pem: &[u8]) -> AppAttestVerifier { + AppAttestVerifier { + app_id: app_id.to_owned(), + apple_root_cert_pem: root_cert_pem.to_vec(), + } + } + + fn verify_dependency( + fixture: &Fixture, + app_id: &str, + challenge: &str, + key_id_b64: &str, + root_cert_pem: &[u8], + ) -> Result<(), DependencyAppAttestError> { + let cbor = STANDARD + .decode(&fixture.attestation_b64) + .expect("fixture attestation is base64"); + let attestation = Attestation::from_cbor_bytes(&cbor)?; + let result = attestation + .verify(challenge, app_id, key_id_b64, root_cert_pem) + .map(|_| ()); + result + } + + #[test] + fn strict_verifier_accepts_good_fixture() { + let fixture = fixture(GOOD_FIXTURE_JSON); + assert_eq!(fixture.aaguid, "appattest"); + verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ) + .expect("strict dependency verifier accepts the generated encoding"); + + let verified = verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .expect("shipped gateway wrapper accepts the generated encoding"); + assert_eq!(verified.key_id.len(), 32); + assert_eq!(verified.public_key.len(), 65); + } + + #[test] + fn wrong_root_is_rejected() { + let good = fixture(GOOD_FIXTURE_JSON); + let wrong_root = fixture(WRONG_ROOT_FIXTURE_JSON); + assert!(verify_dependency( + &wrong_root, + &wrong_root.app_id, + &wrong_root.challenge, + &wrong_root.key_id_b64, + good.root_cert_pem.as_bytes(), + ) + .is_err()); + assert!(verifier(&wrong_root.app_id, good.root_cert_pem.as_bytes()) + .verify_attestation( + &wrong_root.attestation_b64, + &wrong_root.key_id_b64, + wrong_root.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_app_id_is_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let wrong_app_id = "TEAMID.xyz.buzz.wrong"; + assert_eq!( + verify_dependency( + &fixture, + wrong_app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidAppID) + ); + assert!(verifier(wrong_app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_challenge_is_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + let wrong_challenge = "wrong-challenge"; + assert_eq!( + verify_dependency( + &fixture, + &fixture.app_id, + wrong_challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidNonce) + ); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + wrong_challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn wrong_aaguid_is_rejected_as_invalid_aaguid() { + let fixture = fixture(WRONG_AAGUID_FIXTURE_JSON); + assert_eq!(fixture.aaguid, "appattestdevelop"); + assert_eq!( + verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &fixture.key_id_b64, + fixture.root_cert_pem.as_bytes(), + ), + Err(DependencyAppAttestError::InvalidAAGUID) + ); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &fixture.key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + + #[test] + fn short_and_oversize_key_ids_are_rejected() { + let fixture = fixture(GOOD_FIXTURE_JSON); + for key_id_b64 in [STANDARD.encode([0x11; 31]), STANDARD.encode([0x22; 33])] { + assert!(verify_dependency( + &fixture, + &fixture.app_id, + &fixture.challenge, + &key_id_b64, + fixture.root_cert_pem.as_bytes(), + ) + .is_err()); + assert!(verifier(&fixture.app_id, fixture.root_cert_pem.as_bytes()) + .verify_attestation( + &fixture.attestation_b64, + &key_id_b64, + fixture.challenge.as_bytes(), + ) + .is_err()); + } + } + + #[test] + #[allow(clippy::assertions_on_constants, unexpected_cfgs)] + fn gateway_test_build_does_not_define_testing_feature() { + assert!(!cfg!(feature = "testing")); + } + + #[test] + fn constructor_still_pins_the_apple_root() { + let fixture = fixture(GOOD_FIXTURE_JSON); + assert!( + AppAttestVerifier::new(fixture.app_id.clone(), APPLE_ROOT_CERT_PEM.to_vec()).is_ok() + ); + assert!( + AppAttestVerifier::new(fixture.app_id, fixture.root_cert_pem.as_bytes().to_vec(),) + .is_err() + ); + } +} diff --git a/crates/buzz-push-gateway/src/authority.rs b/crates/buzz-push-gateway/src/authority.rs index 36c220885cd..1a7ef4b2a65 100644 --- a/crates/buzz-push-gateway/src/authority.rs +++ b/crates/buzz-push-gateway/src/authority.rs @@ -15,9 +15,16 @@ use uuid::Uuid; pub struct Challenge { pub id: Uuid, pub value: [u8; 32], + pub created_at: i64, pub expires_at: i64, } +/// Challenge issuance is intentionally bounded inside the durable authority +/// store so the public unauthenticated route cannot amplify database writes +/// across gateway replicas. +pub(crate) const CHALLENGE_QUOTA_WINDOW_SECONDS: i64 = 60; +pub(crate) const CHALLENGE_QUOTA_MAX_REQUESTS: usize = 600; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct NewInstallation { pub id: Uuid, @@ -96,6 +103,8 @@ pub enum DeliveryDisposition { pub enum AuthorityError { #[error("authority state rejected the request")] Rejected, + #[error("authority request rate exceeded")] + RateLimited, #[error("authority store unavailable")] Unavailable, } @@ -116,7 +125,20 @@ pub trait AuthorityStore: Send + Sync { async fn create_installation( &self, installation: NewInstallation, + now: i64, ) -> Result<(), AuthorityError>; + /// Return an exact live installation previously committed for the same + /// attested enrollment request. This is the idempotency seam used when a + /// client loses the successful response and replays the signed request. + async fn matching_installation( + &self, + app_attest_key_id: &[u8], + profile: AppProfile, + token_fingerprint: [u8; 32], + endpoint_epoch: i64, + expires_at: i64, + now: i64, + ) -> Result, AuthorityError>; async fn installation(&self, id: Uuid, now: i64) -> Result; async fn advance_assertion_counter( &self, @@ -133,11 +155,13 @@ pub trait AuthorityStore: Send + Sync { token_ciphertext: Vec, token_fingerprint: [u8; 32], ) -> Result<(), AuthorityError>; + /// Revoke an active delegation only when `expected_generation` is current, + /// retaining that generation as the replacement watermark. async fn revoke_delegation( &self, installation_id: Uuid, relay_pubkey: &str, - new_generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError>; async fn revoke_installation( &self, @@ -202,6 +226,17 @@ impl AuthorityStore for MemoryAuthorityStore { async fn put_challenge(&self, challenge: Challenge) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + let window_start = challenge + .created_at + .saturating_sub(CHALLENGE_QUOTA_WINDOW_SECONDS); + if s.challenges + .values() + .filter(|existing| existing.created_at >= window_start) + .count() + >= CHALLENGE_QUOTA_MAX_REQUESTS + { + return Err(AuthorityError::RateLimited); + } if s.challenges.insert(challenge.id, challenge).is_some() { return Err(AuthorityError::Rejected); } @@ -222,13 +257,43 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(()) } - async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + async fn create_installation( + &self, + n: NewInstallation, + now: i64, + ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let token_key = (n.profile, n.token_fingerprint); - if s.installations.contains_key(&n.id) || s.token_owners.contains_key(&token_key) { - // Token possession alone never supersedes a live installation. + if s.installations.contains_key(&n.id) { return Err(AuthorityError::Rejected); } + let replaced = s + .installations + .values() + .filter(|installation| { + installation.app_attest_key_id == n.app_attest_key_id + || (installation.profile == n.profile + && installation.token_fingerprint == n.token_fingerprint) + }) + .map(|installation| installation.id) + .collect::>(); + if replaced.iter().any(|id| { + s.installations + .get(id) + .is_some_and(|installation| !installation.revoked && installation.expires_at >= now) + }) { + // App identity and token possession never supersede a live installation. + return Err(AuthorityError::Rejected); + } + for id in replaced { + if let Some(old) = s.installations.remove(&id) { + s.token_owners.remove(&(old.profile, old.token_fingerprint)); + } + s.delegations + .retain(|(installation_id, _), _| *installation_id != id); + s.delegation_ids + .retain(|_, (installation_id, _)| *installation_id != id); + } s.token_owners.insert(token_key, n.id); s.installations.insert( n.id, @@ -258,6 +323,30 @@ impl AuthorityStore for MemoryAuthorityStore { Ok(i.clone()) } + async fn matching_installation( + &self, + key_id: &[u8], + profile: AppProfile, + fingerprint: [u8; 32], + epoch: i64, + expires_at: i64, + now: i64, + ) -> Result, AuthorityError> { + let s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; + Ok(s.installations + .values() + .find(|installation| { + !installation.revoked + && installation.expires_at >= now + && installation.app_attest_key_id == key_id + && installation.profile == profile + && installation.token_fingerprint == fingerprint + && installation.endpoint_epoch == epoch + && installation.expires_at == expires_at + }) + .cloned()) + } + async fn advance_assertion_counter( &self, id: Uuid, @@ -281,15 +370,14 @@ impl AuthorityStore for MemoryAuthorityStore { async fn upsert_delegation(&self, d: Delegation) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; - let i = s + let installation = s .installations .get(&d.installation_id) .ok_or(AuthorityError::Rejected)?; - if i.revoked - || i.endpoint_epoch != d.endpoint_epoch + if installation.revoked + || installation.endpoint_epoch != d.endpoint_epoch || d.generation < 1 || d.not_before >= d.expires_at - || d.expires_at > i.expires_at { return Err(AuthorityError::Rejected); } @@ -301,6 +389,11 @@ impl AuthorityStore for MemoryAuthorityStore { { return Err(AuthorityError::Rejected); } + let installation = s + .installations + .get_mut(&d.installation_id) + .ok_or(AuthorityError::Rejected)?; + installation.expires_at = installation.expires_at.max(d.expires_at); s.delegation_ids.insert(d.id, key.clone()); s.delegations.insert(key, d); Ok(()) @@ -348,7 +441,7 @@ impl AuthorityStore for MemoryAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let mut s = self.0.lock().map_err(|_| AuthorityError::Unavailable)?; let key = (id, relay.to_owned()); @@ -356,10 +449,9 @@ impl AuthorityStore for MemoryAuthorityStore { .delegations .get_mut(&key) .ok_or(AuthorityError::Rejected)?; - if generation <= old.generation { + if old.revoked || expected_generation != old.generation { return Err(AuthorityError::Rejected); } - old.generation = generation; old.revoked = true; Ok(()) } @@ -514,17 +606,20 @@ mod tests { async fn store() -> MemoryAuthorityStore { let store = MemoryAuthorityStore::default(); store - .create_installation(NewInstallation { - id: Uuid::from_u128(1), - app_attest_key_id: vec![1], - app_attest_public_key: vec![2; 33], - assertion_counter: 0, - profile: AppProfile::BuzzIosProduction, - token_ciphertext: vec![3], - token_fingerprint: [4; 32], - endpoint_epoch: 1, - expires_at: 2_000, - }) + .create_installation( + NewInstallation { + id: Uuid::from_u128(1), + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 2_000, + }, + 1_000, + ) .await .unwrap(); store @@ -543,6 +638,151 @@ mod tests { store } + #[tokio::test] + async fn exact_enrollment_replay_recovers_committed_installation() { + let store = store().await; + + let recovered = store + .matching_installation(&[1], AppProfile::BuzzIosDogfood, [4; 32], 1, 2_000, 1_001) + .await + .unwrap() + .expect("exact replay finds the committed installation"); + + assert_eq!(recovered.id, Uuid::from_u128(1)); + assert!(store + .matching_installation(&[1], AppProfile::BuzzIosDogfood, [5; 32], 1, 2_000, 1_001,) + .await + .unwrap() + .is_none()); + } + + #[tokio::test] + async fn challenge_issuance_is_bounded_per_window() { + let store = MemoryAuthorityStore::default(); + for offset in 0..CHALLENGE_QUOTA_MAX_REQUESTS { + store + .put_challenge(Challenge { + id: Uuid::from_u128(offset as u128 + 1), + value: [offset as u8; 32], + created_at: 1_000, + expires_at: 1_300, + }) + .await + .expect("requests within the quota are admitted"); + } + assert_eq!( + store + .put_challenge(Challenge { + id: Uuid::new_v4(), + value: [0; 32], + created_at: 1_000, + expires_at: 1_300, + }) + .await, + Err(AuthorityError::RateLimited) + ); + store + .put_challenge(Challenge { + id: Uuid::new_v4(), + value: [0; 32], + created_at: 1_061, + expires_at: 1_361, + }) + .await + .expect("quota reopens after the rolling window"); + } + + #[tokio::test] + async fn authenticated_delegation_renews_installation_lifetime() { + let store = store().await; + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(3), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 2, + not_before: 1_900, + expires_at: 2_500, + revoked: false, + }) + .await + .expect("new delegation renews its installation"); + assert_eq!( + store + .installation(Uuid::from_u128(1), 2_400) + .await + .expect("renewed installation remains live") + .expires_at, + 2_500 + ); + + assert_eq!( + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(4), + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation: 2, + not_before: 2_000, + expires_at: 3_000, + revoked: false, + }) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store.installation(Uuid::from_u128(1), 2_600).await, + Err(AuthorityError::Rejected), + "a rejected delegation must not extend installation authority" + ); + } + + #[tokio::test] + async fn expired_installation_can_be_replaced_but_live_installation_cannot() { + let store = store().await; + let replacement = |id| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![5; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![6], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at: 3_000, + }; + + assert_eq!( + store + .create_installation(replacement(Uuid::from_u128(5)), 1_999) + .await, + Err(AuthorityError::Rejected) + ); + store + .create_installation(replacement(Uuid::from_u128(5)), 2_001) + .await + .expect("expired token and App Attest ownership can be replaced"); + assert!(store.installation(Uuid::from_u128(1), 2_001).await.is_err()); + assert!(store.installation(Uuid::from_u128(5), 2_001).await.is_ok()); + assert!(store + .authorize_delivery( + Uuid::from_u128(2), + &"11".repeat(32), + 1, + 1, + &"77".repeat(32), + Uuid::new_v4(), + 2_100, + 60, + 10, + 2_001, + ) + .await + .is_err()); + } + #[tokio::test] async fn retry_releases_request_id_but_burns_auth_event() { let store = store().await; @@ -573,4 +813,69 @@ mod tests { .unwrap(); assert!(admitted(&store, &"33".repeat(32), request).await.is_err()); } + + #[tokio::test] + async fn delegation_revocation_requires_the_current_generation() { + let store = store().await; + + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 0) + .await, + Err(AuthorityError::Rejected) + ); + assert_eq!( + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 2) + .await, + Err(AuthorityError::Rejected) + ); + admitted(&store, &"44".repeat(32), Uuid::new_v4()) + .await + .expect("rejected revocations must leave generation 1 active"); + + store + .revoke_delegation(Uuid::from_u128(1), &"11".repeat(32), 1) + .await + .expect("the current generation can be revoked"); + assert!(admitted(&store, &"55".repeat(32), Uuid::new_v4()) + .await + .is_err()); + + let replacement = |id, generation| Delegation { + id, + installation_id: Uuid::from_u128(1), + relay_pubkey: "11".repeat(32), + endpoint_epoch: 1, + generation, + not_before: 900, + expires_at: 1_500, + revoked: false, + }; + assert_eq!( + store + .upsert_delegation(replacement(Uuid::from_u128(3), 1)) + .await, + Err(AuthorityError::Rejected) + ); + store + .upsert_delegation(replacement(Uuid::from_u128(4), 2)) + .await + .expect("only a strictly newer generation can reactivate the delegation"); + store + .authorize_delivery( + Uuid::from_u128(4), + &"11".repeat(32), + 1, + 2, + &"66".repeat(32), + Uuid::new_v4(), + 1_100, + 60, + 10, + 1_000, + ) + .await + .expect("generation 2 authority is active"); + } } diff --git a/crates/buzz-push-gateway/src/config.rs b/crates/buzz-push-gateway/src/config.rs index c6194edbcb4..f8485a628de 100644 --- a/crates/buzz-push-gateway/src/config.rs +++ b/crates/buzz-push-gateway/src/config.rs @@ -1,11 +1,21 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; -use std::{ - collections::{HashMap, HashSet}, - net::SocketAddr, - path::PathBuf, -}; +use std::{collections::HashMap, net::SocketAddr, path::PathBuf}; use thiserror::Error; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApnsEnvironment { + Production, + Sandbox, +} + +#[derive(Debug, Clone)] +pub struct AppProfileConfig { + pub app_attest_app_id: String, + pub apns_cert_path: PathBuf, + pub apns_topic: String, + pub apns_environment: ApnsEnvironment, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct KeyConfig { pub id: String, @@ -21,19 +31,15 @@ pub struct Config { pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, + /// Server-owned dogfood application identity and APNs transport. + pub profile: AppProfileConfig, pub database_url: String, - pub app_attest_app_id: String, pub app_attest_root_cert_path: PathBuf, /// Ordered current key first, followed by decrypt-only predecessors. pub grant_keys: Vec, /// Independent token-custody keyring. These keys MUST NOT be reused for /// externally presented delivery capabilities. pub token_keys: Vec, - pub apns_key_path: PathBuf, - pub apns_key_id: String, - pub apns_team_id: String, - pub apns_topic: String, } #[derive(Debug, Error)] pub enum ConfigError { @@ -75,6 +81,34 @@ fn parse_keyring( } Ok(keys) } + +fn parse_profile(e: &HashMap) -> Result { + let app_id_key = "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID"; + let cert_key = "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH"; + let topic_key = "BUZZ_PUSH_DOGFOOD_APNS_TOPIC"; + let environment_key = "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT"; + let required = |key: &'static str| { + e.get(key) + .map(String::as_str) + .filter(|value| !value.is_empty()) + .ok_or(ConfigError::Missing(key)) + }; + let app_attest_app_id = required(app_id_key)?.to_owned(); + let apns_topic = required(topic_key)?.to_owned(); + let apns_cert_path = PathBuf::from(required(cert_key)?); + let apns_environment = match e.get(environment_key).map(String::as_str) { + None | Some("production") => ApnsEnvironment::Production, + Some("sandbox") => ApnsEnvironment::Sandbox, + Some(_) => return Err(ConfigError::Invalid(environment_key)), + }; + Ok(AppProfileConfig { + app_attest_app_id, + apns_cert_path, + apns_topic, + apns_environment, + }) +} + impl Config { pub fn from_env() -> Result { Self::from_map(&std::env::vars().collect()) @@ -141,45 +175,32 @@ impl Config { bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS", 10, 86_400)?; let endpoint_quota_max_deliveries = bounded_positive("BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES", 10, 10_000)?; - let enabled_profiles = req(e, "BUZZ_PUSH_ENABLED_PROFILES")? - .split(',') - .map(|profile| match profile { - "buzz-ios-production" => Ok(crate::model::AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(crate::model::AppProfile::BuzzIosSandbox), - _ => Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")), - }) - .collect::, _>>()?; - if enabled_profiles.is_empty() { - return Err(ConfigError::Invalid("BUZZ_PUSH_ENABLED_PROFILES")); - } + let profile = parse_profile(e)?; + let bind_addr = e + .get("BUZZ_PUSH_BIND_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8080") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?; + let health_addr = e + .get("BUZZ_PUSH_HEALTH_ADDR") + .map(String::as_str) + .unwrap_or("0.0.0.0:8081") + .parse::() + .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?; Ok(Self { - bind_addr: e - .get("BUZZ_PUSH_BIND_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8080") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_BIND_ADDR"))?, - health_addr: e - .get("BUZZ_PUSH_HEALTH_ADDR") - .map(String::as_str) - .unwrap_or("0.0.0.0:8081") - .parse() - .map_err(|_| ConfigError::Invalid("BUZZ_PUSH_HEALTH_ADDR"))?, + bind_addr, + health_addr, public_delivery_url, max_grant_lifetime_seconds, max_installation_lifetime_seconds, endpoint_quota_window_seconds, endpoint_quota_max_deliveries, - enabled_profiles, + profile, database_url: req(e, "DATABASE_URL")?.to_owned(), - app_attest_app_id: req(e, "BUZZ_PUSH_APP_ATTEST_APP_ID")?.to_owned(), app_attest_root_cert_path: req(e, "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH")?.into(), grant_keys, token_keys, - apns_key_path: req(e, "BUZZ_PUSH_APNS_KEY_PATH")?.into(), - apns_key_id: req(e, "BUZZ_PUSH_APNS_KEY_ID")?.to_owned(), - apns_team_id: req(e, "BUZZ_PUSH_APNS_TEAM_ID")?.to_owned(), - apns_topic: req(e, "BUZZ_PUSH_APNS_TOPIC")?.to_owned(), }) } } @@ -187,7 +208,6 @@ impl Config { #[cfg(test)] mod tests { use super::*; - fn base() -> HashMap { HashMap::from([ ( @@ -215,25 +235,56 @@ mod tests { "2592000".into(), ), ( - "BUZZ_PUSH_ENABLED_PROFILES".into(), - "buzz-ios-production".into(), + "DATABASE_URL".into(), + "postgres://buzz:test@localhost/buzz".into(), // sadscan:disable np.postgres.1 ), ( - "DATABASE_URL".into(), - "postgres://buzz:test@localhost/buzz".into(), + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID".into(), + "TEAM.xyz.block.buzz.dogfood.mobile".into(), ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID".into(), "TEAM.app".into()), ( "BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH".into(), "/apple-root.pem".into(), ), - ("BUZZ_PUSH_APNS_KEY_PATH".into(), "/key.p8".into()), - ("BUZZ_PUSH_APNS_KEY_ID".into(), "key".into()), - ("BUZZ_PUSH_APNS_TEAM_ID".into(), "team".into()), - ("BUZZ_PUSH_APNS_TOPIC".into(), "app".into()), + ( + "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH".into(), + "/dogfood-identity.pem".into(), + ), + ( + "BUZZ_PUSH_DOGFOOD_APNS_TOPIC".into(), + "xyz.block.buzz.dogfood.mobile".into(), + ), + ( + "BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT".into(), + "production".into(), + ), + ("BUZZ_PUSH_BIND_ADDR".into(), "127.0.0.1:8080".into()), + ("BUZZ_PUSH_HEALTH_ADDR".into(), "127.0.0.1:8081".into()), ]) } + #[test] + fn dogfood_profile_requires_server_owned_identity_and_certificate() { + let config = Config::from_map(&base()).unwrap(); + assert_eq!( + config.profile.apns_cert_path, + PathBuf::from("/dogfood-identity.pem") + ); + assert_eq!(config.profile.apns_topic, "xyz.block.buzz.dogfood.mobile"); + + for variable in [ + "BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH", + "BUZZ_PUSH_DOGFOOD_APNS_TOPIC", + "BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", + ] { + let mut env = base(); + env.remove(variable); + assert!( + matches!(Config::from_map(&env), Err(ConfigError::Missing(key)) if key == variable) + ); + } + } + #[test] fn keyrings_preserve_current_then_predecessor_order_and_are_independent() { let config = Config::from_map(&base()).unwrap(); @@ -255,8 +306,8 @@ mod tests { "BUZZ_PUSH_PUBLIC_DELIVERY_URL", "https://push.example/v1/deliveries/apns", ), - ("BUZZ_PUSH_APP_ATTEST_APP_ID", ""), - ("BUZZ_PUSH_ENABLED_PROFILES", "unknown-profile"), + ("BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID", ""), + ("BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT", "staging"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "0"), ("BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS", "31536001"), ("BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS", "0"), @@ -279,6 +330,17 @@ mod tests { } } + #[test] + fn listener_defaults_remain_public_when_addresses_are_absent() { + let mut env = base(); + env.remove("BUZZ_PUSH_BIND_ADDR"); + env.remove("BUZZ_PUSH_HEALTH_ADDR"); + + let config = Config::from_map(&env).unwrap(); + assert_eq!(config.bind_addr, "0.0.0.0:8080".parse().unwrap()); + assert_eq!(config.health_addr, "0.0.0.0:8081".parse().unwrap()); + } + #[test] fn malformed_or_empty_keyrings_fail_startup() { for (variable, value) in [ diff --git a/crates/buzz-push-gateway/src/grant.rs b/crates/buzz-push-gateway/src/grant.rs index 54a29bac3d1..8eda1d7ce81 100644 --- a/crates/buzz-push-gateway/src/grant.rs +++ b/crates/buzz-push-gateway/src/grant.rs @@ -159,7 +159,7 @@ mod tests { v: 1, delegation_id: uuid::Uuid::nil(), relay_pubkey: "11".repeat(32), - app_profile: AppProfile::BuzzIosProduction, + app_profile: AppProfile::BuzzIosDogfood, endpoint_epoch: 1, generation: 2, expires_at: 99, diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 0564972c078..9a6c66a519a 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -23,7 +23,6 @@ use nostr::{ Event, JsonUtil, Timestamp, }; use std::{ - collections::HashSet, sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -33,19 +32,25 @@ use std::{ use tower::limit::ConcurrencyLimitLayer; use tower_http::{limit::RequestBodyLimitLayer, timeout::TimeoutLayer}; +#[derive(Clone)] +pub struct ProfileRuntime { + pub app_attest: Arc, + pub transport: Arc, +} + #[derive(Clone)] pub struct AppState { pub grant_keyring: Arc, - pub app_attest: Arc, pub authority: Arc, pub token_keyring: Arc, - pub transport: Arc, + /// Server-owned dogfood application identity and APNs transport. The wire + /// profile selector is fixed and App Attest verifies the configured app ID. + pub profile: Arc, pub delivery_url: url::Url, pub max_grant_lifetime_seconds: i64, pub max_installation_lifetime_seconds: i64, pub endpoint_quota_window_seconds: i64, pub endpoint_quota_max_deliveries: i64, - pub enabled_profiles: HashSet, pub now: fn() -> i64, pub accepting: Arc, } @@ -83,6 +88,7 @@ fn decode_challenge(value: &str) -> Option<[u8; 32]> { fn authority_error(e: AuthorityError) -> Response { match e { AuthorityError::Rejected => error(StatusCode::NOT_FOUND, "not_authorized"), + AuthorityError::RateLimited => error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"), AuthorityError::Unavailable => { error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable") } @@ -125,6 +131,7 @@ async fn challenge(State(s): State, body: Bytes) -> Response { let c = Challenge { id: uuid::Uuid::new_v4(), value, + created_at: now, expires_at, }; if let Err(e) = s.authority.put_challenge(c.clone()).await { @@ -163,11 +170,13 @@ async fn enroll(State(s): State, body: Bytes) -> Response { Some(v) => v, None => return error(StatusCode::BAD_REQUEST, "invalid_request"), }; + if r.app_profile != AppProfile::BuzzIosDogfood { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } if r.v != WIRE_VERSION || r.endpoint_epoch != 1 || r.expires_at <= now || r.expires_at > now.saturating_add(s.max_installation_lifetime_seconds) - || !s.enabled_profiles.contains(&r.app_profile) { return error(StatusCode::BAD_REQUEST, "invalid_request"); } @@ -192,12 +201,41 @@ async fn enroll(State(s): State, body: Bytes) -> Response { }; let verified = match s + .profile .app_attest .verify_attestation(&r.attestation, &r.key_id, signed.as_bytes()) { - Ok(v) => v, + Ok(value) => value, Err(_) => return error(StatusCode::UNAUTHORIZED, "invalid_attestation"), }; + let fingerprint = endpoint_fingerprint(r.app_profile, &token); + match s + .authority + .matching_installation( + &verified.key_id, + r.app_profile, + fingerprint, + r.endpoint_epoch, + r.expires_at, + now, + ) + .await + { + Ok(Some(existing)) if existing.app_attest_public_key == verified.public_key => { + return ( + StatusCode::CREATED, + Json(InstallationEnrollResponse { + installation_handle: existing.id, + endpoint_epoch: existing.endpoint_epoch, + expires_at: existing.expires_at, + }), + ) + .into_response(); + } + Ok(Some(_)) => return error(StatusCode::NOT_FOUND, "not_authorized"), + Ok(None) => {} + Err(e) => return authority_error(e), + } if let Err(e) = s .authority .consume_challenge(r.challenge_id, challenge, now) @@ -217,11 +255,11 @@ async fn enroll(State(s): State, body: Bytes) -> Response { assertion_counter: 0, profile: r.app_profile, token_ciphertext: ciphertext, - token_fingerprint: endpoint_fingerprint(r.app_profile, &token), + token_fingerprint: fingerprint, endpoint_epoch: 1, expires_at: r.expires_at, }; - if let Err(e) = s.authority.create_installation(n).await { + if let Err(e) = s.authority.create_installation(n, now).await { return authority_error(e); } ( @@ -252,9 +290,13 @@ async fn verify_installation_assertion( .installation(installation_id, now) .await .map_err(authority_error)?; + if installation.profile != AppProfile::BuzzIosDogfood { + return Err(error(StatusCode::NOT_FOUND, "not_authorized")); + } let transcript = transcript(domain, signed) .ok_or_else(|| error(StatusCode::BAD_REQUEST, "invalid_request"))?; let verified = s + .profile .app_attest .verify_assertion( assertion, @@ -620,6 +662,11 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> crate::metrics::record_delivery_error("invalid_grant"); return error(StatusCode::NOT_FOUND, "invalid_grant"); } + Err(AuthorityError::RateLimited) => { + crate::metrics::record_admission(crate::metrics::Admission::Rejected); + crate::metrics::record_delivery_error("rate_limited"); + return error(StatusCode::TOO_MANY_REQUESTS, "rate_limited"); + } Err(AuthorityError::Unavailable) => { crate::metrics::record_admission(crate::metrics::Admission::Unavailable); crate::metrics::record_delivery_error("temporarily_unavailable"); @@ -634,7 +681,15 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> .await; return error(StatusCode::NOT_FOUND, "invalid_grant"); } - let profile = permit.authority.profile; + if permit.authority.profile != AppProfile::BuzzIosDogfood { + crate::metrics::record_delivery_error("profile_disabled"); + let _ = s + .authority + .finish_delivery(permit, DeliveryDisposition::Retryable) + .await; + return error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault"); + } + let transport = Arc::clone(&s.profile.transport); let endpoint = match s.token_keyring.open(&permit.authority.token_ciphertext) { Ok(token) => hex::encode(token), Err(_) => { @@ -650,23 +705,17 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> request_id: r.request_id, expires_at: r.expires_at, }; - let transport = Arc::clone(&s.transport); let authority_store = Arc::clone(&s.authority); // Admission already committed, so cancellation cannot undo either replay // fence. The detached task completes disposition bookkeeping. let delivery = tokio::spawn(async move { let started = std::time::Instant::now(); - let mut outcome = transport.send(attempt, profile, &endpoint).await; - if outcome == DeliveryOutcome::RefreshCredential { - crate::metrics::record_credential_refresh(); - transport.refresh_credential(); - outcome = transport.send(attempt, profile, &endpoint).await; - } + let outcome = transport.send(attempt, &endpoint).await; crate::metrics::record_apns_delivery(outcome, started.elapsed().as_secs_f64()); let disposition = match outcome { - DeliveryOutcome::Retry { .. } - | DeliveryOutcome::ConfigurationFault - | DeliveryOutcome::RefreshCredential => DeliveryDisposition::Retryable, + DeliveryOutcome::Retry { .. } | DeliveryOutcome::ConfigurationFault => { + DeliveryDisposition::Retryable + } DeliveryOutcome::Accepted | DeliveryOutcome::InvalidEndpoint { .. } | DeliveryOutcome::PermanentRequestFault => DeliveryDisposition::Terminal, @@ -683,6 +732,10 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); } }; + delivery_outcome_response(outcome, grant.generation) +} + +fn delivery_outcome_response(outcome: DeliveryOutcome, generation: i64) -> Response { match outcome { DeliveryOutcome::Accepted => { (StatusCode::OK, Json(DeliveryResponse::Accepted)).into_response() @@ -690,7 +743,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> DeliveryOutcome::InvalidEndpoint { unregistered_at } => ( StatusCode::GONE, Json(DeliveryResponse::InvalidEndpoint { - generation: grant.generation, + generation, invalid_at: unregistered_at, }), ) @@ -704,7 +757,7 @@ async fn deliver(State(s): State, headers: HeaderMap, body: Bytes) -> }), ) .into_response(), - DeliveryOutcome::ConfigurationFault | DeliveryOutcome::RefreshCredential => { + DeliveryOutcome::ConfigurationFault => { error(StatusCode::SERVICE_UNAVAILABLE, "configuration_fault") } DeliveryOutcome::PermanentRequestFault => error(StatusCode::BAD_REQUEST, "invalid_request"), @@ -735,16 +788,21 @@ pub fn router_with_metrics( state: AppState, metrics_handle: Option, ) -> (Router, Router) { - let public = Router::new() - .route("/v1/installations/challenges", post(challenge)) + let enrollment = Router::new() .route("/v1/installations", post(enroll)) + .layer(RequestBodyLimitLayer::new(MAX_ENROLL_REQUEST_BYTES)); + let standard_requests = Router::new() + .route("/v1/installations/challenges", post(challenge)) .route("/v1/delegations", post(delegate)) .route("/v1/delegations/revoke", post(revoke_delegation)) .route("/v1/installations/endpoint", post(rotate_endpoint)) .route("/v1/installations/revoke", post(revoke_installation)) .route("/v1/deliveries/apns", post(deliver)) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)); + let public = Router::new() + .merge(enrollment) + .merge(standard_requests) .with_state(state.clone()) - .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES)) .layer(ConcurrencyLimitLayer::new(256)) .layer(TimeoutLayer::with_status_code( StatusCode::REQUEST_TIMEOUT, @@ -774,3 +832,266 @@ pub fn router_with_metrics( } (public, health) } + +#[cfg(test)] +mod request_limit_tests { + use super::*; + use crate::{ + authority::MemoryAuthorityStore, + grant::{GrantKey, GrantKeyring}, + token::{TokenKey, TokenKeyring}, + }; + use axum::{body::Body, http::Request}; + use tower::ServiceExt; + + struct NeverTransport; + + #[async_trait::async_trait] + impl PushTransport for NeverTransport { + async fn send(&self, _: DeliveryAttempt, _: &str) -> DeliveryOutcome { + panic!("request-size tests never send to APNs") + } + } + + fn fixed_now() -> i64 { + 1_750_000_000 + } + + fn state() -> AppState { + let app_attest = AppAttestVerifier::new( + "TEAMID.xyz.block.buzz.dogfood.mobile".to_owned(), + include_bytes!("../tests/fixtures/apple-app-attestation-root.pem").to_vec(), + ) + .expect("pinned Apple root fixture"); + AppState { + grant_keyring: Arc::new( + GrantKeyring::new(vec![GrantKey::new("test", &[1; 32]).unwrap()]).unwrap(), + ), + authority: Arc::new(MemoryAuthorityStore::default()), + token_keyring: Arc::new( + TokenKeyring::new(vec![TokenKey::new("test", &[2; 32]).unwrap()]).unwrap(), + ), + profile: Arc::new(ProfileRuntime { + app_attest: Arc::new(app_attest), + transport: Arc::new(NeverTransport), + }), + delivery_url: "https://push.buzz.xyz/v1/deliveries/apns".parse().unwrap(), + max_grant_lifetime_seconds: 86_400, + max_installation_lifetime_seconds: 86_400, + endpoint_quota_window_seconds: 60, + endpoint_quota_max_deliveries: 10, + now: fixed_now, + accepting: Arc::new(AtomicBool::new(true)), + } + } + + fn maximum_enrollment_body() -> Vec { + serde_json::to_vec(&InstallationEnrollRequest { + v: WIRE_VERSION, + challenge_id: uuid::Uuid::nil(), + challenge: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0; 32]), + key_id: STANDARD.encode([0; 32]), + attestation: STANDARD.encode(vec![0; MAX_APP_ATTESTATION_BYTES]), + app_profile: AppProfile::BuzzIosDogfood, + endpoint: "ab".repeat(MAX_ENDPOINT_HEX_BYTES), + endpoint_epoch: 1, + expires_at: fixed_now() + 60, + }) + .unwrap() + } + + #[tokio::test] + async fn maximum_valid_enrollment_envelope_reaches_the_handler() { + let body = maximum_enrollment_body(); + assert_eq!(MAX_ENROLL_REQUEST_BYTES, 23_896); + assert!(body.len() > MAX_REQUEST_BYTES); + assert!(body.len() <= MAX_ENROLL_REQUEST_BYTES); + let (public, _) = router(state()); + let response = public + .oneshot( + Request::post("/v1/installations") + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn enrollment_envelope_stays_bounded() { + let (public, _) = router(state()); + let response = public + .oneshot( + Request::post("/v1/installations") + .header("content-type", "application/json") + .body(Body::from(vec![b' '; MAX_ENROLL_REQUEST_BYTES + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + + #[test] + fn ambiguous_apns_profile_failures_remain_retryable_at_the_relay_boundary() { + for reason in ["BadDeviceToken", "DeviceTokenNotForTopic"] { + let outcome = crate::apns::classify(400, Some(reason), None); + assert_eq!(outcome, DeliveryOutcome::ConfigurationFault); + assert_eq!( + delivery_outcome_response(outcome, 7).status(), + StatusCode::SERVICE_UNAVAILABLE + ); + } + + let outcome = crate::apns::classify(410, Some("Unregistered"), Some(42)); + assert_eq!( + delivery_outcome_response(outcome, 7).status(), + StatusCode::GONE + ); + } +} + +/// Known-answer vectors for the exact App Attest transcript bytes defined by +/// NIP-PL ("Exact App Attest transcript construction"). The fixture file is +/// shared ground truth with client-side canonical encoders (the Swift NIP-PL +/// iOS client): a client encoder that fails to reproduce these bytes exactly +/// fails every enroll/delegate/rotate/revoke call with `invalid_attestation`. +#[cfg(test)] +mod transcript_vector_tests { + use super::*; + use sha2::{Digest, Sha256}; + + const VECTORS_JSON: &str = include_str!("../tests/vectors/app_attest_transcripts.json"); + + // Deterministic fixture inputs mirrored in the vector file's `inputs`. + const CHALLENGE_ID: uuid::Uuid = + uuid::Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111); + const INSTALLATION: uuid::Uuid = + uuid::Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222); + // base64url-no-pad of bytes 0x00..=0x1f. + const CHALLENGE: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + // Standard base64 (padded) of 32 bytes of 0xAA. + const KEY_ID: &str = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="; + // 32-byte APNs token, lowercase hex. + const ENDPOINT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + const RELAY_PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn assert_vector(name: &str, actual: &str) { + let file: serde_json::Value = serde_json::from_str(VECTORS_JSON).unwrap(); + let vector = file["vectors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["name"] == name) + .unwrap_or_else(|| panic!("vector {name} missing from fixture")); + assert_eq!( + actual, + vector["transcript"].as_str().unwrap(), + "{name} bytes" + ); + assert_eq!( + hex::encode(Sha256::digest(actual.as_bytes())), + vector["sha256"].as_str().unwrap(), + "{name} sha256" + ); + } + + #[test] + fn fixture_encodings_match_their_raw_bytes() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let challenge_bytes: Vec = (0u8..32).collect(); + assert_eq!(URL_SAFE_NO_PAD.encode(&challenge_bytes), CHALLENGE); + assert_eq!(STANDARD.encode([0xAAu8; 32]), KEY_ID); + assert_eq!(hex::decode(ENDPOINT).unwrap().len(), 32); + } + + #[test] + fn enroll_transcript_vector() { + let t = EnrollTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + key_id: KEY_ID, + app_profile: AppProfile::BuzzIosDogfood, + endpoint: ENDPOINT, + endpoint_epoch: 1, + expires_at: 1_752_624_000, + }; + assert_vector("enroll", &transcript("buzz.push.enroll.v1", &t).unwrap()); + } + + #[test] + fn delegate_transcript_vector() { + let t = DelegateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + generation: 1, + relay_pubkey: RELAY_PUBKEY, + not_before: 1_752_620_000, + expires_at: 1_752_624_000, + }; + assert_vector( + "delegate", + &transcript("buzz.push.delegate.v1", &t).unwrap(), + ); + } + + #[test] + fn rotate_endpoint_transcript_vector() { + let t = RotateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/endpoint", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + endpoint: ENDPOINT, + }; + assert_vector( + "rotate_endpoint", + &transcript("buzz.push.rotate-endpoint.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_delegation_transcript_vector() { + let t = RevokeDelegationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + relay_pubkey: RELAY_PUBKEY, + generation: 2, + }; + assert_vector( + "revoke_delegation", + &transcript("buzz.push.revoke-delegation.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_installation_transcript_vector() { + let t = RevokeInstallationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + }; + assert_vector( + "revoke_installation", + &transcript("buzz.push.revoke-installation.v1", &t).unwrap(), + ); + } +} diff --git a/crates/buzz-push-gateway/src/main.rs b/crates/buzz-push-gateway/src/main.rs index 55e1853d3bf..db35b251104 100644 --- a/crates/buzz-push-gateway/src/main.rs +++ b/crates/buzz-push-gateway/src/main.rs @@ -35,12 +35,23 @@ async fn main() -> Result<(), Box> { } let c = Config::from_env()?; let metrics_handle = buzz_push_gateway::metrics::install()?; - let transport = Arc::new(ApnsTransport::token( - &fs::read(&c.apns_key_path)?, - &c.apns_key_id, - &c.apns_team_id, - c.apns_topic, - )?); + let app_attest_root = fs::read(&c.app_attest_root_cert_path)?; + let configured = &c.profile; + let profile = { + let transport = Arc::new(ApnsTransport::certificate( + &fs::read(&configured.apns_cert_path)?, + configured.apns_topic.clone(), + configured.apns_environment, + )?); + let apple = AppAttestVerifier::new( + configured.app_attest_app_id.clone(), + app_attest_root.clone(), + )?; + buzz_push_gateway::http::ProfileRuntime { + app_attest: Arc::new(apple), + transport, + } + }; let grant_keyring = GrantKeyring::new( c.grant_keys .iter() @@ -77,24 +88,18 @@ async fn main() -> Result<(), Box> { } } }); - let app_attest = Arc::new(AppAttestVerifier::new( - c.app_attest_app_id, - fs::read(&c.app_attest_root_cert_path)?, - )?); let accepting = Arc::new(AtomicBool::new(true)); let (public, health) = router_with_metrics( AppState { grant_keyring: Arc::new(grant_keyring), - app_attest, authority, token_keyring: Arc::new(token_keyring), - transport, + profile: Arc::new(profile), delivery_url: c.public_delivery_url, max_grant_lifetime_seconds: c.max_grant_lifetime_seconds, max_installation_lifetime_seconds: c.max_installation_lifetime_seconds, endpoint_quota_window_seconds: c.endpoint_quota_window_seconds, endpoint_quota_max_deliveries: c.endpoint_quota_max_deliveries, - enabled_profiles: c.enabled_profiles, now: || chrono::Utc::now().timestamp(), accepting: accepting.clone(), }, diff --git a/crates/buzz-push-gateway/src/metrics.rs b/crates/buzz-push-gateway/src/metrics.rs index f40c126c79a..dfc45f467c0 100644 --- a/crates/buzz-push-gateway/src/metrics.rs +++ b/crates/buzz-push-gateway/src/metrics.rs @@ -41,18 +41,24 @@ pub fn install() -> Result { /// Stable metric label for each sanitized delivery outcome. The mapping is total /// over the closed [`DeliveryOutcome`] enum, so the `outcome` label can only take -/// these six values. +/// these five values. fn outcome_label(outcome: DeliveryOutcome) -> &'static str { match outcome { DeliveryOutcome::Accepted => "accepted", DeliveryOutcome::InvalidEndpoint { .. } => "invalid_endpoint", DeliveryOutcome::Retry { .. } => "retry", - DeliveryOutcome::RefreshCredential => "refresh_credential", DeliveryOutcome::ConfigurationFault => "configuration_fault", DeliveryOutcome::PermanentRequestFault => "permanent_request_fault", } } +/// Record entry into the concrete APNs HTTP send seam. This counter is kept +/// separate from terminal outcomes so a control scrape can distinguish +/// "transport never reached" from "APNs send returned an error". +pub fn record_apns_send_attempt() { + metrics::counter!("push_gateway_apns_send_attempts_total").increment(1); +} + /// Record the terminal APNs outcome and its send round-trip latency. pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::counter!("push_gateway_apns_deliveries_total", "outcome" => outcome_label(outcome)) @@ -60,11 +66,6 @@ pub fn record_apns_delivery(outcome: DeliveryOutcome, seconds: f64) { metrics::histogram!("push_gateway_apns_delivery_seconds").record(seconds); } -/// Record that a cached provider credential was refreshed after APNs reported expiry. -pub fn record_credential_refresh() { - metrics::counter!("push_gateway_apns_credential_refreshes_total").increment(1); -} - /// Delivery-admission result at the `authorize_delivery` seam. #[derive(Debug, Clone, Copy)] pub enum Admission { @@ -126,7 +127,7 @@ mod tests { #[test] fn outcome_label_covers_every_variant_with_static_strings() { // Exhaustive over the closed enum; each arm is a compile-time constant, - // so the `outcome` label is structurally bounded to these six values. + // so the `outcome` label is structurally bounded to these five values. for (outcome, expected) in [ (DeliveryOutcome::Accepted, "accepted"), ( @@ -141,7 +142,6 @@ mod tests { }, "retry", ), - (DeliveryOutcome::RefreshCredential, "refresh_credential"), (DeliveryOutcome::ConfigurationFault, "configuration_fault"), ( DeliveryOutcome::PermanentRequestFault, @@ -159,6 +159,7 @@ mod tests { fn recorder_renders_sanitized_bounded_series() { let handle = install().expect("recorder installs exactly once per test process"); + record_apns_send_attempt(); record_apns_delivery(DeliveryOutcome::Accepted, 0.012); record_apns_delivery( DeliveryOutcome::InvalidEndpoint { @@ -166,7 +167,6 @@ mod tests { }, 0.030, ); - record_credential_refresh(); record_admission(Admission::Admitted); record_admission(Admission::Rejected); record_admission(Admission::Unavailable); @@ -180,9 +180,9 @@ mod tests { // All expected series are present. for needle in [ + "push_gateway_apns_send_attempts_total", "push_gateway_apns_deliveries_total", "push_gateway_apns_delivery_seconds", - "push_gateway_apns_credential_refreshes_total", "push_gateway_admissions_total", "push_gateway_delivery_errors_total", "push_gateway_reaper_failures_total", diff --git a/crates/buzz-push-gateway/src/model.rs b/crates/buzz-push-gateway/src/model.rs index 23f8015fe00..390f665d8ab 100644 --- a/crates/buzz-push-gateway/src/model.rs +++ b/crates/buzz-push-gateway/src/model.rs @@ -3,6 +3,13 @@ use serde::{Deserialize, Serialize}; pub const MAX_REQUEST_BYTES: usize = 8 * 1024; +/// Maximum decoded Apple App Attest object accepted by the verifier. +pub const MAX_APP_ATTESTATION_BYTES: usize = 16 * 1024; +/// Enrollment carries the maximum App Attest object as standard base64 plus a +/// bounded APNs endpoint and the closed JSON envelope. Other gateway requests +/// remain subject to `MAX_REQUEST_BYTES`. +pub const MAX_ENROLL_REQUEST_BYTES: usize = + MAX_APP_ATTESTATION_BYTES.div_ceil(3) * 4 + MAX_ENDPOINT_HEX_BYTES * 2 + 1024; pub const MAX_GRANT_BYTES: usize = 4096; pub const MAX_ENDPOINT_HEX_BYTES: usize = 512; pub const APNS_RECONNECT_PAYLOAD: &[u8] = @@ -12,14 +19,12 @@ pub const WIRE_VERSION: u8 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AppProfile { - BuzzIosProduction, - BuzzIosSandbox, + BuzzIosDogfood, } impl AppProfile { pub const fn as_str(self) -> &'static str { match self { - Self::BuzzIosProduction => "buzz-ios-production", - Self::BuzzIosSandbox => "buzz-ios-sandbox", + Self::BuzzIosDogfood => "buzz-ios-dogfood", } } } diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index bd69ec25646..6cbfb45893d 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -63,8 +63,7 @@ fn ts(v: DateTime) -> i64 { } fn profile(v: &str) -> Result { match v { - "buzz-ios-production" => Ok(AppProfile::BuzzIosProduction), - "buzz-ios-sandbox" => Ok(AppProfile::BuzzIosSandbox), + "buzz-ios-dogfood" => Ok(AppProfile::BuzzIosDogfood), _ => Err(AuthorityError::Unavailable), } } @@ -119,16 +118,35 @@ impl AuthorityStore for PostgresAuthorityStore { async fn put_challenge(&self, c: Challenge) -> Result<(), AuthorityError> { use sha2::{Digest, Sha256}; + const CHALLENGE_ISSUANCE_LOCK: i64 = 0x4255_5a5a_504c_0001; + let mut tx = self.pool.begin().await.map_err(db)?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(CHALLENGE_ISSUANCE_LOCK) + .execute(&mut *tx) + .await + .map_err(db)?; + let window_start = at(c.created_at.saturating_sub(CHALLENGE_QUOTA_WINDOW_SECONDS))?; + let issued: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_challenges WHERE created_at >= $1", + ) + .bind(window_start) + .fetch_one(&mut *tx) + .await + .map_err(db)?; + if issued >= CHALLENGE_QUOTA_MAX_REQUESTS as i64 { + return Err(AuthorityError::RateLimited); + } sqlx::query( - "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at) VALUES($1,$2,$3)", + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at,created_at) VALUES($1,$2,$3,$4)", ) .bind(c.id) .bind(Sha256::digest(c.value).to_vec()) .bind(at(c.expires_at)?) - .execute(&self.pool) + .bind(at(c.created_at)?) + .execute(&mut *tx) .await .map_err(db)?; - Ok(()) + tx.commit().await.map_err(db) } async fn consume_challenge( &self, @@ -144,12 +162,55 @@ impl AuthorityStore for PostgresAuthorityStore { } Ok(()) } - async fn create_installation(&self, n: NewInstallation) -> Result<(), AuthorityError> { + async fn create_installation( + &self, + n: NewInstallation, + now: i64, + ) -> Result<(), AuthorityError> { + let mut tx = self.pool.begin().await.map_err(db)?; + let now_at = at(now)?; + let existing = sqlx::query( + "SELECT id,expires_at,revoked_at FROM push_gateway_installations WHERE app_attest_key_id=$1 OR (app_profile=$2 AND token_fingerprint=$3) FOR UPDATE", + ) + .bind(&n.app_attest_key_id) + .bind(n.profile.as_str()) + .bind(n.token_fingerprint.to_vec()) + .fetch_all(&mut *tx) + .await + .map_err(db)?; + if existing.iter().any(|row| { + let revoked = row.try_get::>, _>("revoked_at"); + let expires = row.try_get::, _>("expires_at"); + match (revoked, expires) { + (Ok(None), Ok(expires_at)) => expires_at >= now_at, + (Ok(Some(_)), Ok(_)) => false, + _ => true, + } + }) { + return Err(AuthorityError::Rejected); + } + let replaced = existing + .iter() + .map(|row| row.try_get::("id").map_err(db)) + .collect::, _>>()?; + if !replaced.is_empty() { + sqlx::query("DELETE FROM push_gateway_delegations WHERE installation_id = ANY($1)") + .bind(&replaced) + .execute(&mut *tx) + .await + .map_err(db)?; + sqlx::query("DELETE FROM push_gateway_installations WHERE id = ANY($1)") + .bind(&replaced) + .execute(&mut *tx) + .await + .map_err(db)?; + } let result = sqlx::query("INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT DO NOTHING") - .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&self.pool).await.map_err(db)?; + .bind(n.id).bind(n.app_attest_key_id).bind(n.app_attest_public_key).bind(i64::from(n.assertion_counter)).bind(n.profile.as_str()).bind(n.token_ciphertext).bind(n.token_fingerprint.to_vec()).bind(n.endpoint_epoch).bind(at(n.expires_at)?).execute(&mut *tx).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } + tx.commit().await.map_err(db)?; Ok(()) } async fn installation(&self, id: Uuid, now: i64) -> Result { @@ -169,6 +230,45 @@ impl AuthorityStore for PostgresAuthorityStore { revoked: false, }) } + async fn matching_installation( + &self, + key_id: &[u8], + app_profile: AppProfile, + token_fingerprint: [u8; 32], + endpoint_epoch: i64, + expires_at: i64, + now: i64, + ) -> Result, AuthorityError> { + let r = sqlx::query("SELECT * FROM push_gateway_installations WHERE app_attest_key_id=$1 AND app_profile=$2 AND token_fingerprint=$3 AND endpoint_epoch=$4 AND expires_at=$5 AND revoked_at IS NULL AND expires_at >= $6") + .bind(key_id) + .bind(app_profile.as_str()) + .bind(token_fingerprint.to_vec()) + .bind(endpoint_epoch) + .bind(at(expires_at)?) + .bind(at(now)?) + .fetch_optional(&self.pool) + .await + .map_err(db)?; + r.map(|r| { + let id = r.try_get("id").map_err(db)?; + Ok(Installation { + id, + app_attest_key_id: r.try_get("app_attest_key_id").map_err(db)?, + app_attest_public_key: r.try_get("app_attest_public_key").map_err(db)?, + assertion_counter: u32::try_from( + r.try_get::("assertion_counter").map_err(db)?, + ) + .map_err(|_| AuthorityError::Unavailable)?, + profile: profile(r.try_get("app_profile").map_err(db)?)?, + token_ciphertext: r.try_get("token_ciphertext").map_err(db)?, + token_fingerprint: bytes32(r.try_get("token_fingerprint").map_err(db)?)?, + endpoint_epoch: r.try_get("endpoint_epoch").map_err(db)?, + expires_at: ts(r.try_get("expires_at").map_err(db)?), + revoked: false, + }) + }) + .transpose() + } async fn advance_assertion_counter( &self, id: Uuid, @@ -192,7 +292,6 @@ impl AuthorityStore for PostgresAuthorityStore { .map_err(db)? .is_some() || i.try_get::("endpoint_epoch").map_err(db)? != d.endpoint_epoch - || at(d.expires_at)? > i.try_get::, _>("expires_at").map_err(db)? { return Err(AuthorityError::Rejected); } @@ -202,6 +301,12 @@ impl AuthorityStore for PostgresAuthorityStore { if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } + sqlx::query("UPDATE push_gateway_installations SET expires_at=GREATEST(expires_at,$2),updated_at=now() WHERE id=$1") + .bind(d.installation_id) + .bind(at(d.expires_at)?) + .execute(&mut *tx) + .await + .map_err(db)?; tx.commit().await.map_err(db)?; Ok(()) } @@ -226,10 +331,10 @@ impl AuthorityStore for PostgresAuthorityStore { &self, id: Uuid, relay: &str, - generation: i64, + expected_generation: i64, ) -> Result<(), AuthorityError> { let relay = hex::decode(relay).map_err(|_| AuthorityError::Rejected)?; - let result=sqlx::query("UPDATE push_gateway_delegations SET generation=$3,revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation<$3").bind(id).bind(relay).bind(generation).execute(&self.pool).await.map_err(db)?; + let result=sqlx::query("UPDATE push_gateway_delegations SET revoked_at=now(),updated_at=now() WHERE installation_id=$1 AND relay_pubkey=$2 AND generation=$3 AND revoked_at IS NULL").bind(id).bind(relay).bind(expected_generation).execute(&self.pool).await.map_err(db)?; if result.rows_affected() != 1 { return Err(AuthorityError::Rejected); } @@ -409,7 +514,7 @@ mod tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] @@ -624,7 +729,13 @@ mod tests { // Real DDL from migration 0010 (minus the _operator_global_tables audit // insert, which lives outside the isolated schema). sqlx::raw_sql( - "CREATE TABLE push_gateway_installations ( + "CREATE TABLE push_gateway_challenges ( + id UUID PRIMARY KEY, + challenge_hash BYTEA NOT NULL CHECK (length(challenge_hash) = 32), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE TABLE push_gateway_installations ( id UUID PRIMARY KEY, app_attest_key_id BYTEA NOT NULL UNIQUE, app_attest_public_key BYTEA NOT NULL, @@ -678,12 +789,59 @@ mod tests { const RELAY_HEX: &str = "11111111111111111111111111111111111111111111111111111111111111aa"; const DELEGATION_ID: u128 = 2; + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn concurrent_challenge_issuance_obeys_deployment_global_ceiling() { + let (pool, schema) = full_schema(4).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + for offset in 0..CHALLENGE_QUOTA_MAX_REQUESTS - 1 { + sqlx::query( + "INSERT INTO push_gateway_challenges(id,challenge_hash,expires_at,created_at) VALUES($1,$2,$3,$4)", + ) + .bind(Uuid::from_u128(offset as u128 + 1)) + .bind(vec![offset as u8; 32]) + .bind(at(now + 300).expect("valid expiry")) + .bind(at(now).expect("valid creation time")) + .execute(&pool) + .await + .expect("seed challenge quota"); + } + let challenge = |id| Challenge { + id, + value: [0; 32], + created_at: now, + expires_at: now + 300, + }; + let (first, second) = tokio::join!( + store.put_challenge(challenge(Uuid::new_v4())), + store.put_challenge(challenge(Uuid::new_v4())), + ); + assert_eq!( + [first.is_ok(), second.is_ok()] + .into_iter() + .filter(|admitted| *admitted) + .count(), + 1, + "the cross-connection lock admits only the final quota slot" + ); + assert!( + [first, second] + .into_iter() + .any(|result| result == Err(AuthorityError::RateLimited)), + "the quota loser receives an explicit rate-limit result" + ); + + pool.close().await; + drop_schema(&schema).await; + } + // One installation + one live delegation that admits at now=1_000. async fn install_authority(pool: &PgPool) { let now = Utc::now(); sqlx::query( "INSERT INTO push_gateway_installations(id,app_attest_key_id,app_attest_public_key,assertion_counter,app_profile,token_ciphertext,token_fingerprint,endpoint_epoch,expires_at) - VALUES ($1,$2,$3,0,'buzz-ios-production',$4,$5,1,$6)", + VALUES ($1,$2,$3,0,'buzz-ios-dogfood',$4,$5,1,$6)", ) .bind(Uuid::from_u128(1)) .bind(vec![1u8]) @@ -708,6 +866,72 @@ mod tests { .expect("insert delegation"); } + #[tokio::test] + #[ignore = "requires PostgreSQL"] + async fn delegation_renews_and_expired_enrollment_recovers_token_ownership() { + let (pool, schema) = full_schema(2).await; + let store = PostgresAuthorityStore::new(pool.clone()); + let now = Utc::now().timestamp(); + let installation = |id, expires_at| NewInstallation { + id, + app_attest_key_id: vec![1], + app_attest_public_key: vec![2; 33], + assertion_counter: 0, + profile: AppProfile::BuzzIosDogfood, + token_ciphertext: vec![3], + token_fingerprint: [4; 32], + endpoint_epoch: 1, + expires_at, + }; + + store + .create_installation(installation(Uuid::from_u128(1), now + 100), now) + .await + .expect("create initial installation"); + store + .upsert_delegation(Delegation { + id: Uuid::from_u128(2), + installation_id: Uuid::from_u128(1), + relay_pubkey: RELAY_HEX.to_owned(), + endpoint_epoch: 1, + generation: 1, + not_before: now, + expires_at: now + 1_000, + revoked: false, + }) + .await + .expect("authenticated delegation renews installation"); + assert!(store + .installation(Uuid::from_u128(1), now + 500) + .await + .is_ok()); + assert_eq!( + store + .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 999,) + .await, + Err(AuthorityError::Rejected) + ); + store + .create_installation(installation(Uuid::from_u128(3), now + 2_000), now + 1_001) + .await + .expect("expired ownership can be replaced"); + let old_delegations: i64 = sqlx::query_scalar( + "SELECT count(*) FROM push_gateway_delegations WHERE installation_id=$1", + ) + .bind(Uuid::from_u128(1)) + .fetch_one(&pool) + .await + .expect("count replaced delegations"); + assert_eq!(old_delegations, 0); + assert!(store + .installation(Uuid::from_u128(3), now + 1_001) + .await + .is_ok()); + + pool.close().await; + drop_schema(&schema).await; + } + fn admit<'a>( store: &'a PostgresAuthorityStore, event_hex: &'a str, diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem new file mode 100644 index 00000000000..dc7e9923a54 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-cert-only.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem new file mode 100644 index 00000000000..7461fbe111f --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-encrypted-identity.pem @@ -0,0 +1,19 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIH0MF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCrxiLXIJU5iHcD0IMS +sRI0AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQmlIQbhuOv5VUfS6I +MVPLEwSBkNqbXztd0jeDg0nA1RCDPerWJUZqN5i6TtZtLwxLhpfcrDPT0aVEoFLv +dyRLcdzRmYNmHAoEaO0o0nLahGOlu4PlYqEoTahIq/ursix7JV5NhUJUWMFJFTz9 +qgYSTxsvecejzM4SvMMVx5zVhgn/ojMDbocNOA8DfMW/U6gP9AxBV5RqyMGsMcuK +OPn9XCFJsg== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem new file mode 100644 index 00000000000..f174811712b --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem new file mode 100644 index 00000000000..7c82d17d611 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-key-only.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/p0z63nx4o4jOiA0 +AEwfcyxe4NyuSjl0wPYOW5u3SQahRANCAATVJOs+qdG7RX0ma7NcjEyy0tNu8pEu +RWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +-----END PRIVATE KEY----- diff --git a/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem b/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem new file mode 100644 index 00000000000..bed75b120f2 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apns-test-mismatched-identity.pem @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg18TP8zUw6UBPuIc2 +4zZIQ7TMe4Iu9VtXGxVXMV3PRPqhRANCAASN9Thxojkwcn1d2XN3KswViaVM+tpK +v69Qne0M1q8A6finFJ7chBwu8/G+nFPyYszJZnm6vGwxzxIBEpd9KJT1 +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIBlzCCAT2gAwIBAgIUeX7BQuvIrPDYNQliaZ/L5HUXXRgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWYnV6ei1wdXNoLWdhdGV3YXktdGVzdDAeFw0yNjA3MjkyMDU2 +NTFaFw0zNjA3MjYyMDU2NTFaMCExHzAdBgNVBAMMFmJ1enotcHVzaC1nYXRld2F5 +LXRlc3QwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATVJOs+qdG7RX0ma7NcjEyy +0tNu8pEuRWhAxkej48uXOXw9tdTDo3B++tNV75rbEYlf9D8dR1s6435q5GWslRQS +o1MwUTAdBgNVHQ4EFgQUwSlQC2eYEZxBKrqfl3AXrV0Jw6owHwYDVR0jBBgwFoAU +wSlQC2eYEZxBKrqfl3AXrV0Jw6owDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQD +AgNIADBFAiALSCg8lIR6zkza/sJQl94LBa4I5tRUuac2nfIXo7gb/gIhAOc/zPNP +2doCIHUugcdKePl4W9gL8/rSXhIAbxQbQV+k +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json new file mode 100644 index 00000000000..3bdff5ccdce --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-good.json @@ -0,0 +1,9 @@ +{ + "description": "Valid synthetic Apple App Attest attestation for the gateway strict-verifier acceptance control.", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdgwggHUMIIBeqADAgECAhEA1hkzMVx4LIlx2Z04+dq+DjAKBggqhkjOPQQDAjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA8MSswKQYDVQQDDCJCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBDcmVkZW50aWFsMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPnUoIjO//gCI6cvjfmAw62OnnngGVoId2q7MQYG//94tXIX2tIZChUe1y/spRzJqxLo0JNm7d9QKdoVuLNBpfaNbMFkwDAYDVR0TAQH/BAIwADAUBgNVHSUEDTALBgkqhkiG92NkBBgwMwYJKoZIhvdjZAgCBCYwJKEiBCDi01h8mHF6AJkdlwJoO7ieXb9TDEttdsV48n1Jd57tIDAKBggqhkjOPQQDAgNIADBFAiEAmyNVz7oG03YWXBP55xcqJ1xrwv7INxQmSKjr/lrrXKwCIEGS9+8qhYxQfZa1q/jcegDlNxphatVVqx5j8cQbjNU2WQG6MIIBtjCCATugAwIBAgIQJj6YcsuecIX6zF/ZFQ6wzDAKBggqhkjOPQQDAzA2MSUwIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowPjEtMCsGA1UEAwwkQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgSW50ZXJtZWRpYXRlMQ0wCwYDVQQKDARCdXp6MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfGKED5L/Nh0lvKRJAllDU01J6pZhqYBV/a7HRTphUIkIhW0Jc/Q2BplGB+vrMgUG+QX9eG8k7VvRZjov/m7gbaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDaQAwZgIxAONcQ0m5yYfK4ILWwnRWAZjhQg/ZrwiRY3VEBzkAc082FXwp0mqMjXwicSt/ibULFgIxAKFayHKDgusCMjLMPkoIYbOI2jnR+TY8Vftq89b33qLQ2EebRB1PGDld2mvVY01OU2dyZWNlaXB0QGhhdXRoRGF0YVhXH5nFfKMZs8qsLEqZv4n7atEJxvG0oHWjDbycL/O5tJlBAAAAAGFwcGF0dGVzdAAAAAAAAAAAIOtFw/nPMzM0gQAeS/gQ1R2aF7oMMjXIx08QJN8q0cuk", + "key_id_b64": "60XD+c8zMzSBAB5L+BDVHZoXugwyNcjHTxAk3yrRy6Q=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json new file mode 100644 index 00000000000..56d1260bead --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-aaguid.json @@ -0,0 +1,9 @@ +{ + "description": "Synthetic attestation with a development AAGUID and a correctly recomputed nonce; the strict verifier must report InvalidAAGUID.", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattestdevelop", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhAXdDyYByLYxE4WftXjOFC1MAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ8ZGvDc7xMJINZw6mLHRU6xr1kFY+vn+PRZYIMypdlYb99U/l8VCK9zWQt+xXSEAyNvzdcZiom5N/fKuAI5xh/o1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEILEfYIC8xsY+hZnqOrQF1PpWR3VioqnjjQwo5/YmtAwRMAoGCCqGSM49BAMCA0gAMEUCIQCpOzhfo94xcJ0ojQki6wxpOdORPsNwXtZz+eByIhtwlwIgPr71d/DiOaQ3Jd9jDaiCFrzozcR5owB0kaKRzvFuBv1ZAbowggG2MIIBO6ADAgECAhAmPphyy55whfrMX9kVDrDMMAoGCCqGSM49BAMDMDYxJTAjBgNVBAMMHEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAR8YoQPkv82HSW8pEkCWUNTTUnqlmGpgFX9rsdFOmFQiQiFbQlz9DYGmUYH6+syBQb5Bf14byTtW9FmOi/+buBtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNpADBmAjEA41xDSbnJh8rggtbCdFYBmOFCD9mvCJFjdUQHOQBzTzYVfCnSaoyNfCJxK3+JtQsWAjEAoVrIcoOC6wIyMsw+Sghhs4jaOdH5NjxV+2rz1vfeotDYR5tEHU8YOV3aa9VjTU5TZ3JlY2VpcHRAaGF1dGhEYXRhWFcfmcV8oxmzyqwsSpm/iftq0QnG8bSgdaMNvJwv87m0mUEAAAAAYXBwYXR0ZXN0ZGV2ZWxvcAAg6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", + "key_id_b64": "6lNJNJZYorHNGU3B6PRi8TohIJen5fWQhzHT95gt6VI=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIByjCCAVCgAwIBAgIQMLQQs1cI8JQdL88vvx4tZjAKBggqhkjOPQQDAzA2MSUw\nIwYDVQQDDBxCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQKDARC\ndXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowNjElMCMGA1UEAwwc\nQnV6eiBBcHAgQXR0ZXN0IEZpeHR1cmUgUm9vdDENMAsGA1UECgwEQnV6ejB2MBAG\nByqGSM49AgEGBSuBBAAiA2IABHRmKNadAoMBUMGUELKgZJrT7Qg3h0HwHGSvvZnN\nWkDJdDCFODg7AZVQsCv7Wx0DYK0TAX+y/JxItkd0qvZKWRhtyN3VednVNJ+qRVFh\nt0r5R3Jn14A3es+y7w+mDdqXS6MjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B\nAf8EBAMCAQYwCgYIKoZIzj0EAwMDaAAwZQIwSv2nREeLrLyau01dS8/fInt6Xc/r\ntB9sV92eezwl1asw+MoY9NOuMo6QO/KaWtgiAjEAlnP+9HKkQ3SsHcAHnoc0Sjue\nCjNMcsRxsusqjzy7+uSZzyIprpBvzTS4oGE+2/Ky\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json new file mode 100644 index 00000000000..7129b63939e --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/app-attest-wrong-root.json @@ -0,0 +1,9 @@ +{ + "description": "Internally valid synthetic attestation signed by an unrelated root; the verifier configured with the good fixture root must reject it.", + "app_id": "TEAMID.xyz.buzz.mobile", + "challenge": "buzz-app-attest-strict-verifier-fixture", + "aaguid": "appattest", + "attestation_b64": "o2NmbXRvYXBwbGUtYXBwYXR0ZXN0Z2F0dFN0bXSiY3g1Y4JZAdcwggHTMIIBeaADAgECAhBGe4kbr8X3vBBmRW24fEPWMAoGCCqGSM49BAMCMD4xLTArBgNVBAMMJEJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIEludGVybWVkaWF0ZTENMAsGA1UECgwEQnV6ejAeFw0yNjA4MDIwNTI3MDJaFw00NjA4MDIwNTI3MDJaMDwxKzApBgNVBAMMIkJ1enogQXBwIEF0dGVzdCBGaXh0dXJlIENyZWRlbnRpYWwxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3NUd9f8Ma88b5fiKPmvgL0akkZfv3Q5v2jJMGVQ+pDY2ZFkZTQnzTfAPydFBFtVQE9HpPLlx22e/8eixSUFdLo1swWTAMBgNVHRMBAf8EAjAAMBQGA1UdJQQNMAsGCSqGSIb3Y2QEGDAzBgkqhkiG92NkCAIEJjAkoSIEIF7PDSiNaYyhbJlVGsubqOBUPUSS4sT5PJ0Ri8mGDRjSMAoGCCqGSM49BAMCA0gAMEUCIQCSjdrbcQurd+avRl+OcRIZPusoJBNVGLun3Rda9tJ5NwIgOFEcGxdOZi3atz7Nwzwe409oVcu4GdXOVo9N86pOu8dZAb8wggG7MIIBQaADAgECAhEAx1cRnQUJhJKCUll92sLeGDAKBggqhkjOPQQDAzA7MSowKAYDVQQDDCFVbnJlbGF0ZWQgQXBwIEF0dGVzdCBGaXh0dXJlIFJvb3QxDTALBgNVBAoMBEJ1enowHhcNMjYwODAyMDUyNzAyWhcNNDYwODAyMDUyNzAyWjA+MS0wKwYDVQQDDCRCdXp6IEFwcCBBdHRlc3QgRml4dHVyZSBJbnRlcm1lZGlhdGUxDTALBgNVBAoMBEJ1enowWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASz7MX/nc9MaCmjSQ3f+L8SCsgNdFEcDyZ7FxREEPu4bGUujA+P5exSwDuA8L64WrznNITC1J8sZ98VZ/tTNWFtoyMwITAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNoADBlAjEA8ABjGCavBGyl6FgO9u58hV/xzRnhdlFiTUPiN/XCvmfxDkOyYwzLk06/k4JmdqfCAjBADKJsa+9138UAMZgU8iYWTOY+FO96DHsdC+8H9vBoLBE/DxzQHsX2Wd/DEbggUGJncmVjZWlwdEBoYXV0aERhdGFYVx+ZxXyjGbPKrCxKmb+J+2rRCcbxtKB1ow28nC/zubSZQQAAAABhcHBhdHRlc3QAAAAAAAAAACCvcDv+nttQP9RSSwBycpsL+NiE13xuEsfU7iKqeRaTsQ==", + "key_id_b64": "r3A7/p7bUD/UUksAcnKbC/jYhNd8bhLH1O4iqnkWk7E=", + "root_cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB1DCCAVugAwIBAgIRALE3l3fzQ4wPjIL/IjBs02IwCgYIKoZIzj0EAwMwOzEq\nMCgGA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYD\nVQQKDARCdXp6MB4XDTI2MDgwMjA1MjcwMloXDTQ2MDgwMjA1MjcwMlowOzEqMCgG\nA1UEAwwhVW5yZWxhdGVkIEFwcCBBdHRlc3QgRml4dHVyZSBSb290MQ0wCwYDVQQK\nDARCdXp6MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEPa8SWuDIcjNVDwTXlTQnWbKj\n5Vt8TCiGGH0CiSJajPOlevvjHBEYuVHf7bFYa5N/7OzXQ3qkZomCyizJ6nc5tBEN\nGL3rkz7vZjb9J3QPfixkBwyUHFHmx1WJ84fgAYcDoyMwITAPBgNVHRMBAf8EBTAD\nAQH/MA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAwNnADBkAjAMO0cvuHHJSqWj\n4DxJorq8LH7VH9ILTGjcZmz91rLlO7w4oDqiewFQE+GVFl9boekCMBhaa0a/WiW2\nyf2j5d04SOkXREM1NkbHsd1yH1jqSOCuj6PU3Z6zDSSXy1z3HjQIBg==\n-----END CERTIFICATE-----\n" +} diff --git a/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem b/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem new file mode 100644 index 00000000000..4cff2277b51 --- /dev/null +++ b/crates/buzz-push-gateway/tests/fixtures/apple-app-attestation-root.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICITCCAaegAwIBAgIQC/O+DvHN0uD7jG5yH2IXmDAKBggqhkjOPQQDAzBSMSYw +JAYDVQQDDB1BcHBsZSBBcHAgQXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwK +QXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTAeFw0yMDAzMTgxODMyNTNa +Fw00NTAzMTUwMDAwMDBaMFIxJjAkBgNVBAMMHUFwcGxlIEFwcCBBdHRlc3RhdGlv +biBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJbmMuMRMwEQYDVQQIDApDYWxpZm9y +bmlhMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERTHhmLW07ATaFQIEVwTtT4dyctdh +NbJhFs/Ii2FdCgAHGbpphY3+d8qjuDngIN3WVhQUBHAoMeQ/cLiP1sOUtgjqK9au +Yen1mMEvRq9Sk3Jm5X8U62H+xTD3FE9TgS41o0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSskRBTM72+aEH/pwyp5frq5eWKoTAOBgNVHQ8BAf8EBAMCAQYw +CgYIKoZIzj0EAwMDaAAwZQIwQgFGnByvsiVbpTKwSga0kP0e8EeDS4+sQmTvb7vn +53O5+FRXgeLhpJ06ysC5PrOyAjEAp5U4xDgEgllF7En3VcE3iexZZtKeYnpqtijV +oyFraWVIyd/dganmrduC1bmTBGwD +-----END CERTIFICATE----- diff --git a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json new file mode 100644 index 00000000000..27b84035d6c --- /dev/null +++ b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json @@ -0,0 +1,48 @@ +{ + "description": "Known-answer vectors for the exact App Attest transcript bytes defined by NIP-PL ('Exact App Attest transcript construction'). Generated by the gateway's own transcript encoder (crates/buzz-push-gateway/src/http.rs transcript()). Client canonical encoders (Swift NIP-PL iOS client) MUST reproduce `transcript` byte-for-byte; `sha256` is the hex digest of those UTF-8 bytes (the App Attest clientDataHash input for assertion routes, and the exact clientData for enrollment).", + "inputs": { + "challenge_id": "11111111-1111-4111-8111-111111111111", + "installation_handle": "22222222-2222-4222-8222-222222222222", + "challenge": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "challenge_note": "base64url-no-pad of bytes 0x00..0x1f", + "key_id": "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=", + "key_id_note": "standard base64 (padded) of 32 bytes of 0xAA", + "app_profile": "buzz-ios-dogfood", + "endpoint": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "relay_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "not_before": 1752620000, + "expires_at": 1752624000 + }, + "vectors": [ + { + "name": "enroll", + "domain": "buzz.push.enroll.v1", + "transcript": "buzz.push.enroll.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"key_id\":\"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=\",\"app_profile\":\"buzz-ios-dogfood\",\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\",\"endpoint_epoch\":1,\"expires_at\":1752624000}", + "sha256": "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270" + }, + { + "name": "delegate", + "domain": "buzz.push.delegate.v1", + "transcript": "buzz.push.delegate.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"generation\":1,\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"not_before\":1752620000,\"expires_at\":1752624000}", + "sha256": "7466177cc2dc2a4f9a075fdbb461531692fc858778a171a5862b855cccfaa059" + }, + { + "name": "rotate_endpoint", + "domain": "buzz.push.rotate-endpoint.v1", + "transcript": "buzz.push.rotate-endpoint.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/endpoint\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2,\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\"}", + "sha256": "601aba0c8d4021ddf97ce1e434b9c7ad1e051bf02a44929aaebd8c6bd724e7b3" + }, + { + "name": "revoke_delegation", + "domain": "buzz.push.revoke-delegation.v1", + "transcript": "buzz.push.revoke-delegation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"generation\":2}", + "sha256": "d6bcd4b25235adcb519ef189820b08dd0386fc752fd4e4c77bc3ffb7a519a84a" + }, + { + "name": "revoke_installation", + "domain": "buzz.push.revoke-installation.v1", + "transcript": "buzz.push.revoke-installation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2}", + "sha256": "0ba51827af6586a5e1230e9b770b99544fb342efb55db3ab1ce499cf24a893c8" + } + ] +} diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 6ee93ee5ced..e035752ec3a 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -341,10 +341,14 @@ pub struct Config { /// Used to authenticate internal policy endpoint requests. pub git_hook_hmac_secret: String, + /// Whether NIP-PL push discovery, lease acceptance, matching, and delivery + /// are enabled for this deployment. Defaults to false. + pub push_enabled: bool, /// Descriptor key identifier accepted in kind:30350 `exec` tags. pub push_executor_key_id: String, /// Exact HTTPS gateway endpoint used to submit client-authorized APNs delivery capabilities. - /// Push lease support is disabled when unset. + /// An absent setting selects the canonical Buzz gateway. An explicitly + /// empty setting is allowed only while push is disabled. pub push_gateway_delivery_url: Option, /// Hard timeout for one gateway delivery request. pub push_gateway_timeout: Duration, @@ -957,6 +961,7 @@ impl Config { let secret: [u8; 32] = rand::random(); hex::encode(secret) }); + let push_enabled = parse_bool("BUZZ_PUSH_ENABLED", false)?; let push_executor_key_id = std::env::var("BUZZ_PUSH_EXECUTOR_KEY_ID").unwrap_or_else(|_| "relay-v1".to_string()); if push_executor_key_id.is_empty() || push_executor_key_id.len() > 64 { @@ -965,6 +970,12 @@ impl Config { )); } let push_gateway_delivery_url = match std::env::var("BUZZ_PUSH_GATEWAY_DELIVERY_URL") { + Ok(raw) if raw.trim().is_empty() && push_enabled => { + return Err(ConfigError::InvalidValue( + "BUZZ_PUSH_GATEWAY_DELIVERY_URL must not be empty when BUZZ_PUSH_ENABLED=true" + .to_string(), + )); + } Ok(raw) if raw.trim().is_empty() => None, Ok(raw) => Some(parse_push_gateway_delivery_url(&raw)?), Err(_) => Some(parse_push_gateway_delivery_url( @@ -1238,6 +1249,7 @@ impl Config { git_max_repos_per_pubkey, git_max_concurrent_ops, git_hook_hmac_secret, + push_enabled, push_executor_key_id, push_gateway_delivery_url, push_gateway_timeout, @@ -2162,11 +2174,25 @@ mod tests { } #[test] - fn push_gateway_defaults_to_buzz_and_can_be_disabled() { + fn push_is_opt_in_and_gateway_defaults_to_buzz() { let _guard = ENV_MUTEX.lock().unwrap(); + let previous_enabled = std::env::var_os("BUZZ_PUSH_ENABLED"); let previous = std::env::var_os("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); + std::env::remove_var("BUZZ_PUSH_ENABLED"); std::env::remove_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL"); let config = Config::from_env().expect("default config"); + assert!(!config.push_enabled); + assert_eq!( + config + .push_gateway_delivery_url + .as_ref() + .map(url::Url::as_str), + Some(DEFAULT_PUSH_GATEWAY_DELIVERY_URL) + ); + + std::env::set_var("BUZZ_PUSH_ENABLED", "true"); + let config = Config::from_env().expect("enabled push config"); + assert!(config.push_enabled); assert_eq!( config .push_gateway_delivery_url @@ -2176,9 +2202,22 @@ mod tests { ); std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", ""); + let result = Config::from_env(); + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must not be empty") + )); + + std::env::set_var("BUZZ_PUSH_ENABLED", "false"); let config = Config::from_env().expect("disabled push config"); assert!(config.push_gateway_delivery_url.is_none()); + if let Some(value) = previous_enabled { + std::env::set_var("BUZZ_PUSH_ENABLED", value); + } else { + std::env::remove_var("BUZZ_PUSH_ENABLED"); + } if let Some(value) = previous { std::env::set_var("BUZZ_PUSH_GATEWAY_DELIVERY_URL", value); } else { @@ -2186,6 +2225,24 @@ mod tests { } } + #[test] + fn invalid_push_enabled_value_is_rejected() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_PUSH_ENABLED"); + std::env::set_var("BUZZ_PUSH_ENABLED", "sometimes"); + let result = Config::from_env(); + if let Some(value) = previous { + std::env::set_var("BUZZ_PUSH_ENABLED", value); + } else { + std::env::remove_var("BUZZ_PUSH_ENABLED"); + } + assert!(matches!( + result, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_PUSH_ENABLED") + )); + } + #[test] fn push_gateway_url_is_exact_and_fail_closed() { assert!(parse_push_gateway_delivery_url("https://push.example/v1/deliveries/apns").is_ok()); diff --git a/crates/buzz-relay/src/handlers/push_lease.rs b/crates/buzz-relay/src/handlers/push_lease.rs index ec56a096fdc..dd63b438c94 100644 --- a/crates/buzz-relay/src/handlers/push_lease.rs +++ b/crates/buzz-relay/src/handlers/push_lease.rs @@ -12,8 +12,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Number, Value}; use sha2::Digest as _; -pub(crate) const PUSH_KINDS: &[u64] = &[7, 9, 1059, 40007, 46010]; -pub(crate) const URGENT_KINDS: &[u64] = &[]; +/// Message kinds that can produce a mobile Activity-inbox notification. +/// Generic Nostr notes and non-message workflow/agent events are deliberately +/// excluded from the dogfood MVP. +pub(crate) const PUSH_KINDS: &[u64] = &[9, 40_002, 45_001, 45_003]; /// NIP-PL addressable push-lease event kind. pub const KIND_PUSH_LEASE: u32 = 30_350; @@ -68,7 +70,6 @@ pub struct LeaseLimits<'a> { pub app_profiles: &'a [AppProfile<'a>], pub supported_classes: &'a [&'a str], pub push_kinds: &'a [u64], - pub urgent_kinds: &'a [u64], pub max_subscriptions: usize, pub max_kinds: usize, pub max_authors: usize, @@ -245,14 +246,13 @@ fn validate_subscription(sub: &Subscription, limits: &LeaseLimits<'_>) -> Result if !limits.supported_classes.contains(&sub.class.as_str()) { return Err("class not supported".into()); } - validate_filter(&sub.filter, limits, true, &sub.class)?; + validate_filter(&sub.filter, limits, true)?; if sub.ignore.len() > limits.max_ignore { return Err("ignore quota exceeded".into()); } for filter in &sub.ignore { - // Ignore filters can only subtract from an already-positive match, so - // urgent-kind confinement belongs solely to the positive filter. - validate_filter(filter, limits, false, "")?; + // Ignore filters can only subtract from an already-positive match. + validate_filter(filter, limits, false)?; } if sub.suppress.as_ref().is_some_and(|s| s.p_tags_max == 0) { return Err("p_tags_max must be positive".into()); @@ -264,7 +264,6 @@ fn validate_filter( filter: &Map, limits: &LeaseLimits<'_>, require_narrowing: bool, - class: &str, ) -> Result<(), String> { const ALLOWED: &[&str] = &["kinds", "authors", "#p", "#h", "#e"]; if let Some(key) = filter.keys().find(|key| !ALLOWED.contains(&key.as_str())) { @@ -282,10 +281,6 @@ fn validate_filter( if kinds.iter().any(|kind| !limits.push_kinds.contains(kind)) { return Err("kind not push-eligible".into()); } - if class == "urgent" && kinds.iter().any(|kind| !limits.urgent_kinds.contains(kind)) { - return Err("class not permitted for kind".into()); - } - let authors = optional_string_array(filter, "authors", limits.max_authors)?; let p = optional_string_array(filter, "#p", limits.max_tag_values)?; let h = optional_string_array(filter, "#h", limits.max_h)?; @@ -477,7 +472,7 @@ pub async fn accept( const MAX_CONTENT: usize = 65_536; const MAX_PLAINTEXT: usize = 32_768; const MAX_ACTIVE_LEASES: i64 = 16; - if state.config.push_gateway_delivery_url.is_none() { + if !state.config.push_enabled { return Err(AcceptError::Validation("push not supported".to_string())); } let envelope = validate_envelope(event, now, ALLOWED_SKEW, MAX_LEASE_TTL, MAX_CONTENT)?; @@ -496,19 +491,12 @@ pub async fn accept( let limits = LeaseLimits { expected_origin: &origin, author_hex: &author_hex, - app_profiles: &[ - AppProfile { - id: "buzz-ios-production", - transport: "apns", - }, - AppProfile { - id: "buzz-ios-sandbox", - transport: "apns", - }, - ], - supported_classes: &["silent", "default", "time_sensitive"], + app_profiles: &[AppProfile { + id: "buzz-ios-dogfood", + transport: "apns", + }], + supported_classes: &["default"], push_kinds: PUSH_KINDS, - urgent_kinds: URGENT_KINDS, max_subscriptions: 16, max_kinds: 16, max_authors: 20, @@ -531,25 +519,29 @@ pub async fn accept( let subscriptions; let capability; let active = if body.active { - let endpoint = body.endpoint.as_deref().expect("validated active endpoint"); - endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); - let max_class = body + let endpoint = body + .endpoint + .as_deref() + .ok_or_else(|| "active lease is missing endpoint".to_string())?; + let body_subscriptions = body .subscriptions .as_ref() - .expect("validated subscriptions") + .ok_or_else(|| "active lease is missing subscriptions".to_string())?; + let app_profile = body + .app_profile + .as_deref() + .ok_or_else(|| "active lease is missing app profile".to_string())?; + endpoint_hash = sha2::Sha256::digest(endpoint.as_bytes()).to_vec(); + let max_class = body_subscriptions .iter() .map(|sub| sub.class.as_str()) .max_by_key(|class| class_rank(class)) - .expect("non-empty subscriptions"); + .ok_or_else(|| "active lease has no subscriptions".to_string())?; capability = endpoint.to_owned(); - subscriptions = serde_json::to_value( - body.subscriptions - .as_ref() - .expect("validated subscriptions"), - ) - .map_err(|_| "invalid subscriptions".to_string())?; + subscriptions = serde_json::to_value(body_subscriptions) + .map_err(|_| "invalid subscriptions".to_string())?; Some(buzz_db::push::ActiveLease { - app_profile: body.app_profile.as_deref().expect("validated profile"), + app_profile, endpoint_hash: &endpoint_hash, endpoint_grant: &capability, max_class, @@ -572,14 +564,8 @@ pub async fn accept( .map_err(|_| AcceptError::Internal("lease persistence failed".to_string())) } -fn class_rank(class: &str) -> u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } fn canonical_origin(relay_url: &str, host: &str) -> Result { @@ -680,9 +666,8 @@ mod tests { id: "p", transport: "apns", }], - supported_classes: &["default", "urgent"], - push_kinds: &[9, 46010], - urgent_kinds: &[46010], + supported_classes: &["default"], + push_kinds: &[9], max_subscriptions: 4, max_kinds: 4, max_authors: 4, @@ -702,7 +687,7 @@ mod tests { .collect::>() .join(", "); let predicate = format!("NEW.kind IN ({kinds})"); - let migration = include_str!("../../../../migrations/0018_push_match_queue.sql"); + let migration = include_str!("../../../../migrations/0040_push_message_kinds.sql"); assert!( migration.contains(&predicate), "migration trigger must use PUSH_KINDS exactly: {predicate}" @@ -759,13 +744,4 @@ mod tests { assert!(canonical_origin("https://relay.example", "tenant.example").is_err()); assert!(canonical_origin("wss://relay.example", "").is_err()); } - - #[test] - fn urgent_is_limited_by_event_kind() { - let body = parse_plaintext(r##"{"v":1,"origin":"o","generation":1,"active":true,"app_profile":"p","transport":"apns","endpoint":"token","subscriptions":[{"filter":{"kinds":[9],"#p":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]},"class":"urgent"}]}"##, 4096).unwrap(); - assert_eq!( - validate_plaintext(&body, &limits()).unwrap_err(), - "class not permitted for kind" - ); - } } diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 4548616fbb2..d9432589f46 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -161,6 +161,7 @@ async fn main() -> anyhow::Result<()> { metrics_port = config.metrics_port, max_frame_bytes = config.max_frame_bytes, audit_enabled = config.audit_enabled, + push_enabled = config.push_enabled, "Config loaded" ); @@ -168,6 +169,7 @@ async fn main() -> anyhow::Result<()> { let usage_idle_timeout_secs = usage_metrics_idle_timeout_secs(usage_interval_secs); relay_metrics::install(config.metrics_port, usage_idle_timeout_secs); metrics::gauge!("buzz_audit_enabled").set(if config.audit_enabled { 1.0 } else { 0.0 }); + metrics::gauge!("buzz_push_enabled").set(if config.push_enabled { 1.0 } else { 0.0 }); info!( port = config.metrics_port, idle_timeout_secs = usage_idle_timeout_secs, @@ -728,15 +730,16 @@ async fn main() -> anyhow::Result<()> { }); } - // NIP-PL matcher and worker are enabled as one unit. Lease acceptance is - // already disabled without the exact gateway URL, so discovery and runtime - // cannot advertise or accumulate work for an undeliverable configuration. - if state.config.push_gateway_delivery_url.is_some() { + // NIP-PL matcher and worker are enabled as one unit behind the explicit + // deployment opt-in. The gateway URL alone never enables push. + if state.config.push_enabled { tokio::spawn(buzz_relay::push_runtime::run_matcher(Arc::clone(&state))); tokio::spawn(buzz_relay::push_runtime::run_delivery_worker(Arc::clone( &state, ))); info!("NIP-PL push matcher and delivery worker started"); + } else { + info!("NIP-PL push disabled by BUZZ_PUSH_ENABLED"); } // Admin outbox delivery worker — drives `relay_admin_outbox` rows. diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 73855ff3cf8..e6b18cdd0f8 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -252,14 +252,10 @@ fn push_descriptor( "pubkey": relay_keypair.public_key().to_hex(), "current": true }], - "app_profiles": [ - {"id": "buzz-ios-production", "transport": "apns"}, - {"id": "buzz-ios-sandbox", "transport": "apns"} - ], + "app_profiles": [{"id": "buzz-ios-dogfood", "transport": "apns"}], "push_kinds": crate::handlers::push_lease::PUSH_KINDS, - "urgent_kinds": crate::handlers::push_lease::URGENT_KINDS, "h_grammar": "uuid-v4-lowercase", - "class_support": {"apns": ["silent", "default", "time_sensitive"]}, + "class_support": {"apns": ["default"]}, "limitation": { "max_lease_ttl": 2592000, "max_leases_per_pubkey": 16, @@ -297,7 +293,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st admin_api.as_deref(), state.config.klipy.as_ref().map(|_| "klipy"), ); - let tenant_host = if state.config.push_gateway_delivery_url.is_some() { + let tenant_host = if state.config.push_enabled { crate::tenant::bind_community(&state.db, raw_host) .await .ok() @@ -306,7 +302,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st None }; if let Some(push) = push_descriptor( - state.config.push_gateway_delivery_url.is_some(), + state.config.push_enabled, &state.config.relay_url, &state.config.push_executor_key_id, &state.relay_keypair, diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 4946b248c65..246997aac22 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -1,6 +1,9 @@ //! Durable NIP-PL event matcher and gateway delivery worker. -use std::{sync::Arc, time::Duration}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; use base64::Engine as _; use buzz_core::filter::{filters_match, reader_authorized_for_event}; @@ -131,6 +134,8 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc // the whole batch for retry. Jobs that keep failing are reaped by // the periodic sweep once their attempts are exhausted. warn!(%community, "push match context load failed: {e}"); + metrics::counter!("buzz_push_match_jobs_total", "result" => "context_error") + .increment(batch.jobs.len() as u64); let ids: Vec> = batch .jobs .iter() @@ -158,14 +163,26 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc let mut pending = Vec::new(); let mut wakes: Vec = Vec::new(); for job in &batch.jobs { + let match_queue_seconds = Utc::now() + .signed_duration_since(job.event.received_at) + .num_milliseconds() + .max(0) as f64 + / 1_000.0; + metrics::histogram!("buzz_push_match_queue_seconds").record(match_queue_seconds); let event_id = job.event.event.id.as_bytes().to_vec(); match match_job(job, &context) { - Ok(job_wakes) if job_wakes.is_empty() => completed.push(event_id), + Ok(job_wakes) if job_wakes.is_empty() => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "unmatched") + .increment(1); + completed.push(event_id); + } Ok(job_wakes) => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "matched").increment(1); pending.push((event_id, job.attempt)); wakes.extend(job_wakes); } Err(e) => { + metrics::counter!("buzz_push_match_jobs_total", "result" => "error").increment(1); warn!(event_id=%job.event.event.id, attempt=job.attempt, "push match failed: {e}"); if job.attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { // A poison event/lease must not retry forever or pin @@ -182,8 +199,19 @@ async fn process_match_batch(state: &AppState, batch: buzz_db::push::ClaimedMatc // transaction sends the contributing jobs back for an idempotent rematch // (the outbox dedup key absorbs any wakes that did commit elsewhere). match state.db.enqueue_push_wakes(community, &wakes).await { - Ok(_) => completed.extend(pending.into_iter().map(|(event_id, _)| event_id)), + Ok(outcomes) => { + for outcome in outcomes { + let result = match outcome { + buzz_db::push::EnqueueWakeOutcome::Enqueued(_) => "enqueued", + buzz_db::push::EnqueueWakeOutcome::Duplicate(_) => "duplicate", + buzz_db::push::EnqueueWakeOutcome::InactiveLease => "inactive_lease", + }; + metrics::counter!("buzz_push_wakes_total", "result" => result).increment(1); + } + completed.extend(pending.into_iter().map(|(event_id, _)| event_id)); + } Err(e) => { + metrics::counter!("buzz_push_wake_enqueue_errors_total").increment(1); warn!(%community, "push wake batch enqueue failed: {e}"); for (event_id, attempt) in pending { if attempt >= buzz_db::push::MAX_MATCH_ATTEMPTS { @@ -310,10 +338,17 @@ fn push_filter_authorized_for_event( /// Continuously claim due wakes and deliver them through the push gateway. pub async fn run_delivery_worker(state: Arc) { - let http = reqwest::Client::builder() + let http = match reqwest::Client::builder() .timeout(state.config.push_gateway_timeout) .build() - .expect("push HTTP client"); + { + Ok(http) => http, + Err(error) => { + error!(%error, "push HTTP client initialization failed"); + record_delivery("configuration_error"); + return; + } + }; let mut idle_delay = Duration::from_millis(500); loop { let mut found = false; @@ -362,13 +397,23 @@ async fn deliver_one( .db .fail_push_wake(claimed.community, claimed.id, claimed.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { warn!(wake=%claimed.id, "push revalidation failed: {e}"); + record_delivery("worker_error"); return; } }; + if outcome.attempt == 1 { + let wake_queue_seconds = Utc::now() + .signed_duration_since(outcome.queued_at) + .num_milliseconds() + .max(0) as f64 + / 1_000.0; + metrics::histogram!("buzz_push_wake_queue_seconds").record(wake_queue_seconds); + } if let Some(channel) = outcome.channel_id { match state .db @@ -381,6 +426,7 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { @@ -394,6 +440,7 @@ async fn deliver_one( Utc::now() + TimeDelta::seconds(2), ) .await; + record_delivery("retry"); return; } } @@ -411,10 +458,12 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } Err(e) => { warn!(wake=%outcome.id, "final push revalidation failed: {e}"); + record_delivery("worker_error"); return; } }; @@ -432,31 +481,47 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("suppressed"); return; } }; let Some(url) = state.config.push_gateway_delivery_url.as_ref() else { + record_delivery("configuration_error"); return; }; - let body = delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at); + let body = match delivery_body(&outcome.endpoint_grant, outcome.id, outcome.expires_at) { + Ok(body) => body, + Err(error) => { + warn!(wake=%outcome.id, %error, "push delivery body encoding failed"); + record_delivery("worker_error"); + return; + } + }; let auth = match nip98_header(&state.relay_keypair, url.as_str(), &body) { Ok(auth) => auth, Err(e) => { warn!(wake=%outcome.id, "push auth failed: {e}"); + record_delivery("worker_error"); return; } }; if let Err(error) = serving_write.verify().await { warn!(wake=%outcome.id, %error, "push serving lease lost before delivery"); + record_delivery("suppressed"); return; } - let response = match serving_write + metrics::counter!("buzz_push_gateway_requests_total").increment(1); + let gateway_started = Instant::now(); + let protected = serving_write .protect(send_gateway_request(http, url, body, auth)) - .await - { + .await; + metrics::histogram!("buzz_push_gateway_request_seconds") + .record(gateway_started.elapsed().as_secs_f64()); + let response = match protected { Ok(response) => response, Err(error) => { warn!(wake=%outcome.id, %error, "push serving lease lost during delivery"); + record_delivery("suppressed"); return; } }; @@ -467,12 +532,14 @@ async fn deliver_one( .db .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("accepted"); } _ => { let _ = state .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("failed"); } }, Ok(r) if r.status() == reqwest::StatusCode::GONE => { @@ -500,6 +567,7 @@ async fn deliver_one( .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("invalid_endpoint"); } Ok(r) if r.status() == reqwest::StatusCode::SERVICE_UNAVAILABLE => { let delay = match r.json::().await { @@ -510,10 +578,10 @@ async fn deliver_one( .unwrap_or(2), _ => 2, }; - retry_or_fail(state, &outcome, delay).await; + record_delivery(retry_or_fail(state, &outcome, delay).await); } Ok(r) if r.status() == reqwest::StatusCode::TOO_MANY_REQUESTS => { - retry_or_fail(state, &outcome, 2).await + record_delivery(retry_or_fail(state, &outcome, 2).await); } // A timed-out terminal attempt burns the stable request id. Its replay // is indistinguishable from another invalid-grant 404, but sending a @@ -523,13 +591,17 @@ async fn deliver_one( .db .complete_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("replay_terminal"); + } + Err(e) if e.is_timeout() || e.is_connect() => { + record_delivery(retry_or_fail(state, &outcome, 2).await); } - Err(e) if e.is_timeout() || e.is_connect() => retry_or_fail(state, &outcome, 2).await, _ => { let _ = state .db .fail_push_wake(outcome.community, outcome.id, outcome.claim_id) .await; + record_delivery("failed"); } } if let Err(error) = serving_write.finish().await { @@ -537,14 +609,17 @@ async fn deliver_one( } } -fn delivery_body(endpoint_grant: &str, request_id: uuid::Uuid, expires_at: i64) -> Vec { - serde_json::to_vec(&DeliveryRequest { +fn delivery_body( + endpoint_grant: &str, + request_id: uuid::Uuid, + expires_at: i64, +) -> anyhow::Result> { + Ok(serde_json::to_vec(&DeliveryRequest { v: 1, endpoint_grant, request_id, expires_at, - }) - .expect("closed delivery body") + })?) } async fn send_gateway_request( @@ -561,12 +636,21 @@ async fn send_gateway_request( .await } -async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, delay: i64) { +fn record_delivery(outcome: &'static str) { + metrics::counter!("buzz_push_deliveries_total", "outcome" => outcome).increment(1); +} + +async fn retry_or_fail( + state: &AppState, + wake: &buzz_db::push::ClaimedWake, + delay: i64, +) -> &'static str { if wake.attempt >= MAX_ATTEMPTS { let _ = state .db .fail_push_wake(wake.community, wake.id, wake.claim_id) .await; + "exhausted" } else { let secs = delay * (1_i64 << (wake.attempt - 1).clamp(0, 6)); let _ = state @@ -578,6 +662,7 @@ async fn retry_or_fail(state: &AppState, wake: &buzz_db::push::ClaimedWake, dela Utc::now() + TimeDelta::seconds(secs), ) .await; + "retry" } } @@ -597,14 +682,8 @@ fn nip98_header(keys: &nostr::Keys, url: &str, body: &[u8]) -> anyhow::Result u8 { - match class { - "silent" => 0, - "default" => 1, - "time_sensitive" => 2, - "urgent" => 3, - _ => 0, - } +fn class_rank(_: &str) -> u8 { + 1 } #[cfg(test)] @@ -675,7 +754,8 @@ mod tests { let keys = nostr::Keys::generate(); let request_id = uuid::Uuid::new_v4(); for _ in 0..2 { - let body = delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60); + let body = + delivery_body("opaque-grant", request_id, Utc::now().timestamp() + 60).unwrap(); let auth = nip98_header(&keys, url.as_str(), &body).unwrap(); let response = send_gateway_request(&http, &url, body, auth).await.unwrap(); assert!(response.status().is_success()); diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 38f69dee6dc..20ce7567270 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -31,17 +31,18 @@ spec: - { name: BUZZ_PUSH_HEALTH_ADDR, value: "0.0.0.0:8081" } - { name: BUZZ_PUSH_PUBLIC_DELIVERY_URL, value: {{ .Values.publicDeliveryUrl | quote }} } - { name: BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, value: {{ .Values.maxGrantLifetimeSeconds | quote }} } - - { name: BUZZ_PUSH_ENABLED_PROFILES, value: {{ .Values.enabledProfiles | quote }} } - - { name: BUZZ_PUSH_APP_ATTEST_APP_ID, value: {{ .Values.appAttestAppId | quote }} } - { name: BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH, value: /run/buzz/app-attest/root.pem } - - { name: BUZZ_PUSH_APNS_KEY_PATH, value: /run/buzz/apns/provider.p8 } - {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_APNS_KEY_ID" "BUZZ_PUSH_APNS_TEAM_ID" "BUZZ_PUSH_APNS_TOPIC" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} + - { name: BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID, value: {{ .Values.profiles.dogfood.appAttestAppId | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_TOPIC, value: {{ .Values.profiles.dogfood.apnsTopic | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT, value: {{ .Values.profiles.dogfood.apnsEnvironment | quote }} } + - { name: BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH, value: /run/buzz/apns-dogfood/identity.pem } + {{- range $name := list "DATABASE_URL" "BUZZ_PUSH_GRANT_KEYS" "BUZZ_PUSH_TOKEN_KEYS" }} - name: {{ $name }} valueFrom: { secretKeyRef: { name: {{ $.Values.existingSecret }}, key: {{ $name }} } } {{- end }} volumeMounts: - { name: app-attest-root, mountPath: /run/buzz/app-attest, readOnly: true } - - { name: apns-key, mountPath: /run/buzz/apns, readOnly: true } + - { name: apns-dogfood, mountPath: /run/buzz/apns-dogfood, readOnly: true } livenessProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 10, timeoutSeconds: 3, failureThreshold: 3 } readinessProbe: { httpGet: { path: /_readiness, port: health }, periodSeconds: 5, timeoutSeconds: 3, failureThreshold: 3 } startupProbe: { httpGet: { path: /_liveness, port: health }, periodSeconds: 2, failureThreshold: 60 } @@ -49,8 +50,8 @@ spec: volumes: - name: app-attest-root secret: { secretName: {{ .Values.appAttestRoot.secretName }}, items: [{ key: {{ .Values.appAttestRoot.secretKey }}, path: root.pem }] } - - name: apns-key - secret: { secretName: {{ .Values.apnsKey.secretName }}, items: [{ key: {{ .Values.apnsKey.secretKey }}, path: provider.p8 }] } + - name: apns-dogfood + secret: { secretName: {{ .Values.profiles.dogfood.apnsCert.secretName }}, defaultMode: 0400, items: [{ key: {{ .Values.profiles.dogfood.apnsCert.secretKey }}, path: identity.pem }] } {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} diff --git a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml index 20b9894280a..7a718bda718 100644 --- a/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml +++ b/deploy/charts/buzz-push-gateway/templates/prometheusrule.yaml @@ -21,9 +21,9 @@ spec: annotations: summary: Push gateway APNs configuration faults description: >- - APNs is returning configuration faults (bad/expired provider token - or topic). Deliveries are failing without invalidating endpoints. - See runbook: check the APNs .p8 key, key id, team id, and topic. + APNs is returning certificate or topic configuration faults. + Deliveries are failing without invalidating endpoints. See the + runbook and check the APNs certificate identity and topic. # Authority store unavailable at admission = durable dependency is down. - alert: PushGatewayAdmissionUnavailable expr: | diff --git a/deploy/charts/buzz-push-gateway/tests/release-contract.sh b/deploy/charts/buzz-push-gateway/tests/release-contract.sh index 7ae85ce34e3..993c4c05369 100755 --- a/deploy/charts/buzz-push-gateway/tests/release-contract.sh +++ b/deploy/charts/buzz-push-gateway/tests/release-contract.sh @@ -1,30 +1,29 @@ #!/usr/bin/env bash set -euo pipefail -python3 - <<'PY' -from pathlib import Path -import yaml - -auto_path = Path('.github/workflows/auto-tag-on-release-pr-merge.yml') -publish_path = Path('.github/workflows/push-gateway-helm-chart.yml') -auto_text = auto_path.read_text() -publish_text = publish_path.read_text() -# Parse first, then pin the cross-workflow strings whose agreement makes this a -# reachable lane rather than an orphan publisher. -yaml.safe_load(auto_text) -yaml.safe_load(publish_text) -for needle in ( - 'push-chart-release/*)', - 'VERSION="${BRANCH#push-chart-release/}"', - 'TAG_PREFIX="push-chart-v"', - 'DISPATCH="push-gateway-helm-chart"', - 'push-gateway-helm-chart) WORKFLOW="push-gateway-helm-chart.yml"', -): - assert needle in auto_text, f'missing auto-tag gateway chart contract: {needle}' -for needle in ( - 'tags: ["push-chart-v[0-9]*"]', - 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', - 'refs/tags/push-chart-v${version}^{commit}', - 'deploy/charts/buzz-push-gateway', -): - assert needle in publish_text, f'missing gateway chart publisher contract: {needle}' -PY +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml <<'RUBY' +auto_text = File.read('.github/workflows/auto-tag-on-release-pr-merge.yml') +publish_text = File.read('.github/workflows/push-gateway-helm-chart.yml') +# Parse first, then pin the tag producer and consumer strings whose agreement +# makes this a reachable lane rather than an orphan publisher. +YAML.load(auto_text) +YAML.load(publish_text) +[ + 'push-chart-release/*)', + 'VERSION="${BRANCH#push-chart-release/}"', + 'TAG_PREFIX="push-chart-v"', + '- name: Create and push tag', + 'TAG: ${{ steps.release.outputs.tag }}', + 'refs/tags/$TAG', + '-f sha="$TARGET_SHA"', +].each do |needle| + raise "missing auto-tag gateway chart contract: #{needle}" unless auto_text.include?(needle) +end +[ + 'tags: ["push-chart-v[0-9]*"]', + 'version="${INPUT_VERSION:-${REF_NAME#push-chart-v}}"', + 'refs/tags/push-chart-v${version}^{commit}', + 'deploy/charts/buzz-push-gateway', +].each do |needle| + raise "missing gateway chart publisher contract: #{needle}" unless publish_text.include?(needle) +end +RUBY diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 137f8d0add7..250955c5fc2 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -10,7 +10,7 @@ helm template push deploy/charts/buzz-push-gateway >"$out" production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' - --set 'appAttestAppId=REALTEAM.xyz.buzz' + --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' --set 'httpRoute.parentRefs[0].name=production-gateway' --set 'httpRoute.parentRefs[0].namespace=gateway-system' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' @@ -18,60 +18,87 @@ production_args=( helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" -python3 - "$out" "$production_out" <<'PY' -import sys,yaml -xs=list(yaml.safe_load_all(open(sys.argv[1]))) -svc=next(x for x in xs if x and x.get('kind')=='Service') -assert [p['targetPort'] for p in svc['spec']['ports']]==['public'] -d=next(x for x in xs if x and x.get('kind')=='Deployment') -j=next(x for x in xs if x and x.get('kind')=='Job') -runtime={'app.kubernetes.io/name':'buzz-push-gateway','app.kubernetes.io/instance':'push','app.kubernetes.io/component':'runtime'} -migration={**runtime,'app.kubernetes.io/component':'migration'} -assert svc['spec']['selector']==runtime -assert d['spec']['selector']['matchLabels']==runtime -assert d['spec']['template']['metadata']['labels']==runtime -assert j['spec']['template']['metadata']['labels']==migration -assert svc['spec']['selector'] != j['spec']['template']['metadata']['labels'] -jenv={e['name']:e for e in j['spec']['template']['spec']['containers'][0]['env']} -assert jenv['BUZZ_PUSH_RUNTIME_DATABASE_ROLE']['value']=='buzz_push_gateway_runtime' -assert 'valueFrom' in jenv['DATABASE_URL'] -assert j['spec']['template']['spec']['containers'][0]['args']==['--migrate-only'] -assert j['metadata']['annotations']=={ - 'helm.sh/hook':'pre-install,pre-upgrade', - 'helm.sh/hook-weight':'-5', - 'helm.sh/hook-delete-policy':'before-hook-creation,hook-succeeded', +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ + - "$out" "$production_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +svc = xs.find { |x| x["kind"] == "Service" } +assert!(svc.dig("spec", "ports").map { |port| port["targetPort"] } == ["public"]) +d = xs.find { |x| x["kind"] == "Deployment" } +j = xs.find { |x| x["kind"] == "Job" } +runtime = { + "app.kubernetes.io/name" => "buzz-push-gateway", + "app.kubernetes.io/instance" => "push", + "app.kubernetes.io/component" => "runtime", } -env={e['name'] for e in d['spec']['template']['spec']['containers'][0]['env']} -required={'DATABASE_URL','BUZZ_PUSH_APNS_KEY_ID','BUZZ_PUSH_APNS_TEAM_ID','BUZZ_PUSH_APNS_TOPIC','BUZZ_PUSH_GRANT_KEYS','BUZZ_PUSH_TOKEN_KEYS','BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS'} -assert required <= env -assert d['spec']['replicas'] >= 2 -assert not any(x and x.get('kind')=='HTTPRoute' for x in xs) +migration = runtime.merge("app.kubernetes.io/component" => "migration") +assert!(svc.dig("spec", "selector") == runtime) +assert!(d.dig("spec", "selector", "matchLabels") == runtime) +assert!(d.dig("spec", "template", "metadata", "labels") == runtime) +assert!(j.dig("spec", "template", "metadata", "labels") == migration) +assert!(svc.dig("spec", "selector") != j.dig("spec", "template", "metadata", "labels")) +jenv = j.dig("spec", "template", "spec", "containers", 0, "env").to_h { |entry| [entry["name"], entry] } +assert!(jenv.dig("BUZZ_PUSH_RUNTIME_DATABASE_ROLE", "value") == "buzz_push_gateway_runtime") +assert!(jenv.fetch("DATABASE_URL").key?("valueFrom")) +assert!(j.dig("spec", "template", "spec", "containers", 0, "args") == ["--migrate-only"]) +assert!(j.dig("metadata", "annotations") == { + "helm.sh/hook" => "pre-install,pre-upgrade", + "helm.sh/hook-weight" => "-5", + "helm.sh/hook-delete-policy" => "before-hook-creation,hook-succeeded", +}) +env_names = d.dig("spec", "template", "spec", "containers", 0, "env") + .map { |entry| entry["name"] }.to_set +required = Set.new(%w[ + DATABASE_URL BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH + BUZZ_PUSH_DOGFOOD_APNS_TOPIC BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID + BUZZ_PUSH_GRANT_KEYS BUZZ_PUSH_TOKEN_KEYS BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS +]) +assert!(required.subset?(env_names)) +assert!(!env_names.any? { |name| name.include?("APP_STORE") }) +apns_volume = d.dig("spec", "template", "spec", "volumes").find { |volume| volume["name"] == "apns-dogfood" } +assert!(apns_volume.dig("secret", "defaultMode") == 0o400, apns_volume.inspect) +assert!(d.dig("spec", "replicas") >= 2) +assert!(!xs.any? { |x| x["kind"] == "HTTPRoute" }) # Observability is opt-in: default render exposes no scrape CRDs and 8081 stays # free of pod ingress (only 8080 is reachable). -assert not any(x and x.get('kind') in ('PodMonitor','PrometheusRule') for x in xs) -nps=[x for x in xs if x and x.get('kind')=='NetworkPolicy'] -np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway') -migration_np=next(x for x in nps if x['metadata']['name']=='push-buzz-push-gateway-migration') -assert np['spec']['podSelector']['matchLabels']==runtime -assert migration_np['spec']['podSelector']['matchLabels']==migration -assert migration_np['metadata']['annotations']=={ - 'helm.sh/hook':'pre-install,pre-upgrade', - 'helm.sh/hook-weight':'-10', - 'helm.sh/hook-delete-policy':'before-hook-creation', -} -assert int(migration_np['metadata']['annotations']['helm.sh/hook-weight']) < int(j['metadata']['annotations']['helm.sh/hook-weight']) -assert migration_np['spec']['ingress']==[] -assert migration_np['spec']['policyTypes']==['Ingress','Egress'] -migration_ports={p['port'] for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])} -assert migration_ports=={53,5432}, migration_ports -assert all(p['port'] != 443 for rule in migration_np['spec']['egress'] for p in rule.get('ports',[])) -ingress_ports={p['port'] for rule in np['spec']['ingress'] for p in rule.get('ports',[])} -assert ingress_ports=={8080}, ingress_ports -production=list(yaml.safe_load_all(open(sys.argv[2]))) -route=next(x for x in production if x and x.get('kind')=='HTTPRoute') -assert route['spec']['parentRefs'] -assert 'push.buzz.xyz' in route['spec']['hostnames'] -PY +assert!(!xs.any? { |x| %w[PodMonitor PrometheusRule].include?(x["kind"]) }) +nps = xs.select { |x| x["kind"] == "NetworkPolicy" } +np = nps.find { |x| x.dig("metadata", "name") == "push-buzz-push-gateway" } +migration_np = nps.find { |x| x.dig("metadata", "name") == "push-buzz-push-gateway-migration" } +assert!(np.dig("spec", "podSelector", "matchLabels") == runtime) +assert!(migration_np.dig("spec", "podSelector", "matchLabels") == migration) +assert!(migration_np.dig("metadata", "annotations") == { + "helm.sh/hook" => "pre-install,pre-upgrade", + "helm.sh/hook-weight" => "-10", + "helm.sh/hook-delete-policy" => "before-hook-creation", +}) +assert!(migration_np.dig("metadata", "annotations", "helm.sh/hook-weight").to_i < j.dig("metadata", "annotations", "helm.sh/hook-weight").to_i) +assert!(migration_np.dig("spec", "ingress") == []) +assert!(migration_np.dig("spec", "policyTypes") == %w[Ingress Egress]) +migration_ports = migration_np.dig("spec", "egress") + .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set +assert!(migration_ports == Set[53, 5432], migration_ports.inspect) +assert!(!migration_np.dig("spec", "egress").flat_map { |rule| rule.fetch("ports", []) }.any? { |port| port["port"] == 443 }) +ingress_ports = np.dig("spec", "ingress") + .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set +assert!(ingress_ports == Set[8080], ingress_ports.inspect) +production = YAML.load_stream(File.read(ARGV[1])).compact +route = production.find { |x| x["kind"] == "HTTPRoute" } +assert!(!route.dig("spec", "parentRefs").empty?) +assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) +RUBY + +# Legacy token-auth values must fail rather than silently selecting the default +# certificate Secret. +if helm template push deploy/charts/buzz-push-gateway \ + --set apnsKey.secretName=legacy-apns-secret \ + --set apnsKey.secretKey=legacy-provider.p8 >/dev/null 2>&1; then + echo 'expected legacy apnsKey values to fail schema validation' >&2 + exit 1 +fi # Enabling a route without a Gateway attachment must fail schema validation. if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=true >/dev/null 2>&1; then @@ -97,20 +124,28 @@ helm template push deploy/charts/buzz-push-gateway \ --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ >"$monitoring_out" -python3 - "$monitoring_out" <<'PY' -import sys,yaml -xs=list(yaml.safe_load_all(open(sys.argv[1]))) -pm=next(x for x in xs if x and x.get('kind')=='PodMonitor') -ep=pm['spec']['podMetricsEndpoints'][0] -assert ep['port']=='health' and ep['path']=='/metrics', ep -assert next(x for x in xs if x and x.get('kind')=='PrometheusRule')['spec']['groups'] -np=next(x for x in xs if x and x.get('kind')=='NetworkPolicy' and x['metadata']['name']=='push-buzz-push-gateway') -mon=[r for r in np['spec']['ingress'] if {p['port'] for p in r.get('ports',[])}=={8081}] -assert len(mon)==1, 'exactly one scoped 8081 ingress rule' -frm=mon[0]['from'][0] +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ + - "$monitoring_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +pm = xs.find { |x| x["kind"] == "PodMonitor" } +endpoint = pm.dig("spec", "podMetricsEndpoints", 0) +assert!(endpoint["port"] == "health" && endpoint["path"] == "/metrics", endpoint.inspect) +assert!(!xs.find { |x| x["kind"] == "PrometheusRule" }.dig("spec", "groups").empty?) +np = xs.find do |x| + x["kind"] == "NetworkPolicy" && x.dig("metadata", "name") == "push-buzz-push-gateway" +end +monitoring = np.dig("spec", "ingress").select do |rule| + rule.fetch("ports", []).map { |port| port["port"] }.to_set == Set[8081] +end +assert!(monitoring.length == 1, "exactly one scoped 8081 ingress rule") +from = monitoring[0].fetch("from")[0] # 8081 ingress must be scoped by both selectors, never empty/blanket. -assert frm['namespaceSelector']['matchLabels'] and frm['podSelector']['matchLabels'], frm -PY +assert!(!from.dig("namespaceSelector", "matchLabels").empty? && !from.dig("podSelector", "matchLabels").empty?, from.inspect) +RUBY # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 7a0569616fa..8017f6bacdb 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -3,7 +3,9 @@ image: tag: "" digest: "" -appAttestAppId: "" +profiles: + dogfood: + appAttestAppId: "" httpRoute: enabled: true parentRefs: [] diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 631a04aea5a..29eafa22c8d 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -19,10 +19,19 @@ "minimum": 1, "maximum": 31536000 }, - "appAttestAppId": { - "type": "string", - "minLength": 1 + "profiles": { + "type": "object", + "additionalProperties": false, + "required": [ + "dogfood" + ], + "properties": { + "dogfood": { + "$ref": "#/$defs/enabledProfile" + } + } }, + "apnsKey": false, "httpRoute": { "type": "object", "required": [ @@ -232,12 +241,72 @@ } } }, + "$defs": { + "profileBase": { + "type": "object", + "additionalProperties": false, + "required": [ + "appAttestAppId", + "apnsTopic", + "apnsEnvironment" + ], + "properties": { + "appAttestAppId": { + "type": "string", + "minLength": 1 + }, + "apnsTopic": { + "type": "string", + "minLength": 1 + }, + "apnsEnvironment": { + "enum": [ + "production", + "sandbox" + ] + }, + "apnsCert": { + "$ref": "#/$defs/apnsCert" + } + } + }, + "enabledProfile": { + "allOf": [ + { + "$ref": "#/$defs/profileBase" + }, + { + "required": [ + "apnsCert" + ] + } + ] + }, + "apnsCert": { + "type": "object", + "additionalProperties": false, + "required": [ + "secretName", + "secretKey" + ], + "properties": { + "secretName": { + "type": "string", + "minLength": 1 + }, + "secretKey": { + "type": "string", + "minLength": 1 + } + } + } + }, "required": [ "replicaCount", "existingSecret", "publicDeliveryUrl", "maxGrantLifetimeSeconds", - "appAttestAppId", + "profiles", "httpRoute", "image", "migration" diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index ec46d9dbdd8..1f1e90cbb08 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -20,16 +20,18 @@ migration: limits: {cpu: 250m, memory: 128Mi} publicDeliveryUrl: https://push.buzz.xyz/v1/deliveries/apns maxGrantLifetimeSeconds: 2592000 -enabledProfiles: buzz-ios-production -# Example App Attest identifier. Production MUST override this with the exact -# Apple TEAMID.bundle-id value (see values-production.yaml). -appAttestAppId: TEAMID.xyz.buzz +profiles: + dogfood: + # Production MUST override this with the exact Apple TEAMID.bundle-id. + appAttestAppId: TEAMID.xyz.block.buzz.dogfood.mobile + apnsTopic: xyz.block.buzz.dogfood.mobile + apnsEnvironment: production + apnsCert: + secretName: buzz-push-gateway + secretKey: dogfood-apns-identity.pem appAttestRoot: secretName: buzz-push-gateway secretKey: app-attest-root.pem -apnsKey: - secretName: buzz-push-gateway - secretKey: apns-provider.p8 service: port: 8080 httpRoute: diff --git a/docs/nips/NIP-PL.md b/docs/nips/NIP-PL.md index 08fa6ca34d9..6575c98bfb3 100644 --- a/docs/nips/NIP-PL.md +++ b/docs/nips/NIP-PL.md @@ -264,15 +264,22 @@ This section registers the public last-hop profile served at `https://push.buzz. ### Registered values and lease mapping -The registered `app_profile` values are `buzz-ios-production` (Apple production APNs environment) and `buzz-ios-sandbox` (Apple sandbox APNs environment). A gateway deployment MUST enable only profiles for which its App Attest application identifier, APNs topic, credentials, and APNs environment are configured consistently. The APNs token registered with the gateway is called the **installation endpoint** and never leaves gateway custody after enrollment. +The registered `app_profile` value is `buzz-ios-dogfood`. It identifies the +closed Buzz dogfood application identity, not an APNs transport environment. +The canonical gateway owns its exact App Attest application identifier, APNs +topic, certificate-backed connection pool, and APNs environment. Enrollment +succeeds only when App Attest cryptographically verifies the configured +application identifier. The gateway MUST NOT accept an APNs topic from a client. The APNs token +registered with the gateway is called the **installation endpoint** and never +leaves gateway custody after enrollment. The opaque string returned as `endpoint_grant` by `POST /v1/delegations` is the **delivery capability**. For this profile, the active lease plaintext's `endpoint` member MUST contain that `endpoint_grant`, not the raw APNs token. `transport` MUST be `apns`, and `app_profile` MUST equal the profile sealed into the grant. Base-protocol endpoint uniqueness, rotation, hashing, and coalescing operate on this opaque lease `endpoint` within an origin. A capability is scoped to one installation, relay signing pubkey, endpoint epoch, generation, and expiry; grants independently issued to different relays are intentionally distinct. The gateway separately enforces global installation-endpoint uniqueness using `(app_profile, SHA-256(token))`. A public-profile relay MUST treat `endpoint` as opaque and MUST NOT parse or transform it. ### Common HTTP and value rules -All routes below accept only `POST`. Clients MUST send `Content-Type: application/json`; bodies are UTF-8 JSON and MUST be at most 8192 bytes. Every request object is closed: unknown members, duplicate members at any depth, missing or incorrectly typed members, trailing non-whitespace data, or a `v` other than integer `1` are `400 {"error":"invalid_request"}`. Integers are signed JSON integers in the ranges stated below. Unix times are integer seconds. UUIDs use the canonical lowercase hyphenated representation. Relay pubkeys are exactly 64 lowercase hexadecimal characters. APNs endpoints are non-empty, even-length lowercase hexadecimal strings encoding at most 512 bytes. Challenges are exactly 32 bytes encoded as unpadded URL-safe base64. `key_id`, `attestation`, and `assertion` use padded or unpadded standard base64 as accepted by Apple's App Attest API; decoded key ids are exactly 32 bytes, attestations are 1..16384 bytes, and assertions are 1..1024 bytes. An `endpoint_grant`, including its key-id prefix, MUST be at most 4096 bytes. +All routes below accept only `POST`. Clients MUST send `Content-Type: application/json`; bodies are UTF-8 JSON and MUST be at most 8192 bytes, except `POST /v1/installations`, whose body MUST be at most 23896 bytes. That installation-only ceiling is derived from the maximum permitted base64-encoded 16384-byte App Attest object, the maximum 512-byte APNs endpoint encoded as hex, and 1024 bytes for the remaining closed envelope. A body over its applicable limit is rejected with HTTP `413` before JSON parsing. Every request object is closed: unknown members, duplicate members at any depth, missing or incorrectly typed members, trailing non-whitespace data, or a `v` other than integer `1` are `400 {"error":"invalid_request"}`. Integers are signed JSON integers in the ranges stated below. Unix times are integer seconds. UUIDs use the canonical lowercase hyphenated representation. Relay pubkeys are exactly 64 lowercase hexadecimal characters. APNs endpoints are non-empty, even-length lowercase hexadecimal strings encoding at most 512 bytes. Challenges are exactly 32 bytes encoded as unpadded URL-safe base64. `key_id`, `attestation`, and `assertion` use padded or unpadded standard base64 as accepted by Apple's App Attest API; decoded key ids are exactly 32 bytes, attestations are 1..16384 bytes, and assertions are 1..1024 bytes. An `endpoint_grant`, including its key-id prefix, MUST be at most 4096 bytes. -Successful and error responses are UTF-8 `application/json`. Closed error bodies are `{"error":"invalid_request"}`, `{"error":"invalid_attestation"}`, `{"error":"not_authorized"}`, `{"error":"invalid_auth"}`, `{"error":"invalid_grant"}`, `{"error":"temporarily_unavailable"}`, `{"error":"configuration_fault"}`, or `{"error":"not_ready"}`. Authority/custody/quota rejection MUST NOT reveal whether an installation, delegation, or endpoint exists. In particular, delivery grant/authority/replay/quota failures collapse to `404 invalid_grant`; storage failures use `503 temporarily_unavailable`. +Handler responses are UTF-8 `application/json`. Closed error bodies are `{"error":"invalid_request"}`, `{"error":"invalid_attestation"}`, `{"error":"not_authorized"}`, `{"error":"invalid_auth"}`, `{"error":"invalid_grant"}`, `{"error":"rate_limited"}`, `{"error":"temporarily_unavailable"}`, `{"error":"configuration_fault"}`, or `{"error":"not_ready"}`. Authority, custody, and quota rejection MUST NOT reveal whether an installation, delegation, or endpoint exists. Delivery grant, authority, and replay failures collapse to `404 invalid_grant`; endpoint quota exhaustion uses `429 rate_limited`; storage failures use `503 temporarily_unavailable`. ### Exact App Attest transcript construction @@ -296,7 +303,7 @@ Success `200`: {"challenge_id":"","challenge":"","expires_at":} ``` -The challenge is single-use. Invalid input is `400 invalid_request`; storage/randomness failure is `503 temporarily_unavailable`. +The challenge is single-use. Invalid input is `400 invalid_request`; deployment-global challenge issuance limits return `429 rate_limited`; storage/randomness failure is `503 temporarily_unavailable`. ### Installation enrollment @@ -305,7 +312,7 @@ The challenge is single-use. Invalid input is `400 invalid_request`; storage/ran Request members, in any request order: ```json -{"v":1,"challenge_id":"","challenge":"","key_id":"","attestation":"","app_profile":"buzz-ios-production","endpoint":"","endpoint_epoch":1,"expires_at":} +{"v":1,"challenge_id":"","challenge":"","key_id":"","attestation":"","app_profile":"buzz-ios-dogfood","endpoint":"","endpoint_epoch":1,"expires_at":} ``` `expires_at` MUST satisfy `now < expires_at <= now + configured_max_installation_lifetime`; the selected profile MUST be enabled. The exact transcript is domain `buzz.push.enroll.v1` followed by this ordered object: @@ -320,7 +327,9 @@ The gateway verifies Apple's attestation chain, configured application identifie {"installation_handle":"","endpoint_epoch":1,"expires_at":} ``` -Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or duplicate key/token is `404 not_authorized`. +The client MUST durably journal the exact attested enrollment request before its first send and retain it until delegation state is durable. If that exact request is replayed after the installation commit, the gateway MUST return the same success response after re-verifying the attestation, even though the challenge was already consumed. Idempotency requires exact equality of attested key, profile, endpoint fingerprint, epoch, and expiration, and the recovered public key MUST equal the committed key; any mismatch remains indistinguishable from other authority rejection. This recovery rule grants no authority beyond replaying the already authenticated request. + +Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge or a key/token owned by a live installation is `404 not_authorized`. A fresh verified enrollment may replace expired or revoked ownership so an app that missed its renewal window can recover. ### Relay delegation and capability issuance @@ -330,7 +339,7 @@ Invalid attestation is `401 invalid_attestation`; a consumed/expired challenge o {"v":1,"challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"<64-lowercase-hex>","not_before":,"expires_at":,"assertion":""} ``` -`not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= min(now + configured_max_grant_lifetime, installation.expires_at)`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. Transcript domain `buzz.push.delegate.v1`; ordered object: +`not_before <= now + 300`, `not_before < expires_at`, and `expires_at <= now + configured_max_grant_lifetime`. The endpoint epoch MUST equal the current installation epoch. For each `(installation_handle, relay_pubkey)`, generation MUST strictly increase. A successful delegation atomically extends the authenticated installation lifetime through at least the delegation's `expires_at`, allowing renewal without duplicate token enrollment. Transcript domain `buzz.push.delegate.v1`; ordered object: ```json {"v":1,"audience":"https://push.buzz.xyz/v1/delegations","challenge_id":"","challenge":"","installation_handle":"","endpoint_epoch":,"generation":,"relay_pubkey":"","not_before":,"expires_at":} @@ -404,7 +413,8 @@ Responses: - `503 {"error":"configuration_fault"}` — provider configuration fault; request reservation released after processing. - `400 {"error":"invalid_request"}` — malformed request or permanent APNs request fault; a provider-reached permanent fault is terminal. - `401 {"error":"invalid_auth"}` — absent or invalid NIP-98 authorization. -- `404 {"error":"invalid_grant"}` — capability, signer, authority, replay, expiry, or quota rejection. +- `404 {"error":"invalid_grant"}` — capability, signer, authority, replay, or expiry rejection. +- `429 {"error":"rate_limited"}` — endpoint delivery quota exhausted. - `503 {"error":"temporarily_unavailable"}` — durable authority/custody/disposition failure. The gateway performs one APNs request, except that an APNs expired-provider-token response permits one credential refresh and one retry. The application body is always the exact constant registered in the APNs transport profile above; no request or grant field enters it. @@ -439,4 +449,4 @@ Zombie leases (e.g. `#h` after leaving a channel) are neutralized by match-time - NIP-11 `supported_extensions`: contains `"nip-pl"` pre-numbering; descriptor object `push` as specified in Executor Discovery - Classes: `silent`, `default`, `time_sensitive`, `urgent` - `h_grammar` values: `"uuid-v4-lowercase"` (initial entry; origins may register additional grammars with this NIP) -- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profiles `buzz-ios-production`, `buzz-ios-sandbox`; wire version `1` +- Public APNs gateway profile: base URL `https://push.buzz.xyz`; app profile `buzz-ios-dogfood`; wire version `1` diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index 63c63355a11..e9a9ae16055 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -16,21 +16,38 @@ | `BUZZ_PUSH_PUBLIC_DELIVERY_URL` | Exact externally signed URL, normally `https://push.buzz.xyz/v1/deliveries/apns`. | | `BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS` | Maximum delegation capability lifetime (`1..=31536000`). | | `BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS` | Maximum encrypted-token installation lifetime (default 90 days, max one year). Clients must renew before expiry. | -| `BUZZ_PUSH_ENABLED_PROFILES` | Comma-separated `buzz-ios-production` and/or `buzz-ios-sandbox`. | -| `BUZZ_PUSH_APP_ATTEST_APP_ID` | Exact Apple App Attest application identifier (`TEAMID.bundle-id`). | | `BUZZ_PUSH_APP_ATTEST_ROOT_CERT_PATH` | Read-only mounted Apple App Attest root certificate PEM. | -| `BUZZ_PUSH_APNS_KEY_PATH` | Read-only mounted Apple APNs `.p8` provider key. | -| `BUZZ_PUSH_APNS_KEY_ID` | APNs provider key id. | -| `BUZZ_PUSH_APNS_TEAM_ID` | Apple developer team id. | -| `BUZZ_PUSH_APNS_TOPIC` | Buzz iOS bundle id. | +| `BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID` | Exact server-owned Apple App Attest application identifier (`TEAMID.bundle-id`). | +| `BUZZ_PUSH_DOGFOOD_APNS_TOPIC` | Server-owned APNs topic. Never accepted from a client. | +| `BUZZ_PUSH_DOGFOOD_APNS_ENVIRONMENT` | `production` or `sandbox`, selected by deployment configuration. | +| `BUZZ_PUSH_DOGFOOD_APNS_CERT_PATH` | Read-only certificate/private-key PEM. | | `BUZZ_PUSH_GRANT_KEYS` | Capability AEAD keyring, `id:base64-32-bytes[,predecessor...]`; current key first. | | `BUZZ_PUSH_TOKEN_KEYS` | Independent token-custody AEAD keyring in the same format. Never reuse grant keys. | +The canonical `push.buzz.xyz` MVP serves the dogfood application identity +(`xyz.block.buzz.dogfood.mobile`). App Attest must cryptographically validate +the configured application ID before enrollment. Assertions and delivery use +the server-owned APNs topic, certificate-backed connection pool, and +environment. No client request or relay grant can supply or override an APNs +topic. + +This MVP has exactly one compiled-in application profile, +`buzz-ios-dogfood`. The chart value +`profiles.dogfood.appAttestAppId` is rendered as +`BUZZ_PUSH_DOGFOOD_APP_ATTEST_APP_ID`; the gateway rejects startup when it is +missing or empty. The exact `TEAMID.bundle-id` is environment-owned, +non-secret deployment configuration. The chart's production values file leaves +it empty deliberately so a production renderer must supply it from the GitOps +environment rather than baking a Block team identifier into this repository. +Supporting another application identity requires an explicit code, schema, +chart, credential, and deployment change; this gateway does not currently +select among multiple application profiles. + Optional endpoint quota policy variables are `BUZZ_PUSH_ENDPOINT_QUOTA_WINDOW_SECONDS` (default `10`, max `86400`) and `BUZZ_PUSH_ENDPOINT_QUOTA_MAX_DELIVERIES` (default `10`, max `10000`). These are Buzz policy hypotheses, not Apple-published limits; tune under load while retaining a hard ceiling. ## Secret and key rotation rules -Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs key and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. +Mount the App Attest root read-only and startup will reject any byte mismatch. The sole accepted artifact is Apple’s **Apple App Attestation Root CA** from `https://www.apple.com/certificateauthority/Apple_App_Attestation_Root_CA.pem`: certificate SHA-256 fingerprint `1C:B9:82:3B:A2:8B:A6:AD:2D:33:A0:06:94:1D:E2:AE:4F:51:3E:F1:D4:E8:31:B9:F7:E0:FA:7B:62:42:C9:32`; exact PEM-file SHA-256 `c778d09ac341f7fd9f8f3b19e2b815af6aed4ad4490e1e92c05cb355212a5013`. Treat an Apple root rotation as a reviewed code/config rollout, not an unpinned mount replacement. Mount the APNs certificate identity and both AEAD keyrings from a secret manager; never place values in an image, manifest, log, or metrics label. Keep the current AEAD key first and retain decrypt-only predecessors until every capability/token encrypted under them has expired or been re-encrypted. Grant and token key ids and bytes must be distinct. Rotation is an operator rollout: add the new current key while retaining predecessors, deploy, wait through the retention window, then remove the old key. The gateway stores APNs tokens encrypted in PostgreSQL. Database backups therefore contain ciphertext plus authority metadata and must receive the same access controls and retention treatment as the service secrets. @@ -44,13 +61,12 @@ The service reaps expired challenges and replay rows, idle quota rows, expired/r ## Metrics and alerting -The gateway serves Prometheus metrics at `GET /metrics` on the **private health listener** (`BUZZ_PUSH_HEALTH_ADDR`, default `0.0.0.0:8081`) — the same port as the probes, never on the public `8080`. All series are sanitized and bounded-cardinality: label values are drawn only from closed sets (the six APNs outcome classes, the fixed admission results, the static error codes already returned to callers, and the readiness causes). No endpoint, device token, relay pubkey, request id, or any request-scoped identifier is ever used as a label. +The gateway serves Prometheus metrics at `GET /metrics` on the **private health listener** (`BUZZ_PUSH_HEALTH_ADDR`, default `0.0.0.0:8081`) — the same port as the probes, never on the public `8080`. All series are sanitized and bounded-cardinality: label values are drawn only from closed sets (the five APNs outcome classes, the fixed admission results, the static error codes already returned to callers, and the readiness causes). No endpoint, device token, relay pubkey, request id, or any request-scoped identifier is ever used as a label. | Metric | Type | Labels | Meaning | |---|---|---|---| -| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `refresh_credential` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | +| `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | | `push_gateway_apns_delivery_seconds` | histogram | — | APNs send round-trip latency (seconds). | -| `push_gateway_apns_credential_refreshes_total` | counter | — | Provider JWT refreshed after APNs reported expiry. | | `push_gateway_admissions_total` | counter | `result` = `admitted` \| `rejected` \| `unavailable` | Outcome at the `authorize_delivery` replay/quota fence. | | `push_gateway_delivery_errors_total` | counter | `class` (static) | Selected delivery-handler exit classes only (see note). | | `push_gateway_reaper_failures_total` | counter | — | Retention reaper sweep failures. | @@ -64,7 +80,7 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe | Alert | Fires when | Severity | Action | |---|---|---|---| -| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | APNs provider token/topic is unhealthy. Check the `.p8` key, `BUZZ_PUSH_APNS_KEY_ID`, `..._TEAM_ID`, and `..._TOPIC`. No endpoints are being invalidated, but nothing is delivering. | +| `PushGatewayConfigurationFault` | any `configuration_fault` outcomes for 10m | critical | The APNs certificate/topic/environment is unhealthy. Check `BUZZ_PUSH_DOGFOOD_APNS_*` configuration. No endpoints are being invalidated. | | `PushGatewayAdmissionUnavailable` | any admission `unavailable` for 5m | critical | PostgreSQL authority store is unreachable. Check DB connectivity and the pod's `postgresEgressCidrs` NetworkPolicy. | | `PushGatewayReadinessAuthorityFailing` | readiness `authority` failures for 5m | warning | Replicas are being pulled from the Service on DB check failure. Fix DB health before capacity drops below the PodDisruptionBudget. | | `PushGatewayReaperFailing` | reaper failed ≥2 times within 30m (runs every 5m) | warning | Expired reservations aren't being swept, growing the bounded-until-expiry window. Check DB write availability. | @@ -72,23 +88,98 @@ Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`promethe ## Relay configuration -Relays default `BUZZ_PUSH_GATEWAY_DELIVERY_URL` to the exact public delivery URL -`https://push.buzz.xyz/v1/deliveries/apns`. Operators can override it with -another exact HTTPS `/v1/deliveries/apns` URL, or explicitly disable NIP-PL push -by setting the variable to an empty string. When enabled, the relay advertises -its host-scoped NIP-PL descriptor in NIP-11 and starts the matcher and delivery -worker. Relays retain lease matching, authorization, coalescing, durable +Relay push is an explicit deployment opt-in through `BUZZ_PUSH_ENABLED=true`; +the established strict boolean parser rejects unknown values and the default is +false. When enabled, an absent `BUZZ_PUSH_GATEWAY_DELIVERY_URL` selects the exact +canonical URL `https://push.buzz.xyz/v1/deliveries/apns`; operators can provide +another exact HTTPS `/v1/deliveries/apns` URL as an advanced override. An +explicitly empty URL while enabled is a startup error. Only an enabled relay +advertises its host-scoped NIP-PL descriptor, accepts leases, and starts the +matcher and delivery worker. Relays retain lease matching, authorization, durable jobs/retries, and generation checks; they receive only opaque capabilities and never APNs tokens or provider credentials. +An enabled relay exports the following bounded-cardinality series on its +existing Prometheus endpoint. None carries a community, account, relay key, +installation, event, or request identifier as a label. + +| Metric | Type | Labels | Meaning | +|---|---|---|---| +| `buzz_push_enabled` | gauge | — | `1` only when the deployment opt-in is active. | +| `buzz_push_match_jobs_total` | counter | `result` = `matched` \| `unmatched` \| `error` \| `context_error` | Accepted message events evaluated by the matcher. | +| `buzz_push_match_queue_seconds` | histogram | — | Relay receipt to matcher evaluation latency. | +| `buzz_push_wakes_total` | counter | `result` = `enqueued` \| `duplicate` \| `inactive_lease` | Durable wake-enqueue outcomes. | +| `buzz_push_wake_enqueue_errors_total` | counter | — | Set-wise outbox transactions that failed. | +| `buzz_push_wake_queue_seconds` | histogram | — | First-attempt outbox enqueue-to-worker latency. | +| `buzz_push_gateway_requests_total` | counter | — | Relay requests that reached the gateway transport seam. | +| `buzz_push_gateway_request_seconds` | histogram | — | Relay-observed gateway request latency. | +| `buzz_push_deliveries_total` | counter | `outcome` (static closed set) | Accepted, retried, suppressed, exhausted, invalid, or failed relay delivery outcomes. | + ## Relay integration status The operational relay integration is complete: per-origin event matching with read-authorization checks, durable enqueue, send-time revalidation, and NIP-98 -delivery run whenever the gateway URL is enabled. End-to-end use still requires +delivery run only when `BUZZ_PUSH_ENABLED=true`. End-to-end use still requires the client App Attest enrollment/delegation flow to place a gateway-issued opaque capability—not a raw APNs token—into the encrypted relay lease. +## Internal dogfood evaluation and rollback + +The MVP is ready to enable only when the canonical gateway's sole dogfood +profile is configured with its server-owned App Attest app ID, APNs topic, +production certificate identity, and production APNs environment, and only the +selected internal relay deployments set `BUZZ_PUSH_ENABLED=true`. Every iOS +artifact contains the native push bridge and Notification Service Extension, +but the client remains inactive until its current authenticated relay +advertises a fully valid NIP-11 `nip-pl` descriptor. There is no App Store +gateway profile in this MVP. + +Physical-device validation must use an application whose App Attest identity +and APNs topic match the configured dogfood profile. The current gateway cannot +enroll `xyz.block.buzz.mobile` or another bundle identifier merely by changing +deployment values: adding another identity requires the explicit multi-profile +work described above. + +Dogfood end-to-end release validation starts after this feature reaches `main`: +publish the next immutable `mobile-vX.Y.Z-rc.N` candidate from the exact current +`origin/main` commit, build that tag through the normal Block release pipeline, +and wait for the signed `xyz.block.buzz.dogfood.mobile` artifact to appear in +Mobile Releases/Comp Portal before installing it on a physical device. Verify +APNs delivery, fetched and signature-verified notification content, and +exact-message tap routing against the canonical gateway and a push-enabled +internal relay before widening the internal evaluation. + +Before that first candidate, the private dogfood builder's manual signing and +export configuration must map separate distribution +profiles for both `xyz.block.buzz.dogfood.mobile` and +`xyz.block.buzz.dogfood.mobile.NotificationService`; an app-only profile does +not provision the extension. App Store rollout remains off through relay and +gateway deployment configuration until separately approved. +Before enabling rich message presentation, enable Apple's Communication +Notifications capability on the parent dogfood App ID and regenerate its app +provisioning profile. The extension profile does not need that capability. +Apply the same parent-App-ID prerequisite to the eventual App Store rollout; +updating Block Apple portal records is a separately authorized release step. + +For each evaluation cohort, measure relay receipt-to-match, wake queue, relay-to- +gateway, and gateway-to-APNs latencies from the histograms above. Track the +ratio of accepted or replay-terminal relay outcomes to newly enqueued wakes, +gateway APNs accepted/retry/invalid/configuration outcomes, retry exhaustion, +and NSE resolution fallback. APNs acceptance cannot prove device presentation: +record a small manual physical-device sample with event-created, banner-visible, +and notification-tap timestamps, and verify that the visible title/body came +from fetched, signature-verified relay content and that the tap opened the exact +triggering message. Keep fallback-to-channel and placeholder/failure cases as +explicit counts in the manual sample until privacy-preserving client telemetry +is designed. + +Rollback does not require deleting credentials or mutating existing leases. +Set `BUZZ_PUSH_ENABLED=false` on the enabled relays to stop advertisement, lease +acceptance, matching, workers, and new gateway traffic. If the gateway itself +is unhealthy, disable the gateway deployment only after relay delivery is off. +Existing leases and gateway authorities then expire naturally. Adding an App +Store application profile is outside this internal evaluation. + ## Helm production inputs The chart defaults to the `main` image tag because `.github/workflows/docker.yml` publishes it from the push-gateway lane. For a production rollout, open that workflow run's **Publish public push gateway image** job summary and copy its `sha256:...` digest. Verify the published subject and provenance before injecting it: @@ -99,11 +190,11 @@ gh attestation verify \ --owner block ``` -Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. +Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned dogfood Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. Network policy keeps APNs HTTPS and PostgreSQL egress in separate CIDR lists. APNs currently requires broad TCP/443 reachability; `networkPolicy.postgresEgressCidrs` must be narrowed to the production database network, and the DNS namespace/pod selectors must match the cluster DNS deployment. The sample private CIDR is not a claim about the production topology. -Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs credential rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. +Kubernetes does not restart pods when referenced Secret bytes change. AEAD or APNs certificate rotation therefore requires an explicit rolling restart after the secret manager update (for example, `kubectl rollout restart deployment/-buzz-push-gateway`) and readiness verification before removing predecessor keys. Service-account token automount is disabled. ## Gateway chart release diff --git a/migrations/0040_push_message_kinds.sql b/migrations/0040_push_message_kinds.sql new file mode 100644 index 00000000000..a76481b1592 --- /dev/null +++ b/migrations/0040_push_message_kinds.sql @@ -0,0 +1,24 @@ +-- Dogfood push is deliberately message-only. Replace the trigger allowlist +-- additively so already-applied 0018/0023 migrations remain checksum-stable. +CREATE OR REPLACE FUNCTION enqueue_push_match_job() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + -- Keep this allowlist identical to the relay's validated NIP-PL descriptor. + IF NEW.kind IN (9, 40002, 45001, 45003) THEN + PERFORM pg_advisory_xact_lock_shared( + hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0)); + IF EXISTS ( + SELECT 1 FROM push_leases + WHERE community_id = NEW.community_id + AND active + AND endpoint_enabled + AND expires_at > EXTRACT(EPOCH FROM now())::bigint + ) THEN + INSERT INTO push_match_queue (community_id, event_id) + VALUES (NEW.community_id, NEW.id) + ON CONFLICT DO NOTHING; + END IF; + END IF; + RETURN NEW; +END +$$; diff --git a/mobile/.env.json.example b/mobile/.env.json.example index 7960d5bf127..248ccda1b60 100644 --- a/mobile/.env.json.example +++ b/mobile/.env.json.example @@ -1,4 +1,5 @@ { "BUZZ_RELAY_URL": "http://localhost:3000", + "BUZZ_PUSH_GATEWAY_URL": "http://localhost:8080", "BUZZ_DEV_PUBKEY": "" } diff --git a/mobile/README.md b/mobile/README.md index bef08e52098..c108dcece25 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -31,7 +31,8 @@ cd mobile && flutter run ### Worktree-aware debug identity Debug builds produced from a git worktree get a unique app identifier keyed -to the **worktree directory name** (`com.buzz.buzzMobile.` on iOS, +to the **worktree directory name** +(`xyz.block.buzz.dogfood.mobile.` on iOS, `xyz.block.buzz.mobile.` on Android) plus a display-only branch label in the app name (`Buzz (my-branch)`, or a short SHA when the worktree is detached). Because the identifier follows the directory rather than the @@ -92,6 +93,56 @@ connected Android emulators, run `just mobile-clean` (add `--dry-run` via `./scripts/mobile-worktree-clean.sh --dry-run` to preview). Production installs are never touched. +### iOS push capability + +Every iOS artifact builds and embeds the Notification Service Extension and +native push bridge. Runtime activation is fail-closed and scoped to the current +relay. After authenticated connectivity and a fully valid NIP-11 `nip-pl` push +descriptor, Buzz independently requests display permission and registers with +APNs. Display denial or request failure does not gate the device token, gateway +enrollment, or lease publication, so a later user opt-in can display pushes +without rebuilding transport authority. An absent, malformed, or unreachable +descriptor leaves push inactive without partial enrollment. + +Relay rollout remains an explicit deployment opt-in. Only deployments with +`BUZZ_PUSH_ENABLED=true` advertise the descriptor and process push. See +`docs/push-gateway-deployment.md` for the canonical gateway profile contract, +manual physical-device proof, measurements, and rollback procedure. + +For local physical-device development, override the identity and sandbox +environments in the gitignored `mobile/ios/Flutter/AppOverrides.xcconfig`: + +```xcconfig +BUNDLE_IDENTIFIER = xyz.block.buzz.mobile +BUZZ_DEVELOPMENT_TEAM = EYF346PHUG +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development +``` + +This exercises the client, extension, relay, and gateway integration without +requiring a dogfood development signing identity. It uses the canonical +gateway's server-owned App Store profile configured for sandbox in the local +development gateway; it does not validate the internally distributed dogfood +artifact or enable the App Store profile in production. Validate dogfood APNs +end to end by cutting an internal release, waiting for it to reach Mobile +Releases/Comp Portal, and installing that signed artifact on a physical device. + +Parent app identifiers require Apple's Communication +Notifications capability and a regenerated app provisioning profile. The +Notification Service Extension profile does not require that capability. +Enable it on the personal development App ID for local rich-presentation +validation. Enabling it on the Block dogfood and eventual App Store App IDs is +a release follow-up and is not performed by this repository change. Without a +matching parent profile, source and unit validation still work, but the app +cannot be signed for a physical device. + +APNs and the gateway continue to carry only the constant opaque wake-up. The +extension fetches the message from the scoped relay, verifies message, sender +profile, and channel-metadata signatures, and uses a bounded App Group cache +for names and app-rendered avatar thumbnails. It never fetches an avatar URL; +missing, stale, or invalid enrichment falls back to the verified message with a +short sender pubkey, community subtitle, and no image. + ## Checks ```bash diff --git a/mobile/ios/.gitignore b/mobile/ios/.gitignore index 950d73854f7..0562776fd4e 100644 --- a/mobile/ios/.gitignore +++ b/mobile/ios/.gitignore @@ -9,6 +9,7 @@ .tags* **/.vagrant/ **/DerivedData/ +BuzzPushKit/Package.resolved Icon? **/Pods/ **/.symlinks/ diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift new file mode 100644 index 00000000000..5af3d0a6c46 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "BuzzPushKit", + platforms: [.iOS(.v15), .macOS(.v12)], + products: [ + .library(name: "BuzzPushKit", targets: ["BuzzPushKit"]) + ], + dependencies: [ + .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1") + ], + targets: [ + .target( + name: "BuzzPushKit", + dependencies: [.product(name: "P256K", package: "swift-secp256k1")] + ), + .testTarget( + name: "BuzzPushKitTests", + dependencies: [ + "BuzzPushKit", + .product(name: "P256K", package: "swift-secp256k1"), + ], + resources: [.copy("Fixtures/app_attest_transcripts.json")] + ), + ] +) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift new file mode 100644 index 00000000000..174fc90bb23 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct APNsRegistrationUpdate: Equatable, Sendable { + public let method: String + public let arguments: [String: String] + public init(method: String, arguments: [String: String]) { + self.method = method + self.arguments = arguments + } +} + +public final class APNsRegistrationBuffer { + public private(set) var pending: APNsRegistrationUpdate? + private var deliver: ((APNsRegistrationUpdate) -> Void)? + public init() {} + public func attach(_ deliver: @escaping (APNsRegistrationUpdate) -> Void) { + self.deliver = deliver + flush() + } + public func recordToken(_ token: Data) { + record(APNsRegistrationUpdate( + method: "apnsTokenChanged", + arguments: ["token": token.map { String(format: "%02x", $0) }.joined()] + )) + } + public func recordError(_ message: String) { + record(APNsRegistrationUpdate( + method: "apnsRegistrationFailed", arguments: ["message": message] + )) + } + private func record(_ update: APNsRegistrationUpdate) { + pending = update + flush() + } + private func flush() { + guard let pending, let deliver else { return } + self.pending = nil + deliver(pending) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift new file mode 100644 index 00000000000..60f4d9e5110 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzCommunicationNotification.swift @@ -0,0 +1,154 @@ +import Foundation + +/// Verified local values used to specialize an ordinary notification as communication. +public struct BuzzCommunicationNotificationDescriptor: Equatable, Sendable { + public let senderDisplayName: String + public let senderIdentifier: String + public let senderAvatarPNG: Data? + public let messageBody: String + public let conversationIdentifier: String + public let conversationDisplayName: String? + /// Verified recipients represented by the incoming message, excluding its sender. + public let recipientCount: Int + + public init( + senderDisplayName: String, + senderIdentifier: String, + senderAvatarPNG: Data?, + messageBody: String, + conversationIdentifier: String, + conversationDisplayName: String?, + recipientCount: Int + ) { + self.senderDisplayName = senderDisplayName + self.senderIdentifier = senderIdentifier + self.senderAvatarPNG = senderAvatarPNG + self.messageBody = messageBody + self.conversationIdentifier = conversationIdentifier + self.conversationDisplayName = conversationDisplayName + self.recipientCount = recipientCount + } + + public init?(resolution: BuzzPushResolution) { + guard let target = resolution.navigationTarget, + let senderPubkey = resolution.senderPubkey, + !senderPubkey.isEmpty, + let conversationIdentifier = resolution.conversationIdentifier, + !conversationIdentifier.isEmpty, + let recipientCount = resolution.conversationRecipientCount, + recipientCount > 0 + else { return nil } + self.init( + senderDisplayName: resolution.title, + senderIdentifier: BuzzPushPresentationIdentity.sender( + communityID: target.communityID, + pubkey: senderPubkey + ), + senderAvatarPNG: resolution.senderAvatarPNG, + messageBody: resolution.body, + conversationIdentifier: conversationIdentifier, + conversationDisplayName: resolution.conversationDisplayName, + recipientCount: recipientCount + ) + } +} + +#if os(iOS) + import Intents + import UserNotifications + + /// Donates and applies Apple's supported Communication Notifications intent. + public final class BuzzCommunicationNotificationPresenter { + public typealias Donation = (INInteraction, @escaping (Error?) -> Void) -> Void + public typealias ContentUpdate = ( + UNMutableNotificationContent, + INSendMessageIntent + ) throws -> UNNotificationContent + + private let donate: Donation + private let updateContent: ContentUpdate + + public convenience init() { + self.init( + donate: { interaction, completion in + interaction.donate(completion: completion) + }, + updateContent: { content, intent in + try content.updating(from: intent) + } + ) + } + + public init( + donate: @escaping Donation, + updateContent: @escaping ContentUpdate + ) { + self.donate = donate + self.updateContent = updateContent + } + + public func present( + ordinaryContent: UNMutableNotificationContent, + resolution: BuzzPushResolution, + completion: @escaping (UNNotificationContent) -> Void + ) { + guard let descriptor = BuzzCommunicationNotificationDescriptor(resolution: resolution) else { + completion(ordinaryContent) + return + } + let intent = Self.makeIntent(descriptor) + let interaction = INInteraction(intent: intent, response: nil) + interaction.direction = .incoming + donate(interaction) { [updateContent] error in + guard error == nil, + let specialized = try? updateContent(ordinaryContent, intent) + else { + completion(ordinaryContent) + return + } + completion(specialized) + } + } + + public static func makeIntent( + _ descriptor: BuzzCommunicationNotificationDescriptor + ) -> INSendMessageIntent { + let senderAvatar = descriptor.senderAvatarPNG.map(INImage.init(imageData:)) + let sender = INPerson( + personHandle: INPersonHandle(value: descriptor.senderIdentifier, type: .unknown), + nameComponents: nil, + displayName: descriptor.senderDisplayName, + image: senderAvatar, + contactIdentifier: nil, + customIdentifier: descriptor.senderIdentifier, + isMe: false, + suggestionType: .none + ) + let intent = INSendMessageIntent( + recipients: nil, + outgoingMessageType: .outgoingMessageText, + content: descriptor.messageBody, + speakableGroupName: descriptor.conversationDisplayName.map { + INSpeakableString(spokenPhrase: $0) + }, + conversationIdentifier: descriptor.conversationIdentifier, + serviceName: "Buzz", + sender: sender, + attachments: nil + ) + if descriptor.conversationDisplayName != nil { + let donationMetadata = INSendMessageIntentDonationMetadata() + donationMetadata.recipientCount = descriptor.recipientCount + intent.donationMetadata = donationMetadata + if let senderAvatar { + // Communication Notifications render a group conversation's image + // from the speakable-group parameter rather than INPerson.image. + // Buzz channels do not have a separate avatar, so use the verified + // sender thumbnail for the visible incoming-message avatar. + intent.setImage(senderAvatar, forParameterNamed: \.speakableGroupName) + } + } + return intent + } + } +#endif diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift new file mode 100644 index 00000000000..8003193da39 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -0,0 +1,995 @@ +import CryptoKit +import DeviceCheck +import Foundation + +#if canImport(Security) + import Security +#endif + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// The opaque gateway capability and binding metadata needed by a later lease publisher. +public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { + public let relayOrigin: String + /// NIP-PL delegation key selected from the relay push descriptor. + public let relayPubkey: String + /// Optional NIP-11 `self` key that verifies relay-authored NIP-29 metadata. + public let relayMetadataPubkey: String? + /// Gateway installation authority. This is distinct from [installationId], + /// which is the unlinkable per-relay-origin NIP-PL lease address. + public let gatewayInstallationHandle: String? + public let installationId: String + public let endpointGrant: String + public let endpointHash: String + public let appProfile: String + public let endpointEpoch: Int64 + public let generation: Int64 + public let expiresAt: Int64 + + public init( + relayOrigin: String, + relayPubkey: String, + relayMetadataPubkey: String? = nil, + gatewayInstallationHandle: String? = nil, + installationId: String, + endpointGrant: String, + endpointHash: String, + appProfile: String, + endpointEpoch: Int64, + generation: Int64, + expiresAt: Int64 + ) { + precondition(generation > 0, "Endpoint grant generation must be positive") + self.relayOrigin = relayOrigin + self.relayPubkey = relayPubkey + self.relayMetadataPubkey = relayMetadataPubkey + self.gatewayInstallationHandle = gatewayInstallationHandle + self.installationId = installationId + self.endpointGrant = endpointGrant + self.endpointHash = endpointHash + self.appProfile = appProfile + self.endpointEpoch = endpointEpoch + self.generation = generation + self.expiresAt = expiresAt + } +} + +/// Persistence boundary for endpoint grants. The Runner implementation stores +/// records in its Keychain access group and exposes them over the Flutter bridge. +public protocol BuzzPushEndpointGrantStore { + func records() throws -> [BuzzPushEndpointGrantRecord] + func save(_ record: BuzzPushEndpointGrantRecord) throws + func pendingEnrollment( + relayOrigin: String, + appProfile: String + ) throws -> BuzzPushPendingEnrollmentRecord? + func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws + func removePendingEnrollment(relayOrigin: String, appProfile: String) throws +} + +public enum BuzzDevPushEnrollmentError: Error, LocalizedError, Equatable { + case invalidGatewayURL + case invalidRelayURL + case invalidRelayDescriptor + case invalidResponse(route: String) + case unexpectedStatus(route: String, expected: Int, actual: Int, body: String) + case randomGenerationFailed(Int32) + case appAttestUnsupported + case invalidAppAttestKeyId + case generationExhausted + + public var errorDescription: String? { + switch self { + case .invalidGatewayURL: + return "The development push gateway URL must be an HTTP or HTTPS origin." + case .invalidRelayURL: + return "The relay URL must be a ws or wss origin." + case .invalidRelayDescriptor: + return "NIP-11 must contain exactly one valid current push key." + case .invalidResponse(let route): + return "The response from \(route) did not match the closed push protocol." + case .unexpectedStatus(let route, let expected, let actual, let body): + return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)" + case .randomGenerationFailed(let status): + return "Secure random generation failed with status \(status)." + case .appAttestUnsupported: + return "App Attest is unavailable on this device." + case .invalidAppAttestKeyId: + return "The App Attest key identifier is missing or invalid." + case .generationExhausted: + return "The development push grant generation cannot advance further." + } + } +} + +protocol BuzzDevAppAttesting { + func prepareAttestation() async throws -> BuzzDevAttestation + func attestation(_ prepared: BuzzDevAttestation, clientData: Data) async throws + -> BuzzDevAttestation + func assertion(clientData: Data) async throws -> String +} + +struct BuzzDevAttestation: Equatable { + let keyId: String + let attestation: String +} + +private enum BuzzSecureRandom { + static func bytes(count: Int) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw BuzzDevPushEnrollmentError.randomGenerationFailed(status) + } + return Data(bytes) + } +} + +private enum BuzzAppAttestKeyId { + static func isValid(_ keyId: String) -> Bool { + guard !keyId.isEmpty, + keyId.unicodeScalars.allSatisfy(\.isASCII), + let bytes = Data(base64Encoded: keyId) + else { return false } + return bytes.count == 32 && bytes.base64EncodedString() == keyId + } +} + +protocol BuzzAppAttestKeyIdStoring { + func keyId() throws -> String? + func saveKeyId(_ keyId: String) throws +} + +struct BuzzAppAttestKeyIdKeychainStore: BuzzAppAttestKeyIdStoring { + private static let service = "buzz.push.app-attest" + private static let account = "key-id-v1" + + private let accessGroup: String? + private let copyMatching: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + private let update: (CFDictionary, CFDictionary) -> OSStatus + private let add: (CFDictionary, UnsafeMutablePointer?) -> OSStatus + + init( + accessGroup: String?, + copyMatching: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = + SecItemCopyMatching, + update: @escaping (CFDictionary, CFDictionary) -> OSStatus = SecItemUpdate, + add: @escaping (CFDictionary, UnsafeMutablePointer?) -> OSStatus = SecItemAdd + ) { + self.accessGroup = accessGroup + self.copyMatching = copyMatching + self.update = update + self.add = add + } + + func keyId() throws -> String? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = copyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw keychainError(status, operation: "read") + } + guard let data = result as? Data, + let keyId = String(data: data, encoding: .utf8), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + return keyId + } + + func saveKeyId(_ keyId: String) throws { + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let data = Data(keyId.utf8) + let updateStatus = update( + baseQuery() as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var item = baseQuery() + item[kSecValueData as String] = data + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = add(item as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") + } + } + + private func baseQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: Self.account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "App Attest key identifier Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } +} + +protocol BuzzDCAppAttestServicing { + var isSupported: Bool { get } + func generateKey() async throws -> String + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data +} + +extension DCAppAttestService: BuzzDCAppAttestServicing {} + +struct BuzzDCAppAttestProvider: BuzzDevAppAttesting { + private let service: BuzzDCAppAttestServicing + private let keyIdStore: BuzzAppAttestKeyIdStoring + + init( + service: BuzzDCAppAttestServicing = DCAppAttestService.shared, + keyIdStore: BuzzAppAttestKeyIdStoring + ) { + self.service = service + self.keyIdStore = keyIdStore + } + + func prepareAttestation() async throws -> BuzzDevAttestation { + try requireSupportedService() + let keyId = try await service.generateKey() + guard BuzzAppAttestKeyId.isValid(keyId) else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + try keyIdStore.saveKeyId(keyId) + return BuzzDevAttestation(keyId: keyId, attestation: "") + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + precondition(!clientData.isEmpty, "Enrollment client data must not be empty") + try requireSupportedService() + guard BuzzAppAttestKeyId.isValid(prepared.keyId), + try keyIdStore.keyId() == prepared.keyId + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.attestKey( + prepared.keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return BuzzDevAttestation( + keyId: prepared.keyId, + attestation: object.base64EncodedString() + ) + } + + func assertion(clientData: Data) async throws -> String { + precondition(!clientData.isEmpty, "Delegation client data must not be empty") + try requireSupportedService() + guard let keyId = try keyIdStore.keyId(), + BuzzAppAttestKeyId.isValid(keyId) + else { + throw BuzzDevPushEnrollmentError.invalidAppAttestKeyId + } + let object = try await service.generateAssertion( + keyId, + clientDataHash: Data(SHA256.hash(data: clientData)) + ) + return object.base64EncodedString() + } + + private func requireSupportedService() throws { + guard service.isSupported else { + throw BuzzDevPushEnrollmentError.appAttestUnsupported + } + } +} + +/// Enrollment and delegation driver for real App Attest and the gated debug bypass. +public final class BuzzDevPushEnrollmentDriver { + public static let appProfile = "buzz-ios-dogfood" + public static let endpointEpoch: Int64 = 1 + + private let gatewayBaseURL: URL + private let store: BuzzPushEndpointGrantStore + private let session: URLSession + private let appAttest: BuzzDevAppAttesting + private let now: () -> Date + private let lifetimeSeconds: Int64 + private let installationIdBytes: () throws -> Data + + /// Creates a driver backed by Apple's App Attest service and persists the + /// generated App Attest key identifier in the requested Keychain access group. + public convenience init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + appAttestKeychainAccessGroup: String?, + session: URLSession = .shared + ) throws { + try self.init( + gatewayBaseURL: gatewayBaseURL, + store: store, + session: session, + appAttest: BuzzDCAppAttestProvider( + keyIdStore: BuzzAppAttestKeyIdKeychainStore( + accessGroup: appAttestKeychainAccessGroup + ) + ), + now: Date.init, + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzSecureRandom.bytes(count: 16) } + ) + } + + init( + gatewayBaseURL: URL, + store: BuzzPushEndpointGrantStore, + session: URLSession, + appAttest: BuzzDevAppAttesting, + now: @escaping () -> Date, + lifetimeSeconds: Int64, + installationIdBytes: @escaping () throws -> Data = { + try BuzzSecureRandom.bytes(count: 16) + } + ) throws { + guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + self.gatewayBaseURL = gatewayBaseURL + self.store = store + self.session = session + self.appAttest = appAttest + self.now = now + self.lifetimeSeconds = lifetimeSeconds + self.installationIdBytes = installationIdBytes + } + + public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { + try store.records() + } + + /// Fetches the relay's current NIP-11 push key, enrolls the APNs endpoint, + /// delegates to that key, and durably saves the resulting opaque grant. + public func enroll( + deviceToken: Data, + relayURL: URL + ) async throws -> BuzzPushEndpointGrantRecord { + precondition(!deviceToken.isEmpty, "The APNs device token must not be empty") + let relayOrigin = try Self.relayOrigin(relayURL) + let relayKeys = try await fetchCurrentRelayKeys(from: relayOrigin.url) + let relayPubkey = relayKeys.pushPubkey + let endpoint = Self.lowercaseHex(deviceToken) + let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) + let nowSeconds = Int64(now().timeIntervalSince1970) + + let storedRecords = try store.records() + let storedForOrigin = storedRecords.first { + $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + } + var pendingEnrollment = try store.pendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + if let pending = pendingEnrollment, + pending.relayPubkey != relayPubkey || pending.endpointHash != endpointHash + || pending.expiresAt <= nowSeconds + { + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + pendingEnrollment = nil + } + if let current = storedForOrigin, + current.relayPubkey == relayPubkey, + current.endpointHash == endpointHash, + current.endpointEpoch == Self.endpointEpoch, + current.expiresAt > nowSeconds + 300 + { + guard current.relayMetadataPubkey != relayKeys.metadataPubkey else { + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return current + } + let refreshed = BuzzPushEndpointGrantRecord( + relayOrigin: current.relayOrigin, + relayPubkey: current.relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: current.gatewayInstallationHandle, + installationId: current.installationId, + endpointGrant: current.endpointGrant, + endpointHash: current.endpointHash, + appProfile: current.appProfile, + endpointEpoch: current.endpointEpoch, + generation: current.generation, + expiresAt: current.expiresAt + ) + try store.save(refreshed) + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return refreshed + } + + // One gateway delegation is scoped to an installation and relay key, not + // to a Buzz community. A second origin served by the same relay therefore + // gets a fresh unlinkable NIP-PL address while reusing the opaque grant. + if storedForOrigin == nil, + let sharedGrant = storedRecords.first(where: { + $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + && $0.endpointHash == endpointHash && $0.endpointEpoch == Self.endpointEpoch + && $0.expiresAt > nowSeconds + 300 + }) + { + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: sharedGrant.gatewayInstallationHandle, + installationId: try makeInstallationId(), + endpointGrant: sharedGrant.endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: sharedGrant.endpointEpoch, + generation: sharedGrant.generation, + expiresAt: sharedGrant.expiresAt + ) + try store.save(record) + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return record + } + + // A previously attested installation can delegate independently to a new + // relay key, or issue a higher-generation grant for the same relay, + // without attempting duplicate APNs-token enrollment. An installation in + // its final five minutes is renewed by the authenticated delegation. + let reusableInstallation = storedRecords.first { record in + guard record.appProfile == Self.appProfile, + record.endpointHash == endpointHash, + record.endpointEpoch == Self.endpointEpoch, + record.expiresAt > nowSeconds, + let handle = record.gatewayInstallationHandle, + let uuid = UUID(uuidString: handle) + else { return false } + return handle == uuid.uuidString.lowercased() + } + + let (renewedExpiration, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) + guard !expiresOverflow else { + throw BuzzDevPushEnrollmentError.invalidGatewayURL + } + + var pending: BuzzPushPendingEnrollmentRecord + if let existingPending = pendingEnrollment { + pending = existingPending + } else if let reusableInstallation, + let handle = reusableInstallation.gatewayInstallationHandle, + let existing = UUID(uuidString: handle) + { + let expiresAt = + reusableInstallation.expiresAt > nowSeconds + 300 + ? reusableInstallation.expiresAt + : renewedExpiration + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + endpointHash: endpointHash, + appProfile: Self.appProfile, + expiresAt: expiresAt, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + gatewayInstallationHandle: existing.uuidString.lowercased() + ) + try store.savePendingEnrollment(pending) + } else { + let expiresAt = renewedExpiration + let enrollmentChallenge = try await challenge() + let preparedAttestation = try await appAttest.prepareAttestation() + let enrollmentClientData = try BuzzPushTranscript.enroll( + challengeId: enrollmentChallenge.id, + challenge: enrollmentChallenge.value, + keyId: preparedAttestation.keyId, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + let attestation = try await appAttest.attestation( + preparedAttestation, + clientData: enrollmentClientData + ) + guard attestation.keyId == preparedAttestation.keyId else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "development attestation") + } + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + endpointHash: endpointHash, + appProfile: Self.appProfile, + expiresAt: expiresAt, + installationId: try storedForOrigin?.installationId ?? makeInstallationId(), + challengeId: enrollmentChallenge.id.uuidString.lowercased(), + challenge: enrollmentChallenge.value, + keyId: attestation.keyId, + attestation: attestation.attestation + ) + // The exact signed request is durable before the first network attempt. + try store.savePendingEnrollment(pending) + } + + let installation: UUID + if let handle = pending.gatewayInstallationHandle, + let existing = UUID(uuidString: handle), + handle == existing.uuidString.lowercased() + { + installation = existing + } else { + guard let challengeId = pending.challengeId, + let challengeUUID = UUID(uuidString: challengeId), + challengeId == challengeUUID.uuidString.lowercased(), + let challengeValue = pending.challenge, + let keyId = pending.keyId, + let attestation = pending.attestation + else { + throw BuzzDevPushEnrollmentError.invalidResponse( + route: "pending development enrollment" + ) + } + do { + installation = try await enrollInstallation( + challenge: Challenge(id: challengeUUID, value: challengeValue), + endpoint: endpoint, + expiresAt: pending.expiresAt, + attestation: BuzzDevAttestation(keyId: keyId, attestation: attestation) + ) + } catch BuzzDevPushEnrollmentError.unexpectedStatus( + route: "v1/installations", _, actual: 404, _ + ) where pendingEnrollment != nil { + // No installation was committed and the original challenge expired. + // Discard the prepared request and start once with a fresh App Attest key. + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return try await enroll(deviceToken: deviceToken, relayURL: relayURL) + } + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: pending.relayOrigin, + relayPubkey: pending.relayPubkey, + endpointHash: pending.endpointHash, + appProfile: pending.appProfile, + expiresAt: pending.expiresAt, + installationId: pending.installationId, + gatewayInstallationHandle: installation.uuidString.lowercased(), + challengeId: pending.challengeId, + challenge: pending.challenge, + keyId: pending.keyId, + attestation: pending.attestation, + delegationGeneration: pending.delegationGeneration + ) + try store.savePendingEnrollment(pending) + } + + let installationHandle = installation.uuidString.lowercased() + let currentGeneration = + storedRecords + .filter { + $0.gatewayInstallationHandle == installationHandle + && $0.relayPubkey == relayPubkey && $0.appProfile == Self.appProfile + } + .map(\.generation) + .max() + let generationBase = max(currentGeneration ?? 0, pending.delegationGeneration) + let generation: Int64 + if generationBase > 0 { + let (next, overflow) = generationBase.addingReportingOverflow(1) + guard !overflow, next > 0 else { + throw BuzzDevPushEnrollmentError.generationExhausted + } + generation = next + } else { + generation = 1 + } + pending = BuzzPushPendingEnrollmentRecord( + relayOrigin: pending.relayOrigin, + relayPubkey: pending.relayPubkey, + endpointHash: pending.endpointHash, + appProfile: pending.appProfile, + expiresAt: pending.expiresAt, + installationId: pending.installationId, + gatewayInstallationHandle: installationHandle, + challengeId: pending.challengeId, + challenge: pending.challenge, + keyId: pending.keyId, + attestation: pending.attestation, + delegationGeneration: generation + ) + // Reserve before delegation so a committed delegation followed by a local + // save failure is retried at a strictly higher generation. + try store.savePendingEnrollment(pending) + + let delegationChallenge = try await challenge() + let delegationClientData = try BuzzPushTranscript.delegate( + challengeId: delegationChallenge.id, + challenge: delegationChallenge.value, + installationHandle: installation, + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: nowSeconds, + expiresAt: pending.expiresAt + ) + let assertion = try await appAttest.assertion(clientData: delegationClientData) + let endpointGrant = try await delegate( + challenge: delegationChallenge, + installationHandle: installation, + relayPubkey: relayPubkey, + generation: generation, + notBefore: nowSeconds, + expiresAt: pending.expiresAt, + assertion: assertion + ) + + let record = BuzzPushEndpointGrantRecord( + relayOrigin: relayOrigin.text, + relayPubkey: relayPubkey, + relayMetadataPubkey: relayKeys.metadataPubkey, + gatewayInstallationHandle: installationHandle, + installationId: pending.installationId, + endpointGrant: endpointGrant, + endpointHash: endpointHash, + appProfile: Self.appProfile, + endpointEpoch: Self.endpointEpoch, + generation: generation, + expiresAt: pending.expiresAt + ) + try store.save(record) + try store.removePendingEnrollment( + relayOrigin: relayOrigin.text, + appProfile: Self.appProfile + ) + return record + } + + private func makeInstallationId() throws -> String { + let bytes = try installationIdBytes() + precondition( + bytes.count == 16, + "NIP-PL installation identity entropy must be exactly 16 bytes" + ) + // This value is per relay origin and never leaves the relay-facing lease. + return Self.lowercaseHex(bytes) + } + + private func challenge() async throws -> Challenge { + let response: ChallengeResponse = try await post( + route: "v1/installations/challenges", + expectedStatus: 200, + body: VersionRequest(v: 1) + ) + guard let id = UUID(uuidString: response.challengeId), + response.challengeId == id.uuidString.lowercased(), + Self.isBase64URLChallenge(response.challenge), + response.expiresAt > Int64(now().timeIntervalSince1970) + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations/challenges") + } + return Challenge(id: id, value: response.challenge) + } + + private func enrollInstallation( + challenge: Challenge, + endpoint: String, + expiresAt: Int64, + attestation: BuzzDevAttestation + ) async throws -> UUID { + let response: InstallationResponse = try await post( + route: "v1/installations", + expectedStatus: 201, + body: InstallationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + keyId: attestation.keyId, + attestation: attestation.attestation, + appProfile: Self.appProfile, + endpoint: endpoint, + endpointEpoch: Self.endpointEpoch, + expiresAt: expiresAt + ) + ) + guard let installation = UUID(uuidString: response.installationHandle), + response.installationHandle == installation.uuidString.lowercased(), + response.endpointEpoch == Self.endpointEpoch, + response.expiresAt == expiresAt + else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/installations") + } + return installation + } + + private func delegate( + challenge: Challenge, + installationHandle: UUID, + relayPubkey: String, + generation: Int64, + notBefore: Int64, + expiresAt: Int64, + assertion: String + ) async throws -> String { + let response: DelegationResponse = try await post( + route: "v1/delegations", + expectedStatus: 201, + body: DelegationRequest( + v: 1, + challengeId: challenge.id.uuidString.lowercased(), + challenge: challenge.value, + installationHandle: installationHandle.uuidString.lowercased(), + endpointEpoch: Self.endpointEpoch, + generation: generation, + relayPubkey: relayPubkey, + notBefore: notBefore, + expiresAt: expiresAt, + assertion: assertion + ) + ) + guard !response.endpointGrant.isEmpty, response.endpointGrant.utf8.count <= 4_096 else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: "v1/delegations") + } + return response.endpointGrant + } + + private func fetchCurrentRelayKeys(from relayOrigin: URL) async throws -> RelayKeys { + var request = URLRequest(url: relayOrigin) + request.httpMethod = "GET" + request.setValue("application/nostr+json", forHTTPHeaderField: "Accept") + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: "NIP-11", expected: 200) + let document: RelayInformation + do { + document = try JSONDecoder().decode(RelayInformation.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + let current = document.push.keys.filter(\.current) + guard current.count == 1, + Self.isLowercaseHexPubkey(current[0].pubkey) + else { + throw BuzzDevPushEnrollmentError.invalidRelayDescriptor + } + let metadataPubkey = document.relaySelf.flatMap { + Self.isLowercaseHexPubkey($0) ? $0 : nil + } + return RelayKeys( + pushPubkey: current[0].pubkey, + metadataPubkey: metadataPubkey + ) + } + + private func post( + route: String, + expectedStatus: Int, + body: Request + ) async throws -> Response { + let url = route.split(separator: "/").reduce(gatewayBaseURL) { + $0.appendingPathComponent(String($1)) + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: request) + try Self.expectStatus(response, data: data, route: route, expected: expectedStatus) + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + } + + private static func expectStatus( + _ response: URLResponse, + data: Data, + route: String, + expected: Int + ) throws { + guard let http = response as? HTTPURLResponse else { + throw BuzzDevPushEnrollmentError.invalidResponse(route: route) + } + guard http.statusCode == expected else { + let body = String(decoding: data.prefix(512), as: UTF8.self) + throw BuzzDevPushEnrollmentError.unexpectedStatus( + route: route, expected: expected, actual: http.statusCode, body: body + ) + } + } + + private static func isHTTPOrigin(_ url: URL) -> Bool { + (url.scheme == "http" || url.scheme == "https") + && url.host != nil + && (url.path.isEmpty || url.path == "/") + && url.user == nil + && url.password == nil + && url.query == nil + && url.fragment == nil + } + + private static func relayOrigin(_ url: URL) throws -> (url: URL, text: String) { + guard url.scheme == "ws" || url.scheme == "wss", + url.host != nil, + url.path.isEmpty || url.path == "/", + url.user == nil, + url.password == nil, + url.query == nil, + url.fragment == nil + else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + var components = URLComponents() + components.scheme = url.scheme == "wss" ? "https" : "http" + components.host = url.host + components.port = url.port + components.path = "/" + guard let httpURL = components.url else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + var relayComponents = components + relayComponents.scheme = url.scheme + relayComponents.path = "" + guard let relayText = relayComponents.string else { + throw BuzzDevPushEnrollmentError.invalidRelayURL + } + return (httpURL, relayText) + } + + private static func isLowercaseHexPubkey(_ value: String) -> Bool { + value.utf8.count == 64 + && value.utf8.allSatisfy { + (48...57).contains($0) || (97...102).contains($0) + } + } + + private static func isBase64URLChallenge(_ value: String) -> Bool { + guard value.utf8.count == 43, + value.utf8.allSatisfy({ + (48...57).contains($0) || (65...90).contains($0) + || (97...122).contains($0) || $0 == 45 || $0 == 95 + }) + else { return false } + var padded = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + padded += String(repeating: "=", count: (4 - padded.count % 4) % 4) + return Data(base64Encoded: padded)?.count == 32 + } + + private static func lowercaseHex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private struct VersionRequest: Encodable { let v: Int } +private struct Challenge { + let id: UUID + let value: String +} +private struct ChallengeResponse: Decodable { + let challengeId: String + let challenge: String + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case challengeId = "challenge_id" + case challenge + case expiresAt = "expires_at" + } +} +private struct InstallationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let keyId: String + let attestation: String + let appProfile: String + let endpoint: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case keyId = "key_id" + case attestation + case appProfile = "app_profile" + case endpoint + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct InstallationResponse: Decodable { + let installationHandle: String + let endpointEpoch: Int64 + let expiresAt: Int64 + enum CodingKeys: String, CodingKey { + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case expiresAt = "expires_at" + } +} +private struct DelegationRequest: Encodable { + let v: Int + let challengeId: String + let challenge: String + let installationHandle: String + let endpointEpoch: Int64 + let generation: Int64 + let relayPubkey: String + let notBefore: Int64 + let expiresAt: Int64 + let assertion: String + enum CodingKeys: String, CodingKey { + case v + case challengeId = "challenge_id" + case challenge + case installationHandle = "installation_handle" + case endpointEpoch = "endpoint_epoch" + case generation + case relayPubkey = "relay_pubkey" + case notBefore = "not_before" + case expiresAt = "expires_at" + case assertion + } +} +private struct DelegationResponse: Decodable { + let endpointGrant: String + enum CodingKeys: String, CodingKey { case endpointGrant = "endpoint_grant" } +} +private struct RelayInformation: Decodable { + struct Push: Decodable { + struct Key: Decodable { + let pubkey: String + let current: Bool + } + let keys: [Key] + } + let relaySelf: String? + let push: Push + + enum CodingKeys: String, CodingKey { + case relaySelf = "self" + case push + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + push = try container.decode(Push.self, forKey: .push) + relaySelf = try? container.decode(String.self, forKey: .relaySelf) + } +} + +private struct RelayKeys { + let pushPubkey: String + let metadataPubkey: String? +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift new file mode 100644 index 00000000000..9218655021a --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -0,0 +1,85 @@ +import Foundation + +/// A stable destination attached by the notification service extension after +/// it resolves and verifies the event that produced a push wake. +public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { + public static let userInfoKey = "buzz_push_navigation" + + public let eventID: String + public let communityID: String + public let channelID: String + + public init(eventID: String, communityID: String, channelID: String) { + self.eventID = eventID + self.communityID = communityID + self.channelID = channelID + } + + public var userInfoValue: [String: String] { + [ + "event_id": eventID, + "community_id": communityID, + "channel_id": channelID, + ] + } + + /// Decodes a target without trusting other fields from the APNs payload. + public static func decodeIfPresent( + from userInfo: [AnyHashable: Any] + ) -> BuzzPushNavigationTarget? { + guard let raw = userInfo[userInfoKey] as? [String: Any], + raw.count == 3, + let eventID = raw["event_id"] as? String, + let communityID = raw["community_id"] as? String, + let channelID = raw["channel_id"] as? String, + !eventID.isEmpty, + !communityID.isEmpty, + !channelID.isEmpty + else { + return nil + } + return BuzzPushNavigationTarget( + eventID: eventID, + communityID: communityID, + channelID: channelID + ) + } + +} + +/// Thread-safe one-item buffer spanning notification delivery and Flutter +/// engine startup during a cold notification launch. +public final class BuzzPushNavigationBuffer: @unchecked Sendable { + private let lock = NSLock() + private var target: BuzzPushNavigationTarget? + + public init() {} + + public func record(_ target: BuzzPushNavigationTarget) { + lock.lock() + self.target = target + lock.unlock() + } + + public func peek() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + return target + } + + public func take() -> BuzzPushNavigationTarget? { + lock.lock() + defer { lock.unlock() } + let current = target + target = nil + return current + } + + public func remove(ifMatching expected: BuzzPushNavigationTarget) { + lock.lock() + defer { lock.unlock() } + if target == expected { + target = nil + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift new file mode 100644 index 00000000000..47661e81300 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -0,0 +1,647 @@ +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +/// Content resolved from unread Buzz events for a mutable push notification. +public struct BuzzPushResolution: Decodable, Equatable, Sendable { + public let title: String + public let body: String + public let subtitle: String? + public let threadIdentifier: String? + public let navigationTarget: BuzzPushNavigationTarget? + public let senderPubkey: String? + public let senderAvatarPNG: Data? + public let conversationIdentifier: String? + public let conversationDisplayName: String? + /// Exact verified recipient count for Communication Notifications specialization. + public let conversationRecipientCount: Int? + + public init( + title: String, + body: String, + subtitle: String?, + threadIdentifier: String?, + navigationTarget: BuzzPushNavigationTarget? = nil, + senderPubkey: String? = nil, + senderAvatarPNG: Data? = nil, + conversationIdentifier: String? = nil, + conversationDisplayName: String? = nil, + conversationRecipientCount: Int? = nil + ) { + self.title = title + self.body = body + self.subtitle = subtitle + self.threadIdentifier = threadIdentifier + self.navigationTarget = navigationTarget + self.senderPubkey = senderPubkey + self.senderAvatarPNG = senderAvatarPNG + self.conversationIdentifier = conversationIdentifier + self.conversationDisplayName = conversationDisplayName + self.conversationRecipientCount = conversationRecipientCount + } +} + +/// Resolves the content used to mutate a generic Buzz push notification. +public protocol BuzzPushNotificationResolving { + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) +} + +/// Reads configured Buzz communities and resolves their newest unread event. +public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + // Verified profiles may carry bounded inline raster avatars. Keep the refresh + // response capped, but large enough to recover the sender name from those + // otherwise valid kind-0 events when the app cache has not been populated. + static let maximumPresentationResponseBytes = 256 * 1_024 + + private let session: URLSession + private let loadCommunitiesData: () -> Data? + private let loadPresentationCacheData: () -> Data? + private let loadPrivateKey: (String) -> String? + private let now: () -> Date + private let presentationCacheLifetime: TimeInterval + + /// Creates a resolver around the notification extension's App Group and Keychain I/O. + public init( + session: URLSession, + loadCommunitiesData: @escaping () -> Data?, + loadPrivateKey: @escaping (String) -> String?, + loadPresentationCacheData: @escaping () -> Data? = { nil }, + now: @escaping () -> Date = Date.init, + presentationCacheLifetime: TimeInterval = BuzzPushPresentationCacheStore.freshnessLifetime + ) { + self.session = session + self.loadCommunitiesData = loadCommunitiesData + self.loadPresentationCacheData = loadPresentationCacheData + self.loadPrivateKey = loadPrivateKey + self.now = now + self.presentationCacheLifetime = presentationCacheLifetime + } + + public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { + let communities = loadCommunities().filter { + $0.pubkey?.isEmpty == false + && loadPrivateKey($0.id) != nil + && !$0.policies.isEmpty + } + guard !communities.isEmpty else { + completion(nil) + return + } + let group = DispatchGroup() + let lock = NSLock() + var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = [] + for community in communities { + group.enter() + query(community) { candidate in + if let candidate { + lock.lock() + candidates.append(candidate) + lock.unlock() + } + group.leave() + } + } + group.notify(queue: .global(qos: .userInitiated)) { + let newest = candidates.max { + $0.0.createdAt == $1.0.createdAt ? $0.0.id > $1.0.id : $0.0.createdAt < $1.0.createdAt + } + guard let newest else { + completion(nil) + return + } + self.resolvePresentation(event: newest.0, community: newest.1, completion: completion) + } + } + + private func query( + _ community: PushLeaseCommunity, + completion: @escaping ((VerifiedNostrEvent, PushLeaseCommunity)?) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), community.pubkey?.isEmpty == false else { + completion(nil) + return + } + guard + !community.policies.isEmpty, + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL), + let body = try? JSONSerialization.data( + withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) } + ) + else { + completion(nil) + return + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, method: "POST", body: body, privateKeyHex: privateKey + ) + else { + completion(nil) + return + } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.dataTask(with: request) { data, response, _ in + guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode), + let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(nil) + return + } + let candidate = Self.newestMessage( + events: events.filter { event in + event.hasValidIDAndSignature() + && community.policies.contains { policy in + PushLeaseMatcher.matches(event: event, policy: policy) + } + }, + community: community + ) + completion(candidate.map { ($0, community) }) + }.resume() + } + + private func resolvePresentation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + completion: @escaping (BuzzPushResolution?) -> Void + ) { + let snapshot = BuzzPushPresentationCacheSnapshot.decode(loadPresentationCacheData()) + let relayOrigin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(community.relayUrl) + let cachedProfile = relayOrigin.flatMap { + snapshot.profile( + communityID: community.id, + relayOrigin: $0, + pubkey: event.pubkey + ) + } + let channelID = Self.tagValue("h", in: event) + let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() + let cachedChannel = channelID.flatMap { channelID in + relayOrigin.flatMap { + snapshot.channel( + communityID: community.id, + relayOrigin: $0, + channelID: channelID + ) + } + }.flatMap { channel in + channel.relayMetadataPubkey == relayMetadataPubkey ? channel : nil + } + let timestamp = Int(now().timeIntervalSince1970) + let profileNeedsRefresh = Self.isStale( + cachedAt: cachedProfile?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) + let membershipNeedsRefresh: Bool = { + guard let cachedChannel else { return true } + if Self.isStale( + cachedAt: cachedChannel.membershipCachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) { + return true + } + guard let memberCount = cachedChannel.memberCount else { return true } + guard memberCount <= BuzzPushPresentationCacheStore.maximumMembersPerChannel + else { return false } + return cachedChannel.memberDigests?.count != memberCount + }() + let channelNeedsRefresh = + channelID != nil + && (Self.isStale( + cachedAt: cachedChannel?.cachedAt, + now: timestamp, + lifetime: presentationCacheLifetime + ) || membershipNeedsRefresh) + let fallback = Self.makeResolution( + event: event, + community: community, + profile: cachedProfile, + channel: cachedChannel + ) + guard profileNeedsRefresh || channelNeedsRefresh else { + completion(fallback) + return + } + + refreshPresentation( + event: event, + community: community, + refreshProfile: profileNeedsRefresh, + refreshChannel: channelNeedsRefresh + ) { refreshedProfileEvent, refreshedChannelEvent, refreshedMembershipEvent in + let profile = + refreshedProfileEvent.flatMap { + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedProfile?.eventCreatedAt, + existingID: cachedProfile?.eventID, + candidateCreatedAt: $0.createdAt, + candidateID: $0.id + ) + else { return nil } + return Self.ephemeralProfile( + event: $0, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + cached: cachedProfile, + cachedAt: timestamp + ) + } ?? cachedProfile + let newerChannelEvent: VerifiedNostrEvent? = refreshedChannelEvent.flatMap { event in + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.eventCreatedAt, + existingID: cachedChannel?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { return nil } + return event + } + let newerMembershipEvent: VerifiedNostrEvent? = refreshedMembershipEvent.flatMap { event in + guard + BuzzPushPresentationCacheStore.shouldReplace( + existingCreatedAt: cachedChannel?.membershipEventCreatedAt, + existingID: cachedChannel?.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { return nil } + return event + } + let channel = + relayMetadataPubkey.flatMap { + Self.ephemeralChannel( + metadataEvent: newerChannelEvent, + membershipEvent: newerMembershipEvent, + cached: cachedChannel, + communityID: community.id, + relayOrigin: relayOrigin ?? community.relayUrl, + relayMetadataPubkey: $0, + cachedAt: timestamp + ) + } ?? cachedChannel + completion( + Self.makeResolution( + event: event, + community: community, + profile: profile, + channel: channel + ) ?? fallback + ) + } + } + + private func refreshPresentation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + refreshProfile: Bool, + refreshChannel: Bool, + completion: + @escaping ( + VerifiedNostrEvent?, VerifiedNostrEvent?, VerifiedNostrEvent? + ) -> Void + ) { + guard let privateKey = loadPrivateKey(community.id), + let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL) + else { + completion(nil, nil, nil) + return + } + let channelID = Self.tagValue("h", in: event) + let relayMetadataPubkey = community.relayMetadataPubkey?.lowercased() + var filters: [[String: Any]] = [] + if refreshProfile { + filters.append(["kinds": [0], "authors": [event.pubkey.lowercased()], "limit": 1]) + } + if refreshChannel, let channelID, let relayMetadataPubkey { + filters.append([ + "kinds": [39_000], + "authors": [relayMetadataPubkey], + "#d": [channelID], + "limit": 1, + ]) + filters.append([ + "kinds": [39_002], + "authors": [relayMetadataPubkey], + "#d": [channelID], + "limit": 1, + ]) + } + guard !filters.isEmpty, + let body = try? JSONSerialization.data(withJSONObject: filters) + else { + completion(nil, nil, nil) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = body + // A verified kind-0 profile may include a bounded inline raster avatar. + // Railway can take longer than one second to return that larger response, + // while three seconds remains a small fraction of the NSE execution budget. + request.timeoutInterval = 3 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) + else { + completion(nil, nil, nil) + return + } + request.setValue(auth, forHTTPHeaderField: "Authorization") + session.downloadTask(with: request) { fileURL, response, _ in + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let fileURL, + let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= Self.maximumPresentationResponseBytes, + let data = try? Data(contentsOf: fileURL), + let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(nil, nil, nil) + return + } + let verified = events.filter { $0.hasValidIDAndSignature() } + let profile = + refreshProfile + ? Self.newest( + verified.filter { + $0.kind == 0 && $0.pubkey.lowercased() == event.pubkey.lowercased() + }) : nil + let channel = + refreshChannel + ? channelID.flatMap { channelID in + Self.newest( + verified.filter { + $0.kind == 39_000 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + let membership = + refreshChannel + ? channelID.flatMap { channelID in + Self.newest( + verified.filter { + $0.kind == 39_002 + && $0.pubkey.lowercased() == relayMetadataPubkey + && Self.tagValue("d", in: $0) == channelID + }) + } : nil + completion(profile, channel, membership) + }.resume() + } + + static func decodeResolution( + events: [VerifiedNostrEvent], community: PushLeaseCommunity + ) -> (BuzzPushResolution, VerifiedNostrEvent)? { + let event = newestMessage(events: events, community: community) + guard let event else { return nil } + guard + let resolution = makeResolution( + event: event, + community: community, + profile: nil, + channel: nil + ) + else { return nil } + return (resolution, event) + } + + private static func newestMessage( + events: [VerifiedNostrEvent], + community: PushLeaseCommunity + ) -> VerifiedNostrEvent? { + guard let mine = community.pubkey?.lowercased() else { return nil } + return events.filter { + $0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind) + }.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + } + + private static func makeResolution( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + profile: BuzzPushCachedProfile?, + channel: BuzzPushCachedChannel? + ) -> BuzzPushResolution? { + let body = previewBody(event.content) + guard !body.isEmpty else { return nil } + let channelID = tagValue("h", in: event) + let conversationIdentifier = channelID.map { + BuzzPushPresentationIdentity.conversation(communityID: community.id, channelID: $0) + } + let conversation = channel.flatMap { + communicationConversation(event: event, community: community, channel: $0) + } + return BuzzPushResolution( + title: profile?.displayName ?? shortPubkey(event.pubkey), + body: body, + subtitle: community.name, + threadIdentifier: conversationIdentifier ?? community.id, + navigationTarget: channelID.map { + BuzzPushNavigationTarget( + eventID: event.id, + communityID: community.id, + channelID: $0 + ) + }, + senderPubkey: event.pubkey.lowercased(), + senderAvatarPNG: profile?.avatarPNG, + conversationIdentifier: conversationIdentifier, + conversationDisplayName: conversation?.displayName, + conversationRecipientCount: conversation?.recipientCount + ) + } + + private static func ephemeralProfile( + event: VerifiedNostrEvent, + communityID: String, + relayOrigin: String, + cached: BuzzPushCachedProfile?, + cachedAt: Int + ) -> BuzzPushCachedProfile { + let metadata = BuzzPushPresentationCacheStore.profileMetadata(event) + return BuzzPushCachedProfile( + communityID: communityID, + relayOrigin: relayOrigin, + pubkey: event.pubkey.lowercased(), + displayName: metadata.displayName, + pictureHash: metadata.pictureHash, + avatarPNG: cached?.pictureHash == metadata.pictureHash ? cached?.avatarPNG : nil, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + } + + private static func ephemeralChannel( + metadataEvent: VerifiedNostrEvent?, + membershipEvent: VerifiedNostrEvent?, + cached: BuzzPushCachedChannel?, + communityID: String, + relayOrigin: String, + relayMetadataPubkey: String, + cachedAt: Int + ) -> BuzzPushCachedChannel? { + guard metadataEvent != nil || cached != nil else { return nil } + let channelID = metadataEvent.flatMap { tagValue("d", in: $0) } ?? cached?.channelID + guard let channelID, !channelID.isEmpty, + let eventID = metadataEvent?.id ?? cached?.eventID, + let eventCreatedAt = metadataEvent?.createdAt ?? cached?.eventCreatedAt, + let metadataCachedAt = metadataEvent == nil ? cached?.cachedAt : cachedAt + else { return nil } + let membership = membershipEvent.flatMap { + BuzzPushPresentationCacheStore.normalizedChannelMembership( + $0, + communityID: communityID, + channelID: channelID + ) + } + let acceptedMembershipEvent = membership == nil ? nil : membershipEvent + return BuzzPushCachedChannel( + communityID: communityID, + relayOrigin: relayOrigin, + channelID: channelID, + relayMetadataPubkey: relayMetadataPubkey, + displayName: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedDisplayName(tagValue("name", in: $0)) + } ?? cached?.displayName, + channelType: metadataEvent.map { + BuzzPushPresentationCacheStore.normalizedChannelType(tagValue("t", in: $0)) + } ?? cached?.channelType, + memberCount: membership?.count ?? cached?.memberCount, + memberDigests: membership?.digests ?? cached?.memberDigests, + membershipEventID: acceptedMembershipEvent?.id ?? cached?.membershipEventID, + membershipEventCreatedAt: acceptedMembershipEvent?.createdAt + ?? cached?.membershipEventCreatedAt, + membershipCachedAt: acceptedMembershipEvent == nil + ? cached?.membershipCachedAt : cachedAt, + eventID: eventID, + eventCreatedAt: eventCreatedAt, + cachedAt: metadataCachedAt + ) + } + + private static func communicationConversation( + event: VerifiedNostrEvent, + community: PushLeaseCommunity, + channel: BuzzPushCachedChannel + ) -> (displayName: String?, recipientCount: Int)? { + guard let currentUser = community.pubkey?.lowercased(), + let memberCount = channel.memberCount, + let memberDigests = channel.memberDigests, + memberDigests.count == memberCount + else { return nil } + let currentUserDigest = BuzzPushPresentationIdentity.channelMember( + communityID: community.id, + channelID: channel.channelID, + pubkey: currentUser + ) + guard memberDigests.contains(currentUserDigest) else { return nil } + let senderDigest = BuzzPushPresentationIdentity.channelMember( + communityID: community.id, + channelID: channel.channelID, + pubkey: event.pubkey + ) + let senderIsMember = memberDigests.contains(senderDigest) + let recipientCount = memberCount - (senderIsMember ? 1 : 0) + guard recipientCount > 0 else { return nil } + + if channel.channelType == "dm" { + guard memberCount == 2, senderIsMember else { return nil } + return (nil, recipientCount) + } + guard let channelType = channel.channelType, + ["stream", "forum"].contains(channelType), + let displayName = channel.displayName + else { return nil } + return (displayName.hasPrefix("#") ? displayName : "#\(displayName)", recipientCount) + } + + private static func newest(_ events: [VerifiedNostrEvent]) -> VerifiedNostrEvent? { + events.sorted { + $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt + }.first + } + + private static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + event.tags.first { $0.count >= 2 && $0[0] == name }?[1] + } + + private static func isStale( + cachedAt: Int?, + now: Int, + lifetime: TimeInterval + ) -> Bool { + guard let cachedAt else { return true } + return TimeInterval(max(0, now - cachedAt)) > lifetime + } + + static func previewBody(_ content: String) -> String { + var result = content.replacingOccurrences( + of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression) + result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression) + result = result.replacingOccurrences( + of: #"https?://\S+"#, with: "[link]", options: .regularExpression) + result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + return result.count > 180 + ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result + } + + static func shortPubkey(_ pubkey: String) -> String { + pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey + } + + private func loadCommunities() -> [PushLeaseCommunity] { + guard let data = loadCommunitiesData(), + let decoded = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data) + else { return [] } + return decoded.communities + } +} + +extension PushLeaseCommunity { + var relayURL: URL? { + guard var components = URLComponents(string: relayUrl), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + components.scheme = + switch components.scheme?.lowercased() { + case "wss": "https" + case "ws": "http" + case "https": "https" + case "http": "http" + default: nil + } + components.path = "" + return components.url + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift new file mode 100644 index 00000000000..402f90be30f --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPendingEnrollmentRecord.swift @@ -0,0 +1,45 @@ +/// Crash-recovery journal written before installation or delegation requests. +/// It contains no APNs endpoint, only its hash and the exact authenticated +/// enrollment material needed to replay a committed request idempotently. +public struct BuzzPushPendingEnrollmentRecord: Codable, Equatable, Sendable { + public let relayOrigin: String + public let relayPubkey: String + public let endpointHash: String + public let appProfile: String + public let expiresAt: Int64 + public let installationId: String + public let gatewayInstallationHandle: String? + public let challengeId: String? + public let challenge: String? + public let keyId: String? + public let attestation: String? + public let delegationGeneration: Int64 + + public init( + relayOrigin: String, + relayPubkey: String, + endpointHash: String, + appProfile: String, + expiresAt: Int64, + installationId: String, + gatewayInstallationHandle: String? = nil, + challengeId: String? = nil, + challenge: String? = nil, + keyId: String? = nil, + attestation: String? = nil, + delegationGeneration: Int64 = 0 + ) { + self.relayOrigin = relayOrigin + self.relayPubkey = relayPubkey + self.endpointHash = endpointHash + self.appProfile = appProfile + self.expiresAt = expiresAt + self.installationId = installationId + self.gatewayInstallationHandle = gatewayInstallationHandle + self.challengeId = challengeId + self.challenge = challenge + self.keyId = keyId + self.attestation = attestation + self.delegationGeneration = delegationGeneration + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift new file mode 100644 index 00000000000..f4a27e8a9f0 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushPresentationCache.swift @@ -0,0 +1,788 @@ +import CryptoKit +import Foundation + +/// A verified sender profile retained for notification presentation. +public struct BuzzPushCachedProfile: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let pubkey: String + public let displayName: String? + public let pictureHash: String? + public let avatarPNG: Data? + public let eventID: String + public let eventCreatedAt: Int + public let cachedAt: Int + + public init( + communityID: String, + relayOrigin: String, + pubkey: String, + displayName: String?, + pictureHash: String?, + avatarPNG: Data?, + eventID: String, + eventCreatedAt: Int, + cachedAt: Int + ) { + self.communityID = communityID + self.relayOrigin = relayOrigin + self.pubkey = pubkey + self.displayName = displayName + self.pictureHash = pictureHash + self.avatarPNG = avatarPNG + self.eventID = eventID + self.eventCreatedAt = eventCreatedAt + self.cachedAt = cachedAt + } +} + +/// Verified channel metadata retained for notification presentation. +public struct BuzzPushCachedChannel: Codable, Equatable, Sendable { + public let communityID: String + public let relayOrigin: String + public let channelID: String + public let relayMetadataPubkey: String + public let displayName: String? + /// Relay-verified Buzz channel type, when recognized. + public let channelType: String? + /// Exact unique-member count when bounded, or a value above the bound when oversized. + public let memberCount: Int? + /// Complete community-and-channel-scoped member digests, when within bounds. + public let memberDigests: [String]? + /// Event ID that established the cached membership snapshot. + public let membershipEventID: String? + /// Creation time of the cached membership replacement event. + public let membershipEventCreatedAt: Int? + /// Device time when the membership snapshot was cached. + public let membershipCachedAt: Int? + public let eventID: String + public let eventCreatedAt: Int + public let cachedAt: Int + + public init( + communityID: String, + relayOrigin: String, + channelID: String, + relayMetadataPubkey: String, + displayName: String?, + channelType: String? = nil, + memberCount: Int? = nil, + memberDigests: [String]? = nil, + membershipEventID: String? = nil, + membershipEventCreatedAt: Int? = nil, + membershipCachedAt: Int? = nil, + eventID: String, + eventCreatedAt: Int, + cachedAt: Int + ) { + self.communityID = communityID + self.relayOrigin = relayOrigin + self.channelID = channelID + self.relayMetadataPubkey = relayMetadataPubkey + self.displayName = displayName + self.channelType = channelType + self.memberCount = memberCount + self.memberDigests = memberDigests + self.membershipEventID = membershipEventID + self.membershipEventCreatedAt = membershipEventCreatedAt + self.membershipCachedAt = membershipCachedAt + self.eventID = eventID + self.eventCreatedAt = eventCreatedAt + self.cachedAt = cachedAt + } +} + +/// Atomic App Group snapshot shared by the app and its notification extension. +public struct BuzzPushPresentationCacheSnapshot: Codable, Equatable, Sendable { + public static let currentVersion = 1 + + public let version: Int + public var communities: [PushLeaseCommunity] + public var profiles: [BuzzPushCachedProfile] + public var channels: [BuzzPushCachedChannel] + + public init( + version: Int = currentVersion, + communities: [PushLeaseCommunity] = [], + profiles: [BuzzPushCachedProfile] = [], + channels: [BuzzPushCachedChannel] = [] + ) { + self.version = version + self.communities = communities + self.profiles = profiles + self.channels = channels + } + + public static func decode(_ data: Data?) -> Self { + guard let data, + let snapshot = try? JSONDecoder().decode(Self.self, from: data), + snapshot.version == currentVersion + else { return Self() } + return snapshot + } + + public func profile( + communityID: String, + relayOrigin: String, + pubkey: String + ) -> BuzzPushCachedProfile? { + let normalizedPubkey = pubkey.lowercased() + return profiles.first { + $0.communityID == communityID && $0.relayOrigin == relayOrigin + && $0.pubkey == normalizedPubkey + } + } + + public func channel( + communityID: String, + relayOrigin: String, + channelID: String + ) -> BuzzPushCachedChannel? { + channels.first { + $0.communityID == communityID && $0.relayOrigin == relayOrigin + && $0.channelID == channelID + } + } +} + +/// One app-provided profile update, optionally carrying a sanitized local thumbnail. +public struct BuzzPushProfileCacheUpdate: Sendable { + public let event: VerifiedNostrEvent + public let avatarPNG: Data? + + public init(event: VerifiedNostrEvent, avatarPNG: Data? = nil) { + self.event = event + self.avatarPNG = avatarPNG + } +} + +/// Maintains the bounded presentation snapshot. The app is the sole writer. +public final class BuzzPushPresentationCacheStore: @unchecked Sendable { + public static let fileName = "push-snapshot.json" + public static let freshnessLifetime: TimeInterval = 24 * 60 * 60 + public static let maximumProfiles = 256 + public static let maximumChannels = 512 + public static let maximumCommunities = 64 + public static let maximumMembersPerChannel = 512 + public static let maximumTotalMemberDigests = 8_192 + public static let maximumAvatarBytes = 64 * 1024 + public static let maximumTotalAvatarBytes = 4 * 1024 * 1024 + public static let maximumSnapshotBytes = 8 * 1024 * 1024 + static let maximumProfileMetadataBytes = 256 * 1024 + + private let fileURL: URL + private let now: () -> Date + private let lock = NSLock() + + public init(containerURL: URL, now: @escaping () -> Date = Date.init) { + fileURL = containerURL.appendingPathComponent(Self.fileName) + self.now = now + } + + /// Replaces the app's flattened, relay-accepted community query policy. + public func replaceCommunities(_ communities: [PushLeaseCommunity]) throws { + guard communities.count <= Self.maximumCommunities else { return } + lock.lock() + defer { lock.unlock() } + var snapshot = loadLocked() + snapshot.communities = communities + let retained = Set(communities.map(\.id)) + snapshot.profiles.removeAll { !retained.contains($0.communityID) } + snapshot.channels.removeAll { !retained.contains($0.communityID) } + try writeLocked(snapshot) + } + + /// Saves verified kind-0 events and returns the event IDs still needing thumbnails. + @discardableResult + public func updateProfiles( + communityID: String, + relayOrigin: String, + updates: [BuzzPushProfileCacheUpdate] + ) throws -> Set { + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + updates.count <= Self.maximumProfiles + else { return [] } + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + let cachedAt = Int(now().timeIntervalSince1970) + var acceptedEventIDs = Set() + for update in updates { + let event = update.event + guard event.kind == 0, event.hasValidIDAndSignature() else { continue } + let pubkey = event.pubkey.lowercased() + guard Self.isHexPubkey(pubkey) else { continue } + + let metadata = Self.profileMetadata(event) + let index = snapshot.profiles.firstIndex { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.pubkey == pubkey + } + let existing = index.map { snapshot.profiles[$0] } + guard + Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } + + let suppliedAvatar = Self.normalizedAvatarPNG(update.avatarPNG) + let preservedAvatar = + existing?.pictureHash == metadata.pictureHash + ? existing?.avatarPNG : nil + let entry = BuzzPushCachedProfile( + communityID: communityID, + relayOrigin: canonicalRelayOrigin, + pubkey: pubkey, + displayName: metadata.displayName, + pictureHash: metadata.pictureHash, + avatarPNG: metadata.pictureHash == nil ? nil : suppliedAvatar ?? preservedAvatar, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + if let index { + snapshot.profiles[index] = entry + } else { + snapshot.profiles.append(entry) + } + acceptedEventIDs.insert(event.id) + } + + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + return Set( + snapshot.profiles.compactMap { profile in + guard profile.communityID == communityID, + profile.relayOrigin == canonicalRelayOrigin, + acceptedEventIDs.contains(profile.eventID), + profile.pictureHash != nil, + profile.avatarPNG == nil + else { return nil } + return profile.eventID + }) + } + + /// Saves bounded relay-authorized kind-39000 metadata and kind-39002 membership snapshots. + public func updateChannels( + communityID: String, + relayOrigin: String, + relayMetadataPubkey: String, + metadataEvents: [VerifiedNostrEvent], + membershipEvents: [VerifiedNostrEvent] + ) throws { + let normalizedRelayPubkey = relayMetadataPubkey.lowercased() + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + Self.isHexPubkey(normalizedRelayPubkey), + metadataEvents.count <= Self.maximumChannels, + membershipEvents.count <= Self.maximumChannels + else { + return + } + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + let cachedAt = Int(now().timeIntervalSince1970) + for event in metadataEvents { + guard event.kind == 39_000, event.hasValidIDAndSignature(), + event.pubkey.lowercased() == normalizedRelayPubkey, + let channelID = Self.tagValue("d", in: event), + Self.isBoundedOpaqueID(channelID) + else { continue } + let index = snapshot.channels.firstIndex { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.channelID == channelID + } + let existing = index.map { snapshot.channels[$0] } + let hasCurrentAuthority = existing?.relayMetadataPubkey == normalizedRelayPubkey + guard + !hasCurrentAuthority + || Self.shouldReplace( + existingCreatedAt: existing?.eventCreatedAt, + existingID: existing?.eventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } + + let entry = BuzzPushCachedChannel( + communityID: communityID, + relayOrigin: canonicalRelayOrigin, + channelID: channelID, + relayMetadataPubkey: normalizedRelayPubkey, + displayName: Self.normalizedDisplayName(Self.tagValue("name", in: event)), + channelType: Self.normalizedChannelType(Self.tagValue("t", in: event)), + memberCount: hasCurrentAuthority ? existing?.memberCount : nil, + memberDigests: hasCurrentAuthority ? existing?.memberDigests : nil, + membershipEventID: hasCurrentAuthority ? existing?.membershipEventID : nil, + membershipEventCreatedAt: hasCurrentAuthority + ? existing?.membershipEventCreatedAt : nil, + membershipCachedAt: hasCurrentAuthority ? existing?.membershipCachedAt : nil, + eventID: event.id, + eventCreatedAt: event.createdAt, + cachedAt: cachedAt + ) + if let index { + snapshot.channels[index] = entry + } else { + snapshot.channels.append(entry) + } + } + Self.enforceChannelCountBound(&snapshot) + + for event in membershipEvents { + guard event.kind == 39_002, event.hasValidIDAndSignature(), + event.pubkey.lowercased() == normalizedRelayPubkey, + let channelID = Self.tagValue("d", in: event), + Self.isBoundedOpaqueID(channelID), + let membership = Self.normalizedChannelMembership( + event, + communityID: communityID, + channelID: channelID + ), + let index = snapshot.channels.firstIndex(where: { + $0.communityID == communityID && $0.relayOrigin == canonicalRelayOrigin + && $0.channelID == channelID + && $0.relayMetadataPubkey == normalizedRelayPubkey + }) + else { continue } + let existing = snapshot.channels[index] + guard + Self.shouldReplace( + existingCreatedAt: existing.membershipEventCreatedAt, + existingID: existing.membershipEventID, + candidateCreatedAt: event.createdAt, + candidateID: event.id + ) + else { continue } + + snapshot.channels[index] = BuzzPushCachedChannel( + communityID: existing.communityID, + relayOrigin: existing.relayOrigin, + channelID: existing.channelID, + relayMetadataPubkey: existing.relayMetadataPubkey, + displayName: existing.displayName, + channelType: existing.channelType, + memberCount: membership.count, + memberDigests: membership.digests, + membershipEventID: event.id, + membershipEventCreatedAt: event.createdAt, + membershipCachedAt: cachedAt, + eventID: existing.eventID, + eventCreatedAt: existing.eventCreatedAt, + cachedAt: existing.cachedAt + ) + Self.enforceMemberDigestBound(&snapshot) + } + + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + } + + /// Attaches an app-rendered thumbnail to every verified profile with this source digest. + @discardableResult + public func updateAvatar( + communityID: String, + relayOrigin: String, + sourceURL: String, + avatarPNG: Data + ) throws -> Bool { + guard Self.isBoundedOpaqueID(communityID), + let canonicalRelayOrigin = Self.canonicalRelayOrigin(relayOrigin), + let normalizedURL = Self.normalizedAvatarURL(sourceURL), + let normalizedPNG = Self.normalizedAvatarPNG(avatarPNG) + else { return false } + let pictureHash = VerifiedNostrEvent.hex( + SHA256.hash(data: Data(normalizedURL.utf8)) + ) + lock.lock() + defer { lock.unlock() } + + var snapshot = loadLocked() + var changed = false + for index in snapshot.profiles.indices + where + snapshot.profiles[index].communityID == communityID + && snapshot.profiles[index].relayOrigin == canonicalRelayOrigin + && snapshot.profiles[index].pictureHash == pictureHash + && snapshot.profiles[index].avatarPNG != normalizedPNG + { + let profile = snapshot.profiles[index] + snapshot.profiles[index] = BuzzPushCachedProfile( + communityID: profile.communityID, + relayOrigin: profile.relayOrigin, + pubkey: profile.pubkey, + displayName: profile.displayName, + pictureHash: profile.pictureHash, + avatarPNG: normalizedPNG, + eventID: profile.eventID, + eventCreatedAt: profile.eventCreatedAt, + cachedAt: profile.cachedAt + ) + changed = true + } + guard changed else { return false } + Self.enforceBounds(&snapshot) + try writeLocked(snapshot) + return true + } + + private func loadLocked() -> BuzzPushPresentationCacheSnapshot { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= Self.maximumSnapshotBytes + else { return BuzzPushPresentationCacheSnapshot() } + return BuzzPushPresentationCacheSnapshot.decode(try? Data(contentsOf: fileURL)) + } + + private func writeLocked(_ snapshot: BuzzPushPresentationCacheSnapshot) throws { + let data = try Self.encodedBoundedSnapshot(snapshot) + try data.write(to: fileURL, options: [.atomic]) + #if os(iOS) + try FileManager.default.setAttributes( + [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication], + ofItemAtPath: fileURL.path + ) + #endif + } + + static func encodedBoundedSnapshot( + _ snapshot: BuzzPushPresentationCacheSnapshot + ) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + var bounded = snapshot + Self.enforceBounds(&bounded) + var data = try encoder.encode(bounded) + + if data.count > Self.maximumSnapshotBytes { + var estimatedExcess = data.count - Self.maximumSnapshotBytes + 1_024 + for index in bounded.profiles.indices.reversed() { + guard let avatar = bounded.profiles[index].avatarPNG else { continue } + estimatedExcess -= min(estimatedExcess, 4 * ((avatar.count + 2) / 3)) + bounded.profiles[index] = Self.removingAvatar(from: bounded.profiles[index]) + if estimatedExcess == 0 { break } + } + data = try encoder.encode(bounded) + } + + if data.count > Self.maximumSnapshotBytes { + for index in bounded.channels.indices.reversed() { + guard bounded.channels[index].memberDigests != nil else { continue } + bounded.channels[index] = Self.removingMemberDigests(from: bounded.channels[index]) + data = try encoder.encode(bounded) + if data.count <= Self.maximumSnapshotBytes { break } + } + } + + while data.count > Self.maximumSnapshotBytes, + !bounded.profiles.isEmpty || !bounded.channels.isEmpty + { + let entryCount = bounded.profiles.count + bounded.channels.count + let ratio = Double(Self.maximumSnapshotBytes) / Double(data.count) + let targetCount = max(0, min(entryCount - 1, Int(Double(entryCount) * ratio * 0.95))) + Self.removeOldestEntries(entryCount - targetCount, from: &bounded) + data = try encoder.encode(bounded) + } + return data + } + + static func profileMetadata( + _ event: VerifiedNostrEvent + ) -> (displayName: String?, pictureHash: String?) { + guard event.content.utf8.count <= maximumProfileMetadataBytes, + let data = event.content.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return (nil, nil) } + let displayName = + normalizedDisplayName(object["display_name"] as? String) + ?? normalizedDisplayName(object["name"] as? String) + let pictureHash = normalizedAvatarURL(object["picture"] as? String).map { + VerifiedNostrEvent.hex(SHA256.hash(data: Data($0.utf8))) + } + return (displayName, pictureHash) + } + + static func normalizedDisplayName(_ value: String?) -> String? { + guard let value else { return nil } + let collapsed = value.precomposedStringWithCanonicalMapping + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + guard !collapsed.isEmpty else { return nil } + var bounded = String(collapsed.prefix(128)) + while bounded.utf8.count > 512, !bounded.isEmpty { + bounded.removeLast() + } + return bounded.isEmpty ? nil : bounded + } + + static func normalizedChannelType(_ value: String?) -> String? { + guard let value, ["stream", "forum", "dm"].contains(value) else { return nil } + return value + } + + static func normalizedChannelMembership( + _ event: VerifiedNostrEvent, + communityID: String, + channelID: String + ) -> (count: Int, digests: [String]?)? { + var pubkeys = Set() + var exceededMemberBound = false + for tag in event.tags where tag.first == "p" { + guard tag.count >= 2 else { return nil } + let pubkey = tag[1].lowercased() + guard isHexPubkey(pubkey) else { return nil } + if !exceededMemberBound { + pubkeys.insert(pubkey) + exceededMemberBound = pubkeys.count > maximumMembersPerChannel + } + } + let digests = + exceededMemberBound + ? nil + : pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: communityID, + channelID: channelID, + pubkey: $0 + ) + }.sorted() + return ( + exceededMemberBound ? maximumMembersPerChannel + 1 : pubkeys.count, + digests + ) + } + + static func normalizedAvatarURL(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("data:image/") { + guard trimmed.utf8.count <= maximumProfileMetadataBytes, + let separator = trimmed.firstIndex(of: ","), + separator < trimmed.index(before: trimmed.endIndex) + else { return nil } + let metadata = trimmed[.. String? { + guard value.utf8.count <= 2_048, + var components = URLComponents(string: value), + components.host?.isEmpty == false, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { return nil } + switch components.scheme?.lowercased() { + case "wss": components.scheme = "https" + case "ws": components.scheme = "http" + case "https", "http": break + default: return nil + } + components.path = "" + guard let result = components.string, result.utf8.count <= 2_048 else { return nil } + return result + } + + static func tagValue(_ name: String, in event: VerifiedNostrEvent) -> String? { + event.tags.first { $0.count >= 2 && $0[0] == name }?[1] + } + + private static func isBoundedOpaqueID(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 1_024 + } + + private static func isHexPubkey(_ value: String) -> Bool { + value.count == 64 && VerifiedNostrEvent.hexBytes(value)?.count == 32 + } + + private static func normalizedAvatarPNG(_ data: Data?) -> Data? { + guard let data, !data.isEmpty, data.count <= maximumAvatarBytes, + data.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + else { return nil } + return data + } + + static func shouldReplace( + existingCreatedAt: Int?, + existingID: String?, + candidateCreatedAt: Int, + candidateID: String + ) -> Bool { + guard let existingCreatedAt, let existingID else { return true } + return candidateCreatedAt > existingCreatedAt + || (candidateCreatedAt == existingCreatedAt && candidateID <= existingID) + } + + static func enforceBounds(_ snapshot: inout BuzzPushPresentationCacheSnapshot) { + snapshot.communities = Array(snapshot.communities.prefix(maximumCommunities)) + snapshot.profiles = Array( + snapshot.profiles.sorted(by: profileNewestFirst).prefix(maximumProfiles) + ) + enforceChannelCountBound(&snapshot) + enforceMemberDigestBound(&snapshot) + + var avatarBytes = snapshot.profiles.reduce(0) { $0 + ($1.avatarPNG?.count ?? 0) } + guard avatarBytes > maximumTotalAvatarBytes else { return } + for index in snapshot.profiles.indices.reversed() { + guard let avatar = snapshot.profiles[index].avatarPNG else { continue } + avatarBytes -= avatar.count + let profile = snapshot.profiles[index] + snapshot.profiles[index] = removingAvatar(from: profile) + if avatarBytes <= maximumTotalAvatarBytes { break } + } + } + + private static func enforceChannelCountBound( + _ snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + snapshot.channels = Array( + snapshot.channels.sorted(by: channelNewestFirst).prefix(maximumChannels) + ) + } + + private static func enforceMemberDigestBound( + _ snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + snapshot.channels.sort(by: channelNewestFirst) + var memberDigestCount = snapshot.channels.reduce(0) { + $0 + ($1.memberDigests?.count ?? 0) + } + guard memberDigestCount > maximumTotalMemberDigests else { return } + for index in snapshot.channels.indices.reversed() { + guard let memberDigests = snapshot.channels[index].memberDigests else { continue } + memberDigestCount -= memberDigests.count + snapshot.channels[index] = removingMemberDigests(from: snapshot.channels[index]) + if memberDigestCount <= maximumTotalMemberDigests { break } + } + } + + private static func removingAvatar( + from profile: BuzzPushCachedProfile + ) -> BuzzPushCachedProfile { + BuzzPushCachedProfile( + communityID: profile.communityID, + relayOrigin: profile.relayOrigin, + pubkey: profile.pubkey, + displayName: profile.displayName, + pictureHash: profile.pictureHash, + avatarPNG: nil, + eventID: profile.eventID, + eventCreatedAt: profile.eventCreatedAt, + cachedAt: profile.cachedAt + ) + } + + private static func removingMemberDigests( + from channel: BuzzPushCachedChannel + ) -> BuzzPushCachedChannel { + BuzzPushCachedChannel( + communityID: channel.communityID, + relayOrigin: channel.relayOrigin, + channelID: channel.channelID, + relayMetadataPubkey: channel.relayMetadataPubkey, + displayName: channel.displayName, + channelType: channel.channelType, + memberCount: channel.memberCount, + memberDigests: nil, + membershipEventID: channel.membershipEventID, + membershipEventCreatedAt: channel.membershipEventCreatedAt, + membershipCachedAt: channel.membershipCachedAt, + eventID: channel.eventID, + eventCreatedAt: channel.eventCreatedAt, + cachedAt: channel.cachedAt + ) + } + + private static func removeOldestEntries( + _ count: Int, + from snapshot: inout BuzzPushPresentationCacheSnapshot + ) { + for _ in 0.. Bool { + lhs.cachedAt == rhs.cachedAt ? lhs.eventID > rhs.eventID : lhs.cachedAt > rhs.cachedAt + } + + private static func channelNewestFirst( + _ lhs: BuzzPushCachedChannel, + _ rhs: BuzzPushCachedChannel + ) -> Bool { + let lhsCachedAt = channelLastCachedAt(lhs) + let rhsCachedAt = channelLastCachedAt(rhs) + return lhsCachedAt == rhsCachedAt ? lhs.eventID > rhs.eventID : lhsCachedAt > rhsCachedAt + } + + private static func channelLastCachedAt(_ channel: BuzzPushCachedChannel) -> Int { + max(channel.cachedAt, channel.membershipCachedAt ?? 0) + } +} + +/// Stable, privacy-preserving identifiers used only after the NSE resolves an event. +public enum BuzzPushPresentationIdentity { + public static func conversation(communityID: String, channelID: String) -> String { + scoped(namespace: "conversation", values: [communityID, channelID]) + } + + public static func sender(communityID: String, pubkey: String) -> String { + scoped(namespace: "sender", values: [communityID, pubkey.lowercased()]) + } + + /// Returns a stable, channel-scoped digest used for exact local membership checks. + public static func channelMember( + communityID: String, + channelID: String, + pubkey: String + ) -> String { + scoped( + namespace: "channel-member", + values: [communityID, channelID, pubkey.lowercased()] + ) + } + + private static func scoped(namespace: String, values: [String]) -> String { + let encoded = (try? JSONEncoder().encode([namespace] + values)) ?? Data() + return "buzz.\(namespace).\(VerifiedNostrEvent.hex(SHA256.hash(data: encoded)))" + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift new file mode 100644 index 00000000000..fd0bc34d696 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Errors thrown by the canonical transcript encoder. +public enum BuzzPushTranscriptError: Error, Equatable { + /// A string field contained non-ASCII scalars. NIP-PL admits only ASCII + /// authority-bearing strings; rather than guess at UTF-8-vs-escaping + /// behavior we fail closed. + case nonASCIIInput(field: String) +} + +/// Canonical NIP-PL App Attest transcript encoder. +/// +/// NIP-PL ("Exact App Attest transcript construction") pins the exact bytes +/// every App Attest operation signs: +/// +/// + "\n" + +/// +/// The JSON object has no insignificant whitespace, members appear in a fixed +/// per-route order, integers use shortest decimal notation, and strings use +/// minimal JSON escaping (quotation mark, reverse solidus, U+0000..U+001F). +/// The gateway builds the same bytes with serde_json and compares hashes, so +/// any byte difference is a silent `401 invalid_attestation`. This encoder is +/// hand-rolled for that reason: `JSONSerialization` escapes `/` as `\/` and +/// does not guarantee member order, so it must never be used for transcripts. +/// +/// Ground truth: `crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`, +/// generated and asserted by the gateway's own encoder. The tests in this +/// package replay those vectors byte-for-byte. +/// +/// The `audience` member of each transcript is a **fixed protocol constant** +/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route +/// domain-separation string, not a deployment URL: the gateway hardcodes it +/// regardless of where it is hosted, so clients must never derive it from a +/// discovered gateway base URL or relay host. +public enum BuzzPushTranscript { + // MARK: Domains + + public static let enrollDomain = "buzz.push.enroll.v1" + public static let delegateDomain = "buzz.push.delegate.v1" + public static let rotateEndpointDomain = "buzz.push.rotate-endpoint.v1" + public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" + public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" + + // MARK: Fixed audiences (protocol constants, see type docs) + + public static let enrollAudience = "https://push.buzz.xyz/v1/installations" + public static let delegateAudience = "https://push.buzz.xyz/v1/delegations" + public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint" + public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke" + public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke" + + /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. + public static let wireVersion: Int64 = 1 + + // MARK: Transcripts + + /// `buzz.push.enroll.v1` — these exact bytes are the App Attest + /// `clientData` supplied to attestation verification. + public static func enroll( + challengeId: UUID, + challenge: String, + keyId: String, + appProfile: String, + endpoint: String, + endpointEpoch: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.enrollAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + try o.string("key_id", keyId, field: "key_id") + try o.string("app_profile", appProfile, field: "app_profile") + try o.string("endpoint", endpoint, field: "endpoint") + o.int("endpoint_epoch", endpointEpoch) + o.int("expires_at", expiresAt) + return encode(domain: enrollDomain, object: o) + } + + /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func delegate( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + generation: Int64, + relayPubkey: String, + notBefore: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.delegateAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("generation", generation) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("not_before", notBefore) + o.int("expires_at", expiresAt) + return encode(domain: delegateDomain, object: o) + } + + /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func rotateEndpoint( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64, + endpoint: String + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.rotateEndpointAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + try o.string("endpoint", endpoint, field: "endpoint") + return encode(domain: rotateEndpointDomain, object: o) + } + + /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeDelegation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + relayPubkey: String, + generation: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeDelegationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("generation", generation) + return encode(domain: revokeDelegationDomain, object: o) + } + + /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeInstallation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeInstallationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + return encode(domain: revokeInstallationDomain, object: o) + } + + // MARK: Internals + + private static func encode(domain: String, object: CanonicalObject) -> Data { + Data((domain + "\n" + object.encoded()).utf8) + } + + /// Ordered compact JSON object writer. Emission order == call order; + /// there is deliberately no sorting, no whitespace, and no `Encodable` + /// round-trip anywhere near these bytes. + struct CanonicalObject { + private var members: [String] = [] + + mutating func int(_ key: String, _ value: Int64) { + // Swift's Int64 description is shortest decimal notation, which + // is what the spec pins and what serde_json emits. + members.append("\"\(key)\":\(value)") + } + + mutating func uuid(_ key: String, _ value: UUID) { + // Canonical lowercase-hyphenated form, matching uuid::Uuid's + // serde serialization. Foundation's uuidString is uppercase. + members.append("\"\(key)\":\"\(value.uuidString.lowercased())\"") + } + + mutating func string(_ key: String, _ value: String, field: String? = nil) throws { + members.append("\"\(key)\":\"\(try Self.escape(value, field: field ?? key))\"") + } + + func encoded() -> String { + "{" + members.joined(separator: ",") + "}" + } + + /// Minimal JSON string escaping, byte-identical to serde_json: + /// `"` and `\` get two-character escapes; U+0008, U+0009, U+000A, + /// U+000C, U+000D get their short forms; the remaining C0 controls + /// get lowercase `\u00xx`. Nothing else is escaped (in particular + /// `/` is NOT escaped — the JSONSerialization behavior that makes it + /// unusable here). Non-ASCII input is rejected outright. + static func escape(_ s: String, field: String) throws -> String { + var out = String() + out.reserveCapacity(s.count) + for scalar in s.unicodeScalars { + switch scalar.value { + case 0x22: out += "\\\"" + case 0x5C: out += "\\\\" + case 0x08: out += "\\b" + case 0x09: out += "\\t" + case 0x0A: out += "\\n" + case 0x0C: out += "\\f" + case 0x0D: out += "\\r" + case 0x00...0x1F: out += String(format: "\\u%04x", scalar.value) + case 0x20...0x7E: out.unicodeScalars.append(scalar) + default: throw BuzzPushTranscriptError.nonASCIIInput(field: field) + } + } + return out + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift new file mode 100644 index 00000000000..39123704561 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift @@ -0,0 +1,143 @@ +import CryptoKit +import Foundation +import P256K + +public enum NostrHTTPAuthError: Error, Equatable { + case invalidHex + case signingFailed +} + +public struct VerifiedNostrEvent: Codable, Equatable, Sendable { + public let id: String + public let pubkey: String + public let createdAt: Int + public let kind: Int + public let tags: [[String]] + public let content: String + public let sig: String + + enum CodingKeys: String, CodingKey { + case id, pubkey, kind, tags, content, sig + case createdAt = "created_at" + } + + public init( + id: String, pubkey: String, createdAt: Int, kind: Int, + tags: [[String]], content: String, sig: String + ) { + self.id = id + self.pubkey = pubkey + self.createdAt = createdAt + self.kind = kind + self.tags = tags + self.content = content + self.sig = sig + } + + public func hasValidIDAndSignature() -> Bool { + guard let idBytes = Self.hexBytes(id), idBytes.count == 32, + let pubkeyBytes = Self.hexBytes(pubkey), pubkeyBytes.count == 32, + let signatureBytes = Self.hexBytes(sig), signatureBytes.count == 64, + let serialized = try? Self.canonicalSerialization( + pubkey: pubkey.lowercased(), createdAt: createdAt, kind: kind, + tags: tags, content: content + ) + else { return false } + let digest = Array(SHA256.hash(data: serialized)) + guard digest == idBytes, + let signature = try? P256K.Schnorr.SchnorrSignature( + dataRepresentation: Data(signatureBytes) + ) + else { return false } + var message = digest + let key = P256K.Schnorr.XonlyKey(dataRepresentation: pubkeyBytes) + return key.isValid(signature, for: &message) + } + + static func canonicalSerialization( + pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String + ) throws -> Data { + try JSONSerialization.data( + withJSONObject: [0, pubkey, createdAt, kind, tags, content], + options: [.withoutEscapingSlashes] + ) + } + + static func hexBytes(_ value: String) -> [UInt8]? { + guard value.count.isMultiple(of: 2) else { return nil } + var result: [UInt8] = [] + result.reserveCapacity(value.count / 2) + var index = value.startIndex + while index < value.endIndex { + let end = value.index(index, offsetBy: 2) + guard let byte = UInt8(value[index..) -> String { + bytes.map { String(format: "%02x", $0) }.joined() + } +} + +public enum NostrHTTPAuth { + public static func authorizationHeader( + url: URL, + method: String, + body: Data, + privateKeyHex: String, + createdAt: Int = Int(Date().timeIntervalSince1970), + auxiliaryRandomness: [UInt8]? = nil + ) throws -> String { + guard let privateKeyBytes = VerifiedNostrEvent.hexBytes(privateKeyHex), + privateKeyBytes.count == 32 + else { throw NostrHTTPAuthError.invalidHex } + do { + let privateKey = try P256K.Schnorr.PrivateKey( + dataRepresentation: privateKeyBytes + ) + let pubkey = VerifiedNostrEvent.hex(privateKey.xonly.bytes) + let payload = VerifiedNostrEvent.hex(SHA256.hash(data: body)) + let tags = [ + ["u", url.absoluteString], + ["method", method.uppercased()], + ["payload", payload], + ] + let serialized = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, createdAt: createdAt, kind: 27235, + tags: tags, content: "" + ) + let digest = Array(SHA256.hash(data: serialized)) + var message = digest + let signature: P256K.Schnorr.SchnorrSignature + if var randomness = auxiliaryRandomness { + guard randomness.count == 32 else { throw NostrHTTPAuthError.signingFailed } + signature = try privateKey.signature( + message: &message, auxiliaryRand: &randomness + ) + } else { + signature = try privateKey.signature( + message: &message, auxiliaryRand: nil + ) + } + let event = VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: 27235, + tags: tags, + content: "", + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.withoutEscapingSlashes] + return "Nostr " + (try encoder.encode(event)).base64EncodedString() + } catch let error as NostrHTTPAuthError { + throw error + } catch { + throw NostrHTTPAuthError.signingFailed + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift new file mode 100644 index 00000000000..cb7eb791082 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -0,0 +1,143 @@ +import Foundation + +public struct PushLeaseSnapshot: Codable, Equatable, Sendable { + public let communities: [PushLeaseCommunity] + + public init(communities: [PushLeaseCommunity]) { + self.communities = communities + } +} + +public struct PushLeaseCommunity: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let relayUrl: String + /// Relay NIP-11 `self` key used to verify NIP-29 channel metadata. + public let relayMetadataPubkey: String? + public let pubkey: String? + public let policies: [PushResolutionPolicy] + + public init( + id: String, + name: String, + relayUrl: String, + relayMetadataPubkey: String? = nil, + pubkey: String?, + policies: [PushResolutionPolicy] + ) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.relayMetadataPubkey = relayMetadataPubkey + self.pubkey = pubkey + self.policies = policies + } +} + +public struct PushResolutionPolicy: Codable, Equatable, Sendable { + public let filter: PushLeaseFilter + public let ignore: [PushLeaseFilter] + public let suppress: PushLeaseSuppression? + + public init( + filter: PushLeaseFilter, + ignore: [PushLeaseFilter] = [], + suppress: PushLeaseSuppression? = nil + ) { + self.filter = filter + self.ignore = ignore + self.suppress = suppress + } +} + +public struct PushLeaseSuppression: Codable, Equatable, Sendable { + public let pTagsMax: Int + + enum CodingKeys: String, CodingKey { + case pTagsMax = "p_tags_max" + } + + public init(pTagsMax: Int) { + self.pTagsMax = pTagsMax + } +} + +public struct PushLeaseFilter: Codable, Equatable, Sendable { + public let kinds: [Int] + public let authors: [String]? + public let pTags: [String]? + public let hTags: [String]? + public let eTags: [String]? + + enum CodingKeys: String, CodingKey { + case kinds + case authors + case pTags = "#p" + case hTags = "#h" + case eTags = "#e" + } + + public init( + kinds: [Int], + authors: [String]? = nil, + pTags: [String]? = nil, + hTags: [String]? = nil, + eTags: [String]? = nil + ) { + self.kinds = kinds + self.authors = authors + self.pTags = pTags + self.hTags = hTags + self.eTags = eTags + } + + public func queryFilter(since: Int?, limit: Int) -> [String: Any] { + var filter: [String: Any] = ["kinds": kinds, "limit": limit] + if let authors { filter["authors"] = authors } + if let pTags { filter["#p"] = pTags } + if let hTags { filter["#h"] = hTags } + if let eTags { filter["#e"] = eTags } + if let since { filter["since"] = since } + return filter + } + + public func matches(_ event: VerifiedNostrEvent) -> Bool { + guard kinds.contains(event.kind) else { return false } + if let authors, !authors.contains(event.pubkey.lowercased()) { return false } + if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false } + if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false } + if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false } + return true + } +} + +public enum PushLeaseMatcher { + public static func matches( + event: VerifiedNostrEvent, + policy: PushResolutionPolicy + ) -> Bool { + guard policy.filter.matches(event) else { return false } + if policy.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = policy.suppress?.pTagsMax, + event.tagCount(named: "p") > maximum + { + return false + } + return true + } +} + +extension VerifiedNostrEvent { + public func tagCount(named name: String) -> Int { + tags.reduce(into: 0) { count, tag in + if tag.count >= 2 && tag[0] == name { count += 1 } + } + } + + public func hasAnyTag(named name: String, values: [String]) -> Bool { + let expected = Set(values.map { $0.lowercased() }) + return tags.contains { tag in + tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased()) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift new file mode 100644 index 00000000000..657c925e179 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest +@testable import BuzzPushKit + +final class APNsRegistrationBufferTests: XCTestCase { + func testReplaysTokenAfterChannelAttachment() { + let buffer = APNsRegistrationBuffer() + buffer.recordToken(Data([0x01, 0xAB, 0x00])) + var delivered: [APNsRegistrationUpdate] = [] + buffer.attach { delivered.append($0) } + XCTAssertEqual(delivered, [ + APNsRegistrationUpdate(method: "apnsTokenChanged", arguments: ["token": "01ab00"]) + ]) + XCTAssertNil(buffer.pending) + } + + func testKeepsLatestUpdateAndDeliversLiveFailures() { + let buffer = APNsRegistrationBuffer() + buffer.recordToken(Data([0x01])) + buffer.recordError("offline") + var delivered: [APNsRegistrationUpdate] = [] + buffer.attach { delivered.append($0) } + buffer.recordError("denied") + XCTAssertEqual(delivered, [ + APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "offline"]), + APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "denied"]), + ]) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift new file mode 100644 index 00000000000..2345bc46c68 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -0,0 +1,1313 @@ +import CryptoKit +import Foundation +import Security +import XCTest + +@testable import BuzzPushKit + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class BuzzDevPushEnrollmentDriverTests: XCTestCase { + private static let gatewayURL = URL(string: "http://push.example/")! + private static let relayURL = URL(string: "wss://relay.example/")! + private static let relayPubkey = String(repeating: "a", count: 64) + private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" + private static let secondChallengeId = "33333333-3333-4333-8333-333333333333" + private static let installationHandle = "22222222-2222-4222-8222-222222222222" + private static let installationId = "000102030405060708090a0b0c0d0e0f" + private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + private static let now: Int64 = 1_752_620_000 + private static let expiresAt: Int64 = 1_752_624_000 + private static let endpoint = + "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + fileprivate static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + fileprivate static let attestation = Data("test-attestation".utf8).base64EncodedString() + fileprivate static let assertion = Data("buzz-dev-app-assertion-v1".utf8).base64EncodedString() + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testEnrollmentPinsTranscriptsAndPersistsOpaqueGrant() async throws { + let store = MemoryGrantStore() + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/nostr+json") + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["id": "current", "pubkey": Self.relayPubkey, "current": true] + ] + ], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + let body = try Self.body(request) + XCTAssertEqual(body["v"] as? Int, 1) + XCTAssertEqual(body.count, 1) + let id = challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId + return Self.response( + request, + status: 200, + json: [ + "challenge_id": id, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + let body = try Self.body(request) + XCTAssertEqual(body["endpoint"] as? String, Self.endpoint) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["challenge_id"] as? String, Self.firstChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["key_id"] as? String, Self.keyId) + XCTAssertEqual(body["attestation"] as? String, Self.attestation) + XCTAssertEqual(body["app_profile"] as? String, "buzz-ios-dogfood") + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["relay_pubkey"] as? String, Self.relayPubkey) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["challenge_id"] as? String, Self.secondChallengeId) + XCTAssertEqual(body["challenge"] as? String, Self.challenge) + XCTAssertEqual(body["endpoint_epoch"] as? Int, 1) + XCTAssertEqual(body["not_before"] as? Int64, Self.now) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + XCTAssertEqual(body["assertion"] as? String, Self.assertion) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail( + "Unexpected request \(request.httpMethod ?? "nil") \(request.url?.absoluteString ?? "nil")" + ) + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.relayOrigin, "wss://relay.example") + try assertMatchesVector( + "enroll", + actual: appAttest.clientData[0], + expectedSHA256: "58274bd9e9a86489fe5bae36aecbe89618824433189405ff4de8b18b58384270", + fixture: makeFixtureTranscript(name: "enroll", replacements: []) + ) + try assertMatchesVector( + "delegate", + actual: appAttest.clientData[1], + expectedSHA256: "f186db11cb53e4e80f09489c11dd18afc9b641683c3d72a67113c57d32fca323", + fixture: makeFixtureTranscript( + name: "delegate", + replacements: [ + (Self.firstChallengeId, Self.secondChallengeId) + ] + ) + ) + XCTAssertEqual( + record, + BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "opaque-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + ) + XCTAssertEqual(store.saved, [record]) + } + + func testCommittedInstallationRecoversAfterFinalGrantSaveFailure() async throws { + let store = MemoryGrantStore(grantSaveFailuresRemaining: 1) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + var installationCount = 0 + var delegationCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + installationCount += 1 + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + delegationCount += 1 + let body = try Self.body(request) + XCTAssertEqual(body["generation"] as? Int, delegationCount) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant-\(delegationCount)"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the injected local save failure") + } catch { + XCTAssertEqual((error as NSError).domain, "MemoryGrantStore") + } + XCTAssertEqual(store.pending.first?.delegationGeneration, 1) + + let recovered = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(installationCount, 1) + XCTAssertEqual(delegationCount, 2) + XCTAssertEqual(recovered.generation, 2) + XCTAssertEqual(recovered.endpointGrant, "opaque-grant-2") + XCTAssertTrue(store.pending.isEmpty) + } + + func testCommittedInstallationRecoversAfterResponseLoss() async throws { + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + var installationCount = 0 + var firstInstallationBody: [String: Any]? + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + installationCount += 1 + let body = try Self.body(request) + if installationCount == 1 { + firstInstallationBody = body + throw URLError(.networkConnectionLost) + } + XCTAssertTrue( + NSDictionary(dictionary: body).isEqual(to: try XCTUnwrap(firstInstallationBody)) + ) + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + do { + _ = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + XCTFail("Expected the simulated lost installation response") + } catch { + XCTAssertEqual((error as NSError).domain, NSURLErrorDomain) + } + XCTAssertNil(store.pending.first?.gatewayInstallationHandle) + + let recovered = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(challengeCount, 2) + XCTAssertEqual(installationCount, 2) + XCTAssertEqual(recovered.endpointGrant, "opaque-grant") + XCTAssertTrue(store.pending.isEmpty) + } + + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { + let relayURL = URL(string: "wss://relay.example:8443/")! + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example:8443/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: relayURL + ) + + XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") + } + + func testLegacyGrantDecodesWithoutMetadataAuthority() throws { + let data = Data( + #"{"relayOrigin":"wss://relay.example","relayPubkey":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","installationId":"000102030405060708090a0b0c0d0e0f","endpointGrant":"opaque","endpointHash":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","appProfile":"buzz-ios-dogfood","endpointEpoch":1,"generation":1,"expiresAt":1752624000}"# + .utf8 + ) + + let record = try JSONDecoder().decode(BuzzPushEndpointGrantRecord.self, from: data) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + } + + func testRealAppAttestFailsLoudlyWhenUnsupported() async throws { + let service = RecordingDCAppAttestService(isSupported: false) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Expected App Attest to be unavailable") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .appAttestUnsupported) + } + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestGeneratesPersistsAndMapsAttestation() async throws { + let service = RecordingDCAppAttestService( + generatedKeyId: Self.keyId, + attestationObject: Data([0x01, 0x02, 0x03]) + ) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("enrollment transcript".utf8) + + let prepared = try await provider.prepareAttestation() + let attestation = try await provider.attestation(prepared, clientData: clientData) + + XCTAssertEqual(prepared, BuzzDevAttestation(keyId: Self.keyId, attestation: "")) + XCTAssertEqual(keyIdStore.savedKeyIds, [Self.keyId]) + XCTAssertEqual(attestation.keyId, Self.keyId) + XCTAssertEqual(attestation.attestation, Data([0x01, 0x02, 0x03]).base64EncodedString()) + XCTAssertEqual(service.attestedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.attestationClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + } + + func testRealAppAttestAssertionReusesStoredKeyAndMapsObject() async throws { + let service = RecordingDCAppAttestService(assertionObject: Data([0x04, 0x05, 0x06])) + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let clientData = Data("delegation transcript".utf8) + + let assertion = try await provider.assertion(clientData: clientData) + + XCTAssertEqual(assertion, Data([0x04, 0x05, 0x06]).base64EncodedString()) + XCTAssertEqual(service.assertedKeyIds, [Self.keyId]) + XCTAssertEqual( + service.assertionClientDataHashes, + [Data(SHA256.hash(data: clientData))] + ) + XCTAssertEqual(service.generateKeyCallCount, 0) + } + + func testRealAppAttestRejectsInvalidGeneratedKeyBeforePersistence() async throws { + for invalidKeyId in [ + "not-a-key-id", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let service = RecordingDCAppAttestService(generatedKeyId: invalidKeyId) + let keyIdStore = MemoryAppAttestKeyIdStore() + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + + do { + _ = try await provider.prepareAttestation() + XCTFail("Accepted invalid generated key ID: \(invalidKeyId)") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(keyIdStore.savedKeyIds.isEmpty) + } + } + + func testRealAppAttestRejectsMismatchedPreparedKey() async throws { + let service = RecordingDCAppAttestService() + let keyIdStore = MemoryAppAttestKeyIdStore(keyId: Self.keyId) + let provider = BuzzDCAppAttestProvider(service: service, keyIdStore: keyIdStore) + let otherKeyId = Data(repeating: 0xBB, count: 32).base64EncodedString() + + do { + _ = try await provider.attestation( + BuzzDevAttestation(keyId: otherKeyId, attestation: ""), + clientData: Data("enrollment transcript".utf8) + ) + XCTFail("Expected the prepared key ID to match persistent state") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + XCTAssertTrue(service.attestedKeyIds.isEmpty) + } + + func testRealAppAttestForwardsServiceErrors() async throws { + let expected = NSError(domain: "DeviceCheckTest", code: 41) + let service = RecordingDCAppAttestService(error: expected) + let provider = BuzzDCAppAttestProvider( + service: service, + keyIdStore: MemoryAppAttestKeyIdStore(keyId: Self.keyId) + ) + + do { + _ = try await provider.assertion(clientData: Data("delegation transcript".utf8)) + XCTFail("Expected the DeviceCheck error") + } catch { + XCTAssertEqual((error as NSError).domain, expected.domain) + XCTAssertEqual((error as NSError).code, expected.code) + } + } + + func testKeychainStoreReadsKeyIdAndIncludesAccessGroup() throws { + var capturedQuery: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + copyMatching: { query, result in + capturedQuery = query as! [String: Any] + result?.pointee = Data(Self.keyId.utf8) as CFData + return errSecSuccess + } + ) + + XCTAssertEqual(try store.keyId(), Self.keyId) + XCTAssertEqual( + capturedQuery[kSecClass as String] as? String, kSecClassGenericPassword as String) + XCTAssertEqual(capturedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(capturedQuery[kSecAttrAccount as String] as? String, "key-id-v1") + XCTAssertEqual(capturedQuery[kSecAttrAccessGroup as String] as? String, "group.buzz") + XCTAssertEqual(capturedQuery[kSecReturnData as String] as? Bool, true) + XCTAssertEqual(capturedQuery[kSecMatchLimit as String] as? String, kSecMatchLimitOne as String) + } + + func testKeychainStoreReturnsNilOnMissAndRejectsInvalidData() throws { + let missing = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecItemNotFound } + ) + XCTAssertNil(try missing.keyId()) + + for invalidKeyId in [ + "bad", + String(Self.keyId.dropLast(2)) + "p=", + Data(repeating: 0xAA, count: 31).base64EncodedString(), + Data(repeating: 0xAA, count: 33).base64EncodedString(), + ] { + let invalid = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, result in + result?.pointee = Data(invalidKeyId.utf8) as CFData + return errSecSuccess + } + ) + XCTAssertThrowsError(try invalid.keyId(), "Accepted invalid key ID: \(invalidKeyId)") { + XCTAssertEqual($0 as? BuzzDevPushEnrollmentError, .invalidAppAttestKeyId) + } + } + } + + func testKeychainStoreUpdatesExistingKeyId() throws { + var updatedQuery: [String: Any] = [:] + var updatedValues: [String: Any] = [:] + var addCallCount = 0 + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { query, values in + updatedQuery = query as! [String: Any] + updatedValues = values as! [String: Any] + return errSecSuccess + }, + add: { _, _ in + addCallCount += 1 + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(updatedQuery[kSecAttrService as String] as? String, "buzz.push.app-attest") + XCTAssertEqual(updatedValues[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual(addCallCount, 0) + } + + func testKeychainStoreAddsMissingKeyIdWithDeviceOnlyAccessibility() throws { + var addedItem: [String: Any] = [:] + let store = BuzzAppAttestKeyIdKeychainStore( + accessGroup: "group.buzz", + update: { _, _ in errSecItemNotFound }, + add: { item, _ in + addedItem = item as! [String: Any] + return errSecSuccess + } + ) + + try store.saveKeyId(Self.keyId) + + XCTAssertEqual(addedItem[kSecValueData as String] as? Data, Data(Self.keyId.utf8)) + XCTAssertEqual( + addedItem[kSecAttrAccessible as String] as? String, + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String + ) + XCTAssertEqual(addedItem[kSecAttrAccessGroup as String] as? String, "group.buzz") + } + + func testKeychainStoreSurfacesReadUpdateAndAddErrors() throws { + let readFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + copyMatching: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try readFailure.keyId()) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let updateFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecInteractionNotAllowed } + ) + XCTAssertThrowsError(try updateFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecInteractionNotAllowed)) + } + + let addFailure = BuzzAppAttestKeyIdKeychainStore( + accessGroup: nil, + update: { _, _ in errSecItemNotFound }, + add: { _, _ in errSecDuplicateItem } + ) + XCTAssertThrowsError(try addFailure.saveKeyId(Self.keyId)) { + XCTAssertEqual(($0 as NSError).code, Int(errSecDuplicateItem)) + } + } + + func testReusesPersistedUnexpiredGrant() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + guard request.httpMethod == "GET" else { + XCTFail("Persisted grant reuse must not call the gateway") + return Self.response(request, status: 500, json: [:]) + } + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record, existing) + XCTAssertEqual(store.saved, [existing]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testSecondOriginOnSameRelayKeyReusesGrantWithFreshLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://first.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: String(repeating: "f", count: 32), + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 4, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.url?.absoluteString, "https://second.example/") + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: URL(string: "wss://second.example/")! + ) + + XCTAssertEqual(record.relayOrigin, "wss://second.example") + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(record.generation, existing.generation) + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertNotEqual(record.installationId, existing.installationId) + XCTAssertEqual(store.saved.count, 2) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testSecondRelayKeyReusesAttestedInstallationAndCreatesOnlyDelegation() async throws { + let secondRelayPubkey = String(repeating: "b", count: 64) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://first.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: String(repeating: "f", count: 32), + endpointGrant: "first-relay-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 7, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let appAttest = RecordingAppAttest() + let driver = try makeDriver(store: store, appAttest: appAttest) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://second.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": secondRelayPubkey, + "push": ["keys": [["pubkey": secondRelayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["relay_pubkey"] as? String, secondRelayPubkey) + XCTAssertEqual(body["generation"] as? Int, 1) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "second-relay-grant"] + ) + case ("POST", "http://push.example/v1/installations"): + XCTFail("A second relay must not create a duplicate APNs installation") + return Self.response(request, status: 500, json: [:]) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: URL(string: "wss://second.example/")! + ) + + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.relayPubkey, secondRelayPubkey) + XCTAssertEqual(record.endpointGrant, "second-relay-grant") + XCTAssertEqual(record.generation, 1) + XCTAssertEqual(appAttest.clientData.count, 1) + XCTAssertEqual(store.saved.count, 2) + } + + func testExpiringGrantRenewsExistingInstallationAndReusesRelayLeaseAddress() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + gatewayInstallationHandle: Self.installationHandle, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 7, + expiresAt: Self.now + 300 + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver( + store: store, + appAttest: RecordingAppAttest(), + installationIdBytes: { + XCTFail("Grant refresh must reuse the persisted installation id") + return Data(repeating: 0xFF, count: 16) + } + ) + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + return Self.response( + request, + status: 200, + json: [ + "challenge_id": Self.firstChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + XCTFail("An expiring installation must renew through authenticated delegation") + return Self.response(request, status: 500, json: [:]) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["installation_handle"] as? String, Self.installationHandle) + XCTAssertEqual(body["generation"] as? Int, 8) + XCTAssertEqual(body["expires_at"] as? Int64, Self.expiresAt) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "refreshed-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.gatewayInstallationHandle, Self.installationHandle) + XCTAssertEqual(record.generation, 8) + XCTAssertEqual(record.expiresAt, Self.expiresAt) + XCTAssertEqual(record.endpointGrant, "refreshed-grant") + } + + func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["pubkey": Self.relayPubkey, "current": true], + ["pubkey": String(repeating: "b", count: 64), "current": true], + ] + ], + ] + ) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected an invalid relay descriptor") + } catch { + XCTAssertEqual(error as? BuzzDevPushEnrollmentError, .invalidRelayDescriptor) + } + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testTracksRelayMetadataAuthoritySeparatelyFromPushDelegationKey() async throws { + let pushPubkey = String(repeating: "b", count: 64) + let oldMetadataPubkey = String(repeating: "c", count: 64) + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: pushPubkey, + relayMetadataPubkey: oldMetadataPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": [ + "keys": [ + ["pubkey": pushPubkey, "current": true] + ] + ], + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, pushPubkey) + XCTAssertEqual(record.relayMetadataPubkey, Self.relayPubkey) + XCTAssertNotEqual(record.relayMetadataPubkey, oldMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testMissingRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "push": [ + "keys": [["pubkey": Self.relayPubkey, "current": true]] + ] + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testMalformedRelayMetadataAuthorityDoesNotBlockExistingPushGrant() async throws { + let deviceToken = Data((1...32).map(UInt8.init)) + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + relayMetadataPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: deviceToken)), + appProfile: "buzz-ios-dogfood", + endpointEpoch: 1, + generation: 1, + expiresAt: Self.expiresAt + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + json: [ + "self": 42, + "push": [ + "keys": [["pubkey": Self.relayPubkey, "current": true]] + ], + ] + ) + } + + let record = try await driver.enroll(deviceToken: deviceToken, relayURL: Self.relayURL) + + XCTAssertEqual(record.relayPubkey, Self.relayPubkey) + XCTAssertNil(record.relayMetadataPubkey) + XCTAssertEqual(record.endpointGrant, existing.endpointGrant) + XCTAssertEqual(store.saved, [record]) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testFailsLoudlyOnUnexpectedGatewayStatus() async throws { + let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) + URLProtocolStub.handler = { request in + if request.httpMethod == "GET" { + return Self.response( + request, + status: 200, + json: [ + "self": Self.relayPubkey, + "push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]], + ] + ) + } + return Self.response(request, status: 400, json: ["error": "invalid_request"]) + } + + do { + _ = try await driver.enroll(deviceToken: Data([1]), relayURL: Self.relayURL) + XCTFail("Expected the gateway error") + } catch let error as BuzzDevPushEnrollmentError { + XCTAssertEqual( + error, + .unexpectedStatus( + route: "v1/installations/challenges", + expected: 200, + actual: 400, + body: "{\"error\":\"invalid_request\"}" + ) + ) + } + } + + private func makeDriver( + store: BuzzPushEndpointGrantStore, + appAttest: BuzzDevAppAttesting, + installationIdBytes: @escaping () throws -> Data = { + Data(0..<16) + } + ) throws -> BuzzDevPushEnrollmentDriver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: Self.gatewayURL, + store: store, + session: URLSession(configuration: configuration), + appAttest: appAttest, + now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, + lifetimeSeconds: Self.expiresAt - Self.now, + installationIdBytes: installationIdBytes + ) + } + + private func makeFixtureTranscript( + name: String, + replacements: [(String, String)] + ) throws -> (bytes: Data, sha256: String) { + let fixture = try Self.fixture() + let vector = try XCTUnwrap(fixture.vectors.first { $0.name == name }) + let transcript = replacements.reduce(vector.transcript) { + $0.replacingOccurrences(of: $1.0, with: $1.1) + } + return (Data(transcript.utf8), Self.hex(SHA256.hash(data: Data(transcript.utf8)))) + } + + private func assertMatchesVector( + _ name: String, + actual: Data, + expectedSHA256: String, + fixture: (bytes: Data, sha256: String), + file: StaticString = #filePath, + line: UInt = #line + ) throws { + XCTAssertEqual( + fixture.sha256, + expectedSHA256, + "\(name) substituted gateway vector SHA-256", + file: file, + line: line + ) + XCTAssertEqual( + actual, fixture.bytes, "\(name) exact transcript bytes", file: file, line: line) + XCTAssertEqual( + Self.hex(SHA256.hash(data: actual)), + fixture.sha256, + "\(name) transcript SHA-256", + file: file, + line: line + ) + } + + private struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let transcript: String + } + let vectors: [Vector] + } + + private static func fixture() throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)" + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + private static func body(_ request: URLRequest) throws -> [String: Any] { + let data: Data + if let httpBody = request.httpBody { + data = httpBody + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var bytes = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { + throw try XCTUnwrap(stream.streamError) + } + if count == 0 { break } + bytes.append(buffer, count: count) + } + data = bytes + } + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func response( + _ request: URLRequest, + status: Int, + json: [String: Any] + ) -> (HTTPURLResponse, Data) { + let data = try! JSONSerialization.data(withJSONObject: json, options: [.sortedKeys]) + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + private static func hex(_ data: D) -> String where D.Element == UInt8 { + data.map { String(format: "%02x", $0) }.joined() + } +} + +private final class MemoryGrantStore: BuzzPushEndpointGrantStore { + var saved: [BuzzPushEndpointGrantRecord] + var pending: [BuzzPushPendingEnrollmentRecord] = [] + var grantSaveFailuresRemaining: Int + init( + records: [BuzzPushEndpointGrantRecord] = [], + grantSaveFailuresRemaining: Int = 0 + ) { + saved = records + self.grantSaveFailuresRemaining = grantSaveFailuresRemaining + } + func records() throws -> [BuzzPushEndpointGrantRecord] { saved } + func save(_ record: BuzzPushEndpointGrantRecord) throws { + if grantSaveFailuresRemaining > 0 { + grantSaveFailuresRemaining -= 1 + throw NSError(domain: "MemoryGrantStore", code: 1) + } + saved.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + saved.append(record) + } + func pendingEnrollment( + relayOrigin: String, + appProfile: String + ) throws -> BuzzPushPendingEnrollmentRecord? { + pending.first { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + } + func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { + pending.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + pending.append(record) + } + func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + pending.removeAll { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + } +} + +private final class RecordingAppAttest: BuzzDevAppAttesting { + var clientData: [Data] = [] + + func prepareAttestation() async throws -> BuzzDevAttestation { + BuzzDevAttestation( + keyId: BuzzDevPushEnrollmentDriverTests.keyId, + attestation: BuzzDevPushEnrollmentDriverTests.attestation + ) + } + + func attestation( + _ prepared: BuzzDevAttestation, + clientData: Data + ) async throws -> BuzzDevAttestation { + self.clientData.append(clientData) + return prepared + } + + func assertion(clientData: Data) async throws -> String { + self.clientData.append(clientData) + return BuzzDevPushEnrollmentDriverTests.assertion + } +} + +private final class MemoryAppAttestKeyIdStore: BuzzAppAttestKeyIdStoring { + var keyIdValue: String? + var savedKeyIds: [String] = [] + + init(keyId: String? = nil) { + keyIdValue = keyId + } + + func keyId() throws -> String? { keyIdValue } + + func saveKeyId(_ keyId: String) throws { + savedKeyIds.append(keyId) + keyIdValue = keyId + } +} + +private final class RecordingDCAppAttestService: BuzzDCAppAttestServicing { + let isSupported: Bool + let generatedKeyId: String + let attestationObject: Data + let assertionObject: Data + let error: Error? + + var generateKeyCallCount = 0 + var attestedKeyIds: [String] = [] + var attestationClientDataHashes: [Data] = [] + var assertedKeyIds: [String] = [] + var assertionClientDataHashes: [Data] = [] + + init( + isSupported: Bool = true, + generatedKeyId: String = BuzzDevPushEnrollmentDriverTests.keyId, + attestationObject: Data = Data("attestation-object".utf8), + assertionObject: Data = Data("assertion-object".utf8), + error: Error? = nil + ) { + self.isSupported = isSupported + self.generatedKeyId = generatedKeyId + self.attestationObject = attestationObject + self.assertionObject = assertionObject + self.error = error + } + + func generateKey() async throws -> String { + generateKeyCallCount += 1 + if let error { throw error } + return generatedKeyId + } + + func attestKey(_ keyId: String, clientDataHash: Data) async throws -> Data { + attestedKeyIds.append(keyId) + attestationClientDataHashes.append(clientDataHash) + if let error { throw error } + return attestationObject + } + + func generateAssertion(_ keyId: String, clientDataHash: Data) async throws -> Data { + assertedKeyIds.append(keyId) + assertionClientDataHashes.append(clientDataHash) + if let error { throw error } + return assertionObject + } +} + +private final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = + try handler?(request) + ?? { + throw URLError(.unsupportedURL) + }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift new file mode 100644 index 00000000000..c79147dbea5 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushConversationResolverTests.swift @@ -0,0 +1,266 @@ +import Foundation +import XCTest + +@testable import BuzzPushKit + +extension BuzzPushNotificationResolverTests { + func testOpenChannelOutsiderUsesEveryVerifiedMemberAsARecipient() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Hello from an open-channel guest" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, relayPubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, "#General") + XCTAssertEqual(result.conversationRecipientCount, 2) + } + + func testMissingCurrentUserInVerifiedRosterUsesOrdinaryPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Do not fabricate a group" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 1, + memberDigests: Self.memberDigests([message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(result.subtitle, "Community") + } + + func testFreshEvictedMembershipDigestsTriggerOneBoundedRefresh() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Refresh an evicted roster" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: nil, + membershipEventID: "evicted-membership", + membershipEventCreatedAt: Self.now - 1, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + let refreshedMembership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [ + ["d", Self.channelID], + ["p", Self.ownPubkey], + ["p", message.pubkey], + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + XCTAssertEqual(request.timeoutInterval, 3) + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([refreshedMembership]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.conversationDisplayName, "#General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testTwoPersonDMUsesDirectCommunicationPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Direct message" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "DM", + channelType: "dm", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.conversationRecipientCount, 1) + } + + func testGroupDMWithoutVerifiedDisplayLabelUsesOrdinaryPresentation() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Group direct message" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let snapshot = BuzzPushPresentationCacheSnapshot( + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "DM", + channelType: "dm", + memberCount: 3, + memberDigests: Self.memberDigests([ + Self.ownPubkey, + message.pubkey, + relayPubkey, + ]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertNil(result.conversationDisplayName) + XCTAssertNil(result.conversationRecipientCount) + XCTAssertEqual(result.subtitle, "Community") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift new file mode 100644 index 00000000000..414e9446a2e --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -0,0 +1,77 @@ +import Foundation +import Testing + +@testable import BuzzPushKit + +@Test func `Round-trip opaque navigation target through notification user info`() { + let target = BuzzPushNavigationTarget( + eventID: "MESSAGE-ID", + communityID: "community-id", + channelID: "CHANNEL/GENERAL" + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target + ) + #expect(target.eventID == "MESSAGE-ID") + #expect(target.channelID == "CHANNEL/GENERAL") +} + +@Test func `Reject incomplete or malformed navigation target`() { + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "message-id", + "community_id": "community-id", + ] + ] + ) == nil + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "", + "community_id": "community-id", + "channel_id": "channel-id", + ] + ] + ) == nil + ) + + #expect( + BuzzPushNavigationTarget.decodeIfPresent( + from: [ + BuzzPushNavigationTarget.userInfoKey: [ + "event_id": "message-id", + "community_id": "community-id", + "channel_id": "", + ] + ] + ) == nil + ) +} + +@Test func `Buffer preserves cold-start target until consumed`() { + let first = BuzzPushNavigationTarget( + eventID: String(repeating: "a", count: 64), + communityID: "community-id", + channelID: "123e4567-e89b-42d3-a456-426614174000" + ) + let second = BuzzPushNavigationTarget( + eventID: String(repeating: "b", count: 64), + communityID: "community-id", + channelID: "123e4567-e89b-42d3-a456-426614174000" + ) + let buffer = BuzzPushNavigationBuffer() + + buffer.record(first) + buffer.remove(ifMatching: second) + #expect(buffer.peek() == first) + #expect(buffer.take() == first) + #expect(buffer.take() == nil) +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift new file mode 100644 index 00000000000..b25e23595f6 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -0,0 +1,852 @@ +import CryptoKit +import Foundation +import P256K +import XCTest + +@testable import BuzzPushKit + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class BuzzPushNotificationResolverTests: XCTestCase { + static let privateKey = String(repeating: "0", count: 63) + "1" + static let ownPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + static let now = Int(Date().timeIntervalSince1970) + static let profilePrivateKey = String(repeating: "0", count: 63) + "2" + static let relayPrivateKey = String(repeating: "0", count: 63) + "3" + static let gatewayBody = "Reconnect to your relay now" + static let channelID = "123e4567-e89b-42d3-a456-426614174000" + + override func setUp() { + super.setUp() + URLProtocolStub.reset() + } + + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func testResolveReturnsNilWhenCommunitiesDataIsMissing() { + let result = resolve(makeResolver(communitiesData: nil)) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilWhenCommunitiesDataIsUndecodable() { + let result = resolve(makeResolver(communitiesData: Data("not json".utf8))) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilOnKeychainMiss() throws { + let result = resolve( + makeResolver( + communitiesData: try snapshotData([community()]), + privateKeys: [:] + )) + + XCTAssertNil(result) + XCTAssertTrue(URLProtocolStub.requests.isEmpty) + } + + func testResolveReturnsNilForNon2xxRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 503, data: Data()) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testResolveReturnsNilForUndecodableRelayResponse() throws { + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: Data("not events".utf8)) + } + let result = resolve(makeResolver(communitiesData: try snapshotData([community()]))) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testDecodeResolutionFiltersOwnPubkeyEvent() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event(pubkey: Self.ownPubkey, content: "This should be filtered")], + community: community() + ) + + XCTAssertNil(result) + } + + func testDecodeResolutionReturnsNilWhenSanitizedPreviewIsEmpty() { + let event = event(content: " \n\t ") + + let result = BuzzPushNotificationResolver.decodeResolution( + events: [event], + community: community() + ) + + XCTAssertNil(result) + } + + func testPreviewBodySanitizesCodeLinksAndWhitespace() { + let content = """ + Before ```swift + print("secret") + ``` `inline` [docs](https://example.com/docs) + ![image](https://example.com/image.png) https://example.com/raw + After + """ + + XCTAssertEqual( + BuzzPushNotificationResolver.previewBody(content), + "Before [code] inline docs image [link] After" + ) + } + + func testPreviewBodyTruncatesTo178CharactersIncludingEllipsis() { + let preview = BuzzPushNotificationResolver.previewBody(String(repeating: "x", count: 200)) + + XCTAssertEqual(preview.count, 178) + XCTAssertEqual(preview, String(repeating: "x", count: 177) + "…") + } + + func testDecodeResolutionUsesLowestIDWhenCreatedAtTies() { + let result = BuzzPushNotificationResolver.decodeResolution( + events: [ + event(id: "a", content: "lower ID", createdAt: Self.now), + event(id: "b", content: "higher ID", createdAt: Self.now), + ], + community: community() + ) + + XCTAssertEqual(result?.1.id, "a") + XCTAssertEqual(result?.0.body, "lower ID") + } + + func testResolveSucceedsAndMutatesGatewayContent() throws { + let event = try JSONDecoder().decode( + VerifiedNostrEvent.self, + from: Data(Self.fixtureEvent.utf8) + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([event])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community()]) + ))) + + XCTAssertNotEqual(result.title, Self.gatewayBody) + XCTAssertNotEqual(result.body, Self.gatewayBody) + XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…") + XCTAssertEqual(result.body, "Hello Buzz") + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual( + result.threadIdentifier, + BuzzPushPresentationIdentity.conversation( + communityID: "community-id", + channelID: Self.channelID + ) + ) + XCTAssertEqual(result.senderPubkey, event.pubkey) + XCTAssertEqual( + result.navigationTarget, + BuzzPushNavigationTarget( + eventID: event.id, + communityID: "community-id", + channelID: Self.channelID + ) + ) + } + + func testFreshVerifiedCacheResolvesSenderAvatarAndChannelWithoutRefresh() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Hello from Alice" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let avatar = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Alice", + pictureHash: "picture-hash", + avatarPNG: avatar, + eventID: "profile-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "channel-event", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ] + ) + URLProtocolStub.handler = { request in + Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Alice") + XCTAssertEqual(result.senderAvatarPNG, avatar) + XCTAssertEqual(result.conversationDisplayName, "#General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func testStaleVerifiedCacheIsUsedWhileOneBoundedRefreshFails() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Stale cache still presents" + ) + let relayPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Stale Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: "profile-event", + eventCreatedAt: staleAt, + cachedAt: staleAt + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayPubkey, + displayName: "Stale General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "membership-event", + membershipEventCreatedAt: staleAt, + membershipCachedAt: staleAt, + eventID: "channel-event", + eventCreatedAt: staleAt, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + XCTAssertEqual(request.timeoutInterval, 3) + return Self.response(request, status: 503, data: Data()) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community(relayMetadataPubkey: relayPubkey)]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Stale Alice") + XCTAssertEqual(result.conversationDisplayName, "#Stale General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testOlderVerifiedRefreshCannotReplaceNewerStaleCache() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Keep newer cached metadata" + ) + let olderProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now - 20, + kind: 0, + content: #"{"display_name":"Older Alice"}"# + ) + let olderChannel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now - 20, + kind: 39_000, + tags: [["d", Self.channelID], ["name", "Older General"]] + ) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Newer Cached Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: String(repeating: "f", count: 64), + eventCreatedAt: Self.now - 10, + cachedAt: staleAt + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: olderChannel.pubkey, + displayName: "Newer Cached General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "cached-membership", + membershipEventCreatedAt: Self.now - 10, + membershipCachedAt: staleAt, + eventID: String(repeating: "f", count: 64), + eventCreatedAt: Self.now - 10, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([olderProfile, olderChannel]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: olderChannel.pubkey) + ]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Newer Cached Alice") + XCTAssertEqual(result.conversationDisplayName, "#Newer Cached General") + } + + func testChannelOnlyRefreshIgnoresUnrequestedProfileEvent() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Ignore unrelated enrichment" + ) + let unexpectedProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now + 1, + kind: 0, + content: #"{"display_name":"Unexpected Alice"}"# + ) + let relayMetadataPubkey = try Self.pubkey(for: Self.relayPrivateKey) + let staleAt = Self.now - Int(BuzzPushPresentationCacheStore.freshnessLifetime) - 1 + let snapshot = BuzzPushPresentationCacheSnapshot( + profiles: [ + BuzzPushCachedProfile( + communityID: "community-id", + relayOrigin: "https://relay.example", + pubkey: message.pubkey, + displayName: "Cached Alice", + pictureHash: nil, + avatarPNG: nil, + eventID: "cached-profile", + eventCreatedAt: Self.now, + cachedAt: Self.now + ) + ], + channels: [ + BuzzPushCachedChannel( + communityID: "community-id", + relayOrigin: "https://relay.example", + channelID: Self.channelID, + relayMetadataPubkey: relayMetadataPubkey, + displayName: "Stale General", + channelType: "stream", + memberCount: 2, + memberDigests: Self.memberDigests([Self.ownPubkey, message.pubkey]), + membershipEventID: "cached-membership", + membershipEventCreatedAt: Self.now, + membershipCachedAt: Self.now, + eventID: "cached-channel", + eventCreatedAt: Self.now, + cachedAt: staleAt + ) + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([unexpectedProfile]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: relayMetadataPubkey) + ]), + presentationCacheData: try JSONEncoder().encode(snapshot), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Cached Alice") + XCTAssertEqual(result.conversationDisplayName, "#Stale General") + } + + func testMissingCacheRefreshesVerifiedProfileAndChannelTogether() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Fresh metadata" + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Fresh Alice"}"# + ) + let channel = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_000, + tags: [["d", Self.channelID], ["name", "Fresh General"], ["t", "stream"]] + ) + let membership = try Self.signedEvent( + privateKey: Self.relayPrivateKey, + createdAt: Self.now, + kind: 39_002, + tags: [ + ["d", Self.channelID], + ["p", Self.ownPubkey], + ["p", message.pubkey], + ] + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([profile, channel, membership]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: channel.pubkey) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Fresh Alice") + XCTAssertEqual(result.conversationDisplayName, "#Fresh General") + XCTAssertEqual(result.conversationRecipientCount, 1) + XCTAssertNil(result.senderAvatarPNG) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testMalformedAndUnverifiedRefreshFallsBackWithoutBlockingMessage() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Fallback content" + ) + let validProfile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Tampered"}"# + ) + let tamperedProfile = VerifiedNostrEvent( + id: validProfile.id, + pubkey: validProfile.pubkey, + createdAt: validProfile.createdAt, + kind: validProfile.kind, + tags: validProfile.tags, + content: #"{"display_name":"Mallory"}"#, + sig: validProfile.sig + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + return Self.response( + request, + status: 200, + data: try JSONEncoder().encode([tamperedProfile]) + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey)) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.subtitle, "Community") + XCTAssertEqual(result.body, "Fallback content") + } + + func testOversizedPresentationRefreshFallsBackWithoutBlockingMessage() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Bounded fallback" + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: #"{"display_name":"Must Not Be Used"}"# + ) + URLProtocolStub.handler = { request in + if URLProtocolStub.requests.count == 1 { + return Self.response(request, status: 200, data: try JSONEncoder().encode([message])) + } + var oversized = Data( + repeating: 0x20, + count: BuzzPushNotificationResolver.maximumPresentationResponseBytes + ) + oversized.append(try JSONEncoder().encode([profile])) + return Self.response(request, status: 200, data: oversized) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([ + community(relayMetadataPubkey: try Self.pubkey(for: Self.relayPrivateKey)) + ]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, String(message.pubkey.prefix(8)) + "…") + XCTAssertNil(result.conversationDisplayName) + XCTAssertEqual(result.body, "Bounded fallback") + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testBoundedInlineAvatarProfileRefreshStillResolvesDisplayName() throws { + let message = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 9, + tags: [["h", Self.channelID]], + content: "Inline avatar profile" + ) + let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000) + let profileContent = try XCTUnwrap( + String( + data: JSONSerialization.data(withJSONObject: [ + "display_name": "Fizz", + "picture": picture, + ]), + encoding: .utf8 + ) + ) + let profile = try Self.signedEvent( + privateKey: Self.profilePrivateKey, + createdAt: Self.now, + kind: 0, + content: profileContent + ) + let presentationData = try JSONEncoder().encode([profile]) + XCTAssertLessThan( + presentationData.count, + BuzzPushNotificationResolver.maximumPresentationResponseBytes + ) + URLProtocolStub.handler = { request in + Self.response( + request, + status: 200, + data: URLProtocolStub.requests.count == 1 + ? try JSONEncoder().encode([message]) : presentationData + ) + } + + let result = try XCTUnwrap( + resolve( + makeResolver( + communitiesData: try snapshotData([community()]), + now: Date(timeIntervalSince1970: TimeInterval(Self.now)) + ) + ) + ) + + XCTAssertEqual(result.title, "Fizz") + XCTAssertNil(result.senderAvatarPNG) + XCTAssertEqual(result.body, "Inline avatar profile") + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func testResolveCanonicalizesWebSocketRelayOriginForQuery() throws { + URLProtocolStub.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "https://relay.example/query") + return Self.response(request, status: 200, data: Data("[]".utf8)) + } + + let result = resolve( + makeResolver( + communitiesData: try snapshotData([community(relayUrl: "wss://relay.example")]) + )) + + XCTAssertNil(result) + XCTAssertEqual(URLProtocolStub.requests.count, 1) + } + + func makeResolver( + communitiesData: Data?, + privateKeys: [String: String] = ["community-id": privateKey], + presentationCacheData: Data? = nil, + now: Date = Date() + ) -> BuzzPushNotificationResolver { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolStub.self] + return BuzzPushNotificationResolver( + session: URLSession(configuration: configuration), + loadCommunitiesData: { communitiesData }, + loadPrivateKey: { privateKeys[$0] }, + loadPresentationCacheData: { presentationCacheData }, + now: { now } + ) + } + + func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? { + let completed = expectation(description: "resolver completed") + var result: BuzzPushResolution? + resolver.resolve { + result = $0 + completed.fulfill() + } + wait(for: [completed], timeout: 2) + return result + } + + func community( + id: String = "community-id", + name: String = "Community", + relayUrl: String = "https://relay.example", + relayMetadataPubkey: String? = nil, + pubkey: String? = ownPubkey + ) -> PushLeaseCommunity { + PushLeaseCommunity( + id: id, + name: name, + relayUrl: relayUrl, + relayMetadataPubkey: relayMetadataPubkey, + pubkey: pubkey, + policies: [ + PushResolutionPolicy( + filter: PushLeaseFilter( + kinds: [9, 40002, 45001, 45003], + hTags: [Self.channelID] + ) + ) + ] + ) + } + + func snapshotData(_ communities: [PushLeaseCommunity]) throws -> Data { + try JSONEncoder().encode(PushLeaseSnapshot(communities: communities)) + } + + private func event( + id: String = "event-id", + pubkey: String = "author-pubkey", + content: String, + createdAt: Int = now, + kind: Int = 9, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: "signature" + ) + } + + private static let fixtureEvent = #""" + {"kind":9,"created_at":1785551670,"tags":[["h","123e4567-e89b-42d3-a456-426614174000"]],"content":" Hello [Buzz](https://buzz.block.xyz) ","pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","id":"233ccf24ec7c94808f9ef08b0c986b6df1bc3843ff72a9f8d016e2a77c77429b","sig":"d39dcd413839b872ed75a979b2c1542247fde636709966905c9e424e227a43897dc67b71ec84178a3faad0634f9bcdf0b48a56ebac84a2ac6e58124b8b6476e6"} + """# + + static func response( + _ request: URLRequest, + status: Int, + data: Data + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + return (response, data) + } + + static func pubkey(for privateKey: String) throws -> String { + let bytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + return VerifiedNostrEvent.hex(key.xonly.bytes) + } + + static func memberDigests(_ pubkeys: [String]) -> [String] { + pubkeys.map { + BuzzPushPresentationIdentity.channelMember( + communityID: "community-id", + channelID: channelID, + pubkey: $0 + ) + }.sorted() + } + + static func signedEvent( + privateKey: String, + createdAt: Int, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try XCTUnwrap(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } + + final class URLProtocolStub: URLProtocol, @unchecked Sendable { + static let lock = NSLock() + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + static var requests: [URLRequest] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.lock.lock() + Self.requests.append(request) + let handler = Self.handler + Self.lock.unlock() + do { + let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }() + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + static func reset() { + lock.lock() + handler = nil + requests = [] + lock.unlock() + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift new file mode 100644 index 00000000000..ed592c576f6 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushPresentationCacheTests.swift @@ -0,0 +1,748 @@ +import CryptoKit +import Foundation +import P256K +import Testing + +@testable import BuzzPushKit + +@Suite("Push presentation cache") +struct BuzzPushPresentationCacheTests { + private let profileKey = String(repeating: "0", count: 63) + "1" + private let relayKey = String(repeating: "0", count: 63) + "2" + private let otherRelayKey = String(repeating: "0", count: 63) + "3" + + @Test("Verified profile uses display_name, then name, and attaches a bounded local avatar") + func verifiedProfilePrecedenceAndAvatar() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore( + containerURL: directory, + now: { Date(timeIntervalSince1970: 1_700_000_100) } + ) + let event = try signedEvent( + privateKey: profileKey, + createdAt: 1_700_000_000, + kind: 0, + content: + #"{"display_name":" Alice Example ","name":"alice","picture":"https://images.example/alice.png"}"# + ) + + let needsAvatar = try store.updateProfiles( + communityID: "community-a", + relayOrigin: "wss://relay.example/", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + try store.updateProfiles( + communityID: "community-b", + relayOrigin: "wss://relay.example/", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + + #expect(needsAvatar == Set([event.id])) + var snapshot = try loadSnapshot(directory) + var cached = try #require( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.displayName == "Alice Example") + #expect(cached.avatarPNG == nil) + #expect( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://other.example", + pubkey: event.pubkey + ) == nil + ) + + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01]) + #expect( + try store.updateAvatar( + communityID: "community-a", + relayOrigin: "https://relay.example", + sourceURL: "https://images.example/alice.png", + avatarPNG: png + ) + ) + snapshot = try loadSnapshot(directory) + cached = try #require( + snapshot.profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.avatarPNG == png) + #expect( + snapshot.profile( + communityID: "community-b", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.avatarPNG == nil + ) + } + + @Test("Verified inline raster profile retains its name and accepts a local thumbnail") + func verifiedInlineRasterProfileAndAvatar() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let picture = "data:image/png;base64," + String(repeating: "A", count: 170_000) + let content = try #require( + String( + data: JSONSerialization.data(withJSONObject: [ + "display_name": "Fizz", + "picture": picture, + ]), + encoding: .utf8 + ) + ) + let event = try signedEvent(privateKey: profileKey, kind: 0, content: content) + + let needsAvatar = try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + #expect(needsAvatar == Set([event.id])) + #expect( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.displayName == "Fizz" + ) + + let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + #expect( + try store.updateAvatar( + communityID: "community-a", + relayOrigin: "https://relay.example", + sourceURL: picture, + avatarPNG: png + ) + ) + #expect( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + )?.avatarPNG == png + ) + } + + @Test("Verified profile falls back from blank display_name to name") + func verifiedProfileNameFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let event = try signedEvent( + privateKey: profileKey, + kind: 0, + content: #"{"display_name":" ","name":"Alice"}"# + ) + + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: event)] + ) + + let cached = try #require( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: event.pubkey + ) + ) + #expect(cached.displayName == "Alice") + } + + @Test("Malformed verified profile clears presentation while an unverified event is ignored") + func malformedAndUnverifiedProfileFallback() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let named = try signedEvent( + privateKey: profileKey, + createdAt: 100, + kind: 0, + content: #"{"name":"Alice"}"# + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: named)] + ) + let malformed = try signedEvent( + privateKey: profileKey, + createdAt: 101, + kind: 0, + content: "not-json" + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: malformed)] + ) + let tampered = VerifiedNostrEvent( + id: malformed.id, + pubkey: malformed.pubkey, + createdAt: 102, + kind: 0, + tags: [], + content: #"{"display_name":"Mallory"}"#, + sig: malformed.sig + ) + try store.updateProfiles( + communityID: "community-a", + relayOrigin: "https://relay.example", + updates: [BuzzPushProfileCacheUpdate(event: tampered)] + ) + + let cached = try #require( + try loadSnapshot(directory).profile( + communityID: "community-a", + relayOrigin: "https://relay.example", + pubkey: malformed.pubkey + ) + ) + #expect(cached.eventID == malformed.id) + #expect(cached.displayName == nil) + } + + @Test("Channel name requires the expected relay signer and accepts opaque IDs") + func channelAuthorityAndOpaqueID() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let opaqueChannelID = "channel/general:v5" + let verified = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", opaqueChannelID], ["name", " General Chat "], ["t", "stream"]] + ) + let wrongSigner = try signedEvent( + privateKey: otherRelayKey, + createdAt: 101, + kind: 39_000, + tags: [["d", opaqueChannelID], ["name", "Impostor"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "wss://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [wrongSigner, verified], + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: opaqueChannelID + ) + ) + #expect(cached.eventID == verified.id) + #expect(cached.displayName == "General Chat") + #expect(cached.channelType == "stream") + #expect(cached.relayMetadataPubkey == relayPubkey) + } + + @Test("Channel membership requires the same relay authority and keeps exact scoped digests") + func channelMembershipAuthorityAndOrdering() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let firstMember = try pubkey(for: profileKey) + let secondMember = try pubkey(for: otherRelayKey) + let metadata = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + let newestMembership = try signedEvent( + privateKey: relayKey, + createdAt: 102, + kind: 39_002, + tags: [ + ["d", "opaque-channel"], + ["p", firstMember], + ["p", secondMember], + ["p", secondMember], + ] + ) + let olderMembership = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", firstMember]] + ) + let wrongSigner = try signedEvent( + privateKey: otherRelayKey, + createdAt: 103, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", firstMember]] + ) + let malformed = try signedEvent( + privateKey: relayKey, + createdAt: 104, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", "not-a-pubkey"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata], + membershipEvents: [wrongSigner, malformed, newestMembership, olderMembership] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.memberCount == 2) + #expect(cached.membershipEventID == newestMembership.id) + #expect( + cached.memberDigests + == [firstMember, secondMember].map { + BuzzPushPresentationIdentity.channelMember( + communityID: "community-a", + channelID: "opaque-channel", + pubkey: $0 + ) + }.sorted() + ) + } + + @Test("Channel authority rotation clears membership signed by the old authority") + func channelAuthorityRotationClearsMembership() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let rotatedRelayPubkey = try pubkey(for: otherRelayKey) + let member = try pubkey(for: profileKey) + let metadata = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + let membership = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_002, + tags: [["d", "opaque-channel"], ["p", member]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [metadata], + membershipEvents: [membership] + ) + + let rotatedMetadata = try signedEvent( + privateKey: otherRelayKey, + createdAt: 50, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "General"], ["t", "stream"]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: rotatedRelayPubkey, + metadataEvents: [rotatedMetadata], + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.relayMetadataPubkey == rotatedRelayPubkey) + #expect(cached.eventID == rotatedMetadata.id) + #expect(cached.memberCount == nil) + #expect(cached.memberDigests == nil) + #expect(cached.membershipEventID == nil) + } + + @Test("Oversized channel batches are ignored before cache mutation") + func oversizedChannelBatchIsIgnored() throws { + let directory = try temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = BuzzPushPresentationCacheStore(containerURL: directory) + let relayPubkey = try pubkey(for: relayKey) + let initial = try signedEvent( + privateKey: relayKey, + createdAt: 100, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "Initial"], ["t", "stream"]] + ) + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: [initial], + membershipEvents: [] + ) + let replacement = try signedEvent( + privateKey: relayKey, + createdAt: 101, + kind: 39_000, + tags: [["d", "opaque-channel"], ["name", "Replacement"], ["t", "stream"]] + ) + + try store.updateChannels( + communityID: "community-a", + relayOrigin: "https://relay.example", + relayMetadataPubkey: relayPubkey, + metadataEvents: Array( + repeating: replacement, + count: BuzzPushPresentationCacheStore.maximumChannels + 1 + ), + membershipEvents: [] + ) + + let cached = try #require( + try loadSnapshot(directory).channel( + communityID: "community-a", + relayOrigin: "https://relay.example", + channelID: "opaque-channel" + ) + ) + #expect(cached.eventID == initial.id) + #expect(cached.displayName == "Initial") + } + + @Test("Global member-digest bound drops oldest complete rosters first") + func globalMembershipDigestBound() throws { + let membersPerChannel = BuzzPushPresentationCacheStore.maximumMembersPerChannel + let channelCount = + BuzzPushPresentationCacheStore.maximumTotalMemberDigests / membersPerChannel + 1 + let digests = (0.. URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("buzz-push-cache-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func loadSnapshot(_ directory: URL) throws -> BuzzPushPresentationCacheSnapshot { + let data = try Data( + contentsOf: directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName) + ) + return try JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) + } + + private func pubkey(for privateKey: String) throws -> String { + let bytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: bytes) + return VerifiedNostrEvent.hex(key.xonly.bytes) + } + + private func signedEvent( + privateKey: String, + createdAt: Int = 1_700_000_000, + kind: Int, + tags: [[String]] = [], + content: String = "" + ) throws -> VerifiedNostrEvent { + let privateKeyBytes = try #require(VerifiedNostrEvent.hexBytes(privateKey)) + let key = try P256K.Schnorr.PrivateKey(dataRepresentation: privateKeyBytes) + let pubkey = VerifiedNostrEvent.hex(key.xonly.bytes) + let serialization = try VerifiedNostrEvent.canonicalSerialization( + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content + ) + let digest = Array(SHA256.hash(data: serialization)) + var message = digest + var randomness = [UInt8](repeating: UInt8(truncatingIfNeeded: createdAt), count: 32) + let signature = try key.signature(message: &message, auxiliaryRand: &randomness) + return VerifiedNostrEvent( + id: VerifiedNostrEvent.hex(digest), + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: VerifiedNostrEvent.hex(signature.dataRepresentation) + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift new file mode 100644 index 00000000000..3cb242bc4cf --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -0,0 +1,159 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +/// Replays the gateway-generated known-answer vectors +/// (`crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`) +/// against the Swift canonical encoder. Byte-for-byte transcript equality and +/// SHA-256 equality are both asserted, so a drift on either side breaks a test +/// instead of silently stranding iOS clients with `401 invalid_attestation`. +final class BuzzPushTranscriptTests: XCTestCase { + // MARK: Fixture + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let domain: String + let transcript: String + let sha256: String + } + + let vectors: [Vector] + } + + static func fixture( + file: StaticString = #filePath, + line: UInt = #line + ) throws -> Fixture { + let path = try XCTUnwrap( + Bundle.module.url( + forResource: "app_attest_transcripts", + withExtension: "json" + ), + "missing bundled gateway transcript fixture app_attest_transcripts.json in \(Bundle.module.bundleURL.path)", + file: file, + line: line + ) + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(Fixture.self, from: data) + } + + // Deterministic inputs mirroring the fixture's `inputs` block. + static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! + static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + static let appProfile = "buzz-ios-dogfood" + static let endpoint = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + static let relayPubkey = String(repeating: "a", count: 64) + static let notBefore: Int64 = 1_752_620_000 + static let expiresAt: Int64 = 1_752_624_000 + + private func assertMatchesVector(_ name: String, _ bytes: Data, + file: StaticString = #filePath, line: UInt = #line) throws { + guard let vector = try Self.fixture(file: file, line: line).vectors.first(where: { $0.name == name }) else { + XCTFail("missing fixture vector \(name)", file: file, line: line) + return + } + XCTAssertEqual(String(decoding: bytes, as: UTF8.self), vector.transcript, + "\(name) transcript bytes drifted from gateway ground truth", + file: file, line: line) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(digest, vector.sha256, + "\(name) sha256 drifted from gateway ground truth", + file: file, line: line) + } + + // MARK: Known-answer vectors + + func testEnrollVector() throws { + try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + challengeId: Self.challengeId, + challenge: Self.challenge, + keyId: Self.keyId, + appProfile: Self.appProfile, + endpoint: Self.endpoint, + endpointEpoch: 1, + expiresAt: Self.expiresAt + )) + } + + func testDelegateVector() throws { + try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) + } + + func testRotateEndpointVector() throws { + try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2, + endpoint: Self.endpoint + )) + } + + func testRevokeDelegationVector() throws { + try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + relayPubkey: Self.relayPubkey, + generation: 2 + )) + } + + func testRevokeInstallationVector() throws { + try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2 + )) + } + + func testAllFixtureVectorsCovered() throws { + XCTAssertEqual( + Set(try Self.fixture().vectors.map(\.name)), + ["enroll", "delegate", "rotate_endpoint", "revoke_delegation", "revoke_installation"], + "fixture gained or lost a vector; add/remove the matching known-answer test" + ) + } + + // MARK: Escaping edges (the exact JSONSerialization failure modes) + + func testSolidusIsNotEscaped() throws { + // The whole reason this encoder exists: '/' must pass through raw. + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("https://push.buzz.xyz/v1", field: "audience"), + "https://push.buzz.xyz/v1") + } + + func testMinimalEscaping() throws { + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("a\"b\\c\u{08}\u{09}\u{0A}\u{0C}\u{0D}\u{01}", field: "x"), + "a\\\"b\\\\c\\b\\t\\n\\f\\r\\u0001") + } + + func testNonASCIIRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.CanonicalObject.escape("caf\u{00E9}", field: "app_profile")) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .nonASCIIInput(field: "app_profile")) + } + } + + func testUUIDLowercased() throws { + var o = BuzzPushTranscript.CanonicalObject() + o.uuid("k", UUID(uuidString: "ABCDEF12-3456-4789-8ABC-DEF123456789")!) + XCTAssertEqual(o.encoded(), "{\"k\":\"abcdef12-3456-4789-8abc-def123456789\"}") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures new file mode 120000 index 00000000000..cbfddb7a246 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/Fixtures @@ -0,0 +1 @@ +../../../../../crates/buzz-push-gateway/tests/vectors \ No newline at end of file diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift new file mode 100644 index 00000000000..9cffaa5cd28 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift @@ -0,0 +1,76 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +final class NostrHTTPAuthTests: XCTestCase { + private let privateKey = String(repeating: "0", count: 63) + "1" + private let expectedPubkey = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + + func testAuthorizationHeaderConstructsValidNIP98Event() throws { + let body = Data("[{\"kinds\":[9]}]".utf8) + let url = URL(string: "https://relay.example/query")! + let header = try NostrHTTPAuth.authorizationHeader( + url: url, + method: "post", + body: body, + privateKeyHex: privateKey, + createdAt: 1_700_000_000, + auxiliaryRandomness: [UInt8](repeating: 0, count: 32) + ) + + XCTAssertTrue(header.hasPrefix("Nostr ")) + let encoded = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6)))) + let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: encoded) + XCTAssertEqual(event.pubkey, expectedPubkey) + XCTAssertEqual(event.createdAt, 1_700_000_000) + XCTAssertEqual(event.kind, 27235) + XCTAssertEqual(event.content, "") + XCTAssertEqual(event.tags, [ + ["u", "https://relay.example/query"], + ["method", "POST"], + ["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()], + ]) + XCTAssertTrue(event.hasValidIDAndSignature()) + } + + func testEventVerificationRejectsChangedIDSignatureAndContent() throws { + let event = try makeEvent() + XCTAssertTrue(event.hasValidIDAndSignature()) + XCTAssertFalse(copy(event, id: String(repeating: "0", count: 64)).hasValidIDAndSignature()) + XCTAssertFalse(copy(event, sig: String(repeating: "0", count: 128)).hasValidIDAndSignature()) + XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature()) + } + + private func makeEvent() throws -> VerifiedNostrEvent { + let header = try NostrHTTPAuth.authorizationHeader( + url: URL(string: "https://relay.example/query")!, + method: "POST", + body: Data(), + privateKeyHex: privateKey, + createdAt: 1_700_000_000, + auxiliaryRandomness: [UInt8](repeating: 0, count: 32) + ) + let data = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6)))) + return try JSONDecoder().decode(VerifiedNostrEvent.self, from: data) + } + + private func copy( + _ event: VerifiedNostrEvent, + id: String? = nil, + content: String? = nil, + sig: String? = nil + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: id ?? event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: content ?? event.content, + sig: sig ?? event.sig + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift new file mode 100644 index 00000000000..096737d5359 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -0,0 +1,84 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushLeaseTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { + let filter = PushLeaseFilter( + kinds: [7, 1059], + authors: [other], + pTags: [mine], + hTags: ["channel"], + eTags: [String(repeating: "c", count: 64)] + ) + let query = filter.queryFilter(since: 1_000, limit: 10) + + XCTAssertEqual(query["kinds"] as? [Int], [7, 1059]) + XCTAssertEqual(query["authors"] as? [String], [other]) + XCTAssertEqual(query["#p"] as? [String], [mine]) + XCTAssertEqual(query["#h"] as? [String], ["channel"]) + XCTAssertEqual(query["since"] as? Int, 1_000) + } + + func testPushEligibleKindAbsentFromOldConstantMatchesLease() { + let event = makeEvent(kind: 1059, tags: [["p", mine]]) + let policy = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]) + ) + + XCTAssertTrue(PushLeaseMatcher.matches(event: event, policy: policy)) + } + + func testIgnoreAndHellthreadSuppressionRejectCandidates() { + let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) + let ignorePolicy = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + ignore: [PushLeaseFilter(kinds: [9], authors: [other])] + ) + XCTAssertFalse( + PushLeaseMatcher.matches(event: ignored, policy: ignorePolicy) + ) + + let hellthread = makeEvent( + kind: 9, + tags: (0..<21).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushResolutionPolicy( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, policy: suppressed)) + } + + func testDecodesSnapshotContractFromDartShape() throws { + let json = """ + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","policies":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}]} + """ + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.communities.count, 1) + XCTAssertEqual( + snapshot.communities[0].policies.count, + 1 + ) + } + + private func makeEvent( + kind: Int, + pubkey: String? = nil, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: String(repeating: "d", count: 64), + pubkey: pubkey ?? other, + createdAt: 1_000, + kind: kind, + tags: tags, + content: "message", + sig: String(repeating: "e", count: 128) + ) + } +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 1f35905051e..8660e4e3ed0 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -1,12 +1,21 @@ #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DEBUG // Default app bundle identifier. Internal/custom builds can override this // without patching tracked files by writing // `mobile/ios/Flutter/AppOverrides.xcconfig` containing // `BUNDLE_IDENTIFIER = your.app.id` (gitignored). -BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUNDLE_IDENTIFIER = xyz.block.buzz.dogfood.mobile APP_DISPLAY_NAME = Buzz +BUZZ_DEVELOPMENT_TEAM = JMTDPW9CG3 + +// Push support is always present in the iOS artifact. The current relay's +// fully validated NIP-11 descriptor is the runtime activation authority. +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development // Worktree-aware debug identity (gitignored, written by // scripts/mobile-worktree-overrides.sh): a per-worktree bundle identifier @@ -15,7 +24,6 @@ APP_DISPLAY_NAME = Buzz #include? "WorktreeOverrides.xcconfig" // Developer app-specific overrides are included last: xcconfig -// later-include-wins is per variable, so a personal BUNDLE_IDENTIFIER for -// device signing beats the worktree default while unset variables still -// fall through to the worktree values. +// later-include-wins is per variable, so a personal signing identity beats the +// worktree default while unset variables still fall through to tracked values. #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index d287c5fb432..cdacb9e89bc 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -4,8 +4,16 @@ // Defaults for the iOS Release build. Internal/custom builds (e.g. an // enterprise-signed distribution) override these without patching tracked // files by writing `mobile/ios/Flutter/AppOverrides.xcconfig` (gitignored). -BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUNDLE_IDENTIFIER = xyz.block.buzz.mobile APP_DISPLAY_NAME = Buzz CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer +BUZZ_DEVELOPMENT_TEAM = EYF346PHUG + +// Push support is always present in the iOS artifact. Relay advertisement is +// the rollout authority, so a relay without a valid descriptor remains inert. +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/NotificationService/Info.plist b/mobile/ios/NotificationService/Info.plist new file mode 100644 index 00000000000..e66f3a9505d --- /dev/null +++ b/mobile/ios/NotificationService/Info.plist @@ -0,0 +1,35 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/mobile/ios/NotificationService/NotificationService.entitlements b/mobile/ios/NotificationService/NotificationService.entitlements new file mode 100644 index 00000000000..2187d2c03bd --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift new file mode 100644 index 00000000000..44965720a9f --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -0,0 +1,139 @@ +import BuzzPushKit +import Foundation +import Security +import UserNotifications + +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private let communicationPresenter = BuzzCommunicationNotificationPresenter() + private lazy var resolver: BuzzPushNotificationResolving = { + let appGroupIdentifier = + Bundle.main.object( + forInfoDictionaryKey: "BuzzAppGroupIdentifier" + ) as? String + let keychainAccessGroup = + Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + return BuzzPushNotificationResolver( + session: .shared, + loadCommunitiesData: { + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) + }, + loadPrivateKey: { communityID in + Self.loadPrivateKey( + communityID: communityID, + keychainAccessGroup: keychainAccessGroup + ) + }, + loadPresentationCacheData: { + Self.loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier) + } + ) + }() + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + bestAttemptContent = content + var cleanUserInfo = content.userInfo + cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey) + content.userInfo = cleanUserInfo + + resolver.resolve { [weak self] resolution in + guard let self else { return } + if let resolution { + content.title = resolution.title + content.body = resolution.body + if let subtitle = resolution.subtitle { + content.subtitle = subtitle + } + if let threadIdentifier = resolution.threadIdentifier { + content.threadIdentifier = threadIdentifier + } + if let navigationTarget = resolution.navigationTarget { + var userInfo = content.userInfo + userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue + content.userInfo = userInfo + } + self.bestAttemptContent = content + self.communicationPresenter.present( + ordinaryContent: content, + resolution: resolution + ) { [weak self] specializedContent in + self?.finish(specializedContent) + } + return + } + self.finish(content) + } + } + + override func serviceExtensionTimeWillExpire() { + if let bestAttemptContent { + finish(bestAttemptContent) + } + } + + private func finish(_ content: UNNotificationContent) { + guard let contentHandler else { return } + self.contentHandler = nil + contentHandler(content) + } + + private static func loadPrivateKey( + communityID: String, + keychainAccessGroup: String? + ) -> String? { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "buzz.push.nse.signing", + kSecAttrAccount as String: communityID, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let keychainAccessGroup, !keychainAccessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = keychainAccessGroup + } + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return String(data: data, encoding: .utf8) + } + + private static func loadPushSnapshotData(appGroupIdentifier: String?) -> Data? { + loadAppGroupData( + fileName: BuzzPushPresentationCacheStore.fileName, + appGroupIdentifier: appGroupIdentifier, + maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes + ) + } + + private static func loadAppGroupData( + fileName: String, + appGroupIdentifier: String?, + maximumBytes: Int? = nil + ) -> Data? { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + let fileURL = container.appendingPathComponent(fileName) + if let maximumBytes { + guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]), + let fileSize = values.fileSize, + fileSize <= maximumBytes + else { return nil } + } + return try? Data(contentsOf: fileURL) + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 300e15cb4ab..739d8730751 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; }; + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; }; @@ -31,6 +35,10 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; }; + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000027 /* PushEndpointGrantStore.swift */; }; + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002A /* PushSnapshotBridge.swift */; }; + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -48,6 +56,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + BZZ00000000000000000004 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -61,6 +80,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; + BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -90,6 +114,10 @@ 57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEndpointGrantStore.swift; sourceTree = ""; }; + BZZ0000000000000000002A /* PushSnapshotBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushSnapshotBridge.swift; sourceTree = ""; }; + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuzzCommunicationNotificationTests.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; @@ -106,6 +134,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + BZZ0000000000000000000C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3C28C6B702C81085E6F96F2A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -118,6 +154,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */, 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -129,6 +166,7 @@ isa = PBXGroup; children = ( 331C807B294A618700263BE5 /* RunnerTests.swift */, + BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */, 331C80A0294A618700263BE5 /* Fixtures */, ); path = RunnerTests; @@ -171,6 +209,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + BZZ0000000000000000000B /* NotificationService */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -185,6 +224,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + BZZ00000000000000000009 /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -196,9 +236,13 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + BZZ0000000000000000000A /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + BZZ00000000000000000024 /* PushNativeState.swift */, + BZZ00000000000000000027 /* PushEndpointGrantStore.swift */, + BZZ0000000000000000002A /* PushSnapshotBridge.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, @@ -230,6 +274,16 @@ name = Frameworks; sourceTree = ""; }; + BZZ0000000000000000000B /* NotificationService */ = { + isa = PBXGroup; + children = ( + BZZ00000000000000000006 /* NotificationService.swift */, + BZZ00000000000000000007 /* Info.plist */, + BZZ00000000000000000008 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -262,6 +316,7 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + BZZ00000000000000000004 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, ED5DDC1D42A9D342928222CC /* [CP] Copy Pods Resources */, @@ -271,10 +326,33 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + BZZ0000000000000000000E /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + BZZ0000000000000000000D /* Sources */, + BZZ0000000000000000000C /* Frameworks */, + BZZ00000000000000000010 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); + productName = NotificationService; + productReference = BZZ00000000000000000009 /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -285,6 +363,9 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + BZZ0000000000000000000E = { + CreatedOnToolsVersion = 15.0; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -304,17 +385,28 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + BZZ0000000000000000000E /* NotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + BZZ00000000000000000010 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -450,11 +542,20 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + BZZ0000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000001 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -463,6 +564,9 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + BZZ00000000000000000023 /* PushNativeState.swift in Sources */, + BZZ00000000000000000026 /* PushEndpointGrantStore.swift in Sources */, + BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, @@ -494,6 +598,21 @@ }; /* End PBXTargetDependency section */ +/* Begin XCLocalSwiftPackageReference section */ + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = BuzzPushKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + BZZ00000000000000000022 /* BuzzPushKit */ = { + isa = XCSwiftPackageProductDependency; + package = BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */; + productName = BuzzPushKit; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -573,8 +692,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -761,8 +881,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -784,8 +905,9 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -799,9 +921,91 @@ }; name = Release; }; + BZZ00000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + BZZ00000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + BZZ00000000000000000015 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = "$(BUZZ_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BZZ00000000000000000011 /* Debug */, + BZZ00000000000000000012 /* Release */, + BZZ00000000000000000015 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..320fe8c569c --- /dev/null +++ b/mobile/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "08a49d61c7de953c8fb77e34cc8578189c85a707e518c346697669ad28235ec0", + "pins" : [ + { + "identity" : "swift-secp256k1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/21-DOT-DEV/swift-secp256k1.git", + "state" : { + "revision" : "8c62aba8a3011c9bcea232e5ee007fb0b34a15e2", + "version" : "0.21.1" + } + } + ], + "version" : 3 +} diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 3f772632693..a770451b619 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,11 +1,32 @@ import AVFoundation +import BuzzPushKit import Flutter import UIKit import UserNotifications +import os.log @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + private var pushChannel: FlutterMethodChannel? + private let apnsRegistrationBuffer = APNsRegistrationBuffer() + private let pushNavigationBuffer = BuzzPushNavigationBuffer() + private var apnsDeviceToken: Data? + private lazy var endpointGrantStore = BuzzPushEndpointGrantKeychainStore( + accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + ) + private var enrollmentTask: Task? + private var appGroupIdentifier: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String + } + private var pushKeychainAccessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + } + private lazy var pushSnapshotBridge = BuzzPushSnapshotBridge( + appGroupIdentifier: appGroupIdentifier, + endpointGrantStore: endpointGrantStore, + keychainAccessGroup: pushKeychainAccessGroup + ) private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? @@ -19,7 +40,7 @@ import UserNotifications _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + UNUserNotificationCenter.current().delegate = self return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -34,6 +55,16 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + pushChannel = FlutterMethodChannel( + name: "buzz/push", + binaryMessenger: messenger + ) + pushChannel?.setMethodCallHandler { [weak self] call, result in + self?.handlePushMethodCall(call, result: result) + } + apnsRegistrationBuffer.attach { [weak self] update in + self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments) + } qrScannerChannel = FlutterMethodChannel( name: "buzz/qr_scanner", binaryMessenger: messenger @@ -173,7 +204,8 @@ import UserNotifications if #available(iOS 16.0, *), let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeMessageActionSurface" - ) { + ) + { nativeMessageActionsRegistrar.register( NativeMessageActionSurfaceFactory(messenger: messenger), withId: "buzz/native_message_action_surface" @@ -242,6 +274,248 @@ import UserNotifications .safeAreaInsets.top ?? 0 } + override func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + apnsDeviceToken = deviceToken + apnsRegistrationBuffer.recordToken(deviceToken) + } + + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + apnsRegistrationBuffer.recordError(error.localizedDescription) + } + + override func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: response.actionIdentifier, + userInfo: response.notification.request.content.userInfo, + onTarget: { target in + pushNavigationBuffer.record(target) + deliverPushNavigationTarget(target) + }, + forwardToFlutter: { pluginCompletion in + self.forwardPushNotificationResponseToFlutter( + center, + response: response, + completion: pluginCompletion + ) + }, + completion: completionHandler + ) + } + + private func forwardPushNotificationResponseToFlutter( + _ center: UNUserNotificationCenter, + response: UNNotificationResponse, + completion: @escaping () -> Void + ) { + super.userNotificationCenter( + center, + didReceive: response, + withCompletionHandler: completion + ) + } + + private func deliverPushNavigationTarget(_ target: BuzzPushNavigationTarget) { + pushChannel?.invokeMethod( + "notificationOpened", + arguments: target.flutterArguments + ) { [weak self] result in + guard result as? String == "handled" else { return } + self?.pushNavigationBuffer.remove(ifMatching: target) + } + } + + private func handlePushMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + if pushSnapshotBridge.handle(call, result: result) { + return + } + switch call.method { + case "startRegistration": + startPushRegistration(result: result) + case "takePendingNotificationResponse": + result(pushNavigationBuffer.take()?.flutterArguments) + case "notificationAuthorizationStatus": + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + result(Self.pushAuthorizationStatusName(settings.authorizationStatus)) + } + } + case "openNotificationSettings": + openNotificationSettings(result: result) + case "endpointGrants": + do { + result(try endpointGrantStore.records().map(\.flutterArguments)) + } catch { + result( + FlutterError( + code: "endpoint_grant_read_failed", + message: "Unable to read persisted push endpoint grants.", + details: error.localizedDescription + ) + ) + } + case "enrollPush": + handleDevPushEnrollment(call, result: result) + default: + result(FlutterMethodNotImplemented) + } + } + + static func pushAuthorizationStatusName(_ status: UNAuthorizationStatus) -> String { + switch status { + case .notDetermined: + return "notDetermined" + case .denied: + return "denied" + case .authorized: + return "authorized" + case .provisional: + return "provisional" + case .ephemeral: + return "ephemeral" + @unknown default: + return "unknown" + } + } + + private func openNotificationSettings(result: @escaping FlutterResult) { + let settingsURLString: String + if #available(iOS 16.0, *) { + settingsURLString = UIApplication.openNotificationSettingsURLString + } else { + settingsURLString = UIApplication.openSettingsURLString + } + guard let url = URL(string: settingsURLString) else { + result(false) + return + } + UIApplication.shared.open(url, options: [:]) { opened in + DispatchQueue.main.async { + result(opened) + } + } + } + + private func startPushRegistration(result: @escaping FlutterResult) { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + _, error in + if let error { + os_log( + "Buzz notification authorization request failed: %{public}@", + type: .error, + error.localizedDescription + ) + } + } + // APNs token registration is independent from display authorization. A + // denied or failed prompt must not prevent gateway enrollment and leases. + UIApplication.shared.registerForRemoteNotifications() + result(nil) + } + + private func handleDevPushEnrollment( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard enrollmentTask == nil else { + result( + FlutterError( + code: "enrollment_in_progress", + message: "Development push enrollment is already running.", + details: nil + ) + ) + return + } + guard let deviceToken = apnsDeviceToken else { + result( + FlutterError( + code: "missing_apns_token", + message: "APNs has not supplied a device token.", + details: nil + ) + ) + return + } + guard !deviceToken.isEmpty else { + result( + FlutterError( + code: "invalid_apns_token", + message: "APNs supplied an empty device token.", + details: nil + ) + ) + return + } + guard let arguments = call.arguments as? [String: Any], + let relayText = arguments["relayUrl"] as? String, + let relayURL = URL(string: relayText), + let gatewayText = arguments["gatewayUrl"] as? String, + let gatewayURL = URL(string: gatewayText) + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Development push enrollment requires relayUrl and gatewayUrl.", + details: nil + ) + ) + return + } + + do { + let driver = try BuzzDevPushEnrollmentDriver( + gatewayBaseURL: gatewayURL, + store: endpointGrantStore, + appAttestKeychainAccessGroup: Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String + ) + enrollmentTask = Task { [weak self] in + defer { self?.enrollmentTask = nil } + do { + let record = try await driver.enroll( + deviceToken: deviceToken, + relayURL: relayURL + ) + await MainActor.run { result(record.flutterArguments) } + } catch { + await MainActor.run { + result( + FlutterError( + code: "dev_enrollment_failed", + message: "Development push enrollment failed.", + details: error.localizedDescription + ) + ) + } + } + } + } catch { + result( + FlutterError( + code: "dev_enrollment_configuration_failed", + message: "Development push enrollment is not configured.", + details: error.localizedDescription + ) + ) + } + } + private func handleMediaUploadMethodCall( _ call: FlutterMethodCall, result: @escaping FlutterResult @@ -436,8 +710,7 @@ import UserNotifications ) destinationVideo.preferredTransform = sourceVideo.preferredTransform - if - let sourceAudio, + if let sourceAudio, let destinationAudio = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid @@ -559,7 +832,8 @@ import UserNotifications do { let durationSeconds = CMTimeGetSeconds(asset.duration) - let middleTime = durationSeconds.isFinite && durationSeconds > 0 + let middleTime = + durationSeconds.isFinite && durationSeconds > 0 ? min(durationSeconds / 2, 1) : 0 let candidateTimes = [0, 0.1, middleTime] @@ -579,11 +853,12 @@ import UserNotifications } guard let posterImage else { - throw lastError ?? NSError( - domain: "BuzzVideoPoster", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] - ) + throw lastError + ?? NSError( + domain: "BuzzVideoPoster", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to decode a video frame."] + ) } guard let jpegData = try MediaSanitizer.encodeJpeg(UIImage(cgImage: posterImage)) else { throw NSError( @@ -679,3 +954,13 @@ import UserNotifications ) } } + +extension BuzzPushNavigationTarget { + fileprivate var flutterArguments: [String: String] { + [ + "eventId": eventID, + "communityId": communityID, + "channelId": channelID, + ] + } +} diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 3f93df5b97e..544f2517bdb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion @@ -53,6 +57,10 @@ Buzz uses your photo library to select profile photos and images to attach to messages. NSPhotoLibraryAddUsageDescription Buzz needs permission to save images to your photo library. + NSUserActivityTypes + + INSendMessageIntent + PHPhotoLibraryPreventAutomaticLimitedAccessAlert UIApplicationSceneManifest diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift new file mode 100644 index 00000000000..ffedbedd19a --- /dev/null +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -0,0 +1,154 @@ +import BuzzPushKit +import Foundation +import Security + +/// Keychain-backed endpoint grant storage. The opaque grant is never written to +/// UserDefaults or logs. Dart can read the closed record through the push bridge. +final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { + private static let service = "buzz.push.endpoint-grants" + private static let recordsAccount = "v1" + private static let pendingAccount = "pending-v1" + + private let accessGroup: String? + + init(accessGroup: String?) { + self.accessGroup = accessGroup + } + + func records() throws -> [BuzzPushEndpointGrantRecord] { + var query = baseQuery(account: Self.recordsAccount) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read") + } + do { + return try JSONDecoder().decode([BuzzPushEndpointGrantRecord].self, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Stored endpoint grants are invalid: \(error)"] + ) + } + } + + func save(_ record: BuzzPushEndpointGrantRecord) throws { + var all = try records() + all.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + all.append(record) + try replace(all, account: Self.recordsAccount) + } + + func pendingEnrollment( + relayOrigin: String, + appProfile: String + ) throws -> BuzzPushPendingEnrollmentRecord? { + try pendingEnrollments().first { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + } + + func savePendingEnrollment(_ record: BuzzPushPendingEnrollmentRecord) throws { + var all = try pendingEnrollments() + all.removeAll { + $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile + } + all.append(record) + try replace(all, account: Self.pendingAccount) + } + + func removePendingEnrollment(relayOrigin: String, appProfile: String) throws { + var all = try pendingEnrollments() + all.removeAll { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + } + try replace(all, account: Self.pendingAccount) + } + + private func pendingEnrollments() throws -> [BuzzPushPendingEnrollmentRecord] { + var query = baseQuery(account: Self.pendingAccount) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess, let data = result as? Data else { + throw keychainError(status, operation: "read pending enrollment") + } + do { + return try JSONDecoder().decode([BuzzPushPendingEnrollmentRecord].self, from: data) + } catch { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Stored pending enrollments are invalid: \(error)"] + ) + } + } + + private func replace(_ values: [T], account: String) throws { + let data = try JSONEncoder().encode(values) + let updateStatus = SecItemUpdate( + baseQuery(account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw keychainError(updateStatus, operation: "update") + } + + var add = baseQuery(account: account) + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw keychainError(addStatus, operation: "add") + } + } + + private func baseQuery(account: String) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.service, + kSecAttrAccount as String: account, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } + + private func keychainError(_ status: OSStatus, operation: String) -> Error { + NSError( + domain: NSOSStatusErrorDomain, + code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: + "Endpoint grant Keychain \(operation) failed: \(SecCopyErrorMessageString(status, nil) ?? "unknown" as CFString)" + ] + ) + } +} + +extension BuzzPushEndpointGrantRecord { + var flutterArguments: [String: Any] { + let arguments: [String: Any] = [ + "relayOrigin": relayOrigin, + "relayPubkey": relayPubkey, + "installationId": installationId, + "endpointGrant": endpointGrant, + "endpointHash": endpointHash, + "appProfile": appProfile, + "endpointEpoch": endpointEpoch, + "generation": generation, + "expiresAt": expiresAt, + ] + return arguments + } +} diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift new file mode 100644 index 00000000000..6253f656ce4 --- /dev/null +++ b/mobile/ios/Runner/PushNativeState.swift @@ -0,0 +1,80 @@ +import BuzzPushKit +import Foundation +import Security +import UserNotifications + +final class BuzzOneShotCompletion { + private let lock = NSLock() + private var completion: (() -> Void)? + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func call() { + lock.lock() + let completion = completion + self.completion = nil + lock.unlock() + completion?() + } +} + +enum BuzzPushNotificationResponseCoordinator { + static func handle( + actionIdentifier: String, + userInfo: [AnyHashable: Any], + onTarget: (BuzzPushNavigationTarget) -> Void, + forwardToFlutter: (@escaping () -> Void) -> Void, + completion: @escaping () -> Void + ) { + let completionGate = BuzzOneShotCompletion(completion) + defer { completionGate.call() } + + if actionIdentifier == UNNotificationDefaultActionIdentifier, + let target = BuzzPushNavigationTarget.decodeIfPresent(from: userInfo) + { + onTarget(target) + } + forwardToFlutter { completionGate.call() } + } +} + +enum BuzzPushKeychain { + static let service = "buzz.push.nse.signing" + + static func replace(signingKeys: [String: String], accessGroup: String?) throws { + var query = baseQuery(accessGroup: accessGroup) + SecItemDelete(query as CFDictionary) + for (communityID, privateKeyHex) in signingKeys { + query[kSecAttrAccount as String] = communityID + query[kSecValueData as String] = Data(privateKeyHex.utf8) + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary) + throw NSError( + domain: NSOSStatusErrorDomain, code: Int(status), + userInfo: [ + NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) + ?? "Keychain write failed" as CFString + ] + ) + } + query.removeValue(forKey: kSecValueData as String) + query.removeValue(forKey: kSecAttrAccessible as String) + query.removeValue(forKey: kSecAttrAccount as String) + } + } + + private static func baseQuery(accessGroup: String?) -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + ] + if let accessGroup, !accessGroup.isEmpty { + query[kSecAttrAccessGroup as String] = accessGroup + } + return query + } +} diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift new file mode 100644 index 00000000000..5c7bfb5fac0 --- /dev/null +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -0,0 +1,271 @@ +import BuzzPushKit +import Flutter +import Foundation + +final class BuzzPushSnapshotBridge { + private let appGroupIdentifier: String? + private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore + private let keychainAccessGroup: String? + private let queue = DispatchQueue( + label: "xyz.block.buzz.push-snapshot", + qos: .utility + ) + private lazy var store: BuzzPushPresentationCacheStore? = { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { return nil } + return BuzzPushPresentationCacheStore(containerURL: container) + }() + + init( + appGroupIdentifier: String?, + endpointGrantStore: BuzzPushEndpointGrantKeychainStore, + keychainAccessGroup: String? + ) { + self.appGroupIdentifier = appGroupIdentifier + self.endpointGrantStore = endpointGrantStore + self.keychainAccessGroup = keychainAccessGroup + } + + @discardableResult + func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool { + guard call.method == "syncPushSnapshot", + let arguments = call.arguments as? [String: Any], + let section = arguments["section"] as? String + else { + return false + } + switch section { + case "communities": syncCommunities(arguments, result: result) + case "profiles": cacheProfiles(arguments, result: result) + case "channels": cacheChannels(arguments, result: result) + case "avatar": cacheAvatar(arguments, result: result) + default: return false + } + return true + } + + private func syncCommunities(_ arguments: [String: Any], result: @escaping FlutterResult) { + guard let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String], + communities.count <= BuzzPushPresentationCacheStore.maximumCommunities + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected bounded communities and signing keys.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let store else { + Self.complete(result, value: nil) + return + } + // Relay-metadata enrichment is optional presentation state. A damaged + // grant cache must not block the core NSE snapshot and key update. + let grants = (try? endpointGrantStore.records()) ?? [] + let enriched = communities.map { community -> [String: Any] in + var community = community + guard let relayURL = community["relayUrl"] as? String, + let relayMetadataPubkey = Self.relayMetadataPubkey( + relayURL: relayURL, + grants: grants + ) + else { return community } + community["relayMetadataPubkey"] = relayMetadataPubkey + return community + } + let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys]) + let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data) + try store.replaceCommunities(decoded) + try BuzzPushKeychain.replace( + signingKeys: signingKeys, + accessGroup: keychainAccessGroup + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "snapshot_sync_failed", + message: "Unable to sync push community state.", + details: error.localizedDescription + ) + ) + } + } + } + + static func relayMetadataPubkey( + relayURL: String, + grants: [BuzzPushEndpointGrantRecord] + ) -> String? { + guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else { + return nil + } + return grants.filter { + $0.appProfile == BuzzDevPushEnrollmentDriver.appProfile + && BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin + }.max { + $0.generation < $1.generation + }?.relayMetadataPubkey + } + + private func cacheProfiles(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let rawEvents = arguments["events"] as? [[String: Any]], + rawEvents.count <= BuzzPushPresentationCacheStore.maximumProfiles + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected communityId and profile events.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID) else { + Self.complete(result, value: nil) + return + } + let events = try decodeEvents(rawEvents) + try store?.updateProfiles( + communityID: communityID, + relayOrigin: community.relayUrl, + updates: events.map { BuzzPushProfileCacheUpdate(event: $0) } + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "profile_cache_failed", + message: "Unable to cache push sender profiles.", + details: error.localizedDescription + ) + ) + } + } + } + + private func cacheChannels(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let rawMetadataEvents = arguments["metadataEvents"] as? [[String: Any]], + let rawMembershipEvents = arguments["membershipEvents"] as? [[String: Any]], + rawMetadataEvents.count <= BuzzPushPresentationCacheStore.maximumChannels, + rawMembershipEvents.count <= BuzzPushPresentationCacheStore.maximumChannels + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected communityId, channel metadata, and membership events.", + details: nil + ) + ) + return + } + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID), + let relayMetadataPubkey = community.relayMetadataPubkey + else { + Self.complete(result, value: nil) + return + } + try store?.updateChannels( + communityID: communityID, + relayOrigin: community.relayUrl, + relayMetadataPubkey: relayMetadataPubkey, + metadataEvents: try decodeEvents(rawMetadataEvents), + membershipEvents: try decodeEvents(rawMembershipEvents) + ) + Self.complete(result, value: nil) + } catch { + Self.complete( + result, + value: FlutterError( + code: "channel_cache_failed", + message: "Unable to cache push channel metadata.", + details: error.localizedDescription + ) + ) + } + } + } + + private func cacheAvatar(_ rawArguments: Any?, result: @escaping FlutterResult) { + guard let arguments = rawArguments as? [String: Any], + let communityID = arguments["communityId"] as? String, + let sourceURL = arguments["sourceUrl"] as? String, + let avatar = arguments["png"] as? FlutterStandardTypedData + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected an avatar source and PNG thumbnail.", + details: nil + ) + ) + return + } + let avatarData = avatar.data + queue.async { [weak self] in + do { + guard let self, let community = community(id: communityID) else { + Self.complete(result, value: false) + return + } + let updated = + try store?.updateAvatar( + communityID: communityID, + relayOrigin: community.relayUrl, + sourceURL: sourceURL, + avatarPNG: avatarData + ) ?? false + Self.complete(result, value: updated) + } catch { + Self.complete( + result, + value: FlutterError( + code: "avatar_cache_failed", + message: "Unable to cache a push sender avatar.", + details: error.localizedDescription + ) + ) + } + } + } + + private func community(id: String) -> PushLeaseCommunity? { + guard let appGroupIdentifier, + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ), + let data = try? Data(contentsOf: container.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)), + let snapshot = try? JSONDecoder().decode(BuzzPushPresentationCacheSnapshot.self, from: data) + else { return nil } + return snapshot.communities.first { $0.id == id } + } + + private func decodeEvents(_ rawEvents: [[String: Any]]) throws -> [VerifiedNostrEvent] { + let data = try JSONSerialization.data(withJSONObject: rawEvents) + return try JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + } + + private static func complete(_ result: @escaping FlutterResult, value: Any?) { + DispatchQueue.main.async { + result(value) + } + } +} diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 00000000000..7fca08a0f35 --- /dev/null +++ b/mobile/ios/Runner/Runner.entitlements @@ -0,0 +1,20 @@ + + + + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.devicecheck.appattest-environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.developer.usernotifications.communication + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift new file mode 100644 index 00000000000..156aa615210 --- /dev/null +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -0,0 +1,282 @@ +import BuzzPushKit +import Intents +import UserNotifications +import XCTest + +@testable import Buzz + +final class BuzzCommunicationNotificationTests: XCTestCase { + func testIntentUsesVerifiedSenderAvatarAndGroupName() throws { + let resolution = communicationResolution( + displayName: "Alice", + groupName: "General", + avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) + ) + let descriptor = try XCTUnwrap( + BuzzCommunicationNotificationDescriptor(resolution: resolution) + ) + + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + + XCTAssertEqual(intent.sender?.displayName, "Alice") + XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier) + XCTAssertNotNil(intent.sender?.image) + XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName)) + XCTAssertEqual(intent.content, "Hello Buzz") + XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General") + XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier) + XCTAssertNil(intent.recipients) + XCTAssertEqual(descriptor.recipientCount, 1) + XCTAssertEqual( + (intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount, + 1 + ) + } + + func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws { + let resolution = communicationResolution( + displayName: "Alice", + groupName: nil, + avatarPNG: Data([0x89, 0x50, 0x4E, 0x47]) + ) + let descriptor = try XCTUnwrap( + BuzzCommunicationNotificationDescriptor(resolution: resolution) + ) + + let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor) + + XCTAssertEqual(intent.sender?.displayName, "Alice") + XCTAssertNotNil(intent.sender?.image) + XCTAssertNil(intent.image(forParameterNamed: \.speakableGroupName)) + XCTAssertNil(intent.speakableGroupName) + XCTAssertNil(intent.donationMetadata) + } + + func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() { + XCTAssertNil( + BuzzCommunicationNotificationDescriptor( + resolution: communicationResolution(recipientCount: nil) + ) + ) + } + + func testPresentationFallsBackWhenDonationFails() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + ordinary.body = "Hello Buzz" + var updateCalled = false + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in + completion(NSError(domain: "test", code: 1)) + }, + updateContent: { content, _ in + updateCalled = true + return content + } + ) + let completed = expectation(description: "ordinary fallback returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "Alice") + XCTAssertEqual(content.body, "Hello Buzz") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + XCTAssertFalse(updateCalled) + } + + func testPresentationDonatesBeforeSpecializing() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + var order: [String] = [] + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in + order.append("donate") + completion(nil) + }, + updateContent: { _, _ in + order.append("update") + let specialized = UNMutableNotificationContent() + specialized.title = "specialized" + return specialized + } + ) + let completed = expectation(description: "specialized content returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "specialized") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + XCTAssertEqual(order, ["donate", "update"]) + } + + func testPresentationFallsBackWhenSpecializationFails() { + let ordinary = UNMutableNotificationContent() + ordinary.title = "Alice" + ordinary.body = "Hello Buzz" + let presenter = BuzzCommunicationNotificationPresenter( + donate: { _, completion in completion(nil) }, + updateContent: { _, _ in throw NSError(domain: "test", code: 2) } + ) + let completed = expectation(description: "ordinary fallback returned") + + presenter.present( + ordinaryContent: ordinary, + resolution: communicationResolution() + ) { content in + XCTAssertEqual(content.title, "Alice") + XCTAssertEqual(content.body, "Hello Buzz") + completed.fulfill() + } + + wait(for: [completed], timeout: 1) + } + + private func communicationResolution( + displayName: String = "Alice", + groupName: String? = "General", + avatarPNG: Data? = nil, + recipientCount: Int? = 1 + ) -> BuzzPushResolution { + let communityID = "community-id" + let channelID = "channel/general:v5" + return BuzzPushResolution( + title: displayName, + body: "Hello Buzz", + subtitle: "Community", + threadIdentifier: BuzzPushPresentationIdentity.conversation( + communityID: communityID, + channelID: channelID + ), + navigationTarget: BuzzPushNavigationTarget( + eventID: "message-id", + communityID: communityID, + channelID: channelID + ), + senderPubkey: String(repeating: "a", count: 64), + senderAvatarPNG: avatarPNG, + conversationIdentifier: BuzzPushPresentationIdentity.conversation( + communityID: communityID, + channelID: channelID + ), + conversationDisplayName: groupName, + conversationRecipientCount: recipientCount + ) + } +} + +final class BuzzPushSnapshotEnrichmentTests: XCTestCase { + func testMetadataAuthorityUsesCurrentAppProfileForMatchingRelay() { + let correctProfile = grant( + appProfile: BuzzDevPushEnrollmentDriver.appProfile, + generation: 2, + metadataPubkey: String(repeating: "a", count: 64) + ) + let wrongProfile = grant( + appProfile: "other-profile", + generation: 99, + metadataPubkey: String(repeating: "b", count: 64) + ) + + XCTAssertEqual( + BuzzPushSnapshotBridge.relayMetadataPubkey( + relayURL: "wss://relay.example/", + grants: [wrongProfile, correctProfile] + ), + correctProfile.relayMetadataPubkey + ) + } + + private func grant( + appProfile: String, + generation: Int64, + metadataPubkey: String + ) -> BuzzPushEndpointGrantRecord { + BuzzPushEndpointGrantRecord( + relayOrigin: "https://relay.example", + relayPubkey: String(repeating: "c", count: 64), + relayMetadataPubkey: metadataPubkey, + installationId: "installation", + endpointGrant: "opaque-grant", + endpointHash: String(repeating: "d", count: 64), + appProfile: appProfile, + endpointEpoch: 1, + generation: generation, + expiresAt: 1_900_000_000 + ) + } +} + +final class BuzzPushNotificationResponseTests: XCTestCase { + func testValidDefaultActionRoutesAndCompletesExactlyOnce() { + let target = BuzzPushNavigationTarget( + eventID: "message-id", + communityID: "community-id", + channelID: "opaque-channel-id" + ) + var routedTargets: [BuzzPushNavigationTarget] = [] + var forwarded = 0 + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { pluginCompletion in + forwarded += 1 + pluginCompletion() + pluginCompletion() + }, + completion: { completions += 1 } + ) + + XCTAssertEqual(routedTargets, [target]) + XCTAssertEqual(forwarded, 1) + XCTAssertEqual(completions, 1) + } + + func testMalformedTargetFallsBackToOneCompletion() { + var routedTargets: [BuzzPushNavigationTarget] = [] + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: UNNotificationDefaultActionIdentifier, + userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { _ in }, + completion: { completions += 1 } + ) + + XCTAssertTrue(routedTargets.isEmpty) + XCTAssertEqual(completions, 1) + } + + func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws { + var routedTargets: [BuzzPushNavigationTarget] = [] + var pluginCompletion: (() -> Void)? + var completions = 0 + + BuzzPushNotificationResponseCoordinator.handle( + actionIdentifier: "buzz.reply", + userInfo: [:], + onTarget: { routedTargets.append($0) }, + forwardToFlutter: { pluginCompletion = $0 }, + completion: { completions += 1 } + ) + let capturedCompletion = try XCTUnwrap(pluginCompletion) + capturedCompletion() + capturedCompletion() + + XCTAssertTrue(routedTargets.isEmpty) + XCTAssertEqual(completions, 1) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 65087046711..14c35dd21ec 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -1,12 +1,21 @@ import AVFoundation import Flutter import UIKit +import UserNotifications import XCTest @testable import Buzz class RunnerTests: XCTestCase { + func testPushAuthorizationStatusNamesCoverDisplayPermissionStates() { + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.notDetermined), "notDetermined") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.denied), "denied") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.authorized), "authorized") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.provisional), "provisional") + XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.ephemeral), "ephemeral") + } + func testHuddleActiveTalkerSelectorBoundsAndReactivates() { var selector = HuddleActiveTalkerSelector(capacity: 15) diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index a281ec03e2c..66b880679f0 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -29,6 +29,8 @@ import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/emoji/emoji_burst.dart'; +import 'shared/push/push_subscription_provider.dart'; +import 'shared/push/push_relay_capability_provider.dart'; import 'shared/relay/relay.dart'; import 'shared/read_state/read_state_provider.dart'; import 'shared/theme/theme.dart'; @@ -325,6 +327,11 @@ class App extends HookConsumerWidget { ref.watch(observerRelayProvider); ref.watch(appLifecycleProvider); ref.watch(userStatusCacheProvider); + if (ref.watch(activeCommunityProvider).value?.pushNotificationsEnabled == + true && + ref.watch(currentRelayPushDescriptorProvider).value != null) { + ref.watch(pushSubscriptionSyncProvider); + } hasUnreadInbox = ref.watch(_unreadInboxItemCountProvider) > 0; } diff --git a/mobile/lib/features/channels/channel_member_snapshots.dart b/mobile/lib/features/channels/channel_member_snapshots.dart new file mode 100644 index 00000000000..33f266ba6b9 --- /dev/null +++ b/mobile/lib/features/channels/channel_member_snapshots.dart @@ -0,0 +1,37 @@ +part of 'channels_provider.dart'; + +extension on ChannelsNotifier { + void _cacheMemberSnapshots( + Iterable events, { + bool replaceAll = false, + }) { + final latestByChannelId = {}; + for (final event in events) { + final channelId = event.getTagValue('d'); + if (channelId == null) continue; + final current = latestByChannelId[channelId]; + if (current == null || event.createdAt > current.createdAt) { + latestByChannelId[channelId] = event; + } + } + + final snapshots = replaceAll + ? >{} + : Map>.of(_memberSnapshotsByChannelId); + snapshots.addAll({ + for (final entry in latestByChannelId.entries) + entry.key: List.unmodifiable([ + for (final member in membersFromEvent(entry.value)) + ChannelMember( + pubkey: member.pubkey, + role: member.role, + joinedAt: DateTime.fromMillisecondsSinceEpoch( + entry.value.createdAt * 1000, + isUtc: true, + ), + ), + ]), + }); + _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); + } +} diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index d3bb29a0708..33bfa210d15 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -5,6 +5,8 @@ import 'dart:math'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/community/community_provider.dart'; +import '../../shared/push/push_presentation_cache.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme_provider.dart'; import '../../shared/utils/string_utils.dart'; @@ -20,6 +22,7 @@ import 'unread_badge/observed_unread_event.dart'; import 'unread_badge/should_notify_for_event.dart'; part 'channel_directory.dart'; +part 'channel_member_snapshots.dart'; part 'channels_provider_lifecycle.dart'; const _channelTypeOrder = {'stream': 0, 'forum': 1, 'dm': 2}; @@ -160,6 +163,7 @@ class ChannelsNotifier extends AsyncNotifier> { }) async { final myPk = ref.read(myPubkeyProvider); if (myPk == null) throw StateError('No signing identity available'); + final communityID = ref.read(activeCommunityProvider).value?.id; _loadThreadInterestStores(myPk); final session = ref.read(relaySessionProvider.notifier); @@ -210,11 +214,14 @@ class ChannelsNotifier extends AsyncNotifier> { final id = event.getTagValue('d'); if (id == null) continue; final existing = latestMetaPerId[id]; - if (existing == null || event.createdAt > existing.createdAt) { + if (existing == null || + event.createdAt > existing.createdAt || + (event.createdAt == existing.createdAt && + event.id.compareTo(existing.id) < 0)) { latestMetaPerId[id] = event; } } - final dedupedMetas = latestMetaPerId.values; + final dedupedMetas = latestMetaPerId.values.toList(); // Resolve DM participant display names. Extracted into the part file so // `channels_provider.dart` stays under the 1000-line ceiling enforced by @@ -283,6 +290,12 @@ class ChannelsNotifier extends AsyncNotifier> { // Use the membership snapshots already fetched above for both Huddle // linkage validation and member-count hydration. if (memberEvents.isNotEmpty) _cacheMemberSnapshots(memberEvents); + unawaited( + cacheBuzzPushChannelEvents(communityID, dedupedMetas, [ + ...memberships, + ...memberEvents, + ]), + ); final memberCounts = _memberCountsByChannelId(memberEvents); for (var i = 0; i < channels.length; i++) { final count = memberCounts[channels[i].id]; @@ -390,40 +403,6 @@ class ChannelsNotifier extends AsyncNotifier> { return channels; } - void _cacheMemberSnapshots( - Iterable events, { - bool replaceAll = false, - }) { - final latestByChannelId = {}; - for (final event in events) { - final channelId = event.getTagValue('d'); - if (channelId == null) continue; - final current = latestByChannelId[channelId]; - if (current == null || event.createdAt > current.createdAt) { - latestByChannelId[channelId] = event; - } - } - - final snapshots = replaceAll - ? >{} - : Map>.of(_memberSnapshotsByChannelId); - snapshots.addAll({ - for (final entry in latestByChannelId.entries) - entry.key: List.unmodifiable([ - for (final member in membersFromEvent(entry.value)) - ChannelMember( - pubkey: member.pubkey, - role: member.role, - joinedAt: DateTime.fromMillisecondsSinceEpoch( - entry.value.createdAt * 1000, - isUtc: true, - ), - ), - ]), - }); - _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); - } - /// Fetches each channel's independent latest-message window in one HTTP /// bridge request. The relay preserves NIP-01 per-filter limits while /// executing the filters with bounded concurrency, avoiding an unbounded diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index a7f509de13b..9d3c0fdc8a4 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -74,7 +74,43 @@ class _DeepLinkDispatcherState extends ConsumerState { !widget.dispatchMessageLinks) { return; } + if (link is MessageDeepLink) { + unawaited(_dispatchNotificationLink(link)); + return; + } + + _dispatchNavigableLink(link); + } + + Future _dispatchNotificationLink(MessageDeepLink link) async { + final preparation = await ref + .read(pendingDeepLinkProvider.notifier) + .prepareCommunity(link); + if (!mounted || ref.read(pendingDeepLinkProvider) != link) return; + switch (preparation) { + case DeepLinkCommunityPreparation.ready: + _dispatchNavigableLink(link); + case DeepLinkCommunityPreparation.switched: + // The community-scoped app subtree remounts and consumes the parked + // target after its channels load. + return; + case DeepLinkCommunityPreparation.unavailable: + ref.read(pendingDeepLinkProvider.notifier).consume(); + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Notification community is no longer available'), + ), + ); + case DeepLinkCommunityPreparation.failed: + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar( + content: Text('Could not open the notification community'), + ), + ); + } + } + void _dispatchNavigableLink(BuzzDeepLink link) { final channelId = switch (link) { MessageDeepLink(:final channelId) => channelId, ChannelDeepLink(:final channelId) => channelId, diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 9bb8f15c09f..b5faa970a19 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -11,6 +12,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/community/community_membership_provider.dart'; +import '../../shared/push/push_bridge.dart'; import '../../shared/relay/relay.dart'; import '../pairing/pairing_provider.dart'; import '../../shared/theme/theme.dart'; @@ -26,6 +28,7 @@ import 'theme_picker_page.dart'; part 'settings_page/community_section.dart'; part 'settings_page/connection_section.dart'; +part 'settings_page/notifications_section.dart'; Widget _emptyProfileEditPage(BuildContext context) => const SizedBox.shrink(); @@ -212,6 +215,7 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, _CommunitySection(invitePageBuilder: invitePageBuilder), + const _NotificationsSection(), _ConnectionSection( identityRecoveryPageBuilder: identityRecoveryPageBuilder, ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index 7f989b29db0..6e7592440a5 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -158,12 +158,21 @@ void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) { child: const Text('Cancel'), ), FilledButton( - onPressed: () { + onPressed: () async { Navigator.of(ctx).pop(); // close dialog + try { + await ref.read(authProvider.notifier).signOut(); + } catch (error) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not remove community: $error')), + ); + return; + } + if (!context.mounted) return; // Pop all pushed routes back to root so MaterialApp.home rebuilds // to PairingPage when auth state changes. Navigator.of(context).popUntil((route) => route.isFirst); - ref.read(authProvider.notifier).signOut(); }, style: FilledButton.styleFrom(backgroundColor: ctx.colors.error), child: const Text('Remove'), diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart new file mode 100644 index 00000000000..8560846b0ef --- /dev/null +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -0,0 +1,79 @@ +part of '../settings_page.dart'; + +class _NotificationsSection extends ConsumerWidget { + const _NotificationsSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return const SizedBox.shrink(); + } + final community = ref.watch(activeCommunityProvider).value; + if (community == null) return const SizedBox.shrink(); + final authorization = ref.watch(buzzPushAuthorizationStatusProvider); + final status = authorization.value; + final permissionUnavailable = authorization.hasError; + final permissionDenied = status == BuzzPushAuthorizationStatus.denied; + final showSettingsRecovery = + community.pushNotificationsEnabled && + (permissionDenied || permissionUnavailable); + final subtitle = !community.pushNotificationsEnabled + ? 'Off for this community' + : switch (status) { + BuzzPushAuthorizationStatus.notDetermined => + 'Waiting for iOS notification permission', + BuzzPushAuthorizationStatus.denied => + 'Enabled in Buzz, but disabled in iOS Settings', + BuzzPushAuthorizationStatus.authorized || + BuzzPushAuthorizationStatus.provisional || + BuzzPushAuthorizationStatus.ephemeral => + 'Receive message notifications from this community', + null when authorization.isLoading => + 'Checking iOS notification permission', + null => 'Enabled in Buzz; iOS permission status unavailable', + }; + + return AppListCard( + label: 'Notifications', + verticalPadding: Grid.twelve, + children: [ + AppListRow( + key: const ValueKey('push-notifications-enabled'), + icon: LucideIcons.bell, + title: 'Push notifications', + subtitle: subtitle, + subtitleStyle: showSettingsRecovery + ? context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ) + : null, + trailing: Switch.adaptive( + value: community.pushNotificationsEnabled, + onChanged: (enabled) => unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, enabled), + ), + ), + onTap: () => unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled( + community.id, + !community.pushNotificationsEnabled, + ), + ), + ), + if (showSettingsRecovery) + AppListRow( + key: const ValueKey('push-notifications-open-settings'), + icon: LucideIcons.settings, + title: 'Open iOS Notification Settings', + onTap: () => unawaited( + ref.read(buzzPushNotificationSettingsOpenerProvider)(), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 83ded086f20..8f360a3db91 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,10 +4,16 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; import 'features/invites/invite_join_provider.dart'; +import 'shared/push/push_bootstrap.dart'; +import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; -void main() async { +void main() => runBuzzApp(const App()); + +Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); + installBuzzPushMethodHandler(); + await syncPendingBuzzPushNotificationResponse(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); @@ -21,7 +27,7 @@ void main() async { (scope) => buildMobileInviteJoinRecovery(ref, scope), ), ], - child: const App(), + child: BuzzPushBootstrap(child: app), ), ); } diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ef79934e3fe..9dc17e1e741 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -24,6 +24,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); final communities = await storage.loadAll(); if (communities.isEmpty) { + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -36,6 +37,7 @@ class AuthNotifier extends AsyncNotifier { await storage.saveActiveId(active.id); if (_hasValidNsec(active.nsec)) { + await syncCommunitySnapshot(ref, communities); return AuthState(status: AuthStatus.authenticated, community: active); } @@ -47,6 +49,7 @@ class AuthNotifier extends AsyncNotifier { } await storage.clearActiveId(); + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -59,6 +62,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); await storage.save(community); await storage.saveActiveId(community.id); + await syncStoredCommunitySnapshot(ref); // Invalidate community providers so other consumers pick up the new data. ref.invalidate(communityListProvider); @@ -71,33 +75,24 @@ class AuthNotifier extends AsyncNotifier { } Future signOut() { - return ref.read(communityTransitionProvider).runExclusive(() async { - await ref.read(communityTransitionProvider).run(); + return () async { final storage = ref.read(communityStorageProvider); - final activeId = await storage.loadActiveId(); - if (activeId != null) { - await storage.remove(activeId); - await storage.clearActiveId(); - } + await ref + .read(communityListProvider.notifier) + .removeActiveCommunityForSignOut(); - // Check if other communities remain — switch to the next one instead of - // forcing the user back to the pairing screen. + // Community removal already persisted the outbox, deleted credentials, + // selected the next active community, and removed NSE state. Authentication + // only needs to publish the truthful resulting account state. final remaining = await storage.loadAll(); - - // Invalidate community providers so other consumers pick up the change. - ref.invalidate(communityListProvider); ref.invalidate(activeCommunityProvider); - - if (remaining.isNotEmpty) { - final next = remaining.first; - await storage.saveActiveId(next.id); - // Re-run build() to validate the next community's credentials. - ref.invalidateSelf(); - await future; - } else { + if (remaining.isEmpty) { state = const AsyncData(AuthState(status: AuthStatus.unauthenticated)); + return; } - }); + ref.invalidateSelf(); + await future; + }(); } } diff --git a/mobile/lib/shared/community/community.dart b/mobile/lib/shared/community/community.dart index 6763f953ee5..86db62bb627 100644 --- a/mobile/lib/shared/community/community.dart +++ b/mobile/lib/shared/community/community.dart @@ -1,7 +1,20 @@ +import 'dart:math'; + import 'package:uuid/uuid.dart'; +import '../push/push_subscription.dart'; + const _uuid = Uuid(); const _sentinel = Object(); +final _pushLeaseInstallationIdPattern = RegExp(r'^[0-9a-f]{32}$'); + +String _newPushLeaseInstallationId() { + final random = Random.secure(); + return List.generate( + 16, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); +} enum SensitiveActionPolicy { enabled, disabledByUser } @@ -12,6 +25,14 @@ class Community { final String? pubkey; final String? nsec; final SensitiveActionPolicy sensitiveActionPolicy; + final bool pushNotificationsEnabled; + final BuzzPushLeaseSubscriptionState pushSubscriptionState; + + /// Stable random address component for this community's relay push lease. + /// + /// Legacy records omit this value and continue using the endpoint grant's + /// installation id so their already-published lease remains addressable. + final String? pushLeaseInstallationId; /// Whether invite-created starter channels still need to be recovered. final bool starterSetupIncomplete; @@ -24,6 +45,9 @@ class Community { this.pubkey, this.nsec, this.sensitiveActionPolicy = SensitiveActionPolicy.disabledByUser, + this.pushNotificationsEnabled = false, + this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(), + this.pushLeaseInstallationId, this.starterSetupIncomplete = false, required this.addedAt, }); @@ -44,6 +68,7 @@ class Community { pubkey: pubkey, nsec: nsec, sensitiveActionPolicy: sensitiveActionPolicy, + pushLeaseInstallationId: _newPushLeaseInstallationId(), starterSetupIncomplete: starterSetupIncomplete, addedAt: DateTime.now(), ); @@ -55,6 +80,9 @@ class Community { Object? pubkey = _sentinel, Object? nsec = _sentinel, SensitiveActionPolicy? sensitiveActionPolicy, + bool? pushNotificationsEnabled, + BuzzPushLeaseSubscriptionState? pushSubscriptionState, + Object? pushLeaseInstallationId = _sentinel, bool? starterSetupIncomplete, }) { return Community( @@ -65,6 +93,13 @@ class Community { nsec: nsec == _sentinel ? this.nsec : nsec as String?, sensitiveActionPolicy: sensitiveActionPolicy ?? this.sensitiveActionPolicy, + pushNotificationsEnabled: + pushNotificationsEnabled ?? this.pushNotificationsEnabled, + pushSubscriptionState: + pushSubscriptionState ?? this.pushSubscriptionState, + pushLeaseInstallationId: pushLeaseInstallationId == _sentinel + ? this.pushLeaseInstallationId + : pushLeaseInstallationId as String?, starterSetupIncomplete: starterSetupIncomplete ?? this.starterSetupIncomplete, addedAt: addedAt, @@ -78,23 +113,51 @@ class Community { if (pubkey != null) 'pubkey': pubkey, if (nsec != null) 'nsec': nsec, 'sensitiveActionPolicy': sensitiveActionPolicy.name, + 'pushNotificationsEnabled': pushNotificationsEnabled, + 'pushSubscriptionState': pushSubscriptionState.toJson(), + if (pushLeaseInstallationId != null) + 'pushLeaseInstallationId': pushLeaseInstallationId, 'starterSetupIncomplete': starterSetupIncomplete, 'addedAt': addedAt.toIso8601String(), }; - factory Community.fromJson(Map json) => Community( - id: json['id'] as String, - name: json['name'] as String, - relayUrl: json['relayUrl'] as String, - pubkey: json['pubkey'] as String?, - nsec: json['nsec'] as String?, - sensitiveActionPolicy: SensitiveActionPolicy.values.firstWhere( - (value) => value.name == json['sensitiveActionPolicy'], - orElse: () => SensitiveActionPolicy.disabledByUser, - ), - starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, - addedAt: DateTime.parse(json['addedAt'] as String), - ); + factory Community.fromJson(Map json) { + final pushLeaseInstallationId = json['pushLeaseInstallationId'] as String?; + if (pushLeaseInstallationId != null && + !_pushLeaseInstallationIdPattern.hasMatch(pushLeaseInstallationId)) { + throw const FormatException( + 'Push lease installation id must be 16 random bytes encoded as lowercase hex', + ); + } + final pushNotificationsEnabled = + json['pushNotificationsEnabled'] as bool? ?? false; + var pushSubscriptionState = json['pushSubscriptionState'] == null + ? const BuzzPushLeaseSubscriptionState.desired() + : BuzzPushLeaseSubscriptionState.fromJson( + Map.from(json['pushSubscriptionState'] as Map), + ); + if (!pushNotificationsEnabled && + pushSubscriptionState.pendingTombstoneGeneration == null) { + pushSubscriptionState = pushSubscriptionState + .withPendingTombstoneAtCursor(); + } + return Community( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + nsec: json['nsec'] as String?, + sensitiveActionPolicy: SensitiveActionPolicy.values.firstWhere( + (value) => value.name == json['sensitiveActionPolicy'], + orElse: () => SensitiveActionPolicy.disabledByUser, + ), + pushNotificationsEnabled: pushNotificationsEnabled, + pushSubscriptionState: pushSubscriptionState, + pushLeaseInstallationId: pushLeaseInstallationId, + starterSetupIncomplete: json['starterSetupIncomplete'] as bool? ?? false, + addedAt: DateTime.parse(json['addedAt'] as String), + ); + } /// Derive a human-friendly community name from a relay URL. static String nameFromUrl(String url) { diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index ce03de7003a..7788082745e 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,8 +1,16 @@ +import 'dart:async'; import 'dart:developer' as developer; +import 'dart:math'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; import '../auth/auth_provider.dart'; +import '../push/dev_push_lease.dart'; +import '../push/push_bridge.dart'; +import '../push/push_lease_revocation_outbox.dart'; +import '../push/push_subscription.dart'; +import '../relay/signed_event_relay.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -69,11 +77,168 @@ final communityStorageProvider = Provider((ref) { return CommunityStorage(); }); +typedef CommunitySnapshotWriter = + Future Function(List communities); + +/// Writes the complete persisted community set to storage shared with the iOS +/// notification service extension. Tests override this provider to verify that +/// every persistence path refreshes (or clears) the native snapshot. +final communitySnapshotWriterProvider = Provider(( + ref, +) { + return registerBuzzPushCommunitySnapshot; +}); + +final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) { + return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider)); +}); + +typedef CommunityPushLeaseDeactivator = + Future Function(Community community, {int? generation}); + +final communityPushLeaseDeactivatorProvider = + Provider((ref) { + return (community, {generation}) => + _deactivateCommunityPushLease(community, generation: generation); + }); + +typedef CommunityPushLeaseRevocationEnqueuer = + Future Function(Community community); + +final communityPushLeaseRevocationEnqueuerProvider = + Provider((ref) { + return ref.read(buzzPushLeaseRevocationOutboxProvider).enqueueCommunity; + }); + +typedef CommunityPushLeaseRevocationTrigger = Future Function(); + +final communityPushLeaseRevocationTriggerProvider = + Provider((ref) { + return ref.read(buzzPushLeaseRevocationOutboxProvider).trigger; + }); + +Future _deactivateCommunityPushLease( + Community community, { + int? generation, +}) async { + final state = community.pushSubscriptionState; + final acceptedGeneration = state.acceptedGeneration; + final nsec = community.nsec; + if (acceptedGeneration == null && generation == null) { + return; + } + if (nsec == null || nsec.isEmpty) { + throw StateError('Push lease tombstone requires community signing key.'); + } + final decoded = nostr.Nip19.decode(payload: nsec); + final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; + final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final matchingGrant = (await readBuzzPushEndpointGrants()) + .where( + (grant) => + grant.relayOrigin == descriptor.origin && + grant.appProfile == buzzDevPushAppProfile, + ) + .firstOrNull; + if (matchingGrant == null) { + throw StateError('No endpoint grant exists for push lease tombstone.'); + } + final installationId = + community.pushLeaseInstallationId ?? matchingGrant.installationId; + final uri = Uri.parse(community.relayUrl); + final httpScheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => uri.scheme, + }; + final wsScheme = httpScheme == 'https' ? 'wss' : 'ws'; + final wsUrl = uri.replace(scheme: wsScheme).toString(); + // Skip over the one renewal generation that could already be in flight + // when removal begins. Strict relay monotonicity then makes any stale + // active publication lose to this tombstone. + final tombstoneGeneration = + generation ?? (state.generationCursor ?? acceptedGeneration!) + 2; + await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: tombstoneGeneration, + nsec: nsec, + memberPubkey: memberPubkey, + submit: ({required kind, required content, required tags, createdAt}) => + submitSignedEventOnce( + wsUrl: wsUrl, + nsec: nsec, + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ), + ); + pushLeaseCleanupError.value = null; +} + +class _CommunitySnapshotSync { + _CommunitySnapshotSync(this._writer); + + final CommunitySnapshotWriter _writer; + String? _lastSuccessfulSnapshot; + + Future write(List communities) async { + final fingerprint = communities + .map( + (community) => [ + community.id, + community.name, + community.relayUrl, + community.pubkey, + community.nsec, + community.pushNotificationsEnabled, + buzzPushSubscriptionStateFingerprint( + community.pushSubscriptionState, + ), + ].join('\u0000'), + ) + .join('\u0001'); + if (fingerprint == _lastSuccessfulSnapshot) return; + + await _writer(communities); + _lastSuccessfulSnapshot = fingerprint; + } +} + +Future syncCommunitySnapshot(Ref ref, List communities) async { + try { + await ref.read(_communitySnapshotSyncProvider).write(communities); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } +} + +Future syncStoredCommunitySnapshot(Ref ref) async { + final communities = await ref.read(communityStorageProvider).loadAll(); + await syncCommunitySnapshot(ref, communities); +} + class CommunityListNotifier extends AsyncNotifier> { + Future _pushMutationTail = Future.value(); + final Map> _tombstoneAttempts = {}; + + Future _serializePushMutation(Future Function() operation) { + final result = _pushMutationTail.then((_) => operation()); + _pushMutationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + @override Future> build() async { final storage = ref.read(communityStorageProvider); - return storage.loadAll(); + final communities = await storage.loadAll(); + await syncCommunitySnapshot(ref, communities); + return communities; } /// Add a community. If one with the same relay URL already exists, update @@ -97,25 +262,55 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[existingIndex] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return existing.id; } await storage.save(community); - state = AsyncData([...current, community]); + final updatedList = [...current, community]; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return community.id; } - Future removeCommunity(String id) { + Future removeCommunity(String id) => + _removeCommunity(id, invalidateAuthentication: true); + + /// Removes the active community through the same local-first path as the + /// community list while allowing [AuthNotifier] to publish its final state. + Future removeActiveCommunityForSignOut() => + _removeCommunity(null, invalidateAuthentication: false); + + Future _removeCommunity( + String? requestedId, { + required bool invalidateAuthentication, + }) { return ref.read(communityTransitionProvider).runExclusive(() async { + var revocationJournaled = false; final storage = ref.read(communityStorageProvider); final activeId = await storage.loadActiveId(); + final id = requestedId ?? activeId; + if (id == null) return; if (activeId == id) { await ref.read(communityTransitionProvider).run(); } + final current = state.value ?? await storage.loadAll(); + final removedIndex = current.indexWhere( + (community) => community.id == id, + ); + if (removedIndex >= 0) { + // Persist every remote-cleanup dependency before erasing credentials. + // This local transaction is the only removal prerequisite. Relay I/O + // starts after the community and NSE snapshot have been removed. + revocationJournaled = await ref.read( + communityPushLeaseRevocationEnqueuerProvider, + )(current[removedIndex]); + } await storage.remove(id); - final current = state.value ?? []; - state = AsyncData(current.where((w) => w.id != id).toList()); + final updatedList = current.where((w) => w.id != id).toList(); + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); // If we removed the active community, switch to another or sign out. if (activeId == id) { @@ -124,14 +319,24 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.saveActiveId(remaining.first.id); // Reassign list state so activeCommunityProvider picks up the new ID. state = AsyncData([...remaining]); - ref.invalidate(authProvider); + if (invalidateAuthentication) ref.invalidate(authProvider); } else { await storage.clearActiveId(); // Invalidate auth so it re-evaluates against the now-empty storage // and transitions to unauthenticated. - ref.invalidate(authProvider); + if (invalidateAuthentication) ref.invalidate(authProvider); } } + if (revocationJournaled) { + unawaited( + ref.read(communityPushLeaseRevocationTriggerProvider)().catchError(( + Object error, + StackTrace stackTrace, + ) { + reportPushLeaseCleanupError(error, stackTrace); + }), + ); + } }); } @@ -152,6 +357,219 @@ class CommunityListNotifier extends AsyncNotifier> { }); } + Future updateDesiredPushSubscriptions( + String id, + List desired, + ) => _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + if (buzzPushSubscriptionsFingerprint( + community.pushSubscriptionState.desired, + ) == + buzzPushSubscriptionsFingerprint(desired)) { + return; + } + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withDesired( + desired, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + }); + + Future reservePushLeaseGeneration(String id) { + return _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) throw StateError('Push community is unavailable.'); + + final community = current[index]; + if (!community.pushNotificationsEnabled) { + throw StateError('Push notifications are disabled.'); + } + final cursor = + community.pushSubscriptionState.generationCursor ?? + community.pushSubscriptionState.acceptedGeneration ?? + 0; + final generation = cursor + 1; + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState + .withReservedGeneration(generation), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + return generation; + }); + } + + Future markPushLeaseAccepted( + String id, { + required List subscriptions, + required int generation, + }) => _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + final acceptedGeneration = + community.pushSubscriptionState.acceptedGeneration ?? 0; + final generationCursor = + community.pushSubscriptionState.generationCursor ?? 0; + if (generation < max(acceptedGeneration, generationCursor)) return; + final updated = community.copyWith( + pushSubscriptionState: community.pushSubscriptionState.withAccepted( + subscriptions: subscriptions, + generation: generation, + ), + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + }); + + Future setPushNotificationsEnabled(String id, bool enabled) async { + var shouldDeactivate = false; + await _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + + final community = current[index]; + if (community.pushNotificationsEnabled == enabled) return; + var pushState = community.pushSubscriptionState; + if (!enabled && + (pushState.acceptedGeneration != null || + pushState.generationCursor != null)) { + final cursor = + pushState.generationCursor ?? pushState.acceptedGeneration ?? 0; + pushState = pushState.withPendingTombstone(cursor + 1); + } + final updated = community.copyWith( + pushNotificationsEnabled: enabled, + pushSubscriptionState: pushState, + ); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + shouldDeactivate = + !enabled && pushState.pendingTombstoneGeneration != null; + }); + if (shouldDeactivate) { + try { + await retryPendingPushLeaseTombstone(id); + } catch (_) { + // The durable journal remains pending. BuzzPushBootstrap retries it + // after reconnect while all registration/enrollment paths stay off. + } + } + } + + /// Publishes a durably journaled opt-out tombstone. + /// + /// A retry advances the generation before network I/O. That makes an + /// ambiguous relay-commit/local-save failure idempotent in effect: the next + /// inactive replacement wins even if the prior tombstone already committed. + Future retryPendingPushLeaseTombstone( + String id, { + bool advanceGeneration = false, + }) { + final existing = _tombstoneAttempts[id]; + if (existing != null) return existing; + final attempt = _retryPendingPushLeaseTombstone( + id, + advanceGeneration: advanceGeneration, + ); + _tombstoneAttempts[id] = attempt; + void clearAttempt() { + if (identical(_tombstoneAttempts[id], attempt)) { + _tombstoneAttempts.remove(id); + } + } + + attempt.then( + (_) => clearAttempt(), + onError: (_, _) => clearAttempt(), + ); + return attempt; + } + + Future _retryPendingPushLeaseTombstone( + String id, { + required bool advanceGeneration, + }) async { + Community? pendingCommunity; + int? pendingGeneration; + await _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + final community = current[index]; + var pushState = community.pushSubscriptionState; + final pending = pushState.pendingTombstoneGeneration; + if (community.pushNotificationsEnabled || pending == null) return; + if (advanceGeneration) { + final cursor = pushState.generationCursor ?? pending; + pushState = pushState.withPendingTombstone(cursor + 1); + } + final updated = community.copyWith(pushSubscriptionState: pushState); + if (!identical(pushState, community.pushSubscriptionState)) { + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + } + pendingCommunity = updated; + pendingGeneration = pushState.pendingTombstoneGeneration; + }); + final community = pendingCommunity; + final generation = pendingGeneration; + if (community == null || generation == null) return; + try { + await ref.read(communityPushLeaseDeactivatorProvider)( + community, + generation: generation, + ); + await _markPushLeaseTombstoneAccepted(id, generation); + pushLeaseCleanupError.value = null; + } catch (error, stackTrace) { + reportPushLeaseCleanupError(error, stackTrace); + rethrow; + } + } + + Future _markPushLeaseTombstoneAccepted(String id, int generation) => + _serializePushMutation(() async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? await storage.loadAll(); + final index = current.indexWhere((community) => community.id == id); + if (index < 0) return; + final community = current[index]; + final updatedState = community.pushSubscriptionState + .withAcceptedTombstone(generation); + if (identical(updatedState, community.pushSubscriptionState)) return; + final updated = community.copyWith(pushSubscriptionState: updatedState); + await storage.save(updated); + final updatedList = [...current]..[index] = updated; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); + }); + Future renameCommunity(String id, String name) async { final storage = ref.read(communityStorageProvider); final current = state.value ?? []; @@ -164,6 +582,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[index] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); } } diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index ca785f9df39..c58acefd218 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -72,6 +72,10 @@ class ChannelDeepLink extends BuzzDeepLink { /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { + /// Local community identifier for notification-originated links. + /// Canonical shared links omit this because community IDs are device-local. + final String? communityId; + /// Channel UUID from the `channel` query param. final String channelId; @@ -82,6 +86,7 @@ class MessageDeepLink extends BuzzDeepLink { final String? threadRootId; const MessageDeepLink({ + this.communityId, required this.channelId, required this.messageId, this.threadRootId, @@ -90,16 +95,18 @@ class MessageDeepLink extends BuzzDeepLink { @override bool operator ==(Object other) => other is MessageDeepLink && + other.communityId == communityId && other.channelId == channelId && other.messageId == messageId && other.threadRootId == threadRootId; @override - int get hashCode => Object.hash(channelId, messageId, threadRootId); + int get hashCode => + Object.hash(communityId, channelId, messageId, threadRootId); @override String toString() => - 'MessageDeepLink(channel: $channelId, id: $messageId, ' + 'MessageDeepLink(community: $communityId, channel: $channelId, id: $messageId, ' 'thread: $threadRootId)'; } diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 4875d94fcfa..70f1a6a3b6f 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -6,6 +6,10 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'deep_link.dart'; +import '../community/community_provider.dart'; +import '../push/push_bridge.dart'; + +enum DeepLinkCommunityPreparation { ready, switched, unavailable, failed } /// Holds supported deep links until they can be dispatched. /// @@ -22,17 +26,27 @@ class PendingDeepLinkNotifier extends Notifier { StreamSubscription? _subscription; final Queue _waiting = Queue(); + VoidCallback? _pushNotificationListener; @override BuzzDeepLink? build() { _waiting.clear(); final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; _subscription = stream.listen(open); + _pushNotificationListener = () { + final link = pendingPushNotificationLink.value; + if (link != null) _enqueue(link); + }; + pendingPushNotificationLink.addListener(_pushNotificationListener!); ref.onDispose(() { _subscription?.cancel(); _subscription = null; + if (_pushNotificationListener case final listener?) { + pendingPushNotificationLink.removeListener(listener); + } + _pushNotificationListener = null; }); - return null; + return pendingPushNotificationLink.value; } /// Parse and park an incoming URI. Unsupported links are ignored loudly. @@ -42,17 +56,53 @@ class PendingDeepLinkNotifier extends Notifier { debugPrint('deep-link: ignoring unsupported link: $uri'); return; } - if (state == null) { - state = link; - } else { - _waiting.addLast(link); - } + _enqueue(link); } /// Acknowledge the current link and expose the next queued link, if any. void consume() { + if (pendingPushNotificationLink.value == state) { + pendingPushNotificationLink.value = null; + } state = _waiting.isEmpty ? null : _waiting.removeFirst(); } + + /// Selects the device-local community carried by a structured push target. + /// Ordinary shared deep links have no community ID and remain unchanged. + Future prepareCommunity( + BuzzDeepLink link, + ) async { + if (link is! MessageDeepLink || link.communityId == null) { + return DeepLinkCommunityPreparation.ready; + } + final communityId = link.communityId!; + try { + final communities = await ref.read(communityListProvider.future); + if (!communities.any((community) => community.id == communityId)) { + return DeepLinkCommunityPreparation.unavailable; + } + final active = await ref.read(activeCommunityProvider.future); + if (active?.id == communityId) return DeepLinkCommunityPreparation.ready; + await ref + .read(communityListProvider.notifier) + .switchCommunity(communityId); + return DeepLinkCommunityPreparation.switched; + } catch (error) { + debugPrint( + 'notification-routing: failed to switch to community ' + '$communityId: $error', + ); + return DeepLinkCommunityPreparation.failed; + } + } + + void _enqueue(BuzzDeepLink link) { + if (state == null) { + state = link; + } else { + _waiting.addLast(link); + } + } } final pendingDeepLinkProvider = diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index d11db73b1cf..c974b4a055f 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../community/community_provider.dart'; import '../crypto/nip_oa.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; import 'user_profile.dart'; @@ -120,6 +122,7 @@ class UserCacheNotifier extends Notifier> { var succeeded = false; try { + final communityID = ref.read(activeCommunityProvider).value?.id; final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.profilesBatch(pubkeys), @@ -137,6 +140,9 @@ class UserCacheNotifier extends Notifier> { ..clear() ..addAll(updatedOrders); state = updated; + if (communityID != null) { + unawaited(cacheBuzzPushProfileEvents(communityID, events)); + } succeeded = true; } catch (_) { // Silently fail — non-gating callers will just show pubkeys. diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart new file mode 100644 index 00000000000..e7af8dc738a --- /dev/null +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -0,0 +1,658 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/nostr_models.dart'; +import '../relay/signed_event_relay.dart'; +import 'push_bridge.dart'; +import 'push_subscription.dart'; + +const buzzPushLeaseKind = 30350; +const buzzDevPushAppProfile = 'buzz-ios-dogfood'; +const buzzPushTransport = 'apns'; +const _maxSafeJsonInteger = 9007199254740991; +const _maxLeaseLifetimeSeconds = 2592000; +const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; +const _installationIdPattern = r'^[0-9a-f]{32}$'; + +class BuzzPushLeaseDescriptor { + final String origin; + final String executorKeyId; + final String executorPubkey; + final String transport; + final int maxLeaseTtlSeconds; + final int maxContentLength; + final int maxPlaintextLength; + final int maxEndpointLength; + final int maxStringLength; + + const BuzzPushLeaseDescriptor({ + required this.origin, + required this.executorKeyId, + required this.executorPubkey, + required this.transport, + required this.maxLeaseTtlSeconds, + required this.maxContentLength, + required this.maxPlaintextLength, + required this.maxEndpointLength, + required this.maxStringLength, + }); + + factory BuzzPushLeaseDescriptor.fromRelayInformation( + Map information, + ) { + _requireExactKeys( + information, + required: const {}, + allowed: const { + 'name', + 'description', + 'pubkey', + 'contact', + 'supported_nips', + 'supported_extensions', + 'software', + 'version', + 'limitation', + 'retention', + 'relay_countries', + 'language_tags', + 'tags', + 'posting_policy', + 'payments_url', + 'fees', + 'icon', + 'self', + 'pairing_relay_url', + 'push', + }, + name: 'NIP-11 document', + ); + final extensions = _stringList( + information['supported_extensions'], + name: 'supported_extensions', + ); + if (!extensions.contains('nip-pl')) { + throw const FormatException('NIP-11 does not advertise nip-pl'); + } + + final push = _stringMap(information['push'], name: 'push'); + _requireExactKeys( + push, + required: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + allowed: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + name: 'push descriptor', + ); + + final origin = _canonicalOrigin(push['origin']); + final keys = _mapList(push['keys'], name: 'push.keys'); + final keyIds = {}; + final currentKeys = >[]; + for (final key in keys) { + _requireExactKeys( + key, + required: const {'id', 'pubkey'}, + allowed: const {'id', 'pubkey', 'current', 'retiring'}, + name: 'push key', + ); + final id = _nonEmptyString(key['id'], name: 'push key id'); + if (!keyIds.add(id)) { + throw FormatException('Duplicate push key id: $id'); + } + _lowercaseHex64(key['pubkey'], name: 'push key pubkey'); + final current = key['current']; + final retiring = key['retiring']; + if (current != null && current is! bool) { + throw const FormatException('push key current must be a boolean'); + } + if (retiring != null && retiring is! bool) { + throw const FormatException('push key retiring must be a boolean'); + } + if (current == true) currentKeys.add(key); + } + if (currentKeys.length != 1) { + throw const FormatException( + 'push descriptor must contain exactly one current key', + ); + } + final currentKey = currentKeys.single; + + final profiles = _mapList(push['app_profiles'], name: 'app_profiles'); + final profileIds = {}; + String? transport; + for (final profile in profiles) { + _requireExactKeys( + profile, + required: const {'id', 'transport'}, + allowed: const {'id', 'transport'}, + name: 'app profile', + ); + final id = _nonEmptyString(profile['id'], name: 'app profile id'); + if (!profileIds.add(id)) { + throw FormatException('Duplicate app profile id: $id'); + } + final candidate = _nonEmptyString( + profile['transport'], + name: 'app profile transport', + ); + if (id == buzzDevPushAppProfile) transport = candidate; + } + if (transport != buzzPushTransport) { + throw const FormatException( + 'NIP-11 does not advertise the dogfood APNs profile', + ); + } + + final pushKinds = _intList(push['push_kinds'], name: 'push_kinds'); + if (!buzzPushEligibleKinds.every(pushKinds.contains)) { + throw const FormatException( + 'NIP-11 does not advertise every Buzz message kind for push', + ); + } + final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); + if (hGrammar != 'uuid-v4-lowercase') { + throw const FormatException('Unsupported push h_grammar'); + } + + final classSupport = _stringMap( + push['class_support'], + name: 'class_support', + ); + final supportedClasses = _stringList( + classSupport[buzzPushTransport], + name: 'class_support.apns', + ); + const knownClasses = {'default'}; + if (supportedClasses.any((value) => !knownClasses.contains(value))) { + throw const FormatException('class_support contains an unknown class'); + } + if (!supportedClasses.contains('default')) { + throw const FormatException('APNs does not support the default class'); + } + + final limitation = _stringMap(push['limitation'], name: 'limitation'); + _requireExactKeys( + limitation, + required: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + allowed: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + name: 'push limitation', + ); + for (final entry in limitation.entries) { + _positiveInt(entry.value, name: 'limitation.${entry.key}'); + } + final maxStringLength = limitation['max_string_len'] as int; + _checkStringLength(origin, maxStringLength, name: 'origin'); + _checkStringLength( + currentKey['id'] as String, + maxStringLength, + name: 'push key id', + ); + final maxLeaseTtl = limitation['max_lease_ttl'] as int; + if (maxLeaseTtl > _maxLeaseLifetimeSeconds) { + throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit'); + } + + return BuzzPushLeaseDescriptor( + origin: origin, + executorKeyId: currentKey['id'] as String, + executorPubkey: currentKey['pubkey'] as String, + transport: transport!, + maxLeaseTtlSeconds: maxLeaseTtl, + maxContentLength: limitation['max_content_len'] as int, + maxPlaintextLength: limitation['max_plaintext_len'] as int, + maxEndpointLength: limitation['max_endpoint_len'] as int, + maxStringLength: maxStringLength, + ); + } +} + +Future fetchBuzzPushLeaseDescriptor( + String relayBaseUrl, { + http.Client? client, + Duration timeout = const Duration(seconds: 8), +}) async { + final uri = Uri.tryParse(relayBaseUrl); + if (uri == null || + !const {'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty) { + throw FormatException('Invalid relay HTTP URL: $relayBaseUrl'); + } + final requestUri = uri.resolve('/'); + final ownedClient = client ?? http.Client(); + try { + final response = await ownedClient + .get(requestUri, headers: const {'Accept': 'application/nostr+json'}) + .timeout(timeout); + if (response.statusCode != 200) { + throw StateError( + 'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('NIP-11 response must be a JSON object'); + } + return BuzzPushLeaseDescriptor.fromRelayInformation(decoded); + } finally { + if (client == null) ownedClient.close(); + } +} + +class BuzzPushLeasePublication { + final String eventId; + final int expiration; + final String plaintext; + + const BuzzPushLeasePublication({ + required this.eventId, + required this.expiration, + required this.plaintext, + }); +} + +typedef BuzzPushLeaseSubmit = + Future Function({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }); + +Future publishBuzzDevPushLease({ + required BuzzPushEndpointGrant grant, + String? leaseInstallationId, + int? leaseGeneration, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required List subscriptions, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + _validateGrant(grant, descriptor); + final effectiveLeaseInstallationId = + leaseInstallationId ?? grant.installationId; + if (!RegExp(_installationIdPattern).hasMatch(effectiveLeaseInstallationId)) { + throw const FormatException( + 'Lease installation id must be 16 random bytes encoded as lowercase hex', + ); + } + final effectiveLeaseGeneration = leaseGeneration ?? grant.generation; + if (effectiveLeaseGeneration <= 0 || + effectiveLeaseGeneration > _maxSafeJsonInteger) { + throw const FormatException('Lease generation is invalid'); + } + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + final signingPubkey = nostr.Keys(decoded.data).public; + if (signingPubkey != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = min( + grant.expiresAt, + nowSeconds + descriptor.maxLeaseTtlSeconds, + ); + if (expiration <= nowSeconds) { + throw const FormatException('Endpoint grant is already expired'); + } + + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'app_profile': grant.appProfile, + 'transport': descriptor.transport, + 'endpoint': grant.endpointGrant, + 'generation': effectiveLeaseGeneration, + 'active': true, + 'subscriptions': [ + for (final subscription in subscriptions) subscription.toJson(), + ], + }; + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final conversationKey = getConversationKey( + decoded.data, + descriptor.executorPubkey, + ); + final content = nip44Encrypt(conversationKey, plaintext); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', effectiveLeaseInstallationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push lease'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzDevPushLeaseThroughRelay({ + required BuzzPushEndpointGrant grant, + String? leaseInstallationId, + int? leaseGeneration, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required List subscriptions, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzDevPushLease( + grant: grant, + leaseInstallationId: leaseInstallationId, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: nsec, + memberPubkey: memberPubkey, + subscriptions: subscriptions, + submit: relay.submit, + now: now, +); + +/// Publishes the minimal higher-generation inactive lease used when a +/// community is removed from this device. Gateway delegation is intentionally +/// untouched because it is scoped to the installation and relay key, not the +/// community. +Future publishBuzzPushLeaseTombstone({ + required BuzzPushLeaseDescriptor descriptor, + required String installationId, + required int generation, + required String nsec, + required String memberPubkey, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + if (!RegExp(_installationIdPattern).hasMatch(installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (generation <= 0 || generation > _maxSafeJsonInteger) { + throw const FormatException('Tombstone generation is invalid'); + } + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + if (nostr.Keys(decoded.data).public != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = nowSeconds + descriptor.maxLeaseTtlSeconds; + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'generation': generation, + 'active': false, + }; + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final content = nip44Encrypt( + getConversationKey(decoded.data, descriptor.executorPubkey), + plaintext, + ); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', installationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push tombstone'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzPushLeaseTombstoneThroughRelay({ + required BuzzPushLeaseDescriptor descriptor, + required String installationId, + required int generation, + required String nsec, + required String memberPubkey, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: installationId, + generation: generation, + nsec: nsec, + memberPubkey: memberPubkey, + submit: relay.submit, + now: now, +); + +void _validateGrant( + BuzzPushEndpointGrant grant, + BuzzPushLeaseDescriptor descriptor, +) { + if (utf8.encode(grant.installationId).length > 64 || + !RegExp(_installationIdPattern).hasMatch(grant.installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (grant.relayPubkey != descriptor.executorPubkey) { + throw const FormatException( + 'Stored endpoint grant is delegated to a different relay key', + ); + } + if (grant.appProfile != buzzDevPushAppProfile) { + throw const FormatException('Endpoint grant is not for buzz-ios-dogfood'); + } + if (grant.endpointGrant.isEmpty || + utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) { + throw const FormatException( + 'Endpoint grant violates the advertised endpoint limit', + ); + } + if (grant.endpointEpoch <= 0) { + throw const FormatException('Endpoint grant epoch is invalid'); + } + if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) { + throw const FormatException('Endpoint grant generation is invalid'); + } +} + +String _canonicalOrigin(Object? value) { + final origin = _nonEmptyString(value, name: 'origin'); + final uri = Uri.tryParse(origin); + if (uri == null || + !const {'ws', 'wss'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.path.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + '${uri.scheme}://${uri.authority}' != origin) { + throw FormatException('Invalid canonical push origin: $origin'); + } + return origin; +} + +void _checkStringLength(String value, int maximum, {required String name}) { + if (maximum <= 0 || utf8.encode(value).length > maximum) { + throw FormatException('$name exceeds its advertised byte limit'); + } +} + +String _lowercaseHex64(Object? value, {required String name}) { + final text = _nonEmptyString(value, name: name); + if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) { + throw FormatException('$name must be exactly 64 lowercase hex characters'); + } + return text; +} + +String _nonEmptyString(Object? value, {required String name}) { + if (value is! String || value.isEmpty) { + throw FormatException('$name must be a non-empty string'); + } + return value; +} + +int _positiveInt(Object? value, {required String name}) { + if (value is! int || value <= 0) { + throw FormatException('$name must be a positive integer'); + } + return value; +} + +Map _stringMap(Object? value, {required String name}) { + if (value is! Map) { + throw FormatException('$name must be an object'); + } + return value; +} + +List> _mapList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty array'); + } + return [ + for (final item in value) + if (item is Map) + item + else + throw FormatException('$name entries must be objects'), + ]; +} + +List _stringList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty string array'); + } + return [ + for (final item in value) + if (item is String && item.isNotEmpty) + item + else + throw FormatException('$name entries must be non-empty strings'), + ]; +} + +List _intList( + Object? value, { + required String name, + bool allowEmpty = false, +}) { + if (value is! List || (!allowEmpty && value.isEmpty)) { + throw FormatException( + '$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array', + ); + } + return [ + for (final item in value) + if (item is int && item >= 0) + item + else + throw FormatException('$name entries must be non-negative integers'), + ]; +} + +void _requireExactKeys( + Map value, { + required Set required, + required Set allowed, + required String name, +}) { + final missing = required.difference(value.keys.toSet()); + if (missing.isNotEmpty) { + throw FormatException('$name is missing ${missing.join(', ')}'); + } + final unexpected = value.keys.toSet().difference(allowed); + if (unexpected.isNotEmpty) { + throw FormatException('$name contains unknown field ${unexpected.first}'); + } +} diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart new file mode 100644 index 00000000000..0e86745f92d --- /dev/null +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import '../relay/signed_event_relay.dart'; +import 'dev_push_lease.dart'; +import 'push_bridge.dart'; +import 'push_lease_revocation_outbox.dart'; +import 'push_relay_capability_provider.dart'; +import 'push_subscription.dart'; + +const _pushBootstrapRetryDelay = Duration(seconds: 5); + +@visibleForTesting +class BuzzPushAttemptGate { + BuzzPushAttemptGate({this.retryDelay = _pushBootstrapRetryDelay}); + + final Duration retryDelay; + String? _attempt; + Timer? _retryTimer; + + bool tryBegin(String attempt) { + if (_attempt == attempt) return false; + _retryTimer?.cancel(); + _retryTimer = null; + _attempt = attempt; + return true; + } + + void failed(String attempt, {required VoidCallback retry}) { + if (_attempt != attempt) return; + _attempt = null; + _retryTimer?.cancel(); + _retryTimer = Timer(retryDelay, () { + _retryTimer = null; + if (_attempt == null) retry(); + }); + } + + void retryAfter( + String attempt, { + required Duration delay, + required VoidCallback retry, + }) { + if (_attempt != attempt) return; + _retryTimer?.cancel(); + _retryTimer = Timer(delay, () { + _retryTimer = null; + if (_attempt != attempt) return; + _attempt = null; + retry(); + }); + } + + void complete(String attempt) { + if (_attempt != attempt) return; + _retryTimer?.cancel(); + _retryTimer = null; + _attempt = null; + } + + void dispose() => _retryTimer?.cancel(); +} + +@visibleForTesting +String buzzPushPublicationAttemptKey({ + required String communityId, + required String relayBaseUrl, + required String token, + required BuzzPushLeaseDescriptor descriptor, + required List subscriptions, +}) => [ + communityId, + relayBaseUrl, + token, + descriptor.executorKeyId, + descriptor.executorPubkey, + buzzPushSubscriptionsFingerprint(subscriptions), +].join('|'); + +@visibleForTesting +bool buzzPushLifecycleEnabled({ + required Community? community, + required BuzzPushLeaseDescriptor? descriptor, +}) => community?.pushNotificationsEnabled == true && descriptor != null; + +@visibleForTesting +Future publishBuzzPushLeaseRecoverably({ + required Future Function() reserveGeneration, + required Future Function(int generation) publish, + required Future Function(int generation) markAccepted, +}) async { + final generation = await reserveGeneration(); + await publish(generation); + await markAccepted(generation); + return generation; +} + +/// Starts the push lifecycle only after authenticated relay connectivity and a +/// push-capable NIP-11 descriptor are both present. +class BuzzPushBootstrap extends HookConsumerWidget { + const BuzzPushBootstrap({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(apnsDeviceToken); + final registrationAttempt = useMemoized(BuzzPushAttemptGate.new); + final publicationAttempt = useMemoized(BuzzPushAttemptGate.new); + final tombstoneAttempt = useMemoized(BuzzPushAttemptGate.new); + final registrationRetry = useState(0); + final publicationRetry = useState(0); + final tombstoneRetry = useState(0); + final revocationOutbox = ref.watch(buzzPushLeaseRevocationOutboxProvider); + final session = ref.watch(relaySessionProvider); + final communities = ref.watch(communityListProvider).value ?? const []; + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + + useEffect(() { + final listener = AppLifecycleListener( + onResume: () => _runRevocationOutbox(revocationOutbox.trigger), + ); + _runRevocationOutbox(revocationOutbox.start); + return listener.dispose; + }, [revocationOutbox]); + + useEffect(() { + if (session.status == SessionStatus.connected) { + _runRevocationOutbox(revocationOutbox.trigger); + } + return null; + }, [revocationOutbox, session.status]); + + useEffect( + () => () { + registrationAttempt.dispose(); + publicationAttempt.dispose(); + tombstoneAttempt.dispose(); + }, + const [], + ); + + useEffect( + () { + final pendingCommunities = communities + .where( + (candidate) => + !candidate.pushNotificationsEnabled && + candidate.pushSubscriptionState.pendingTombstoneGeneration != + null, + ) + .toList(); + if (session.status != SessionStatus.connected || + pendingCommunities.isEmpty) { + return null; + } + const attempt = 'pending-tombstones'; + if (!tombstoneAttempt.tryBegin(attempt)) return null; + unawaited(() async { + try { + Object? firstError; + StackTrace? firstStack; + for (final pendingCommunity in pendingCommunities) { + try { + await ref + .read(communityListProvider.notifier) + .retryPendingPushLeaseTombstone( + pendingCommunity.id, + advanceGeneration: true, + ); + } catch (error, stack) { + firstError ??= error; + firstStack ??= stack; + } + } + if (firstError != null) { + Error.throwWithStackTrace(firstError, firstStack!); + } + tombstoneAttempt.complete(attempt); + } catch (error, stack) { + tombstoneAttempt.failed( + attempt, + retry: () { + if (context.mounted) tombstoneRetry.value += 1; + }, + ); + debugPrint('Push lease tombstone retry failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + session.status, + for (final candidate in communities) + '${candidate.id}|${candidate.pushNotificationsEnabled}|' + '${candidate.pushSubscriptionState.pendingTombstoneGeneration}', + tombstoneRetry.value, + ], + ); + + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + !buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + )) { + return null; + } + final activeCommunity = community!; + final activeDescriptor = descriptor!; + final attempt = '${activeCommunity.id}|${config.baseUrl}'; + if (!registrationAttempt.tryBegin(attempt)) return null; + unawaited(() async { + try { + await startBuzzPushRegistrationIfCapable( + activeDescriptor, + startRegistration: startBuzzPushRegistration, + ); + } catch (error, stack) { + registrationAttempt.failed( + attempt, + retry: () { + if (context.mounted) registrationRetry.value += 1; + }, + ); + debugPrint('Push registration bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + session.status, + config.baseUrl, + community?.id, + memberPubkey, + descriptor, + registrationRetry.value, + ], + ); + + final token = apnsDeviceToken.value; + useEffect( + () { + if (!_ready(session, config, community, memberPubkey) || + !buzzPushLifecycleEnabled( + community: community, + descriptor: descriptor, + ) || + token == null) { + return null; + } + final activeCommunity = community!; + final activeDescriptor = descriptor!; + final state = activeCommunity.pushSubscriptionState; + if (state.desired.isEmpty) return null; + final attempt = buzzPushPublicationAttemptKey( + communityId: activeCommunity.id, + relayBaseUrl: config.baseUrl, + token: token, + descriptor: activeDescriptor, + subscriptions: state.desired, + ); + if (!publicationAttempt.tryBegin(attempt)) return null; + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec!, + ); + unawaited(() async { + try { + final grant = await _publish( + ref, + config, + activeCommunity, + memberPubkey!, + relay, + ); + final renewInMilliseconds = + grant.expiresAt * 1000 - + DateTime.now().millisecondsSinceEpoch - + const Duration(minutes: 5).inMilliseconds; + publicationAttempt.retryAfter( + attempt, + delay: Duration( + milliseconds: renewInMilliseconds > 1000 + ? renewInMilliseconds + : 1000, + ), + retry: () { + if (context.mounted) publicationRetry.value += 1; + }, + ); + } catch (error, stack) { + publicationAttempt.failed( + attempt, + retry: () { + if (context.mounted) publicationRetry.value += 1; + }, + ); + debugPrint('Push lease bootstrap failed: $error'); + debugPrintStack(stackTrace: stack); + } + }()); + return null; + }, + [ + session.status, + config.baseUrl, + community?.id, + community?.pushSubscriptionState, + memberPubkey, + descriptor, + token, + publicationRetry.value, + ], + ); + + return child; + } + + static bool _ready( + SessionState session, + RelayConfig config, + Community? community, + String? memberPubkey, + ) => + session.status == SessionStatus.connected && + community != null && + config.nsec != null && + config.nsec!.isNotEmpty && + memberPubkey != null && + memberPubkey.isNotEmpty; + + static Future _publish( + WidgetRef ref, + RelayConfig config, + Community community, + String memberPubkey, + SignedEventRelay relay, + ) async { + final state = community.pushSubscriptionState; + final desired = state.desired; + final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final grant = await enrollBuzzPush( + config.wsUrl, + Env.pushGatewayUrl, + communitiesForSnapshotRefresh: + ref.read(communityListProvider).value ?? [community], + ); + // Relay lease replacement and gateway delegation are independent state + // machines. Subscription changes advance only the kind-30350 generation; + // the opaque grant remains reusable until its own authority changes. + final notifier = ref.read(communityListProvider.notifier); + await publishBuzzPushLeaseRecoverably( + reserveGeneration: () => + notifier.reservePushLeaseGeneration(community.id), + publish: (leaseGeneration) => publishBuzzDevPushLeaseThroughRelay( + grant: grant, + leaseInstallationId: community.pushLeaseInstallationId, + leaseGeneration: leaseGeneration, + descriptor: descriptor, + nsec: config.nsec!, + memberPubkey: memberPubkey, + subscriptions: desired, + relay: relay, + ), + markAccepted: (leaseGeneration) => notifier.markPushLeaseAccepted( + community.id, + subscriptions: desired, + generation: leaseGeneration, + ), + ); + return grant; + } +} + +void _runRevocationOutbox(Future Function() operation) { + unawaited( + operation().catchError((Object error, StackTrace stackTrace) { + reportPushLeaseCleanupError(error, stackTrace); + }), + ); +} diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart new file mode 100644 index 00000000000..a0c674f14f6 --- /dev/null +++ b/mobile/lib/shared/push/push_bridge.dart @@ -0,0 +1,326 @@ +import 'dart:async'; + +import 'package:nostr/nostr.dart' as nostr; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community.dart'; +import '../deeplink/deep_link.dart'; +import '../relay/relay_provider.dart'; +import '../relay/app_lifecycle_provider.dart'; +import 'push_snapshot.dart'; + +const _channel = MethodChannel('buzz/push'); + +enum BuzzPushAuthorizationStatus { + notDetermined, + denied, + authorized, + provisional, + ephemeral, +} + +typedef BuzzPushAuthorizationStatusReader = + Future Function(); +typedef BuzzPushNotificationSettingsOpener = Future Function(); + +final buzzPushAuthorizationStatusReaderProvider = + Provider((ref) { + return readBuzzPushAuthorizationStatus; + }); + +final buzzPushNotificationSettingsOpenerProvider = + Provider((ref) { + return openBuzzPushNotificationSettings; + }); + +final buzzPushAuthorizationStatusProvider = + AsyncNotifierProvider< + BuzzPushAuthorizationStatusNotifier, + BuzzPushAuthorizationStatus + >(BuzzPushAuthorizationStatusNotifier.new); + +class BuzzPushAuthorizationStatusNotifier + extends AsyncNotifier { + @override + Future build() async { + ref.listen(appLifecycleProvider, (previous, next) { + if (previous != AppLifecycleState.resumed && + next == AppLifecycleState.resumed) { + unawaited(refresh()); + } + }); + return ref.read(buzzPushAuthorizationStatusReaderProvider)(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + ref.read(buzzPushAuthorizationStatusReaderProvider), + ); + } +} + +Future readBuzzPushAuthorizationStatus() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return BuzzPushAuthorizationStatus.authorized; + } + final raw = await _channel.invokeMethod( + 'notificationAuthorizationStatus', + ); + return switch (raw) { + 'notDetermined' => BuzzPushAuthorizationStatus.notDetermined, + 'denied' => BuzzPushAuthorizationStatus.denied, + 'authorized' => BuzzPushAuthorizationStatus.authorized, + 'provisional' => BuzzPushAuthorizationStatus.provisional, + 'ephemeral' => BuzzPushAuthorizationStatus.ephemeral, + _ => throw FormatException( + 'Native push bridge returned unknown authorization status: $raw', + ), + }; +} + +Future openBuzzPushNotificationSettings() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return false; + return await _channel.invokeMethod('openNotificationSettings') ?? false; +} + +/// Latest APNs registration state, including callbacks replayed by iOS after +/// the Flutter method channel attaches. +final apnsDeviceToken = ValueNotifier(null); +final apnsRegistrationError = ValueNotifier(null); + +final pushEndpointGrants = ValueNotifier>([]); +final pushEndpointGrantError = ValueNotifier(null); + +/// The most recent notification response waiting for app navigation. +/// +/// Native iOS buffers cold-start responses until Dart asks for them. This +/// notifier also carries warm responses into the existing deep-link pipeline. +final pendingPushNotificationLink = ValueNotifier(null); + +MessageDeepLink? _pushNotificationLink(Object? arguments) { + if (arguments is! Map) return null; + final eventId = arguments['eventId']; + final communityId = arguments['communityId']; + final channelId = arguments['channelId']; + if (eventId is! String || + eventId.isEmpty || + communityId is! String || + communityId.isEmpty || + channelId is! String || + channelId.isEmpty) { + return null; + } + return MessageDeepLink( + communityId: communityId, + channelId: channelId, + messageId: eventId, + ); +} + +/// Pulls a notification response that arrived before the Flutter method +/// handler was installed. +Future syncPendingBuzzPushNotificationResponse() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final arguments = await _channel.invokeMapMethod( + 'takePendingNotificationResponse', + ); + final link = _pushNotificationLink(arguments); + if (link != null) pendingPushNotificationLink.value = link; + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +/// Starts the independent iOS notification-authorization and APNs-registration +/// requests. Display authorization is intentionally not returned or persisted: +/// APNs registration and enrollment remain valid while display is denied. +Future startBuzzPushRegistration() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + await _channel.invokeMethod('startRegistration'); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +class BuzzPushEndpointGrant { + final String relayOrigin; + final String relayPubkey; + final String installationId; + final String endpointGrant; + final String endpointHash; + final String appProfile; + final int endpointEpoch; + final int generation; + final int expiresAt; + + const BuzzPushEndpointGrant({ + required this.relayOrigin, + required this.relayPubkey, + required this.installationId, + required this.endpointGrant, + required this.endpointHash, + required this.appProfile, + required this.endpointEpoch, + required this.generation, + required this.expiresAt, + }); + + factory BuzzPushEndpointGrant.fromMap(Map map) { + final generation = map['generation'] as int; + return BuzzPushEndpointGrant( + relayOrigin: map['relayOrigin'] as String, + relayPubkey: map['relayPubkey'] as String, + installationId: map['installationId'] as String, + endpointGrant: map['endpointGrant'] as String, + endpointHash: map['endpointHash'] as String, + appProfile: map['appProfile'] as String, + endpointEpoch: map['endpointEpoch'] as int, + generation: generation, + expiresAt: map['expiresAt'] as int, + ); + } +} + +Future> readBuzzPushEndpointGrants() async { + if (defaultTargetPlatform != TargetPlatform.iOS) return const []; + try { + final raw = await _channel.invokeListMethod('endpointGrants'); + final grants = [ + for (final value in raw ?? const []) + BuzzPushEndpointGrant.fromMap(value as Map), + ]; + pushEndpointGrants.value = grants; + pushEndpointGrantError.value = null; + return grants; + } catch (error) { + pushEndpointGrantError.value = error.toString(); + rethrow; + } +} + +/// Enrolls the endpoint and optionally rewrites the NSE snapshot afterward. +/// +/// The rewrite propagates NIP-11 `self` rotations even when the opaque grant +/// and accepted relay lease remain reusable and their generations do not move. +Future enrollBuzzPush( + String relayUrl, + String gatewayUrl, { + List? communitiesForSnapshotRefresh, +}) async { + final raw = await _channel.invokeMapMethod('enrollPush', { + 'relayUrl': relayUrl, + 'gatewayUrl': gatewayUrl, + }); + if (raw == null) { + throw StateError('Native push enrollment returned no grant.'); + } + final grant = BuzzPushEndpointGrant.fromMap(raw); + await readBuzzPushEndpointGrants(); + if (communitiesForSnapshotRefresh != null) { + try { + await registerBuzzPushCommunitySnapshot(communitiesForSnapshotRefresh); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } + } + return grant; +} + +/// Latest failure to export the community snapshot used by the iOS +/// notification service extension. Snapshot export is push enrichment and must +/// never gate authentication or community persistence. +final pushCommunitySnapshotError = ValueNotifier(null); +final pushLeaseCleanupError = ValueNotifier(null); + +void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) { + pushCommunitySnapshotError.value = error.toString(); + debugPrint('Push community snapshot export failed: $error'); + debugPrintStack(stackTrace: stackTrace); +} + +void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) { + pushLeaseCleanupError.value = error.toString(); + debugPrint('Push lease cleanup failed: $error'); + debugPrintStack(stackTrace: stackTrace); +} + +Future registerBuzzPushCommunitySnapshot( + List communities, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final snapshots = [ + for (final community in communities) + if (community.pushNotificationsEnabled) + BuzzPushCommunitySnapshot( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, + pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + subscriptions: community.pushSubscriptionState.authoritative, + ), + ]; + final signingKeys = {}; + for (final community in communities) { + if (!community.pushNotificationsEnabled) continue; + final nsec = community.nsec; + if (nsec == null || nsec.isEmpty) continue; + try { + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || + decoded.data.length != 64) { + continue; + } + signingKeys[community.id] = decoded.data; + } catch (_) { + // Native storage is fail-closed; malformed keys are never exported. + } + } + await _channel.invokeMethod('syncPushSnapshot', { + 'section': 'communities', + 'communities': [for (final snapshot in snapshots) snapshot.toJson()], + 'signingKeys': signingKeys, + }); + } on MissingPluginException { + // Flutter tests and non-Runner embeddings do not install the native bridge. + } +} + +void installBuzzPushMethodHandler() { + _channel.setMethodCallHandler((call) async { + switch (call.method) { + case 'apnsTokenChanged': + final args = call.arguments; + if (args is Map) { + final token = args['token']; + if (token is String && token.isNotEmpty) { + apnsDeviceToken.value = token; + apnsRegistrationError.value = null; + } + } + return null; + case 'apnsRegistrationFailed': + final args = call.arguments; + final message = args is Map ? args['message'] : null; + apnsRegistrationError.value = message is String && message.isNotEmpty + ? message + : 'APNs registration failed'; + debugPrint('APNs registration failed: ${apnsRegistrationError.value}'); + return null; + case 'notificationOpened': + final link = _pushNotificationLink(call.arguments); + if (link == null) return 'ignored'; + pendingPushNotificationLink.value = link; + return 'handled'; + default: + throw MissingPluginException('Unknown buzz/push method ${call.method}'); + } + }); +} diff --git a/mobile/lib/shared/push/push_lease_revocation_outbox.dart b/mobile/lib/shared/push/push_lease_revocation_outbox.dart new file mode 100644 index 00000000000..3ba44e17574 --- /dev/null +++ b/mobile/lib/shared/push/push_lease_revocation_outbox.dart @@ -0,0 +1,553 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../community/community.dart'; +import '../relay/signed_event_relay.dart'; +import 'dev_push_lease.dart'; +import 'push_bridge.dart'; + +const _maxSafeJsonInteger = 9007199254740991; +const _baseRetryDelay = Duration(seconds: 30); +const _maximumRetryDelay = Duration(hours: 6); +const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; +const _installationIdPattern = r'^[0-9a-f]{32}$'; + +typedef BuzzPushLeaseRevocationPublisher = + Future Function(BuzzPushLeaseRevocationRecord record); +typedef BuzzPushLeaseRevocationClock = DateTime Function(); +typedef BuzzPushLeaseRevocationJitter = double Function(); +typedef BuzzPushLeaseRevocationErrorReporter = + void Function(Object error, StackTrace stackTrace); +typedef BuzzPushLeaseRevocationWakeScheduler = + void Function() Function(Duration delay, void Function() wake); + +/// Durable state for retracting one precise NIP-PL lease address. +/// +/// The signing key is intentionally retained in secure storage only until the +/// relay accepts the tombstone or the old endpoint grant expires. +@immutable +class BuzzPushLeaseRevocationRecord { + final String relayUrl; + final String relayOrigin; + final String memberPubkey; + final String nsec; + final String installationId; + + /// Generation reserved for the next relay attempt. + final int generation; + final int expiresAt; + final int attemptCount; + final int nextAttemptAt; + + BuzzPushLeaseRevocationRecord({ + required this.relayUrl, + required this.relayOrigin, + required this.memberPubkey, + required this.nsec, + required this.installationId, + required this.generation, + required this.expiresAt, + required this.attemptCount, + required this.nextAttemptAt, + }) { + if (canonicalBuzzPushRelayOrigin(relayUrl) != relayOrigin) { + throw const FormatException( + 'Push revocation relay URL and origin do not match.', + ); + } + if (!RegExp(_lowercaseHex64Pattern).hasMatch(memberPubkey)) { + throw const FormatException( + 'Push revocation member pubkey must be exact lowercase hex.', + ); + } + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || + decoded.data.length != 64 || + nostr.Keys(decoded.data).public != memberPubkey) { + throw const FormatException( + 'Push revocation signing key does not match its member pubkey.', + ); + } + if (!RegExp(_installationIdPattern).hasMatch(installationId)) { + throw const FormatException( + 'Push revocation installation id must be exact lowercase hex.', + ); + } + if (generation <= 0 || generation > _maxSafeJsonInteger) { + throw const FormatException('Push revocation generation is invalid.'); + } + if (expiresAt <= 0 || attemptCount < 0 || nextAttemptAt < 0) { + throw const FormatException('Push revocation retry state is invalid.'); + } + } + + String get leaseAddress => '$memberPubkey|$relayOrigin|$installationId'; + + BuzzPushLeaseRevocationRecord copyWith({ + int? generation, + int? expiresAt, + int? attemptCount, + int? nextAttemptAt, + }) => BuzzPushLeaseRevocationRecord( + relayUrl: relayUrl, + relayOrigin: relayOrigin, + memberPubkey: memberPubkey, + nsec: nsec, + installationId: installationId, + generation: generation ?? this.generation, + expiresAt: expiresAt ?? this.expiresAt, + attemptCount: attemptCount ?? this.attemptCount, + nextAttemptAt: nextAttemptAt ?? this.nextAttemptAt, + ); + + Map toJson() => { + 'version': 1, + 'relayUrl': relayUrl, + 'relayOrigin': relayOrigin, + 'memberPubkey': memberPubkey, + 'nsec': nsec, + 'installationId': installationId, + 'generation': generation, + 'expiresAt': expiresAt, + 'attemptCount': attemptCount, + 'nextAttemptAt': nextAttemptAt, + }; + + factory BuzzPushLeaseRevocationRecord.fromJson(Map json) { + const keys = { + 'version', + 'relayUrl', + 'relayOrigin', + 'memberPubkey', + 'nsec', + 'installationId', + 'generation', + 'expiresAt', + 'attemptCount', + 'nextAttemptAt', + }; + if (json.keys.toSet().difference(keys).isNotEmpty || + keys.difference(json.keys.toSet()).isNotEmpty || + json['version'] != 1 || + json['relayUrl'] is! String || + json['relayOrigin'] is! String || + json['memberPubkey'] is! String || + json['nsec'] is! String || + json['installationId'] is! String || + json['generation'] is! int || + json['expiresAt'] is! int || + json['attemptCount'] is! int || + json['nextAttemptAt'] is! int) { + throw const FormatException('Invalid push revocation record.'); + } + return BuzzPushLeaseRevocationRecord( + relayUrl: json['relayUrl'] as String, + relayOrigin: json['relayOrigin'] as String, + memberPubkey: json['memberPubkey'] as String, + nsec: json['nsec'] as String, + installationId: json['installationId'] as String, + generation: json['generation'] as int, + expiresAt: json['expiresAt'] as int, + attemptCount: json['attemptCount'] as int, + nextAttemptAt: json['nextAttemptAt'] as int, + ); + } +} + +class BuzzPushLeaseRevocationStorage { + static const _key = 'buzz_push_lease_revocations_v1'; + + final FlutterSecureStorage _secure; + + BuzzPushLeaseRevocationStorage({FlutterSecureStorage? secure}) + : _secure = secure ?? const FlutterSecureStorage(); + + Future> loadAll() async { + final raw = await _secure.read(key: _key); + if (raw == null) return []; + final decoded = jsonDecode(raw); + if (decoded is! List) { + throw const FormatException('Push revocation outbox must be a list.'); + } + final records = [ + for (final value in decoded) + BuzzPushLeaseRevocationRecord.fromJson( + Map.from(value as Map), + ), + ]; + final addresses = records.map((record) => record.leaseAddress).toSet(); + if (addresses.length != records.length) { + throw const FormatException( + 'Push revocation outbox contains duplicate lease addresses.', + ); + } + return records; + } + + Future replaceAll( + Iterable records, + ) async { + final values = records.toList(); + final addresses = values.map((record) => record.leaseAddress).toSet(); + if (addresses.length != values.length) { + throw const FormatException( + 'Push revocation outbox contains duplicate lease addresses.', + ); + } + if (values.isEmpty) { + await _secure.delete(key: _key); + return; + } + await _secure.write( + key: _key, + value: jsonEncode([for (final record in values) record.toJson()]), + ); + } +} + +/// A durable, single-flight retry coordinator for removed-community leases. +/// +/// Every attempt reserves its successor generation and retry time before +/// network I/O. A process death during an ambiguous publication therefore +/// cannot cause an immediate replay or reuse a generation the relay may have +/// accepted. +class BuzzPushLeaseRevocationOutbox { + BuzzPushLeaseRevocationOutbox({ + required this.storage, + required this.publisher, + this.now = DateTime.now, + BuzzPushLeaseRevocationJitter? jitter, + this.reportError = reportPushLeaseCleanupError, + BuzzPushLeaseRevocationWakeScheduler? scheduleWake, + }) : jitter = jitter ?? Random.secure().nextDouble, + scheduleWake = scheduleWake ?? _scheduleTimer; + + final BuzzPushLeaseRevocationStorage storage; + final BuzzPushLeaseRevocationPublisher publisher; + final BuzzPushLeaseRevocationClock now; + final BuzzPushLeaseRevocationJitter jitter; + final BuzzPushLeaseRevocationErrorReporter reportError; + final BuzzPushLeaseRevocationWakeScheduler scheduleWake; + + Future _storageTail = Future.value(); + Future? _drain; + int _wakeGeneration = 0; + void Function()? _cancelWake; + bool _started = false; + bool _disposed = false; + + Future _serialize(Future Function() operation) { + final result = _storageTail.then((_) => operation()); + _storageTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + Future enqueue(BuzzPushLeaseRevocationRecord record) async { + await _serialize(() async { + final records = await storage.loadAll(); + final index = records.indexWhere( + (candidate) => candidate.leaseAddress == record.leaseAddress, + ); + if (index >= 0) { + if (record.generation <= records[index].generation) return; + records[index] = record; + } else { + records.add(record); + } + await storage.replaceAll(records); + }); + if (_started) _scheduleNextWake(); + } + + Future enqueueCommunity( + Community community, { + Future> Function()? readGrants, + }) async { + final state = community.pushSubscriptionState; + final highestGeneration = + state.generationCursor ?? state.acceptedGeneration; + if (highestGeneration == null || + (!community.pushNotificationsEnabled && + state.pendingTombstoneGeneration == null)) { + return false; + } + final nsec = community.nsec; + if (nsec == null || nsec.isEmpty) { + throw StateError( + 'Push lease revocation requires a community signing key.', + ); + } + final decoded = nostr.Nip19.decode(payload: nsec); + final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; + final relayUrl = canonicalBuzzPushRelayHttpUrl(community.relayUrl); + final relayOrigin = canonicalBuzzPushRelayOrigin(relayUrl); + final grants = await (readGrants ?? readBuzzPushEndpointGrants)(); + final matching = grants + .where( + (grant) => + grant.relayOrigin == relayOrigin && + grant.appProfile == buzzDevPushAppProfile, + ) + .toList(); + if (matching.length != 1) { + throw StateError( + 'Expected exactly one endpoint grant for push lease revocation.', + ); + } + final currentSeconds = now().millisecondsSinceEpoch ~/ 1000; + final grant = matching.single; + if (grant.expiresAt <= currentSeconds) return false; + if (highestGeneration + 2 > _maxSafeJsonInteger) { + throw StateError('Push lease generation is exhausted.'); + } + await enqueue( + BuzzPushLeaseRevocationRecord( + relayUrl: relayUrl, + relayOrigin: relayOrigin, + memberPubkey: memberPubkey, + nsec: nsec, + installationId: + community.pushLeaseInstallationId ?? grant.installationId, + generation: highestGeneration + 2, + expiresAt: grant.expiresAt, + attemptCount: 0, + nextAttemptAt: currentSeconds, + ), + ); + return true; + } + + Future start() async { + if (_disposed) throw StateError('Push revocation outbox is disposed.'); + if (_started) return trigger(); + _started = true; + await trigger(); + } + + Future trigger() { + if (_disposed) return Future.value(); + final active = _drain; + if (active != null) return active; + final drain = _drainDue(); + _drain = drain; + void finish() { + if (identical(_drain, drain)) _drain = null; + if (!_disposed) _scheduleNextWake(); + } + + drain.then((_) => finish(), onError: (_, _) => finish()); + return drain; + } + + Future _drainDue() async { + while (!_disposed) { + final record = await _reserveNextDueAttempt(); + if (record == null) return; + try { + await publisher(record); + await _removeAccepted(record); + pushLeaseCleanupError.value = null; + } catch (error, stackTrace) { + reportError(error, stackTrace); + } + } + } + + Future _reserveNextDueAttempt() => _serialize( + () async { + final currentSeconds = now().millisecondsSinceEpoch ~/ 1000; + final records = await storage.loadAll(); + final active = records + .where((record) => record.expiresAt > currentSeconds) + .toList(); + final due = + active + .where((record) => record.nextAttemptAt <= currentSeconds) + .toList() + ..sort((left, right) { + final schedule = left.nextAttemptAt.compareTo( + right.nextAttemptAt, + ); + return schedule != 0 + ? schedule + : left.leaseAddress.compareTo(right.leaseAddress); + }); + if (due.isEmpty) { + if (active.length != records.length) { + await storage.replaceAll(active); + } + return null; + } + final record = due.first; + if (record.generation >= _maxSafeJsonInteger) { + throw StateError('Push lease generation is exhausted.'); + } + final reserved = record.copyWith( + generation: record.generation + 1, + attemptCount: record.attemptCount + 1, + nextAttemptAt: currentSeconds + _retryDelaySeconds(record.attemptCount), + ); + final index = active.indexWhere( + (candidate) => candidate.leaseAddress == record.leaseAddress, + ); + if (index < 0) { + throw StateError('Reserved push revocation record disappeared.'); + } + active[index] = reserved; + await storage.replaceAll(active); + return record; + }, + ); + + int _retryDelaySeconds(int priorAttempts) { + final exponent = min(priorAttempts, 20); + final factor = 1 << exponent; + final maximumSeconds = min( + _maximumRetryDelay.inSeconds, + _baseRetryDelay.inSeconds * factor, + ); + final half = maximumSeconds ~/ 2; + return half + (jitter() * (maximumSeconds - half)).floor(); + } + + Future _removeAccepted(BuzzPushLeaseRevocationRecord attempted) => + _serialize(() async { + final records = await storage.loadAll(); + records.removeWhere( + (record) => + record.leaseAddress == attempted.leaseAddress && + record.generation == attempted.generation + 1, + ); + await storage.replaceAll(records); + }); + + void _scheduleNextWake() { + if (!_started || _disposed) return; + final generation = ++_wakeGeneration; + _cancelWake?.call(); + _cancelWake = null; + unawaited( + _serialize(() async { + final currentSeconds = now().millisecondsSinceEpoch ~/ 1000; + final records = await storage.loadAll(); + final active = records + .where((record) => record.expiresAt > currentSeconds) + .toList(); + if (active.length != records.length) { + await storage.replaceAll(active); + } + if (active.isEmpty || generation != _wakeGeneration || _disposed) { + return; + } + final wakeAt = active + .map((record) => min(record.nextAttemptAt, record.expiresAt)) + .reduce(min); + final delay = Duration(seconds: max(0, wakeAt - currentSeconds)); + _cancelWake = scheduleWake(delay, () { + if (generation != _wakeGeneration || _disposed) return; + _cancelWake = null; + unawaited(trigger()); + }); + }), + ); + } + + void dispose() { + _disposed = true; + _wakeGeneration += 1; + _cancelWake?.call(); + _cancelWake = null; + } +} + +void Function() _scheduleTimer(Duration delay, void Function() wake) { + final timer = Timer(delay, wake); + return timer.cancel; +} + +String canonicalBuzzPushRelayOrigin(String relayUrl) { + final uri = _buzzPushRelayUri(relayUrl); + final scheme = switch (uri.scheme) { + 'https' || 'wss' => 'wss', + 'http' || 'ws' => 'ws', + _ => throw StateError('Validated relay URL has an unsupported scheme.'), + }; + return '$scheme://${uri.authority}'; +} + +String canonicalBuzzPushRelayHttpUrl(String relayUrl) { + final uri = _buzzPushRelayUri(relayUrl); + final scheme = switch (uri.scheme) { + 'https' || 'wss' => 'https', + 'http' || 'ws' => 'http', + _ => throw StateError('Validated relay URL has an unsupported scheme.'), + }; + return uri.replace(scheme: scheme, path: '/').toString(); +} + +Uri _buzzPushRelayUri(String relayUrl) { + final uri = Uri.tryParse(relayUrl); + if (uri == null || + !const {'http', 'https', 'ws', 'wss'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + (uri.path.isNotEmpty && uri.path != '/') || + uri.hasQuery || + uri.hasFragment) { + throw FormatException('Invalid relay URL for push revocation: $relayUrl'); + } + return uri; +} + +Future publishBuzzPushLeaseRevocation( + BuzzPushLeaseRevocationRecord record, +) async { + final descriptor = await fetchBuzzPushLeaseDescriptor(record.relayUrl); + if (descriptor.origin != record.relayOrigin) { + throw StateError('Relay push origin changed while revocation was pending.'); + } + final uri = Uri.parse(record.relayUrl); + final wsUrl = uri + .replace(scheme: uri.scheme == 'https' ? 'wss' : 'ws') + .toString(); + await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: record.installationId, + generation: record.generation, + nsec: record.nsec, + memberPubkey: record.memberPubkey, + submit: ({required kind, required content, required tags, createdAt}) => + submitSignedEventOnce( + wsUrl: wsUrl, + nsec: record.nsec, + kind: kind, + content: content, + tags: tags, + createdAt: createdAt, + ), + ); +} + +final buzzPushLeaseRevocationStorageProvider = + Provider( + (ref) => BuzzPushLeaseRevocationStorage(), + ); + +final buzzPushLeaseRevocationOutboxProvider = + Provider((ref) { + final outbox = BuzzPushLeaseRevocationOutbox( + storage: ref.read(buzzPushLeaseRevocationStorageProvider), + publisher: publishBuzzPushLeaseRevocation, + ); + ref.onDispose(outbox.dispose); + return outbox; + }); diff --git a/mobile/lib/shared/push/push_presentation_cache.dart b/mobile/lib/shared/push/push_presentation_cache.dart new file mode 100644 index 00000000000..82cc78a89d2 --- /dev/null +++ b/mobile/lib/shared/push/push_presentation_cache.dart @@ -0,0 +1,240 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/nostr_models.dart'; + +const _pushPresentationChannel = MethodChannel('buzz/push'); +const _maximumAvatarSourceBytes = 512 * 1024; +const _maximumAvatarPNGBytes = 64 * 1024; +Future _avatarEncodeTail = Future.value(); + +/// The latest best-effort App Group presentation-cache failure. +final pushPresentationCacheError = ValueNotifier(null); + +/// Revalidates a relay event before it crosses into the native cache writer. +bool isVerifiedPushPresentationEvent(NostrEvent event) { + try { + nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ); + return true; + } catch (_) { + return false; + } +} + +/// Exports raw verified kind-0 events. Native code verifies them again before storage. +Future cacheBuzzPushProfileEvents( + String communityID, + Iterable events, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS || communityID.isEmpty) { + return; + } + final verified = _newestVerifiedEvents( + events, + kind: 0, + scope: (event) => event.pubkey.toLowerCase(), + ).values.toList(); + if (verified.isEmpty) return; + await _invokeBestEffort({ + 'section': 'profiles', + 'communityId': communityID, + 'events': [for (final event in verified) event.toJson()], + }); +} + +/// Exports verified channel metadata and membership for native authority checks. +Future cacheBuzzPushChannelEvents( + String? communityID, + Iterable metadataEvents, + Iterable membershipEvents, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS || + communityID == null || + communityID.isEmpty) { + return; + } + final batch = selectPushChannelEvents(metadataEvents, membershipEvents); + final verifiedMetadata = batch.metadata; + final verifiedMembership = batch.membership; + if (verifiedMetadata.isEmpty && verifiedMembership.isEmpty) return; + await _invokeBestEffort({ + 'section': 'channels', + 'communityId': communityID, + 'metadataEvents': [for (final event in verifiedMetadata) event.toJson()], + 'membershipEvents': [ + for (final event in verifiedMembership) event.toJson(), + ], + }); +} + +/// Selects the newest paired verified channel metadata and membership events. +@visibleForTesting +({List metadata, List membership}) +selectPushChannelEvents( + Iterable metadataEvents, + Iterable membershipEvents, +) { + final verifiedMembershipByChannel = _newestVerifiedEvents( + membershipEvents, + kind: 39002, + scope: (event) => event.getTagValue('d'), + ); + final selectedChannelIDs = verifiedMembershipByChannel.keys.toSet(); + final verifiedMetadataByChannel = _newestVerifiedEvents( + metadataEvents, + kind: 39000, + scope: (event) => event.getTagValue('d'), + allowedScopes: selectedChannelIDs.isEmpty ? null : selectedChannelIDs, + ); + if (selectedChannelIDs.isEmpty) { + selectedChannelIDs.addAll(verifiedMetadataByChannel.keys); + } + final verifiedMetadata = [ + for (final entry in verifiedMetadataByChannel.entries) + if (selectedChannelIDs.contains(entry.key)) entry.value, + ]; + final verifiedMembership = [ + for (final entry in verifiedMembershipByChannel.entries) + if (selectedChannelIDs.contains(entry.key)) entry.value, + ]; + return (metadata: verifiedMetadata, membership: verifiedMembership); +} + +Map _newestVerifiedEvents( + Iterable events, { + required int kind, + required String? Function(NostrEvent event) scope, + Set? allowedScopes, +}) { + final selected = {}; + for (final event in events) { + if (event.kind != kind || !isVerifiedPushPresentationEvent(event)) continue; + final key = scope(event); + if (key == null || key.isEmpty) continue; + if (allowedScopes != null && !allowedScopes.contains(key)) continue; + final existing = selected[key]; + if (existing != null) { + if (_isNewerEvent(event, existing)) selected[key] = event; + continue; + } + selected[key] = event; + } + return selected; +} + +bool _isNewerEvent(NostrEvent candidate, NostrEvent existing) => + candidate.createdAt > existing.createdAt || + (candidate.createdAt == existing.createdAt && + candidate.id.compareTo(existing.id) < 0); + +/// Reuses bytes already fetched for a visible foreground avatar. +/// +/// This never starts network I/O. Oversized, malformed, or unsupported images +/// are ignored, and notification delivery remains independent of the cache. +Future cacheBuzzPushAvatarFromLoadedBytes( + String communityID, + String sourceURL, + Uint8List sourceBytes, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS || + communityID.isEmpty || + sourceBytes.isEmpty || + sourceBytes.length > _maximumAvatarSourceBytes || + !isCacheablePushAvatarSource(sourceURL)) { + return; + } + final previous = _avatarEncodeTail; + final release = Completer(); + _avatarEncodeTail = release.future; + await previous; + try { + final png = await _boundedAvatarPNG(sourceBytes); + if (png == null) return; + await _invokeBestEffort({ + 'section': 'avatar', + 'communityId': communityID, + 'sourceUrl': sourceURL, + 'png': png, + }); + } finally { + release.complete(); + } +} + +Future _invokeBestEffort(Map arguments) async { + try { + await _pushPresentationChannel.invokeMethod( + 'syncPushSnapshot', + arguments, + ); + pushPresentationCacheError.value = null; + } on MissingPluginException { + // Non-Runner embeddings do not provide the native snapshot bridge. + } catch (error, stackTrace) { + pushPresentationCacheError.value = error.toString(); + debugPrint('Push presentation cache update failed: $error'); + debugPrintStack(stackTrace: stackTrace); + } +} + +@visibleForTesting +bool isCacheablePushAvatarSource(String value) { + final trimmed = value.trim(); + if (trimmed.startsWith('data:image/')) { + try { + final data = UriData.parse(trimmed); + return data.mimeType.startsWith('image/') && + data.mimeType != 'image/svg+xml' && + data.contentAsBytes().isNotEmpty; + } on FormatException { + return false; + } + } + final uri = Uri.tryParse(value.trim()); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty && + uri.userInfo.isEmpty; +} + +Future _boundedAvatarPNG(Uint8List sourceBytes) async { + for (final size in const [128, 96, 64, 48]) { + ui.Codec? codec; + ui.Image? image; + try { + codec = await ui.instantiateImageCodec( + sourceBytes, + targetWidth: size, + targetHeight: size, + allowUpscaling: false, + ); + final frame = await codec.getNextFrame(); + image = frame.image; + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) continue; + final png = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + if (png.isNotEmpty && png.length <= _maximumAvatarPNGBytes) return png; + } catch (_) { + return null; + } finally { + image?.dispose(); + codec?.dispose(); + } + } + return null; +} diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart new file mode 100644 index 00000000000..f2e8eebd8be --- /dev/null +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -0,0 +1,61 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import 'dev_push_lease.dart'; + +typedef BuzzPushDescriptorFetcher = + Future Function(String relayBaseUrl); + +final buzzPushDescriptorFetcherProvider = Provider( + (ref) => fetchBuzzPushLeaseDescriptor, +); + +/// The fully validated push capability advertised by the current relay. +/// +/// Discovery fails closed. An absent, malformed, or unreachable NIP-11 push +/// descriptor is represented as no capability, so no notification permission, +/// APNs registration, gateway enrollment, or relay lease can begin. +final currentRelayPushDescriptorProvider = + FutureProvider.autoDispose((ref) async { + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final community = ref.watch(activeCommunityProvider).value; + final memberPubkey = ref.watch(myPubkeyProvider); + if (session.status != SessionStatus.connected || + community == null || + config.nsec == null || + config.nsec!.isEmpty || + memberPubkey == null || + memberPubkey.isEmpty) { + return null; + } + + return discoverBuzzPushRelayCapability( + config.baseUrl, + fetchDescriptor: ref.read(buzzPushDescriptorFetcherProvider), + ); + }); + +Future discoverBuzzPushRelayCapability( + String relayBaseUrl, { + required BuzzPushDescriptorFetcher fetchDescriptor, +}) async { + try { + return await fetchDescriptor(relayBaseUrl); + } catch (error, stackTrace) { + debugPrint('Current relay does not advertise valid push: $error'); + debugPrintStack(stackTrace: stackTrace); + return null; + } +} + +Future startBuzzPushRegistrationIfCapable( + BuzzPushLeaseDescriptor? descriptor, { + required Future Function() startRegistration, +}) async { + if (descriptor == null) return; + await startRegistration(); +} diff --git a/mobile/lib/shared/push/push_snapshot.dart b/mobile/lib/shared/push/push_snapshot.dart new file mode 100644 index 00000000000..f269d05c0c5 --- /dev/null +++ b/mobile/lib/shared/push/push_snapshot.dart @@ -0,0 +1,53 @@ +import 'push_subscription.dart'; + +/// The minimum community state shared with the iOS notification extension. +class BuzzPushCommunitySnapshot { + final String id; + final String name; + final String relayUrl; + final String? pubkey; + final List subscriptions; + + BuzzPushCommunitySnapshot({ + required this.id, + required this.name, + required this.relayUrl, + this.pubkey, + required Iterable subscriptions, + }) : subscriptions = List.unmodifiable(subscriptions); + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + if (pubkey != null) 'pubkey': pubkey, + 'policies': [ + for (final subscription in subscriptions) + { + 'filter': subscription.filter.toJson(), + if (subscription.ignore.isNotEmpty) + 'ignore': [ + for (final filter in subscription.ignore) filter.toJson(), + ], + if (subscription.suppress != null) + 'suppress': subscription.suppress!.toJson(), + }, + ], + }; + + factory BuzzPushCommunitySnapshot.fromJson(Map json) { + return BuzzPushCommunitySnapshot( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + subscriptions: [ + for (final raw in json['policies'] as List) + BuzzPushSubscription.fromJson({ + ...Map.from(raw as Map), + 'class': 'default', + }), + ], + ); + } +} diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart new file mode 100644 index 00000000000..41ef1808fec --- /dev/null +++ b/mobile/lib/shared/push/push_subscription.dart @@ -0,0 +1,579 @@ +import 'dart:convert'; + +/// User-visible Buzz message kinds. This mirrors +/// `EventKind.channelMessageEventKinds` without importing feature code into +/// the shared push layer. +const buzzPushEligibleKinds = [9, 40002, 45001, 45003]; +const buzzPushSelfDirectedKinds = buzzPushEligibleKinds; +const buzzPushRenderableKinds = buzzPushEligibleKinds; +const buzzPushChannelKinds = [9]; +const buzzPushChannelChunkSize = 50; +const buzzPushMaxSubscriptions = 16; +const buzzPushMaxIgnoreFilters = 8; +const buzzPushHellthreadParticipantLimit = 20; + +const _supportedNotificationClasses = {'default'}; +const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; +final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); +final _channelIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', +); + +enum BuzzPushLeaseSubscriptionAuthority { desired, accepted } + +class BuzzPushFilter { + final List kinds; + final List? authors; + final List? pTags; + final List? hTags; + final List? eTags; + + BuzzPushFilter({ + required Iterable kinds, + Iterable? authors, + Iterable? pTags, + Iterable? hTags, + Iterable? eTags, + }) : kinds = List.unmodifiable(kinds), + authors = _optionalList(authors), + pTags = _optionalList(pTags), + hTags = _optionalList(hTags), + eTags = _optionalList(eTags) { + _validate(); + } + + Map toJson() => { + 'kinds': kinds, + if (authors != null) 'authors': authors, + if (pTags != null) '#p': pTags, + if (hTags != null) '#h': hTags, + if (eTags != null) '#e': eTags, + }; + + factory BuzzPushFilter.fromJson(Map json) { + _rejectUnknownKeys(json, _filterKeys, 'push filter'); + return BuzzPushFilter( + kinds: _intList(json, 'kinds'), + authors: _optionalStringList(json, 'authors'), + pTags: _optionalStringList(json, '#p'), + hTags: _optionalStringList(json, '#h'), + eTags: _optionalStringList(json, '#e'), + ); + } + + void _validate() { + if (kinds.isEmpty || + kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) { + throw const FormatException('Push filter contains invalid kinds.'); + } + for (final value in [...?authors, ...?pTags, ...?eTags]) { + if (!_exactHexPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains a non-exact hex value.', + ); + } + } + for (final value in hTags ?? const []) { + if (!_channelIdPattern.hasMatch(value)) { + throw const FormatException( + 'Push filter contains an invalid channel ID.', + ); + } + } + } +} + +class BuzzPushSuppression { + final int pTagsMax; + + const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0); + + Map toJson() => {'p_tags_max': pTagsMax}; + + factory BuzzPushSuppression.fromJson(Map json) { + _rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression'); + final value = json['p_tags_max']; + if (value is! int || value <= 0) { + throw const FormatException('p_tags_max must be a positive integer.'); + } + return BuzzPushSuppression(pTagsMax: value); + } +} + +class BuzzPushSubscription { + final BuzzPushFilter filter; + final String notificationClass; + final List ignore; + final BuzzPushSuppression? suppress; + + BuzzPushSubscription({ + required this.filter, + required this.notificationClass, + Iterable ignore = const [], + this.suppress, + }) : ignore = List.unmodifiable(ignore) { + if (!_supportedNotificationClasses.contains(notificationClass)) { + throw const FormatException('Unsupported push notification class.'); + } + if (filter.authors == null && + filter.pTags == null && + filter.hTags == null) { + throw const FormatException('Push subscription filter is not narrowed.'); + } + if (this.ignore.length > buzzPushMaxIgnoreFilters) { + throw const FormatException( + 'Push subscription has too many ignore filters.', + ); + } + } + + Map toJson() => { + 'filter': filter.toJson(), + 'class': notificationClass, + if (ignore.isNotEmpty) + 'ignore': [for (final filter in ignore) filter.toJson()], + if (suppress != null) 'suppress': suppress!.toJson(), + }; + + factory BuzzPushSubscription.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'filter', + 'class', + 'ignore', + 'suppress', + }, 'push subscription'); + final filter = json['filter']; + final notificationClass = json['class']; + final ignore = json['ignore']; + final suppress = json['suppress']; + if (filter is! Map || notificationClass is! String) { + throw const FormatException('Malformed push subscription.'); + } + if (ignore != null && ignore is! List) { + throw const FormatException('Push subscription ignore must be a list.'); + } + if (suppress != null && suppress is! Map) { + throw const FormatException( + 'Push subscription suppress must be an object.', + ); + } + return BuzzPushSubscription( + filter: BuzzPushFilter.fromJson(Map.from(filter)), + notificationClass: notificationClass, + ignore: [ + for (final raw in ignore as List? ?? const []) + if (raw is Map) + BuzzPushFilter.fromJson(Map.from(raw)) + else + throw const FormatException('Malformed push ignore filter.'), + ], + suppress: suppress == null + ? null + : BuzzPushSuppression.fromJson(Map.from(suppress)), + ); + } +} + +/// Desired and relay-accepted lease subscriptions are intentionally separate. +/// Keeping both sets makes relay rejection or expiry detectable instead of +/// assuming the desired lease was accepted unchanged. +class BuzzPushLeaseSubscriptionState { + final BuzzPushLeaseSubscriptionAuthority authority; + final List desired; + final List? accepted; + + /// Monotonic generation of the relay-facing kind-30350 lease. + final int? acceptedGeneration; + + /// Highest lease generation durably reserved by the client. This advances + /// before relay publication so a relay commit followed by a local failure + /// cannot make the next retry reuse a stale generation. + final int? generationCursor; + + /// Higher-generation inactive lease that still needs relay acceptance. + /// + /// This is persisted before publication. Ambiguous retries reserve a newer + /// generation so a relay-accepted tombstone whose local acknowledgement was + /// lost is safely superseded without weakening strict relay monotonicity. + final int? pendingTombstoneGeneration; + + const BuzzPushLeaseSubscriptionState.desired({ + this.desired = const [], + this.accepted, + this.acceptedGeneration, + this.generationCursor, + this.pendingTombstoneGeneration, + }) : assert( + pendingTombstoneGeneration == null || + (pendingTombstoneGeneration > (acceptedGeneration ?? 0) && + generationCursor != null && + pendingTombstoneGeneration <= generationCursor), + ), + authority = BuzzPushLeaseSubscriptionAuthority.desired; + + BuzzPushLeaseSubscriptionState.accepted({ + required Iterable desired, + required Iterable acceptedSubscriptions, + required this.acceptedGeneration, + this.generationCursor, + this.pendingTombstoneGeneration, + }) : authority = BuzzPushLeaseSubscriptionAuthority.accepted, + desired = List.unmodifiable(desired), + accepted = List.unmodifiable(acceptedSubscriptions) { + if (acceptedGeneration == null || acceptedGeneration! <= 0) { + throw const FormatException( + 'Accepted push authority requires a positive lease generation.', + ); + } + if (generationCursor != null && generationCursor! < acceptedGeneration!) { + throw const FormatException( + 'Push lease generation cursor cannot trail the accepted generation.', + ); + } + _validatePendingTombstone(); + } + + void _validatePendingTombstone() { + final pending = pendingTombstoneGeneration; + if (pending == null) return; + if (pending <= (acceptedGeneration ?? 0) || + generationCursor == null || + pending > generationCursor!) { + throw const FormatException( + 'Pending push tombstone must be newer than accepted state and durably reserved.', + ); + } + } + + List get authoritative => switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => desired, + BuzzPushLeaseSubscriptionAuthority.accepted => accepted!, + }; + + BuzzPushLeaseSubscriptionState withDesired( + Iterable subscriptions, + ) { + final updated = List.unmodifiable(subscriptions); + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: updated, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generationCursor, + pendingTombstoneGeneration: pendingTombstoneGeneration, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: updated, + acceptedSubscriptions: accepted!, + acceptedGeneration: acceptedGeneration, + generationCursor: generationCursor, + pendingTombstoneGeneration: pendingTombstoneGeneration, + ), + }; + } + + BuzzPushLeaseSubscriptionState withAccepted({ + required Iterable subscriptions, + required int generation, + }) => BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: subscriptions, + acceptedGeneration: generation, + generationCursor: generationCursor == null || generation > generationCursor! + ? generation + : generationCursor, + pendingTombstoneGeneration: + pendingTombstoneGeneration != null && + generation < pendingTombstoneGeneration! + ? pendingTombstoneGeneration + : null, + ); + + BuzzPushLeaseSubscriptionState withReservedGeneration(int generation) { + if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) { + throw const FormatException( + 'Reserved push lease generation must advance monotonically.', + ); + } + return switch (authority) { + BuzzPushLeaseSubscriptionAuthority.desired => + BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + pendingTombstoneGeneration: pendingTombstoneGeneration, + ), + BuzzPushLeaseSubscriptionAuthority.accepted => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted!, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + pendingTombstoneGeneration: pendingTombstoneGeneration, + ), + }; + } + + BuzzPushLeaseSubscriptionState withPendingTombstone(int generation) { + if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) { + throw const FormatException( + 'Pending push tombstone generation must advance monotonically.', + ); + } + return BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + pendingTombstoneGeneration: generation, + ); + } + + /// Migrates a generation reserved by an older client before the explicit + /// tombstone journal field existed. + BuzzPushLeaseSubscriptionState withPendingTombstoneAtCursor() { + final generation = generationCursor; + if (generation == null || generation <= (acceptedGeneration ?? 0)) { + return this; + } + return BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generation, + pendingTombstoneGeneration: generation, + ); + } + + BuzzPushLeaseSubscriptionState withAcceptedTombstone(int generation) { + if (generation != pendingTombstoneGeneration || + generation < (acceptedGeneration ?? 0)) { + return this; + } + return BuzzPushLeaseSubscriptionState.desired( + desired: desired, + acceptedGeneration: generation, + generationCursor: + generationCursor == null || generation > generationCursor! + ? generation + : generationCursor, + ); + } + + Map toJson() => { + 'authority': authority.name, + 'desired': [for (final subscription in desired) subscription.toJson()], + if (accepted != null) + 'accepted': [for (final subscription in accepted!) subscription.toJson()], + if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration, + if (generationCursor != null) 'generationCursor': generationCursor, + if (pendingTombstoneGeneration != null) + 'pendingTombstoneGeneration': pendingTombstoneGeneration, + }; + + factory BuzzPushLeaseSubscriptionState.fromJson(Map json) { + _rejectUnknownKeys(json, const { + 'authority', + 'desired', + 'accepted', + 'acceptedGeneration', + 'generationCursor', + 'pendingTombstoneGeneration', + }, 'push subscription state'); + final authority = json['authority']; + final desired = _subscriptionList( + json['desired'], + 'desired', + allowEmpty: authority == 'desired', + ); + final acceptedRaw = json['accepted']; + final accepted = acceptedRaw == null + ? null + : _subscriptionList(acceptedRaw, 'accepted'); + final acceptedGeneration = json['acceptedGeneration']; + final generationCursor = json['generationCursor']; + final pendingTombstoneGeneration = json['pendingTombstoneGeneration']; + if (acceptedGeneration != null && acceptedGeneration is! int) { + throw const FormatException( + 'Accepted push lease generation must be an integer.', + ); + } + if (generationCursor != null && generationCursor is! int) { + throw const FormatException( + 'Push lease generation cursor must be an integer.', + ); + } + if (pendingTombstoneGeneration != null && + pendingTombstoneGeneration is! int) { + throw const FormatException( + 'Pending push tombstone generation must be an integer.', + ); + } + if (authority == 'desired' && + pendingTombstoneGeneration is int && + (pendingTombstoneGeneration <= (acceptedGeneration as int? ?? 0) || + generationCursor is! int || + pendingTombstoneGeneration > generationCursor)) { + throw const FormatException( + 'Pending push tombstone must be newer than accepted state and durably reserved.', + ); + } + return switch (authority) { + 'desired' => BuzzPushLeaseSubscriptionState.desired( + desired: desired, + accepted: accepted, + acceptedGeneration: acceptedGeneration as int?, + generationCursor: generationCursor as int?, + pendingTombstoneGeneration: pendingTombstoneGeneration as int?, + ), + 'accepted' when accepted != null && acceptedGeneration is int => + BuzzPushLeaseSubscriptionState.accepted( + desired: desired, + acceptedSubscriptions: accepted, + acceptedGeneration: acceptedGeneration, + generationCursor: generationCursor as int?, + pendingTombstoneGeneration: pendingTombstoneGeneration as int?, + ), + 'accepted' => throw const FormatException( + 'Accepted push authority requires accepted subscriptions and generations.', + ), + _ => throw const FormatException('Unknown push subscription authority.'), + }; + } +} + +List buildDesiredBuzzPushSubscriptions({ + required String myPubkey, + Iterable channelIds = const [], + Iterable mutedChannelIds = const [], +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + if (!_exactHexPattern.hasMatch(normalizedPubkey)) { + throw const FormatException('Push subscription pubkey must be exact hex.'); + } + + final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet(); + final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet(); + final activeMuted = + normalizedMuted.intersection(normalizedChannelIds).toList()..sort(); + final mutedIgnoreFilters = []; + for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) { + mutedIgnoreFilters.add( + BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + ); + } + if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) { + throw const FormatException('Too many muted channels for a push lease.'); + } + + final selfAuthored = BuzzPushFilter( + kinds: buzzPushRenderableKinds, + authors: [normalizedPubkey], + ); + final ignores = [selfAuthored, ...mutedIgnoreFilters]; + const suppression = BuzzPushSuppression( + pTagsMax: buzzPushHellthreadParticipantLimit, + ); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter( + kinds: buzzPushSelfDirectedKinds, + pTags: [normalizedPubkey], + ), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ]; + + final channels = normalizedChannelIds.difference(normalizedMuted).toList() + ..sort(); + for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) { + subscriptions.add( + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk), + notificationClass: 'default', + ignore: ignores, + suppress: suppression, + ), + ); + } + if (subscriptions.length > buzzPushMaxSubscriptions) { + throw const FormatException('Too many channels for a push lease.'); + } + return List.unmodifiable(subscriptions); +} + +String buzzPushSubscriptionsFingerprint( + List subscriptions, +) => jsonEncode([ + for (final subscription in subscriptions) subscription.toJson(), +]); + +String buzzPushSubscriptionStateFingerprint( + BuzzPushLeaseSubscriptionState state, +) => jsonEncode(state.toJson()); + +List> _chunks(List values, int size) => [ + for (var offset = 0; offset < values.length; offset += size) + values.sublist(offset, (offset + size).clamp(0, values.length)), +]; + +String _normalizeChannelID(String value) { + final normalized = value.toLowerCase(); + if (!_channelIdPattern.hasMatch(normalized)) { + throw const FormatException('Push subscription channel ID is invalid.'); + } + return normalized; +} + +List? _optionalList(Iterable? values) => + values == null ? null : List.unmodifiable(values); + +List _intList(Map json, String key) { + final raw = json[key]; + if (raw is! List || raw.any((value) => value is! int)) { + throw FormatException('$key must be an integer list.'); + } + return raw.cast(); +} + +List? _optionalStringList(Map json, String key) { + if (!json.containsKey(key)) return null; + final raw = json[key]; + if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) { + throw FormatException('$key must be a non-empty string list.'); + } + return raw.cast(); +} + +List _subscriptionList( + Object? raw, + String label, { + bool allowEmpty = false, +}) { + if (raw is! List || (!allowEmpty && raw.isEmpty)) { + throw FormatException('$label subscriptions must be a non-empty list.'); + } + return [ + for (final item in raw) + if (item is Map) + BuzzPushSubscription.fromJson(Map.from(item)) + else + throw FormatException('Malformed $label subscription.'), + ]; +} + +void _rejectUnknownKeys( + Map json, + Set allowed, + String label, +) { + final unknown = json.keys.where((key) => !allowed.contains(key)); + if (unknown.isNotEmpty) { + throw FormatException('$label contains unknown field ${unknown.first}.'); + } +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart new file mode 100644 index 00000000000..9d24e7cebc9 --- /dev/null +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../features/channels/channel.dart'; +import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; +import '../../features/channels/channels_provider.dart'; +import '../community/community.dart'; +import '../community/community_provider.dart'; +import '../relay/relay_provider.dart'; +import 'push_subscription.dart'; + +/// Keeps the persisted desired lease and the App Group snapshot aligned with +/// the active identity, joined channels, and mute state. This is desired client +/// policy only. Relay-accepted authority is introduced by lease publication. +final pushSubscriptionSyncProvider = Provider((ref) { + final active = ref.watch(activeCommunityProvider).value; + final channels = ref.watch(channelsProvider).value; + final mutes = ref.watch(channelMutesProvider); + if (active == null || + !active.pushNotificationsEnabled || + channels == null || + !mutes.isReady) { + return; + } + + final subscriptions = desiredBuzzPushSubscriptions( + community: active, + channels: channels, + mutedChannelIds: [ + for (final entry in mutes.store.channels.entries) + if (entry.value.muted) entry.key, + ], + ); + if (subscriptions == null) return; + unawaited( + ref + .read(communityListProvider.notifier) + .updateDesiredPushSubscriptions(active.id, subscriptions), + ); +}); + +List? desiredBuzzPushSubscriptions({ + required Community community, + required Iterable channels, + required Iterable mutedChannelIds, +}) { + final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec); + if (pubkey == null || pubkey.isEmpty) return null; + return buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: [ + // Activity surfaces channel-wide traffic only for joined DM channels. + // Other message kinds enter the inbox through an exact #p mention or + // participant-thread tag, so subscribing to every joined channel would + // over-notify compared with the product predicate. + for (final channel in channels) + if (channel.isDm && channel.isMember && !channel.isArchived) channel.id, + ], + mutedChannelIds: mutedChannelIds, + ); +} diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 14187df081c..0ab5b5aa204 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -38,6 +38,10 @@ class MediaImageProvider extends ImageProvider { final double scale; final MediaGetAuthService auth; + /// Optional observer for bytes already fetched by the foreground image path. + /// Excluded from equality because it is a side effect, not cache identity. + final ValueChanged? onBytesLoaded; + /// Excluded from equality: transport, not identity. final http.Client client; @@ -45,6 +49,7 @@ class MediaImageProvider extends ImageProvider { required this.url, required this.auth, required this.client, + this.onBytesLoaded, this.scale = 1.0, }); @@ -109,6 +114,7 @@ class MediaImageProvider extends ImageProvider { _cooldownUntil[url] = debugNow().add(_defaultCooldown); throw NetworkImageLoadException(statusCode: 200, uri: uri); } + onBytesLoaded?.call(bytes); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); return decode(buffer); } catch (_) { @@ -181,6 +187,7 @@ class MediaImage extends ConsumerWidget { final FilterQuality filterQuality; final double? decodeWidth; final bool boundDecodeToLayout; + final ValueChanged? onBytesLoaded; const MediaImage({ super.key, @@ -194,6 +201,7 @@ class MediaImage extends ConsumerWidget { this.filterQuality = FilterQuality.medium, this.decodeWidth, this.boundDecodeToLayout = true, + this.onBytesLoaded, }); @override @@ -202,6 +210,7 @@ class MediaImage extends ConsumerWidget { url: url, auth: ref.watch(mediaGetAuthServiceProvider), client: ref.watch(mediaHttpClientProvider), + onBytesLoaded: onBytesLoaded, ); if (decodeWidth != null) { diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 061dd6cb386..00fcf65b716 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -62,7 +62,8 @@ class RelayConfig { /// Compile-time environment config via --dart-define. /// /// Run with: -/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 +/// flutter run --dart-define=BUZZ_RELAY_URL=http://localhost:3000 \ +/// --dart-define=BUZZ_PUSH_GATEWAY_URL=http://localhost:8080 /// /// Or create a `.env.json` and use --dart-define-from-file=.env.json class Env { @@ -70,6 +71,10 @@ class Env { 'BUZZ_RELAY_URL', defaultValue: 'http://localhost:3000', ); + static const pushGatewayUrl = String.fromEnvironment( + 'BUZZ_PUSH_GATEWAY_URL', + defaultValue: 'https://push.buzz.xyz', + ); } class RelayConfigNotifier extends Notifier { diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index a739b765941..b0106639eb7 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -1,7 +1,10 @@ +import 'dart:async'; + import 'package:nostr/nostr.dart' as nostr; import 'nostr_models.dart'; import 'relay_session.dart'; +import 'relay_socket.dart'; /// Signs and submits Nostr events through the relay WebSocket connection. class SignedEventRelay { @@ -57,3 +60,73 @@ class SignedEventRelay { return _session.publish(nostrEvent); } } + +/// Publishes one signed event over a short-lived authenticated NIP-42 socket. +/// +/// This is used for community-removal tombstones because the community being +/// removed is not necessarily the app's active relay session. +Future submitSignedEventOnce({ + required String wsUrl, + required String nsec, + required int kind, + required String content, + required List> tags, + int? createdAt, + Duration timeout = const Duration(seconds: 12), +}) async { + final privateKey = nostr.Nip19.decode(payload: nsec).data; + if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); + final signed = nostr.Event.from( + kind: kind, + content: content, + tags: tags, + secretKey: privateKey, + createdAt: createdAt, + verify: false, + ); + final event = NostrEvent.fromJson(signed.toMap()); + final result = Completer(); + late final RelaySocket socket; + socket = RelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: (message) { + if (message case [ + 'OK', + final String eventId, + final bool accepted, + final String detail, + ..., + ] when eventId == event.id) { + if (accepted) { + result.complete( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: detail, + sig: event.sig, + ), + ); + } else { + result.completeError(Exception('Relay rejected event: $detail')); + } + } + }, + onConnected: () => socket.send(['EVENT', event.toJson()]), + onDisconnected: (error) { + if (!result.isCompleted) { + result.completeError(error ?? Exception('Relay disconnected')); + } + }, + ); + final resultFuture = result.future.timeout(timeout); + try { + await socket.connect(); + return await resultFuture; + } finally { + await socket.disconnect(); + } +} diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index 0a5379cf333..b869bf8fbc4 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -1,12 +1,16 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../animated_avatar.dart'; +import '../community/community_provider.dart'; import '../emoji/emoji_avatar.dart'; import '../emoji/native_emoji_glyph.dart'; +import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -52,7 +56,7 @@ class AvatarImage extends StatelessWidget { } /// Image content for avatar surfaces whose shape is supplied by their parent. -class AvatarImageContent extends StatefulWidget { +class AvatarImageContent extends ConsumerStatefulWidget { final String? imageUrl; final Widget fallback; final BoxFit fit; @@ -65,11 +69,12 @@ class AvatarImageContent extends StatefulWidget { }); @override - State createState() => _AvatarImageContentState(); + ConsumerState createState() => _AvatarImageContentState(); } -class _AvatarImageContentState extends State { +class _AvatarImageContentState extends ConsumerState { late _AvatarSource? _source = _AvatarSource.parse(widget.imageUrl); + String? _scheduledPushAvatar; @override void didUpdateWidget(AvatarImageContent oldWidget) { @@ -82,6 +87,7 @@ class _AvatarImageContentState extends State { @override Widget build(BuildContext context) { final centeredFallback = Center(child: widget.fallback); + final communityID = ref.watch(activeCommunityProvider).value?.id; return switch (_source) { _EmojiAvatarSource(:final emoji, :final color) => ColoredBox( @@ -105,19 +111,50 @@ class _AvatarImageContentState extends State { placeholderBuilder: (_) => centeredFallback, errorBuilder: (_, _, _) => centeredFallback, ), - _RasterDataAvatarSource(:final bytes) => Image.memory( - bytes, - fit: widget.fit, - errorBuilder: (_, _, _) => centeredFallback, + _RasterDataAvatarSource(:final bytes) => _rasterImage( + communityID: communityID, + sourceURL: widget.imageUrl, + bytes: bytes, + fallback: centeredFallback, ), _NetworkAvatarSource(:final url) => MediaImage( url: url, fit: widget.fit, + onBytesLoaded: communityID == null + ? null + : (bytes) => unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, url, bytes), + ), errorBuilder: (_, _, _) => centeredFallback, ), null => centeredFallback, }; } + + Widget _rasterImage({ + required String? communityID, + required String? sourceURL, + required Uint8List bytes, + required Widget fallback, + }) { + if (communityID != null && sourceURL != null) { + final identity = '$communityID\u0000$sourceURL'; + if (_scheduledPushAvatar != identity) { + _scheduledPushAvatar = identity; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _scheduledPushAvatar != identity) return; + unawaited( + cacheBuzzPushAvatarFromLoadedBytes(communityID, sourceURL, bytes), + ); + }); + } + } + return Image.memory( + bytes, + fit: widget.fit, + errorBuilder: (_, _, _) => fallback, + ); + } } sealed class _AvatarSource { diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index a26308b284e..da1746df06c 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -157,6 +157,74 @@ void main() { expect(destination.link, same(link)); }); + testWidgets('switches to the notification community before dispatch', ( + tester, + ) async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + await storage.save(_firstCommunity); + await storage.save(_notificationCommunity); + await storage.saveActiveId(_firstCommunity.id); + const link = MessageDeepLink( + communityId: 'community-2', + channelId: 'channel-1', + messageId: 'message-2', + ); + + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + key: const ValueKey('before-community-switch'), + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(await storage.loadActiveId(), _notificationCommunity.id); + expect(container.read(pendingDeepLinkProvider), link); + + // Production remounts the community-scoped app subtree after a switch. + // The parked link is consumed by the replacement dispatcher. + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: DeepLinkDispatcher( + key: const ValueKey('after-community-switch'), + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.link, link); + }); + testWidgets('retains invite and surfaces prepare failure', (tester) async { const link = InviteDeepLink( relayUrl: 'wss://relay.example.com', @@ -619,6 +687,19 @@ final _channel = Channel( isMember: true, ); +final _firstCommunity = Community( + id: 'community-1', + name: 'First', + relayUrl: 'wss://first.example', + addedAt: DateTime(2026), +); + +final _notificationCommunity = Community( + id: 'community-2', + name: 'Notification', + relayUrl: 'wss://notification.example', + addedAt: DateTime(2026), +); final _welcomeEveryoneChannel = Channel( id: 'welcome-everyone-id', name: 'welcome-everyone', diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 59b7fefff48..e8f6fb6ade6 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -1,6 +1,10 @@ import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/community/community_membership_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; import 'package:flutter/material.dart'; @@ -10,6 +14,147 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { + testWidgets('shows the persisted per-community push opt-in on iOS', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith((ref) async => community), + appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.authorized, + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('push-notifications-enabled')), + findsOneWidget, + ); + expect(tester.widget(find.byType(Switch)).value, isTrue); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('shows denied display permission and opens iOS settings', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true); + var openSettingsCalls = 0; + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith((ref) async => community), + appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.denied, + ), + buzzPushNotificationSettingsOpenerProvider.overrideWithValue( + () async { + openSettingsCalls += 1; + return true; + }, + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + find.text('Enabled in Buzz, but disabled in iOS Settings'), + findsOneWidget, + ); + await tester.tap( + find.byKey(const ValueKey('push-notifications-open-settings')), + ); + await tester.pump(); + expect(openSettingsCalls, 1); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('shows permission lookup errors with settings recovery', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final community = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith(pushNotificationsEnabled: true); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + activeCommunityProvider.overrideWith((ref) async => community), + appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => throw StateError('authorization unavailable'), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: SettingsPage( + profileHeader: const SizedBox.shrink(), + invitePageBuilder: (_) => const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => const SizedBox.shrink(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Enabled in Buzz; iOS permission status unavailable'), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('push-notifications-open-settings')), + findsOneWidget, + ); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('opens profile edit choices and routes photo directly', ( tester, ) async { @@ -329,3 +474,8 @@ void main() { ); }); } + +class _SettingsLifecycleNotifier extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 72c42cb3ed1..9dcced5f333 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -7,6 +8,7 @@ import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; import '../community/community_storage_test.dart'; @@ -137,8 +139,16 @@ void main() { ); await storage.save(invalid); await storage.saveActiveId(invalid.id); + final snapshots = >[]; final container = ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(storage)], + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + ], ); addTearDown(container.dispose); @@ -147,9 +157,163 @@ void main() { expect(auth.status, AuthStatus.unauthenticated); expect(await storage.loadAll(), isEmpty); expect(await storage.loadActiveId(), isNull); + expect(snapshots.last, isEmpty); }, ); + test('authenticate exports the complete stored community snapshot', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final existing = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + final added = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(existing); + final snapshots = >[]; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(added); + + expect(snapshots.last.map((community) => community.id), { + existing.id, + added.id, + }); + }); + + test( + 'sign out removes the active community from the shared snapshot', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final first = Community.create( + name: 'First', + relayUrl: 'https://first.example', + nsec: nostr.Keys.generate().nsec, + ); + final second = Community.create( + name: 'Second', + relayUrl: 'https://second.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(first); + await storage.save(second); + await storage.saveActiveId(first.id); + final snapshots = >[]; + final journaledCommunityIds = []; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( + community, + ) async { + journaledCommunityIds.add(community.id); + return true; + }), + communityPushLeaseRevocationTriggerProvider.overrideWithValue( + () async {}, + ), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + + await container.read(authProvider.notifier).signOut(); + + expect( + snapshots.any((snapshot) { + return snapshot.length == 1 && snapshot.single.id == second.id; + }), + isTrue, + ); + expect( + snapshots.last.map((community) => community.id), + isNot(contains(first.id)), + ); + expect(journaledCommunityIds, [first.id]); + }, + ); + + test( + 'snapshot export failure does not gate startup authentication', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(community); + await storage.saveActiveId(community.id); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + final auth = await container.read(authProvider.future); + + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect(pushCommunitySnapshotError.value, contains('save_failed')); + }, + ); + + test('snapshot export failure does not gate direct authentication', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(community); + + final auth = await container.read(authProvider.future); + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect((await storage.loadAll()).single.id, community.id); + }); + test('falls through to the next valid saved community', () async { final storage = CommunityStorage(secure: FakeSecureStorage()); final invalid = Community.create( diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index 5396822b319..345226e84d1 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -1,10 +1,13 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'community_storage_test.dart'; @@ -12,17 +15,51 @@ void main() { late FakeSecureStorage fakeSecure; late CommunityStorage communityStorage; late ProviderContainer container; + late List> snapshots; + late List deactivatedCommunityIds; + late List deactivationGenerations; + late List journaledCommunityIds; + late int revocationTriggers; + late CommunityPushLeaseRevocationTrigger revocationTrigger; + late CommunityPushLeaseDeactivator deactivator; setUp(() { fakeSecure = FakeSecureStorage(); communityStorage = CommunityStorage(secure: fakeSecure); + snapshots = []; + deactivatedCommunityIds = []; + deactivationGenerations = []; + journaledCommunityIds = []; + revocationTriggers = 0; + revocationTrigger = () async { + revocationTriggers += 1; + }; + deactivator = (community, {generation}) async { + deactivatedCommunityIds.add(community.id); + deactivationGenerations.add(generation); + }; }); tearDown(() => container.dispose()); ProviderContainer createContainer() { return ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(communityStorage)], + overrides: [ + communityStorageProvider.overrideWithValue(communityStorage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(deactivator), + communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( + community, + ) async { + journaledCommunityIds.add(community.id); + return true; + }), + communityPushLeaseRevocationTriggerProvider.overrideWithValue( + revocationTrigger, + ), + ], ); } @@ -31,6 +68,40 @@ void main() { container = createContainer(); final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(snapshots, [isEmpty]); + }); + + test('exports migrated communities on startup', () async { + final community = Community.create( + name: 'Migrated', + relayUrl: 'https://migrated.example.com', + nsec: nostr.Keys.generate().nsec, + ); + // Seed legacy storage to exercise the same migration path as an app + // upgrade. + fakeSecure['buzz_workspaces'] = jsonEncode([community.toJson()]); + + container = createContainer(); + await container.read(communityListProvider.future); + + expect(snapshots.single.single.id, community.id); + expect(fakeSecure['buzz_workspaces'], isNull); + }); + + test('skips an unchanged snapshot after provider invalidation', () async { + final community = Community.create( + name: 'Stored', + relayUrl: 'https://stored.example.com', + nsec: nostr.Keys.generate().nsec, + ); + await communityStorage.save(community); + container = createContainer(); + + await container.read(communityListProvider.future); + container.invalidate(communityListProvider); + await container.read(communityListProvider.future); + + expect(snapshots, hasLength(1)); }); test('addCommunity adds to list', () async { @@ -48,6 +119,242 @@ void main() { expect(communities.first.name, 'Test'); }); + test( + 'push notifications default off and opt-in survives restart', + () async { + container = createContainer(); + await container.read(communityListProvider.future); + final community = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + await container + .read(communityListProvider.notifier) + .addCommunity(community); + expect( + (await container.read( + communityListProvider.future, + )).single.pushNotificationsEnabled, + isFalse, + ); + + await container + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, true); + container.dispose(); + container = createContainer(); + + expect( + (await container.read( + communityListProvider.future, + )).single.pushNotificationsEnabled, + isTrue, + ); + }, + ); + + test( + 'lease retry reserves beyond a locally unaccepted generation', + () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final subscriptionState = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 4); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: subscriptionState, + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + expect(await notifier.reservePushLeaseGeneration(community.id), 5); + expect(await notifier.reservePushLeaseGeneration(community.id), 6); + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushSubscriptionState.acceptedGeneration, 4); + expect(stored.pushSubscriptionState.generationCursor, 6); + }, + ); + + test('older lease success cannot regress accepted generation', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 6), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: const [], + generation: 5, + ); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushSubscriptionState.acceptedGeneration, 6); + expect( + stored.pushSubscriptionState.accepted!.single.toJson(), + subscription.toJson(), + ); + }); + + test('opt-out tombstones an in-flight first publication', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + expect(await notifier.reservePushLeaseGeneration(community.id), 1); + await notifier.setPushNotificationsEnabled(community.id, false); + await notifier.markPushLeaseAccepted( + community.id, + subscriptions: [subscription], + generation: 1, + ); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.acceptedGeneration, 2); + expect(stored.pushSubscriptionState.generationCursor, 2); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull); + expect(deactivationGenerations, [2]); + }); + + test('opt-out persists first and publishes a higher tombstone', () async { + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 7), + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + await notifier.setPushNotificationsEnabled(community.id, false); + + final stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.generationCursor, 8); + expect(stored.pushSubscriptionState.acceptedGeneration, 8); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull); + expect(deactivatedCommunityIds, [community.id]); + expect(deactivationGenerations, [8]); + + container.dispose(); + container = createContainer(); + expect( + (await container.read( + communityListProvider.future, + )).single.pushNotificationsEnabled, + isFalse, + ); + }); + + test( + 'failed opt-out tombstone retries after restart at a newer generation', + () async { + var failTombstone = true; + deactivator = (community, {generation}) async { + deactivatedCommunityIds.add(community.id); + deactivationGenerations.add(generation); + if (failTombstone) { + throw StateError('injected tombstone failure'); + } + }; + container = createContainer(); + await container.read(communityListProvider.future); + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ).copyWith( + pushNotificationsEnabled: true, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 7), + ); + await container + .read(communityListProvider.notifier) + .addCommunity(community); + + await container + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, false); + + var stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.acceptedGeneration, 7); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, 8); + expect(deactivationGenerations, [8]); + + container.dispose(); + failTombstone = false; + container = createContainer(); + await container.read(communityListProvider.future); + await container + .read(communityListProvider.notifier) + .retryPendingPushLeaseTombstone( + community.id, + advanceGeneration: true, + ); + + stored = (await communityStorage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect(stored.pushSubscriptionState.acceptedGeneration, 9); + expect(stored.pushSubscriptionState.generationCursor, 9); + expect(stored.pushSubscriptionState.pendingTombstoneGeneration, isNull); + expect(deactivationGenerations, [8, 9]); + }, + ); + test('removeCommunity removes from list', () async { container = createContainer(); await container.read(communityListProvider.future); @@ -63,6 +370,66 @@ void main() { final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(journaledCommunityIds, [ws.id]); + expect(deactivatedCommunityIds, isEmpty); + expect(revocationTriggers, 1); + }); + + test('remote tombstone attempt cannot block local removal', () async { + final remoteAttempt = Completer(); + revocationTrigger = () { + revocationTriggers += 1; + return remoteAttempt.future; + }; + container = createContainer(); + await container.read(communityListProvider.future); + final community = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + await notifier.removeCommunity(community.id); + + expect(await communityStorage.loadAll(), isEmpty); + expect(journaledCommunityIds, [community.id]); + expect(revocationTriggers, 1); + remoteAttempt.complete(); + }); + + test('journal persistence failure keeps community credentials', () async { + container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(communityStorage), + communitySnapshotWriterProvider.overrideWithValue((_) async {}), + communityPushLeaseRevocationEnqueuerProvider.overrideWithValue(( + _, + ) async { + throw StateError('secure storage unavailable'); + }), + communityPushLeaseRevocationTriggerProvider.overrideWithValue( + () async {}, + ), + ], + ); + await container.read(communityListProvider.future); + final community = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(community); + + await expectLater( + notifier.removeCommunity(community.id), + throwsStateError, + ); + + expect( + (await communityStorage.loadAll()).map((item) => item.id), + contains(community.id), + ); }); test( diff --git a/mobile/test/shared/community/community_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart index be14ef43178..486457e6ac5 100644 --- a/mobile/test/shared/community/community_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real @@ -117,8 +118,85 @@ void main() { expect(loaded.first.name, 'Test'); expect(loaded.first.relayUrl, 'https://relay.example.com'); expect(loaded.first.pubkey, 'abc123'); + expect( + loaded.first.pushSubscriptionState.authority, + BuzzPushLeaseSubscriptionAuthority.desired, + ); + }); + + test('round-trips desired push subscription state', () async { + final pubkey = 'a' * 64; + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: const ['123e4567-e89b-42d3-a456-426614174000'], + ); + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + pubkey: pubkey, + ).copyWith( + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ), + ); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect( + loaded.pushSubscriptionState.toJson(), + community.pushSubscriptionState.toJson(), + ); }); + test('round-trips a pending push tombstone journal', () async { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ); + final state = + BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) + .withAccepted(subscriptions: [subscription], generation: 4) + .withPendingTombstone(5); + final community = Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + ).copyWith(pushSubscriptionState: state); + + await storage.save(community); + final loaded = (await storage.loadAll()).single; + + expect(loaded.pushSubscriptionState.toJson(), state.toJson()); + expect(loaded.pushSubscriptionState.pendingTombstoneGeneration, 5); + }); + + test( + 'migrates a disabled reserved generation into a tombstone journal', + () { + final community = + Community.create( + name: 'Push', + relayUrl: 'https://relay.example.com', + ).copyWith( + pushNotificationsEnabled: false, + pushSubscriptionState: + const BuzzPushLeaseSubscriptionState.desired( + acceptedGeneration: 4, + generationCursor: 5, + ), + ); + final json = community.toJson(); + (json['pushSubscriptionState'] as Map).remove( + 'pendingTombstoneGeneration', + ); + + final migrated = Community.fromJson(json); + + expect(migrated.pushSubscriptionState.pendingTombstoneGeneration, 5); + }, + ); + test('save updates existing community with same id', () async { final ws = Community.create( name: 'Original', diff --git a/mobile/test/shared/community/community_test.dart b/mobile/test/shared/community/community_test.dart index 5c5b337a453..0d229d23660 100644 --- a/mobile/test/shared/community/community_test.dart +++ b/mobile/test/shared/community/community_test.dart @@ -15,6 +15,21 @@ void main() { SensitiveActionPolicy.disabledByUser, ); expect(community.starterSetupIncomplete, isFalse); + expect(community.pushLeaseInstallationId, isNull); + }); + + test('new community gets a unique canonical push lease address id', () { + final first = Community.create(name: 'One', relayUrl: 'https://relay.test'); + final second = Community.create( + name: 'Two', + relayUrl: 'https://relay.test', + ); + + expect(first.pushLeaseInstallationId, matches(RegExp(r'^[0-9a-f]{32}$'))); + expect( + second.pushLeaseInstallationId, + isNot(first.pushLeaseInstallationId), + ); }); test('community settings round trip', () { @@ -23,6 +38,7 @@ void main() { name: 'Buzz', relayUrl: 'https://relay.test', sensitiveActionPolicy: SensitiveActionPolicy.enabled, + pushLeaseInstallationId: 'a' * 32, starterSetupIncomplete: true, addedAt: DateTime.utc(2026, 8, 5), ); @@ -30,5 +46,19 @@ void main() { final roundTrip = Community.fromJson(community.toJson()); expect(roundTrip.sensitiveActionPolicy, SensitiveActionPolicy.enabled); expect(roundTrip.starterSetupIncomplete, isTrue); + expect(roundTrip.pushLeaseInstallationId, 'a' * 32); + }); + + test('malformed stored push lease address id is rejected', () { + expect( + () => Community.fromJson({ + 'id': 'one', + 'name': 'Buzz', + 'relayUrl': 'https://relay.test', + 'pushLeaseInstallationId': 'not-canonical', + 'addedAt': '2026-08-05T00:00:00.000Z', + }), + throwsFormatException, + ); }); } diff --git a/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart new file mode 100644 index 00000000000..b13f42df6d2 --- /dev/null +++ b/mobile/test/shared/deeplink/pending_deep_link_provider_test.dart @@ -0,0 +1,49 @@ +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + setUp(() { + PendingDeepLinkNotifier.debugUriStreamOverride = const Stream.empty(); + pendingPushNotificationLink.value = null; + }); + + tearDown(() { + PendingDeepLinkNotifier.debugUriStreamOverride = null; + pendingPushNotificationLink.value = null; + }); + + test('parks and consumes a native notification message link', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + expect(container.read(pendingDeepLinkProvider), isNull); + + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + await pumpEventQueue(); + + expect(container.read(pendingDeepLinkProvider), link); + container.read(pendingDeepLinkProvider.notifier).consume(); + expect(container.read(pendingDeepLinkProvider), isNull); + expect(pendingPushNotificationLink.value, isNull); + }); + + test('preserves a cold-start target present before provider build', () { + const link = MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'event-id', + ); + pendingPushNotificationLink.value = link; + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(pendingDeepLinkProvider), link); + }); +} diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart new file mode 100644 index 00000000000..713504c7dac --- /dev/null +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -0,0 +1,436 @@ +import 'dart:convert'; + +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + final signer = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final descriptor = _descriptor(relay.public); + final grant = _grant(relay.public); + final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + + test('publishes strict kind-30350 lease and waits for accepted OK', () async { + Map? submitted; + final publication = await publishBuzzDevPushLease( + grant: grant, + leaseGeneration: 7, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + notificationClass: 'default', + ), + ], + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'accepted-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'accepted-id'); + expect(grant.relayOrigin, descriptor.origin); + expect(jsonDecode(publication.plaintext)['origin'], descriptor.origin); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['createdAt'], 1752620000); + expect(submitted!['tags'], [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-dogfood', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 7, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [9], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }); + }); + + test('uses the community lease address instead of the endpoint id', () async { + List>? submittedTags; + + await publishBuzzDevPushLease( + grant: grant, + leaseInstallationId: 'e' * 32, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: const [], + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submittedTags = tags; + return const NostrEvent( + id: 'accepted-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(submittedTags!.first, ['d', 'e' * 32]); + }); + + test('rejects a malformed community lease address', () async { + await expectLater( + publishBuzzDevPushLease( + grant: grant, + leaseInstallationId: 'malformed', + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: const [], + now: () => now, + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async => throw StateError('should not publish'), + ), + throwsFormatException, + ); + }); + + test( + 'mutation control rejects relay OK false then accepts restored event', + () async { + final events = []; + final acknowledgements = >[]; + final session = RelaySessionNotifier(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + authProvider.overrideWith(() => _UnauthenticatedAuthNotifier()), + ], + ); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + addTearDown(container.dispose); + final socket = _MutatingRelaySocket( + events, + onEvent: (event) => event.kind == 40002 + ? (accepted: false, message: 'invalid: kind not push-eligible') + : (accepted: true, message: 'saved'), + onAcknowledgement: acknowledgements.add, + ); + session.debugAttachSocketForTest(socket); + final relayClient = SignedEventRelay(session: session, nsec: signer.nsec); + + final mutated = relayClient.submit( + kind: 40002, + content: 'mutated', + tags: const [], + createdAt: 1752620000, + ); + final rejection = expectLater( + mutated, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: kind not push-eligible'), + ), + ), + ); + await _deliverAcknowledgement(acknowledgements, session); + await rejection; + final acceptedFuture = relayClient.submit( + kind: buzzPushLeaseKind, + content: 'restored', + tags: [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ], + createdAt: 1752620001, + ); + await _deliverAcknowledgement(acknowledgements, session); + final accepted = await acceptedFuture; + + expect(accepted.content, 'saved'); + expect(events.map((event) => event.kind), [40002, buzzPushLeaseKind]); + expect(events.every((event) => event.pubkey == signer.public), isTrue); + for (final event in events) { + expect( + () => nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ), + returnsNormally, + ); + } + }, + ); + + test('propagates relay rejection instead of accepting locally', () async { + await expectLater( + publishBuzzDevPushLease( + grant: grant, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + subscriptions: [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [signer.public]), + notificationClass: 'default', + ), + ], + now: () => now, + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async => throw Exception('invalid: origin mismatch'), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: origin mismatch'), + ), + ), + ); + }); + + test('publishes a minimal higher-generation inactive tombstone', () async { + Map? submitted; + final publication = await publishBuzzPushLeaseTombstone( + descriptor: descriptor, + installationId: grant.installationId, + generation: 3, + nsec: signer.nsec, + memberPubkey: signer.public, + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'tombstone-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'tombstone-id'); + expect(jsonDecode(publication.plaintext), { + 'v': 1, + 'origin': descriptor.origin, + 'generation': 3, + 'active': false, + }); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['tags'], [ + ['d', grant.installationId], + ['expiration', '1755212000'], + ['exec', descriptor.executorKeyId], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), jsonDecode(publication.plaintext)); + }); + + test('descriptor rejects canonical origin with a trailing slash', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['origin'] = + 'wss://tenant.example:8443/'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unsupported h grammar', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = 'opaque'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unknown push fields', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['future'] = true; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); +} + +class _UnauthenticatedAuthNotifier extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _MutatingRelaySocket extends RelaySocket { + final List events; + final ({bool accepted, String message}) Function(NostrEvent event) onEvent; + final void Function(List message) _onAcknowledgement; + + _MutatingRelaySocket( + this.events, { + required this.onEvent, + required void Function(List message) onAcknowledgement, + }) : _onAcknowledgement = onAcknowledgement, + super( + wsUrl: 'wss://tenant.example:8443', + nsec: null, + onMessage: _ignoreMessage, + onConnected: _ignoreConnected, + onDisconnected: _ignoreDisconnected, + ); + + @override + SocketState get state => SocketState.connected; + + @override + void send(List payload) { + if (payload case ['EVENT', final Map eventJson]) { + final event = NostrEvent.fromJson(eventJson); + events.add(event); + final response = onEvent(event); + _onAcknowledgement(['OK', event.id, response.accepted, response.message]); + } + } + + @override + Future disconnect() async {} + + @override + void dispose() {} +} + +void _ignoreConnected() {} +void _ignoreDisconnected(Object? _) {} +void _ignoreMessage(List _) {} + +Future _deliverAcknowledgement( + List> acknowledgements, + RelaySessionNotifier session, +) async { + while (acknowledgements.isEmpty) { + await Future.delayed(Duration.zero); + } + session.debugHandleMessage(acknowledgements.removeAt(0)); +} + +BuzzPushLeaseDescriptor _descriptor(String relayPubkey) => + BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey)); + +Map _descriptorJson(String relayPubkey) => { + 'supported_extensions': ['nip-er', 'nip-pl'], + 'push': { + 'origin': 'wss://tenant.example:8443', + 'keys': [ + {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, + ], + 'app_profiles': [ + {'id': 'buzz-ios-dogfood', 'transport': 'apns'}, + ], + 'push_kinds': [9, 40002, 45001, 45003], + 'h_grammar': 'uuid-v4-lowercase', + 'class_support': { + 'apns': ['default'], + }, + 'limitation': { + 'max_lease_ttl': 2592000, + 'max_leases_per_pubkey': 16, + 'max_subscriptions_per_lease': 16, + 'max_kinds': 16, + 'max_authors': 20, + 'max_h': 50, + 'max_tag_values': 20, + 'max_ignore': 8, + 'max_content_len': 65536, + 'max_plaintext_len': 32768, + 'max_endpoint_len': 4096, + 'max_string_len': 512, + }, + }, +}; + +BuzzPushEndpointGrant _grant(String relayPubkey) => BuzzPushEndpointGrant( + relayOrigin: 'wss://tenant.example:8443', + relayPubkey: relayPubkey, + installationId: 'c' * 32, + endpointGrant: 'opaque-grant', + endpointHash: 'd' * 64, + appProfile: 'buzz-ios-dogfood', + endpointEpoch: 1, + generation: 1, + expiresAt: 1756212000, +); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart new file mode 100644 index 00000000000..6380837bf6d --- /dev/null +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -0,0 +1,204 @@ +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('failed bootstrap attempt becomes retryable after the delay', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('attempt'), isTrue); + gate.failed('attempt', retry: () => retries += 1); + await Future.delayed(Duration.zero); + + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + + test('a new attempt cancels an obsolete scheduled retry', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('old'), isTrue); + gate.failed('old', retry: () => retries += 1); + expect(gate.tryBegin('new'), isTrue); + await Future.delayed(Duration.zero); + + expect(retries, 0); + expect(gate.tryBegin('new'), isFalse); + }); + + test('successful bootstrap becomes retryable at renewal time', () async { + final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); + addTearDown(gate.dispose); + var retries = 0; + + expect(gate.tryBegin('attempt'), isTrue); + gate.retryAfter('attempt', delay: Duration.zero, retry: () => retries += 1); + await Future.delayed(Duration.zero); + + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + + test('completed bootstrap attempt can run again for later work', () { + final gate = BuzzPushAttemptGate(); + addTearDown(gate.dispose); + + expect(gate.tryBegin('attempt'), isTrue); + gate.complete('attempt'); + expect(gate.tryBegin('attempt'), isTrue); + }); + + test('publication attempt changes when the relay executor rotates', () { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), + notificationClass: 'default', + ); + final original = buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + subscriptions: [subscription], + ); + + expect( + buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v2', pubkey: _hex('b')), + subscriptions: [subscription], + ), + isNot(original), + ); + expect( + buzzPushPublicationAttemptKey( + communityId: 'community', + relayBaseUrl: 'https://relay.example', + token: 'token', + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('c')), + subscriptions: [subscription], + ), + isNot(original), + ); + }); + + test('relay capability alone does not activate push without opt-in', () { + final disabled = Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ); + final enabled = disabled.copyWith(pushNotificationsEnabled: true); + + expect( + buzzPushLifecycleEnabled( + community: disabled, + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + ), + isFalse, + ); + expect( + buzzPushLifecycleEnabled( + community: enabled, + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + ), + isTrue, + ); + expect( + buzzPushLifecycleEnabled(community: enabled, descriptor: null), + isFalse, + ); + }); + + test('pending opt-out tombstone keeps active push lifecycle disabled', () { + final subscription = BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]), + notificationClass: 'default', + ); + final community = + Community.create( + name: 'Team', + relayUrl: 'wss://relay.example', + ).copyWith( + pushNotificationsEnabled: false, + pushSubscriptionState: + BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) + .withAccepted(subscriptions: [subscription], generation: 3) + .withPendingTombstone(4), + ); + + expect( + buzzPushLifecycleEnabled( + community: community, + descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')), + ), + isFalse, + ); + }); + + test( + 'relay commit followed by local failure retries at a newer generation', + () async { + var durableCursor = 0; + var relayGeneration = 0; + var acceptedGeneration = 0; + var failLocalSave = true; + + Future reserve() async => ++durableCursor; + Future publish(int generation) async { + expect(generation, greaterThan(relayGeneration)); + relayGeneration = generation; + } + + Future markAccepted(int generation) async { + if (failLocalSave) { + failLocalSave = false; + throw StateError('injected local persistence failure'); + } + acceptedGeneration = generation; + } + + await expectLater( + publishBuzzPushLeaseRecoverably( + reserveGeneration: reserve, + publish: publish, + markAccepted: markAccepted, + ), + throwsStateError, + ); + expect(relayGeneration, 1); + expect(acceptedGeneration, 0); + + await publishBuzzPushLeaseRecoverably( + reserveGeneration: reserve, + publish: publish, + markAccepted: markAccepted, + ); + expect(relayGeneration, 2); + expect(acceptedGeneration, 2); + }, + ); +} + +BuzzPushLeaseDescriptor _descriptor({ + required String keyId, + required String pubkey, +}) => BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: keyId, + executorPubkey: pubkey, + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); + +String _hex(String character) => List.filled(64, character).join(); diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart new file mode 100644 index 00000000000..4bff3a2c2c3 --- /dev/null +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -0,0 +1,417 @@ +import 'dart:async'; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const _channel = MethodChannel('buzz/push'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + apnsDeviceToken.value = null; + apnsRegistrationError.value = null; + pushEndpointGrants.value = const []; + pushEndpointGrantError.value = null; + pushCommunitySnapshotError.value = null; + pendingPushNotificationLink.value = null; + installBuzzPushMethodHandler(); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + debugDefaultTargetPlatformOverride = null; + }); + + test('captures APNs token success and clears the previous error', () async { + apnsRegistrationError.value = 'old error'; + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('apnsTokenChanged', {'token': '01ab'}), + ), + (_) {}, + ); + expect(apnsDeviceToken.value, '01ab'); + expect(apnsRegistrationError.value, isNull); + }); + + test( + 'starts native permission and APNs registration without a result gate', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'startRegistration'); + return null; + }); + + await startBuzzPushRegistration(); + }, + ); + + test('reads native notification authorization status', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'notificationAuthorizationStatus'); + return 'denied'; + }); + + expect( + await readBuzzPushAuthorizationStatus(), + BuzzPushAuthorizationStatus.denied, + ); + }); + + test('opens native notification settings', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'openNotificationSettings'); + return true; + }); + + expect(await openBuzzPushNotificationSettings(), isTrue); + }); + + test('refreshes not-determined permission to denied on resume', () async { + final statuses = [ + BuzzPushAuthorizationStatus.notDetermined, + BuzzPushAuthorizationStatus.denied, + ]; + final container = ProviderContainer( + overrides: [ + appLifecycleProvider.overrideWith(_TestAppLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => statuses.removeAt(0), + ), + ], + ); + addTearDown(container.dispose); + + expect( + await container.read(buzzPushAuthorizationStatusProvider.future), + BuzzPushAuthorizationStatus.notDetermined, + ); + final lifecycle = + container.read(appLifecycleProvider.notifier) + as _TestAppLifecycleNotifier; + lifecycle.setState(AppLifecycleState.paused); + lifecycle.setState(AppLifecycleState.resumed); + await _waitForAuthorization(container, BuzzPushAuthorizationStatus.denied); + }); + + test('refreshes externally revoked permission on resume', () async { + final statuses = [ + BuzzPushAuthorizationStatus.authorized, + BuzzPushAuthorizationStatus.denied, + ]; + final container = ProviderContainer( + overrides: [ + appLifecycleProvider.overrideWith(_TestAppLifecycleNotifier.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => statuses.removeAt(0), + ), + ], + ); + addTearDown(container.dispose); + + expect( + await container.read(buzzPushAuthorizationStatusProvider.future), + BuzzPushAuthorizationStatus.authorized, + ); + final lifecycle = + container.read(appLifecycleProvider.notifier) + as _TestAppLifecycleNotifier; + lifecycle.setState(AppLifecycleState.paused); + lifecycle.setState(AppLifecycleState.resumed); + await _waitForAuthorization(container, BuzzPushAuthorizationStatus.denied); + }); + + test('reads and exposes persisted endpoint grants on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'endpointGrants'); + return [_grantMap('opaque-grant')]; + }); + + final grants = await readBuzzPushEndpointGrants(); + + expect(grants, hasLength(1)); + expect(grants.single.relayOrigin, 'wss://relay.example'); + expect(grants.single.relayPubkey, 'a' * 64); + expect(grants.single.installationId, 'c' * 32); + expect(grants.single.endpointGrant, 'opaque-grant'); + expect(grants.single.endpointHash, 'b' * 64); + expect(grants.single.appProfile, 'buzz-ios-dogfood'); + expect(grants.single.endpointEpoch, 1); + expect(grants.single.generation, 1); + expect(grants.single.expiresAt, 1752624000); + expect(pushEndpointGrants.value.single.endpointGrant, 'opaque-grant'); + expect(pushEndpointGrantError.value, isNull); + }); + + test( + 'debug enrollment carries the configured relay and gateway URLs', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final methods = []; + final enrollmentArguments = []; + final snapshotArguments = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + methods.add(call.method); + if (call.method == 'enrollPush') { + enrollmentArguments.add(call.arguments); + return _grantMap('new-grant'); + } + if (call.method == 'endpointGrants') { + return [_grantMap('new-grant')]; + } + if (call.method == 'syncPushSnapshot') { + snapshotArguments.add(call.arguments); + return null; + } + fail('Unexpected method ${call.method}'); + }); + + final firstGrant = await enrollBuzzPush( + 'wss://relay.example/', + 'https://gateway-one.example/', + ); + final secondGrant = await enrollBuzzPush( + 'wss://relay.example/', + 'https://gateway-two.example/', + communitiesForSnapshotRefresh: [ + Community( + id: 'community-id', + name: 'Community', + relayUrl: 'wss://relay.example/', + pubkey: 'd' * 64, + pushNotificationsEnabled: true, + addedAt: DateTime.fromMillisecondsSinceEpoch(0), + ), + ], + ); + + expect(firstGrant.endpointGrant, 'new-grant'); + expect(secondGrant.endpointGrant, 'new-grant'); + expect(enrollmentArguments, [ + { + 'relayUrl': 'wss://relay.example/', + 'gatewayUrl': 'https://gateway-one.example/', + }, + { + 'relayUrl': 'wss://relay.example/', + 'gatewayUrl': 'https://gateway-two.example/', + }, + ]); + expect(methods, [ + 'enrollPush', + 'endpointGrants', + 'enrollPush', + 'endpointGrants', + 'syncPushSnapshot', + ]); + expect(snapshotArguments, [ + { + 'section': 'communities', + 'communities': [ + { + 'id': 'community-id', + 'name': 'Community', + 'relayUrl': 'wss://relay.example/', + 'pubkey': 'd' * 64, + 'policies': [], + }, + ], + 'signingKeys': {}, + }, + ]); + expect(pushEndpointGrants.value.single.endpointGrant, 'new-grant'); + }, + ); + + test('development push gateway matches the compiled configuration', () { + const expectedGateway = String.fromEnvironment( + 'BUZZ_PUSH_GATEWAY_URL', + defaultValue: 'https://push.buzz.xyz', + ); + expect(Env.pushGatewayUrl, expectedGateway); + }); + + test('exposes APNs registration failure', () async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('apnsRegistrationFailed', {'message': 'denied'}), + ), + (_) {}, + ); + expect(apnsRegistrationError.value, 'denied'); + }); + + test('routes a warm notification response with opaque IDs', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'MESSAGE-ID', + 'communityId': 'community-id', + 'channelId': 'CHANNEL/GENERAL', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'handled'); + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: 'CHANNEL/GENERAL', + messageId: 'MESSAGE-ID', + ), + ); + }); + + test('rejects a notification response with an empty channel ID', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 'message-id', + 'communityId': 'community-id', + 'channelId': '', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }); + + test( + 'rejects a notification response with a non-string message ID', + () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + MethodCall('notificationOpened', { + 'eventId': 42, + 'communityId': 'community-id', + 'channelId': 'channel-id', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }, + ); + + test('rejects a notification response with an absent message ID', () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'communityId': 'community-id', + 'channelId': 'channel-id', + }), + ), + response.complete, + ); + + final envelope = await response.future; + expect(envelope, isNotNull); + expect(_channel.codec.decodeEnvelope(envelope!), 'ignored'); + expect(pendingPushNotificationLink.value, isNull); + }); + + test('pulls a cold-start notification response from native iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + expect(call.method, 'takePendingNotificationResponse'); + return { + 'eventId': 'b' * 64, + 'communityId': 'community-id', + 'channelId': '123e4567-e89b-42d3-a456-426614174000', + }; + }); + + await syncPendingBuzzPushNotificationResponse(); + + expect( + pendingPushNotificationLink.value, + MessageDeepLink( + communityId: 'community-id', + channelId: '123e4567-e89b-42d3-a456-426614174000', + messageId: 'b' * 64, + ), + ); + }); +} + +class _TestAppLifecycleNotifier extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; + + void setState(AppLifecycleState value) => state = value; +} + +Future _waitForAuthorization( + ProviderContainer container, + BuzzPushAuthorizationStatus expected, +) async { + for (var attempt = 0; attempt < 20; attempt++) { + if (container.read(buzzPushAuthorizationStatusProvider).value == expected) { + return; + } + await Future.delayed(Duration.zero); + } + fail('Authorization status did not refresh to $expected'); +} + +Map _grantMap(String endpointGrant) => { + 'relayOrigin': 'wss://relay.example', + 'relayPubkey': 'a' * 64, + 'installationId': 'c' * 32, + 'endpointGrant': endpointGrant, + 'endpointHash': 'b' * 64, + 'appProfile': 'buzz-ios-dogfood', + 'endpointEpoch': 1, + 'generation': 1, + 'expiresAt': 1752624000, +}; diff --git a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart new file mode 100644 index 00000000000..bfc6dd6bff9 --- /dev/null +++ b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart @@ -0,0 +1,331 @@ +import 'dart:async'; + +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_lease_revocation_outbox.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../community/community_storage_test.dart'; + +void main() { + late FakeSecureStorage secure; + late BuzzPushLeaseRevocationStorage storage; + late _Clock clock; + late _WakeScheduler scheduler; + late nostr.Keys keys; + + setUp(() { + secure = FakeSecureStorage(); + storage = BuzzPushLeaseRevocationStorage(secure: secure); + clock = _Clock(1_000_000); + scheduler = _WakeScheduler(); + keys = nostr.Keys.generate(); + }); + + test('community removal journals one precise lease address', () async { + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async {}, + ); + final community = + Community.create( + name: 'Test', + relayUrl: 'wss://relay.example', + nsec: keys.nsec, + ).copyWith( + pubkey: keys.public, + pushNotificationsEnabled: true, + pushSubscriptionState: const BuzzPushLeaseSubscriptionState.desired() + .withReservedGeneration(4), + ); + final grant = _grant(expiresAt: clock.seconds + 3600); + + await outbox.enqueueCommunity(community, readGrants: () async => [grant]); + await outbox.enqueueCommunity(community, readGrants: () async => [grant]); + + final records = await storage.loadAll(); + expect(records, hasLength(1)); + expect( + records.single.leaseAddress, + '${keys.public}|wss://relay.example|${community.pushLeaseInstallationId}', + ); + expect(records.single.generation, 6); + expect(records.single.expiresAt, grant.expiresAt); + expect(records.single.relayUrl, 'https://relay.example/'); + }); + + test('legacy community revokes its grant-addressed lease', () async { + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async {}, + ); + final community = Community( + id: 'legacy', + name: 'Legacy', + relayUrl: 'wss://relay.example', + pubkey: keys.public, + nsec: keys.nsec, + pushNotificationsEnabled: true, + pushSubscriptionState: const BuzzPushLeaseSubscriptionState.desired() + .withReservedGeneration(4), + addedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + final grant = _grant(expiresAt: clock.seconds + 3600); + + await outbox.enqueueCommunity(community, readGrants: () async => [grant]); + + expect( + (await storage.loadAll()).single.leaseAddress, + '${keys.public}|wss://relay.example|${grant.installationId}', + ); + }); + + test('concurrent reconnect and resume triggers share one attempt', () async { + final started = Completer(); + final release = Completer(); + var calls = 0; + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async { + calls += 1; + if (!started.isCompleted) started.complete(); + await release.future; + }, + ); + await outbox.enqueue(_record(keys: keys, clock: clock)); + + final startup = outbox.start(); + await started.future; + final reconnect = outbox.trigger(); + final resume = outbox.trigger(); + + expect(calls, 1); + expect(identical(reconnect, resume), isTrue); + release.complete(); + await Future.wait([startup, reconnect, resume]); + expect(calls, 1); + expect(await storage.loadAll(), isEmpty); + }); + + test('failure reserves durable backoff and restart waits for it', () async { + var failedCalls = 0; + final first = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async { + failedCalls += 1; + throw StateError('offline'); + }, + ); + await first.enqueue(_record(keys: keys, clock: clock)); + await first.trigger(); + + final pending = (await storage.loadAll()).single; + expect(failedCalls, 1); + expect(pending.attemptCount, 1); + expect(pending.generation, 8); + expect(pending.nextAttemptAt, clock.seconds + 15); + + var restartedCalls = 0; + final restarted = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async => restartedCalls += 1, + ); + await restarted.start(); + await restarted.trigger(); + await restarted.trigger(); + expect(restartedCalls, 0); + + clock.seconds = pending.nextAttemptAt; + await restarted.trigger(); + expect(restartedCalls, 1); + expect(await storage.loadAll(), isEmpty); + }); + + test('repeated failed restarts cannot create a retry storm', () async { + await storage.replaceAll([_record(keys: keys, clock: clock)]); + var calls = 0; + + for (var restart = 0; restart < 4; restart += 1) { + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async { + calls += 1; + throw StateError('offline'); + }, + ); + await outbox.start(); + await Future.wait([outbox.trigger(), outbox.trigger(), outbox.trigger()]); + outbox.dispose(); + } + + expect(calls, 1); + final pending = (await storage.loadAll()).single; + expect(pending.attemptCount, 1); + expect(pending.nextAttemptAt, greaterThan(clock.seconds)); + }); + + test('retry delay grows exponentially and remains bounded', () async { + await storage.replaceAll([ + _record( + keys: keys, + clock: clock, + ).copyWith(expiresAt: clock.seconds + 10_000_000), + ]); + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async => throw StateError('offline'), + ); + final delays = []; + + for (var attempt = 0; attempt < 14; attempt += 1) { + await outbox.trigger(); + final pending = (await storage.loadAll()).single; + delays.add(pending.nextAttemptAt - clock.seconds); + clock.seconds = pending.nextAttemptAt; + } + + expect(delays.take(4), [15, 30, 60, 120]); + expect(delays.every((delay) => delay <= 6 * 60 * 60), isTrue); + expect(delays.last, 3 * 60 * 60); + }); + + test('expired lease is erased without a relay attempt', () async { + final expired = _record( + keys: keys, + clock: clock, + ).copyWith(expiresAt: clock.seconds); + await storage.replaceAll([expired]); + var calls = 0; + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async => calls += 1, + ); + + await outbox.start(); + + expect(calls, 0); + expect(await storage.loadAll(), isEmpty); + }); + + test('different records are globally serialized', () async { + final firstStarted = Completer(); + final releaseFirst = Completer(); + var inFlight = 0; + var maximumInFlight = 0; + var calls = 0; + final outbox = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async { + calls += 1; + inFlight += 1; + maximumInFlight = inFlight > maximumInFlight + ? inFlight + : maximumInFlight; + if (calls == 1) { + firstStarted.complete(); + await releaseFirst.future; + } + inFlight -= 1; + }, + ); + await outbox.enqueue(_record(keys: keys, clock: clock)); + final otherKeys = nostr.Keys.generate(); + await outbox.enqueue( + _record(keys: otherKeys, clock: clock, installationId: '1' * 32), + ); + + final draining = outbox.trigger(); + await firstStarted.future; + expect(calls, 1); + releaseFirst.complete(); + await draining; + + expect(calls, 2); + expect(maximumInFlight, 1); + expect(await storage.loadAll(), isEmpty); + }); +} + +BuzzPushLeaseRevocationOutbox _outbox({ + required BuzzPushLeaseRevocationStorage storage, + required _Clock clock, + required _WakeScheduler scheduler, + required BuzzPushLeaseRevocationPublisher publisher, +}) => BuzzPushLeaseRevocationOutbox( + storage: storage, + publisher: publisher, + now: clock.call, + jitter: () => 0, + reportError: (_, _) {}, + scheduleWake: scheduler.schedule, +); + +BuzzPushLeaseRevocationRecord _record({ + required nostr.Keys keys, + required _Clock clock, + String installationId = '00000000000000000000000000000000', +}) => BuzzPushLeaseRevocationRecord( + relayUrl: 'https://relay.example', + relayOrigin: 'wss://relay.example', + memberPubkey: keys.public, + nsec: keys.nsec, + installationId: installationId, + generation: 7, + expiresAt: clock.seconds + 3600, + attemptCount: 0, + nextAttemptAt: clock.seconds, +); + +BuzzPushEndpointGrant _grant({required int expiresAt}) => BuzzPushEndpointGrant( + relayOrigin: 'wss://relay.example', + relayPubkey: 'a' * 64, + installationId: '0' * 32, + endpointGrant: 'opaque', + endpointHash: 'b' * 64, + appProfile: 'buzz-ios-dogfood', + endpointEpoch: 1, + generation: 1, + expiresAt: expiresAt, +); + +class _Clock { + _Clock(this.seconds); + + int seconds; + + DateTime call() => DateTime.fromMillisecondsSinceEpoch(seconds * 1000); +} + +class _WakeScheduler { + final List<_ScheduledWake> _wakes = []; + + void Function() schedule(Duration delay, void Function() callback) { + final wake = _ScheduledWake(); + _wakes.add(wake); + return () => wake.cancelled = true; + } +} + +class _ScheduledWake { + bool cancelled = false; +} diff --git a/mobile/test/shared/push/push_presentation_cache_test.dart b/mobile/test/shared/push/push_presentation_cache_test.dart new file mode 100644 index 00000000000..fff3ef72571 --- /dev/null +++ b/mobile/test/shared/push/push_presentation_cache_test.dart @@ -0,0 +1,144 @@ +import 'package:buzz/shared/push/push_presentation_cache.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const secretKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + + test('accepts a valid signed profile event', () { + final signed = nostr.Event.from( + kind: 0, + content: '{"display_name":"Alice"}', + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('accepts opaque channel IDs in a valid signed metadata event', () { + final signed = nostr.Event.from( + kind: 39000, + content: '', + tags: const [ + ['d', 'channel/general:v5'], + ['name', 'General'], + ], + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('accepts a valid signed channel membership snapshot', () { + final signed = nostr.Event.from( + kind: 39002, + content: '', + tags: const [ + ['d', 'channel/general:v5'], + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + 'member', + ], + ], + secretKey: secretKey, + createdAt: 1700000000, + ); + + expect( + isVerifiedPushPresentationEvent(NostrEvent.fromJson(signed.toMap())), + isTrue, + ); + }); + + test('channel selection keeps newest metadata paired with rosters', () { + NostrEvent signedChannelEvent(int kind, String channelID, int createdAt) { + final signed = nostr.Event.from( + kind: kind, + content: '', + tags: [ + ['d', channelID], + if (kind == 39002) + [ + 'p', + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', + ], + ], + secretKey: secretKey, + createdAt: createdAt, + ); + return NostrEvent.fromJson(signed.toMap()); + } + + final batch = selectPushChannelEvents( + [ + signedChannelEvent(39000, 'channel-0', 100), + signedChannelEvent(39000, 'channel-1', 300), + signedChannelEvent(39000, 'channel-1', 200), + signedChannelEvent(39000, 'metadata-only', 400), + ], + [ + signedChannelEvent(39002, 'channel-0', 300), + signedChannelEvent(39002, 'channel-1', 200), + ], + ); + + expect(batch.metadata.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + expect(batch.membership.map((event) => event.getTagValue('d')).toSet(), { + 'channel-0', + 'channel-1', + }); + }); + + test('rejects changed content and malformed signatures', () { + final signed = nostr.Event.from( + kind: 0, + content: '{"name":"Alice"}', + secretKey: secretKey, + createdAt: 1700000000, + ); + final event = NostrEvent.fromJson(signed.toMap()); + + expect( + isVerifiedPushPresentationEvent( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: '{"name":"Mallory"}', + sig: event.sig, + ), + ), + isFalse, + ); + expect( + isVerifiedPushPresentationEvent( + NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: '00', + ), + ), + isFalse, + ); + }); +} diff --git a/mobile/test/shared/push/push_relay_capability_provider_test.dart b/mobile/test/shared/push/push_relay_capability_provider_test.dart new file mode 100644 index 00000000000..f6cc5874bac --- /dev/null +++ b/mobile/test/shared/push/push_relay_capability_provider_test.dart @@ -0,0 +1,73 @@ +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'valid capability starts independent permission and APNs registration', + () async { + var requests = 0; + + await startBuzzPushRegistrationIfCapable( + _descriptor, + startRegistration: () async { + requests += 1; + }, + ); + + expect(requests, 1); + }, + ); + + test( + 'missing capability cannot start permission or APNs registration', + () async { + var requests = 0; + + await startBuzzPushRegistrationIfCapable( + null, + startRegistration: () async { + requests += 1; + }, + ); + + expect(requests, 0); + }, + ); + + for (final failure in [ + const FormatException('malformed descriptor'), + StateError('relay unreachable'), + ]) { + test('$failure keeps capability inactive without registration', () async { + final descriptor = await discoverBuzzPushRelayCapability( + 'https://relay.example', + fetchDescriptor: (_) async => throw failure, + ); + var requests = 0; + + await startBuzzPushRegistrationIfCapable( + descriptor, + startRegistration: () async { + requests += 1; + }, + ); + + expect(descriptor, isNull); + expect(requests, 0); + }); + } +} + +const _descriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'relay-v1', + executorPubkey: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); diff --git a/mobile/test/shared/push/push_snapshot_test.dart b/mobile/test/shared/push/push_snapshot_test.dart new file mode 100644 index 00000000000..27c2c5ad217 --- /dev/null +++ b/mobile/test/shared/push/push_snapshot_test.dart @@ -0,0 +1,23 @@ +import 'package:buzz/shared/push/push_snapshot.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('push community snapshot carries flattened resolution policies', () { + final subscription = buildDesiredBuzzPushSubscriptions( + myPubkey: 'a' * 64, + ).single; + final snapshot = BuzzPushCommunitySnapshot( + id: 'community', + name: 'Team', + relayUrl: 'https://relay.example.com', + pubkey: 'a' * 64, + subscriptions: [subscription], + ); + + final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson()); + + expect(decoded.toJson(), snapshot.toJson()); + expect(decoded.subscriptions, hasLength(1)); + }); +} diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart new file mode 100644 index 00000000000..d7571065948 --- /dev/null +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -0,0 +1,66 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/push/push_subscription_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + const activeID = '123e4567-e89b-42d3-a456-426614174000'; + const archivedID = '123e4567-e89b-42d3-a456-426614174001'; + const nonMemberID = '123e4567-e89b-42d3-a456-426614174002'; + + test( + 'derives desired subscriptions from nsec, membership, and mute state', + () { + final nsec = nostr.Keys.generate().nsec; + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + nsec: nsec, + ), + channels: [ + channel(activeID), + channel(archivedID, archived: true), + channel(nonMemberID, isMember: false), + ], + mutedChannelIds: const [activeID, archivedID], + ); + + expect(subscriptions, isNotNull); + expect(subscriptions, hasLength(1)); + expect(subscriptions!.single.filter.pTags, hasLength(1)); + expect(subscriptions.single.ignore, hasLength(2)); + expect(subscriptions.single.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.single.ignore.last.hTags, [activeID]); + }, + ); + + test('returns no desired subscriptions without a signing identity', () { + final subscriptions = desiredBuzzPushSubscriptions( + community: Community.create( + name: 'Team', + relayUrl: 'https://relay.example.com', + ), + channels: [channel(activeID)], + mutedChannelIds: const [], + ); + + expect(subscriptions, isNull); + }); +} + +Channel channel(String id, {bool isMember = true, bool archived = false}) => + Channel( + id: id, + name: id, + channelType: 'dm', + visibility: 'open', + description: '', + createdBy: 'author', + createdAt: DateTime(2026), + memberCount: 1, + isMember: isMember, + archivedAt: archived ? DateTime(2026) : null, + ); diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart new file mode 100644 index 00000000000..3ef21938e2a --- /dev/null +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -0,0 +1,154 @@ +import 'dart:convert'; + +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final me = 'a' * 64; + const channelA = '123e4567-e89b-42d3-a456-426614174000'; + const channelB = '123e4567-e89b-42d3-a456-426614174001'; + + test( + 'desired subscription state round-trips with an accepted-state seam', + () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelB, channelA], + ); + final state = BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson( + jsonDecode(jsonEncode(state.toJson())) as Map, + ); + + expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired); + expect(decoded.accepted, isNull); + expect(decoded.toJson(), state.toJson()); + expect(decoded.authoritative, hasLength(2)); + expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]); + }, + ); + + test('accepted authority requires observed accepted subscriptions', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + + expect( + () => BuzzPushLeaseSubscriptionState.fromJson({ + 'authority': 'accepted', + 'desired': [subscription.toJson()], + }), + throwsFormatException, + ); + }); + + test('persists accepted and reserved relay lease generations', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + final state = + BuzzPushLeaseSubscriptionState.desired(desired: [subscription]) + .withAccepted(subscriptions: [subscription], generation: 9) + .withReservedGeneration(10); + + final decoded = BuzzPushLeaseSubscriptionState.fromJson(state.toJson()); + expect(decoded.acceptedGeneration, 9); + expect(decoded.generationCursor, 10); + expect(decoded.toJson(), state.toJson()); + }); + + test('a retry reserves beyond a committed but unrecorded generation', () { + final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single; + final accepted = BuzzPushLeaseSubscriptionState.desired( + desired: [subscription], + ).withAccepted(subscriptions: [subscription], generation: 4); + final committed = accepted.withReservedGeneration(5); + + final recovered = BuzzPushLeaseSubscriptionState.fromJson( + committed.toJson(), + ).withReservedGeneration(6); + + expect(recovered.acceptedGeneration, 4); + expect(recovered.generationCursor, 6); + }); + + test('builds aligned self and unmuted channel subscriptions', () { + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me.toUpperCase(), + channelIds: [channelB, channelA], + mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'], + ); + + expect(subscriptions, hasLength(2)); + expect(subscriptions.first.filter.kinds, buzzPushSelfDirectedKinds); + expect(subscriptions.first.filter.kinds, isNot(contains(7))); + expect(subscriptions.first.filter.pTags, [me]); + expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); + expect(subscriptions.last.filter.hTags, [channelA]); + expect(subscriptions.first.ignore, hasLength(2)); + expect(subscriptions.first.ignore.first.kinds, buzzPushRenderableKinds); + expect(subscriptions.first.ignore.last.hTags, [channelB]); + expect( + subscriptions.first.suppress?.pTagsMax, + buzzPushHellthreadParticipantLimit, + ); + }); + + test('chunks channel subscriptions to relay limits deterministically', () { + final channels = [ + for (var i = 0; i < 51; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]..shuffle(); + + final subscriptions = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: channels, + ); + + expect(subscriptions, hasLength(3)); + expect(subscriptions[1].filter.hTags, hasLength(50)); + expect(subscriptions[2].filter.hTags, hasLength(1)); + expect( + subscriptions[1].filter.hTags, + orderedEquals([...subscriptions[1].filter.hTags!]..sort()), + ); + }); + + test('rejects malformed and unsupported subscription fields', () { + final valid = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + ).single.toJson(); + + expect( + () => buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: const ['not-a-channel'], + ), + throwsFormatException, + ); + + expect( + () => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [7], + '#p': [me], + }, + 'class': 'default', + }), + throwsFormatException, + ); + expect( + () => BuzzPushSubscription.fromJson({ + 'filter': { + 'kinds': [9], + '#p': ['not-a-pubkey'], + }, + 'class': 'default', + }), + throwsFormatException, + ); + }); +} diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 4631cf58dab..821de63a480 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/push/push_presentation_cache.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -23,6 +24,15 @@ void main() { ), ); + test('accepts bounded raster data avatars for the push cache', () { + expect(isCacheablePushAvatarSource('data:image/png;base64,AA=='), isTrue); + expect( + isCacheablePushAvatarSource('data:image/svg+xml;base64,AA=='), + isFalse, + ); + expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); + }); + testWidgets('renders raccoon percent-encoded SVG data avatar', ( tester, ) async { diff --git a/schema/schema.sql b/schema/schema.sql index 1bad6e76bee..54566103335 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -927,7 +927,7 @@ BEGIN -- Keep this allowlist identical to the relay's validated NIP-PL descriptor. -- Centralizing it on the events table covers every durable producer, -- including internal paths that bypass live dispatch. - IF NEW.kind IN (7, 9, 1059, 40007, 46010) THEN + IF NEW.kind IN (9, 40002, 45001, 45003) THEN PERFORM pg_advisory_xact_lock_shared( hashtextextended('buzz_push_gate:' || NEW.community_id::text, 0)); IF EXISTS ( diff --git a/scripts/mobile-worktree-clean.sh b/scripts/mobile-worktree-clean.sh index a64f52a0d16..644737b7e44 100755 --- a/scripts/mobile-worktree-clean.sh +++ b/scripts/mobile-worktree-clean.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # Uninstalls stale worktree-suffixed Buzz debug builds from booted iOS -# simulators and connected Android devices/emulators. Production installs -# (com.buzz.buzzMobile / xyz.block.buzz.mobile, no suffix) are never touched: -# only identifiers with a worktree suffix appended after the production id -# are matched. Run `just mobile-clean` (or this script directly); pass -# --dry-run to list what would be removed without uninstalling. +# simulators and connected Android devices/emulators. Unsuffixed app installs +# (`xyz.block.buzz.dogfood.mobile` and `xyz.block.buzz.mobile`) are never +# touched. Only identifiers with a worktree suffix appended after the dogfood +# or production id are matched. Run `just mobile-clean` (or this script +# directly); pass --dry-run to list what would be removed without uninstalling. set -euo pipefail -ios_prefix="com.buzz.buzzMobile." +ios_prefix="xyz.block.buzz.dogfood.mobile." android_prefix="xyz.block.buzz.mobile." dry_run=0 diff --git a/scripts/mobile-worktree-overrides.sh b/scripts/mobile-worktree-overrides.sh index 2390954ba1a..e43ef1e2794 100755 --- a/scripts/mobile-worktree-overrides.sh +++ b/scripts/mobile-worktree-overrides.sh @@ -75,7 +75,7 @@ case "$android_slug" in [0-9]*) android_slug="w_$android_slug" ;; esac -ios_bundle_id="com.buzz.buzzMobile.${ios_slug}" +ios_bundle_id="xyz.block.buzz.dogfood.mobile.${ios_slug}" android_app_name="${BUZZ_ANDROID_DEBUG_APP_NAME:-Buzz (${label})}" android_suffix="${BUZZ_ANDROID_DEBUG_ID_SUFFIX:-.${android_slug}}" diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index 8a33acd6358..70637f52259 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -64,7 +64,7 @@ out="$("$wt/scripts/mobile-worktree-overrides.sh")" ios="$wt/mobile/ios/Flutter/WorktreeOverrides.xcconfig" android="$wt/mobile/android/worktree.properties" [[ -f "$ios" && -f "$android" ]] || fail "worktree must write both override files" -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \ && pass "iOS bundle identifier keys to the sanitized worktree directory name" \ || fail "iOS bundle identifier must key to the worktree dir, got: $(cat "$ios")" grep -q '^APP_DISPLAY_NAME = Buzz (Fix_Thing-2)$' "$ios" \ @@ -86,7 +86,7 @@ printf '%s' "$out" | grep -q 'Worktree Feature_Work-1' \ # ── Branch switch in the same worktree: identity stable, label follows ─────── git -C "$wt" checkout -q -b "another/branch-name" "$wt/scripts/mobile-worktree-overrides.sh" > /dev/null -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile\.feature-work-1$' "$ios" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile\.feature-work-1$' "$ios" \ && grep -q '^applicationIdSuffix=\.feature_work_1$' "$android" \ && pass "branch switch keeps the install identity stable (per worktree)" \ || fail "install identity must not change on branch switch" @@ -156,6 +156,9 @@ gradle="$repo_root/mobile/android/app/build.gradle.kts" manifest="$repo_root/mobile/android/app/src/main/AndroidManifest.xml" plist="$repo_root/mobile/ios/Runner/Info.plist" +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.dogfood\.mobile$' "$debug_xcconfig" \ + && pass "Debug.xcconfig defaults to the dogfood bundle identifier" \ + || fail "Debug.xcconfig must default to xyz.block.buzz.dogfood.mobile" grep -q 'WorktreeOverrides.xcconfig' "$debug_xcconfig" \ && pass "Debug.xcconfig includes WorktreeOverrides" \ || fail "Debug.xcconfig must include WorktreeOverrides.xcconfig" @@ -166,15 +169,20 @@ if [[ -n "$worktree_line" && -n "$app_line" && "$worktree_line" -lt "$app_line" else fail "Debug.xcconfig must include AppOverrides.xcconfig after WorktreeOverrides.xcconfig" fi +grep -q '^ios_prefix="xyz.block.buzz.dogfood.mobile\."$' "$clean_script" \ + && pass "cleanup targets the iOS dogfood worktree prefix" \ + || fail "cleanup must share the iOS dogfood prefix used by worktree overrides" + grep -q 'WorktreeOverrides' "$release_xcconfig" \ && fail "Release.xcconfig must not include WorktreeOverrides.xcconfig" \ || pass "Release.xcconfig does not include WorktreeOverrides" -grep -q '^BUNDLE_IDENTIFIER = com\.buzz\.buzzMobile$' "$release_xcconfig" \ +grep -q '^BUNDLE_IDENTIFIER = xyz\.block\.buzz\.mobile$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production bundle identifier" \ - || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = com.buzz.buzzMobile" + || fail "Release.xcconfig must keep BUNDLE_IDENTIFIER = xyz.block.buzz.mobile" grep -q '^APP_DISPLAY_NAME = Buzz$' "$release_xcconfig" \ && pass "Release.xcconfig keeps the production display name" \ || fail "Release.xcconfig must keep APP_DISPLAY_NAME = Buzz" + grep -q '$(APP_DISPLAY_NAME)' "$plist" \ && pass "Info.plist display name resolves from build settings" \ || fail "Info.plist CFBundleDisplayName must be \$(APP_DISPLAY_NAME)" From b593c7d7feb6bf4207dbf3439906c25a714b703f Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 15:38:03 -0600 Subject: [PATCH 095/101] perf(mobile): reduce cold startup and channel rendering delays (#6996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinky is opening this PR on Wes’s behalf. ## Summary Reduce two separately measured mobile delays without changing the relay API or removing rich message rendering: - Publish the finite channel-list snapshot without waiting for live subscription setup. - Batch active channel-list subscriptions into sorted, deterministic chunks of at most 128 explicit channel IDs, retaining unchanged chunks. - Install replacement chunks before retiring old coverage. Retain old chunks across thrown replacement failures; filter callbacks to the current relay/identity and still-desired channels; clean up retired/in-flight work across disconnect and disposal. - Scope the custom-emoji Markdown matcher to known shortcodes actually referenced in the rendered content, rather than embedding the whole community palette in every message’s regex. Preserve unknown literals, shared-colon token boundaries, event-tag URL priority, content edits, and code literals. - Honor explicit zero retry hints without inventing a ten-second session-wide gate, while preserving the ordinary live-subscription retry backoff and any already-active gate. ## Matched performance results Medians of three before and three after process-cold launches, alternated on the same authenticated iPhone 17 Pro / iOS 26.5 simulator. Before is mobile source at `e76c81968b65b0755b83efdd59dc3375c59ddf40`; after is this production patch before two documentation-only comment fixes. First channel-list frame: 11.617s → 3.179s · 73% lower latency Live setup duration: 8.475s → 0.185s · 98% lower latency Channel-open first message-list frame: 2.754s → 1.230s · 55% lower latency Message data ready → first frame: 1.977s → 0.286s · 86% lower latency Channel-open reveal complete: 2.845s → 1.394s · 51% lower latency Channel-open data readiness: 0.770s → 0.944s · 23% higher latency The gain is client-side orchestration/rendering, not a claim that the relay became faster. First channel-list frame ranges were 10.835–11.788s before and 2.872–3.395s after; channel-open first-frame ranges were 1.560–2.906s before and 1.149–1.317s after. ### Measurement boundaries - Debug simulator builds, CPU sampling disabled, bounded timestamp probes enabled identically. These are not release/physical-device measurements. - Startup clock starts at Dart `main`; build/install/native pre-main time is excluded. Auth/preferences and OS/disk caches are retained between new processes. - Same account scale: 113 active channels. Latest-message events varied slightly with live activity (1543–1546). - Channel-open uses the same initial 50-row history window, 97 query events, and 67 provider events. The 2306-entry emoji palette is explicitly loaded before navigation on both sides; palette preparation is excluded from the channel-open clock and happens after the startup frame measurement. - Both diagnostic builds temporarily disabled unused avatar segmentation to work around the existing Google ML Kit arm64-simulator slice limitation. The workaround, dependency/native changes, auto-navigation, and all probes are excluded from this PR. ## Validation - Full mobile package suite: `flutter test` — 1890 passed. - `just mobile-check` — 506 files unchanged; analyzer clean. - `just file-size-check` — policy tests and all client ratchets passed. - `git diff --check` — passed. - New lifecycle regressions cover front-sorting insertion across a chunk boundary while replacement readiness is paused, failure retention/departed-channel filtering, retired generation + disconnect cleanup, disposal, chunk limits, unchanged-set reuse, and scope switches. - Emoji unit/widget coverage includes a 2500-unused-emoji palette, unknown tokens, case matching at the component level, shared-colon boundaries, rich text, event URL priority, and content edits. - Fresh-frame source review traced the subscription queue/fences, callback scopes, duplicate-event paths, matcher/wiring, and retry scheduling. - At committed/pushed head `13a83b628c8411c5885e6f76a250ba87accf6067`, all normal pre-push hooks passed: `mobile-checks` (formatter, analyzer, and the full 1890-test mobile suite), `file-size-check`, `branch-skew`, and `push-head-scope`. The commit hook formatted 506 files with no changes. Runtime measurements preceded only the two documentation-comment fixes; no runtime source changed afterward. ## Limits / follow-ups - `RelaySession.subscribe` still settles under its existing EOSE/fallback/retryable-CLOSED contract. “Setup completed” is not an unconditional EOSE or live-delivery guarantee. This PR does not add status-aware replacement ownership. - The channel-message provider still awaits subscribe before fetching history; that separate serialization is not removed here. - Oversized Huddle queries and the separate history batching path above 128 active channels remain follow-ups, as do pre-existing read-state initialization/size warnings. - Palette-only widget refresh and upstream Markdown uppercase-dispatch behavior are not changed. - A clean source build still has the existing Google ML Kit arm64-simulator issue; the profiling workaround is not a proposed product fix. Originating Buzz conversation: buzz://message?channel=793b0522-7995-4375-b1a6-fd94a96fa21d&id=6ba88afdec78ab2cfb6728afcd4a6d10f29e6aa33ff0f62f45d6750381e4d789 --------- Signed-off-by: Wes Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> --- .../features/channels/channels_provider.dart | 165 +------ .../channels/channels_provider_lifecycle.dart | 272 +++++++++++- .../features/channels/message_content.dart | 6 +- .../custom_emoji/custom_emoji_render.dart | 32 +- .../shared/relay/relay_rate_limit_gate.dart | 20 +- mobile/lib/shared/relay/relay_session.dart | 2 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../channels_provider_live_cases.dart | 415 ++++++++++++++++++ .../channels_provider_terminal_cases.dart | 254 +++++++++++ .../channels/channels_provider_test.dart | 260 +++-------- .../message_content_custom_emoji_test.dart | 121 +++++ .../custom_emoji_render_test.dart | 109 +++++ .../relay/relay_rate_limit_gate_test.dart | 63 ++- .../test/shared/relay/relay_session_test.dart | 36 ++ 15 files changed, 1382 insertions(+), 376 deletions(-) create mode 100644 mobile/test/features/channels/channels_provider_live_cases.dart create mode 100644 mobile/test/features/channels/channels_provider_terminal_cases.dart create mode 100644 mobile/test/features/channels/message_content_custom_emoji_test.dart create mode 100644 mobile/test/shared/custom_emoji/custom_emoji_render_test.dart diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 33bfa210d15..a696fbe336a 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -37,16 +37,18 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; /// /// The paginated kind:39000 directory is fetched separately when Browse /// channels opens, so discovery never delays the main Conversations screen. -/// Live updates are layered on top via per-channel subscriptions on the -/// `#h` tag for any of the visible channel event kinds — incoming events -/// bump `lastMessageAt` for that channel. +/// Live updates are layered on top via chunked subscriptions on the `#h` tag +/// for any visible channel event kind. Chunks stay within the relay's explicit +/// channel cap and incoming events bump `lastMessageAt` for their channel. class ChannelsNotifier extends AsyncNotifier> { static const _backstopInterval = Duration(seconds: 60); - final Map _unsubscribersByChannel = {}; + final Map _liveSubscriptionsByChunk = {}; Future _liveSubscriptionQueue = Future.value(); Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; + int _nextLiveChunkGeneration = 0; + final Set _terminallyClosedLiveChunks = {}; String? _subscriptionRelayBaseUrl; Timer? _backstopTimer; final Map _latestObservedByChannel = {}; @@ -66,6 +68,10 @@ class ChannelsNotifier extends AsyncNotifier> { late final _ChannelRefreshCoordinator _refreshCoordinator = _ChannelRefreshCoordinator.forRef(ref); + // Expose the notifier's protected Ref only to same-library extensions used to + // keep lifecycle code below the file-size ratchet. + Ref get _lifecycleRef => ref; + /// The member snapshot already returned while loading the channel list. /// /// Mention autocomplete can use this synchronously while its independent @@ -113,6 +119,9 @@ class ChannelsNotifier extends AsyncNotifier> { !connected.isCompleted) { connected.complete(); } else if (previous?.status != SessionStatus.connected) { + // A new authenticated connection can change relay admission policy. + // Ordinary refreshes must not retry an unchanged terminal rejection. + _terminallyClosedLiveChunks.clear(); unawaited(_backstopRefresh()); } }); @@ -391,11 +400,11 @@ class ChannelsNotifier extends AsyncNotifier> { } } + // Layer live delivery onto the already-built snapshot without making EOSE + // part of the provider's readiness path. Every refresh queues a generation; + // a later refresh or scope switch retires older queued/in-flight work. if (subscribeLive) { - // Subscriptions are shared relay state, so a retired refresh must not - // install them even though its channel list is already built. - fence.ensureCurrent(); - await _fenced(fence, _subscribeLive(channels, fence)); + unawaited(_subscribeLive(channels, fence)); } // Guard the provider-state write in `retryDirectory` and `build`: the // caller assigns whatever this returns, so the last check belongs here. @@ -516,140 +525,6 @@ class ChannelsNotifier extends AsyncNotifier> { ); } - /// Subscribe per-channel to live events (requires `#h` tag for relay - /// channel-scoped fan-out). Also starts a 60s WS backstop poll to reconcile - /// membership changes without repeatedly downloading the global directory. - Future _subscribeLive( - List channels, - _ChannelRefreshFence fence, - ) { - final channelIds = { - for (final channel in channels) - if (channel.isMember && !channel.isArchived) channel.id, - }; - final relayBaseUrl = ref.read(relayConfigProvider).baseUrl; - _desiredLiveChannelIds = channelIds; - final subscriptionVersion = ++_subscriptionVersion; - - final sync = _liveSubscriptionQueue.then( - (_) => _syncLiveSubscriptions( - relayBaseUrl, - subscriptionVersion, - channels, - fence, - ), - ); - _liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) { - if (error is! _StaleChannelRefresh) { - debugPrint( - '[ChannelsNotifier] live subscription sync failed: $error\n$stack', - ); - } - }); - return sync; - } - - Future _syncLiveSubscriptions( - String relayBaseUrl, - int subscriptionVersion, - List channels, - _ChannelRefreshFence fence, - ) async { - fence.ensureCurrent(); - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - - if (subscriptionVersion != _subscriptionVersion) { - return; - } - - if (_subscriptionRelayBaseUrl != relayBaseUrl) { - for (final unsubscribe in _unsubscribersByChannel.values) { - unsubscribe(); - } - _unsubscribersByChannel.clear(); - _subscriptionRelayBaseUrl = relayBaseUrl; - } - if (ref.read(relayConfigProvider).baseUrl != relayBaseUrl) { - return; - } - final session = ref.read(relaySessionProvider.notifier); - final channelIds = _desiredLiveChannelIds; - - for (final entry in _unsubscribersByChannel.entries.toList()) { - if (channelIds.contains(entry.key)) continue; - _unsubscribersByChannel.remove(entry.key); - entry.value(); - } - - for (final channelId in channelIds) { - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - if (_unsubscribersByChannel.containsKey(channelId)) continue; - try { - final unsubscribe = await session.subscribe( - NostrFilter( - kinds: EventKind.channelEventKinds, - tags: { - '#h': [channelId], - }, - limit: 0, - ), - _handleLiveEvent, - ); - if (!fence.isCurrent) { - unsubscribe(); - throw const _StaleChannelRefresh(); - } - if (subscriptionVersion != _subscriptionVersion || - ref.read(relaySessionProvider).status != SessionStatus.connected || - !_desiredLiveChannelIds.contains(channelId) || - ref.read(relayConfigProvider).baseUrl != relayBaseUrl || - _subscriptionRelayBaseUrl != relayBaseUrl) { - unsubscribe(); - return; - } - final replaced = _unsubscribersByChannel[channelId]; - if (replaced != null) { - unsubscribe(); - continue; - } - _unsubscribersByChannel[channelId] = unsubscribe; - } on _StaleChannelRefresh { - rethrow; - } catch (error) { - debugPrint( - '[ChannelsNotifier] live subscription failed for $channelId: $error', - ); - } - } - - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - - if (subscriptionVersion != _subscriptionVersion) { - final desiredChannelIds = _desiredLiveChannelIds; - for (final entry in _unsubscribersByChannel.entries.toList()) { - if (desiredChannelIds.contains(entry.key)) continue; - _unsubscribersByChannel.remove(entry.key); - entry.value(); - } - return; - } - - fence.ensureCurrent(); - unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); - - _backstopTimer?.cancel(); - _backstopTimer = Timer.periodic( - _backstopInterval, - (_) => _backstopRefresh(), - ); - } - /// Backfills unread badges for the channels this refresh just installed. /// /// Runs detached from the refresh that starts it, so the lifecycle token @@ -668,6 +543,7 @@ class ChannelsNotifier extends AsyncNotifier> { _ChannelRefreshFence fence, int subscriptionGeneration, ) async { + if (!ref.mounted) return; final myPk = ref.read(myPubkeyProvider); if (myPk == null) return; @@ -712,7 +588,9 @@ class ChannelsNotifier extends AsyncNotifier> { // The relay round-trip above is the window Jed's probes park in: a newer // refresh, a community switch or an identity switch here means every // write below belongs to a channel list the user has left. - if (_isCatchUpRetired(fence, subscriptionGeneration)) return; + if (!ref.mounted || _isCatchUpRetired(fence, subscriptionGeneration)) { + return; + } for (final event in events) { if (event.pubkey.toLowerCase() == myPk.toLowerCase()) { @@ -752,6 +630,7 @@ class ChannelsNotifier extends AsyncNotifier> { state = state.whenData((channels) => List.of(channels)); } } catch (error) { + if (!ref.mounted) return; debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); } } diff --git a/mobile/lib/features/channels/channels_provider_lifecycle.dart b/mobile/lib/features/channels/channels_provider_lifecycle.dart index 95cb0889ced..8c88377aa9a 100644 --- a/mobile/lib/features/channels/channels_provider_lifecycle.dart +++ b/mobile/lib/features/channels/channels_provider_lifecycle.dart @@ -1,18 +1,274 @@ part of 'channels_provider.dart'; -// This extension exists only to keep `channels_provider.dart` under the -// desktop/mobile file-size ratchet; `_clearLiveSubscriptions` is not a -// standalone semantic boundary and belongs to `ChannelsNotifier`. -extension _ChannelsNotifierSubscriptionCleanup on ChannelsNotifier { +// Mirrors MAX_EXPLICIT_CHANNEL_VALUES in the relay REQ handler. A larger live +// set must use multiple subscriptions or the relay rejects the whole REQ. +const _maxLiveChannelsPerSubscription = 128; + +/// Owns live channel subscription reconciliation and teardown. +/// +/// Keeping this extension in a part preserves access to the notifier's private +/// lifecycle state while keeping `channels_provider.dart` below the mobile +/// file-size ratchet. +extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { + /// Subscribe to live events in deterministic chunks that stay within the + /// relay's aggregate explicit-`#h` limit. Also starts a 60s WS backstop poll + /// to reconcile membership changes without downloading the global directory. + Future _subscribeLive( + List channels, + _ChannelRefreshFence fence, + ) { + final channelIds = { + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel.id, + }; + final relayBaseUrl = _lifecycleRef.read(relayConfigProvider).baseUrl; + _desiredLiveChannelIds = channelIds; + // A changed filter may be admitted, but an unchanged terminal rejection + // cannot recover just because a timer or manual refresh fetched it again. + // Drop obsolete keys so this quarantine stays bounded by desired chunks. + final desiredKeys = chunkChannelIdsForLiveSubscriptions( + channelIds, + ).map(_liveChunkKey).toSet(); + _terminallyClosedLiveChunks.retainAll(desiredKeys); + final subscriptionVersion = ++_subscriptionVersion; + + final sync = _liveSubscriptionQueue.then( + (_) => _syncLiveSubscriptions( + relayBaseUrl, + subscriptionVersion, + channels, + fence, + ), + ); + _liveSubscriptionQueue = sync + .whenComplete(() { + // Reconcile even if a retired generation exits before its successor + // can run (for example, a disconnect while subscribe is pending). + if (_lifecycleRef.mounted) _removeUndesiredLiveChunks(); + }) + .catchError((Object error, StackTrace stack) { + if (error is! _StaleChannelRefresh) { + debugPrint( + '[ChannelsNotifier] live subscription sync failed: $error\n$stack', + ); + } + }); + return _liveSubscriptionQueue; + } + + Future _syncLiveSubscriptions( + String relayBaseUrl, + int subscriptionVersion, + List channels, + _ChannelRefreshFence fence, + ) async { + if (!_lifecycleRef.mounted) return; + fence.ensureCurrent(); + if (_lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected || + subscriptionVersion != _subscriptionVersion) { + return; + } + + if (_subscriptionRelayBaseUrl != relayBaseUrl) { + _clearRetainedLiveChunks(); + _subscriptionRelayBaseUrl = relayBaseUrl; + } + if (_lifecycleRef.read(relayConfigProvider).baseUrl != relayBaseUrl) return; + + final desiredChunks = chunkChannelIdsForLiveSubscriptions( + _desiredLiveChannelIds, + ); + // Keep existing coverage while replacements are installed. Cleanup runs + // when this generation settles, and retains old coverage on failures. + + final session = _lifecycleRef.read(relaySessionProvider.notifier); + for (final chunk in desiredChunks) { + final chunkKey = _liveChunkKey(chunk); + if (_liveSubscriptionsByChunk.containsKey(chunkKey) || + _terminallyClosedLiveChunks.contains(chunkKey)) { + continue; + } + if (_lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected) { + return; + } + final generation = ++_nextLiveChunkGeneration; + final subscription = _LiveChunkSubscription(generation); + _liveSubscriptionsByChunk[chunkKey] = subscription; + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: {'#h': chunk}, + limit: 0, + ), + (event) { + // Superseded chunks can overlap during replacement or recovery. + // Deliver only the current scope's still-desired channels; duplicate + // events are already idempotent in the unread/timestamp stores. + if (!_lifecycleRef.mounted || + _refreshCoordinator.currentScope() != fence.scope || + !_desiredLiveChannelIds.contains(event.channelId)) { + return; + } + _handleLiveEvent(event); + }, + onClosed: (message) => + _handleLiveChunkClosed(chunkKey, generation, message), + ); + subscription.unsubscribe = unsubscribe; + if (!_lifecycleRef.mounted || !fence.isCurrent) { + _liveSubscriptionsByChunk.remove(chunkKey); + unsubscribe(); + if (!fence.isCurrent) throw const _StaleChannelRefresh(); + return; + } + if (subscriptionVersion != _subscriptionVersion || + _lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected || + _lifecycleRef.read(relayConfigProvider).baseUrl != relayBaseUrl || + _subscriptionRelayBaseUrl != relayBaseUrl || + !chunk.every(_desiredLiveChannelIds.contains)) { + _liveSubscriptionsByChunk.remove(chunkKey); + unsubscribe(); + return; + } + if (_liveSubscriptionsByChunk[chunkKey] != subscription) { + unsubscribe(); + continue; + } + } on _StaleChannelRefresh { + rethrow; + } catch (error) { + if (_liveSubscriptionsByChunk[chunkKey] == subscription) { + _liveSubscriptionsByChunk.remove(chunkKey); + } + if (!_lifecycleRef.mounted) return; + debugPrint( + '[ChannelsNotifier] live subscription failed for ' + '${chunk.length} channels: $error', + ); + } + } + + if (!_lifecycleRef.mounted || + _lifecycleRef.read(relaySessionProvider).status != + SessionStatus.connected || + subscriptionVersion != _subscriptionVersion) { + return; + } + + fence.ensureCurrent(); + unawaited(_catchUpUnreadEvents(channels, fence, subscriptionVersion)); + + _backstopTimer?.cancel(); + _backstopTimer = Timer.periodic( + ChannelsNotifier._backstopInterval, + (_) => _backstopRefresh(), + ); + } + + void _handleLiveChunkClosed(String chunkKey, int generation, String message) { + if (!_lifecycleRef.mounted) return; + final subscription = _liveSubscriptionsByChunk[chunkKey]; + if (subscription == null || subscription.generation != generation) return; + _liveSubscriptionsByChunk.remove(chunkKey); + // RelaySession calls onClosed only for terminal admission failures. Its + // transient/rate-limit retries have their own backoff. Do not turn a + // terminal rejection into a fresh membership/history/REQ loop here. + if (chunkChannelIdsForLiveSubscriptions( + _desiredLiveChannelIds, + ).any((chunk) => _liveChunkKey(chunk) == chunkKey)) { + _terminallyClosedLiveChunks.add(chunkKey); + } + debugPrint( + '[ChannelsNotifier] live subscription closed by relay: $message', + ); + } + + void _removeUndesiredLiveChunks() { + final desiredChunks = chunkChannelIdsForLiveSubscriptions( + _desiredLiveChannelIds, + ); + final desiredKeys = desiredChunks.map(_liveChunkKey).toSet(); + final retainedKeys = {...desiredKeys}; + final uncoveredIds = {}; + for (final chunk in desiredChunks) { + final key = _liveChunkKey(chunk); + if (!_liveSubscriptionsByChunk.containsKey(key)) { + uncoveredIds.addAll(chunk); + } + } + final obsolete = _liveSubscriptionsByChunk.entries + .where((entry) => !desiredKeys.contains(entry.key)) + .toList(); + while (uncoveredIds.isNotEmpty) { + MapEntry? best; + var bestCoverage = 0; + for (final entry in obsolete) { + if (retainedKeys.contains(entry.key)) continue; + final coverage = entry.key + .split('\u0000') + .where(uncoveredIds.contains) + .length; + if (coverage > bestCoverage) { + best = entry; + bestCoverage = coverage; + } + } + if (best == null) break; + retainedKeys.add(best.key); + uncoveredIds.removeAll(best.key.split('\u0000')); + } + for (final entry in _liveSubscriptionsByChunk.entries.toList()) { + if (retainedKeys.contains(entry.key)) continue; + _liveSubscriptionsByChunk.remove(entry.key); + entry.value.unsubscribe?.call(); + } + } + + void _clearRetainedLiveChunks() { + for (final subscription in _liveSubscriptionsByChunk.values) { + subscription.unsubscribe?.call(); + } + _liveSubscriptionsByChunk.clear(); + } + void _clearLiveSubscriptions() { _subscriptionVersion++; _desiredLiveChannelIds = const {}; - for (final unsubscribe in _unsubscribersByChannel.values) { - unsubscribe(); - } - _unsubscribersByChannel.clear(); + _terminallyClosedLiveChunks.clear(); + _clearRetainedLiveChunks(); _subscriptionRelayBaseUrl = null; _backstopTimer?.cancel(); _backstopTimer = null; } } + +/// Returns deterministic channel-ID chunks within the relay's live REQ cap. +List> chunkChannelIdsForLiveSubscriptions( + Iterable channelIds, +) { + final sortedIds = channelIds.toList()..sort(); + return [ + for ( + var start = 0; + start < sortedIds.length; + start += _maxLiveChannelsPerSubscription + ) + sortedIds.sublist( + start, + min(start + _maxLiveChannelsPerSubscription, sortedIds.length), + ), + ]; +} + +String _liveChunkKey(List channelIds) => channelIds.join('\u0000'); + +class _LiveChunkSubscription { + _LiveChunkSubscription(this.generation); + + final int generation; + void Function()? unsubscribe; +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 64938b5c879..0fb3022409b 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -287,7 +287,11 @@ class MessageContent extends HookConsumerWidget { agentMentionPubkeys: resolvedAgentMentionPubkeys, onMentionTap: onMentionTap, ), - CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), + CustomEmojiMd( + customEmoji, + content: finalContent, + size: inlineCustomEmojiSize, + ), _ChannelLinkMd( channelNames: resolvedChannelNames, onChannelTap: resolvedChannelTap, diff --git a/mobile/lib/shared/custom_emoji/custom_emoji_render.dart b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart index e4af0cc43ff..feede9aec81 100644 --- a/mobile/lib/shared/custom_emoji/custom_emoji_render.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart @@ -57,8 +57,36 @@ class CustomEmojiMd extends InlineMd { final double size; late final RegExp _exp = _buildPattern(_urlByShortcode.keys); - CustomEmojiMd(List palette, {this.size = kCustomEmojiInlineSize}) - : _urlByShortcode = {for (final e in palette) e.shortcode: e.url}; + /// Only include shortcodes present in the rendered [content]. gpt_markdown + /// embeds this pattern in a combined regex for every parsed text segment, so + /// unrelated community emoji must not make every message expensive to parse. + CustomEmojiMd( + List palette, { + required String content, + this.size = kCustomEmojiInlineSize, + }) : _urlByShortcode = _referencedUrls(palette, content); + + // Look ahead so adjacent tokens sharing a colon are both considered: + // :unknown:known: must still allow the known token to match. + static final _shortcodeScan = RegExp( + r'(?=:([a-z0-9_-]+):)', + caseSensitive: false, + ); + + static Map _referencedUrls( + List palette, + String content, + ) { + final referenced = { + for (final match in _shortcodeScan.allMatches(content)) + match.group(1)!.toLowerCase(), + }; + if (referenced.isEmpty) return const {}; + return { + for (final emoji in palette) + if (referenced.contains(emoji.shortcode)) emoji.shortcode: emoji.url, + }; + } @override RegExp get exp => _exp; diff --git a/mobile/lib/shared/relay/relay_rate_limit_gate.dart b/mobile/lib/shared/relay/relay_rate_limit_gate.dart index 61640139368..92ea7d85f01 100644 --- a/mobile/lib/shared/relay/relay_rate_limit_gate.dart +++ b/mobile/lib/shared/relay/relay_rate_limit_gate.dart @@ -7,7 +7,7 @@ typedef RelayTimerFactory = /// Session-owned gate that pauses relay requests after back-pressure. class RelayRateLimitGate { - /// Default gate duration when the relay omits a positive retry hint. + /// Default gate duration when the relay omits a parseable retry hint. static const defaultRetrySeconds = 10; /// Longest retry hint accepted from a relay response. @@ -32,10 +32,22 @@ class RelayRateLimitGate { } /// Activates or extends the gate without shrinking an existing window. + /// + /// A null [retryInSeconds] means the relay sent no parseable hint, so the + /// conservative [defaultRetrySeconds] applies. An explicit hint is honored as + /// given: the relay sends `retry in 0s` to mean "retry immediately", so a + /// non-positive hint opens no window at all. Treating `0` as if it were + /// absent previously gated every relay read for [defaultRetrySeconds] — on a + /// cold start with many channels that turned the relay's own "go now" into a + /// ten-second stall of the whole session. + /// + /// An already-active window is never shortened, so an immediate hint arriving + /// mid-window leaves that window intact. void activate(int? retryInSeconds) { - final seconds = retryInSeconds != null && retryInSeconds > 0 - ? min(retryInSeconds, maxRetrySeconds) - : defaultRetrySeconds; + if (retryInSeconds != null && retryInSeconds <= 0) return; + final seconds = retryInSeconds == null + ? defaultRetrySeconds + : min(retryInSeconds, maxRetrySeconds); final duration = Duration(seconds: seconds); final newExpiry = _now().add(duration); final currentExpiry = _expiresAt; diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index b8d13ee34a4..a8209a9557b 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -737,7 +737,7 @@ class RelaySessionNotifier extends Notifier { final retrySeconds = parseRateLimitRetrySeconds(message); _rateLimitGate.activate(retrySeconds); final fallbackMs = - (retrySeconds != null && retrySeconds > 0 + (retrySeconds != null ? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds) : RelayRateLimitGate.defaultRetrySeconds) * 1000; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 46ea8cf564a..4526e1f3367 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -354,7 +354,7 @@ packages: source: hosted version: "0.3.12" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 4543888551b..0abcc8a77e1 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,6 +51,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + fake_async: ^1.3.3 camera_platform_interface: ^2.13.1 crypto: ^3.0.7 custom_lint: ^0.8.0 diff --git a/mobile/test/features/channels/channels_provider_live_cases.dart b/mobile/test/features/channels/channels_provider_live_cases.dart new file mode 100644 index 00000000000..28501144121 --- /dev/null +++ b/mobile/test/features/channels/channels_provider_live_cases.dart @@ -0,0 +1,415 @@ +part of 'channels_provider_test.dart'; + +void _liveSubscriptionTests() { + const myPk = 'me'; + + test( + 'subscribes once for joined non-archived channels without blocking snapshot', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + _membership(_channelD, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + // channelD metadata missing -> won't appear in channel list + ], + ); + session.pauseNextSubscribe(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + expect(channels.map((channel) => channel.id).toSet(), { + _channelA, + _channelB, + }); + await session.nextSubscribeStarted; + expect(session.subscribeFilters, hasLength(1)); + expect(session.subscribeFilters.single.tags['#h'], [ + _channelA, + _channelB, + ]); + expect( + session.subscribeFilters.single.kinds, + EventKind.channelEventKinds, + ); + expect(session.subscribeFilters.single.limit, 0); + + session.resumePausedSubscribe(); + await _waitUntil(() => session.activeChannels.length == 2); + }, + ); + + test('chunks live subscriptions at the relay explicit-channel cap', () async { + final channelIds = [for (var i = 0; i < 257; i++) _generatedChannelId(i)]; + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + expect(channels, hasLength(257)); + await _waitUntil(() => session.subscribeFilters.length == 3); + + expect( + session.subscribeFilters.map((filter) => filter.tags['#h']!.length), + [128, 128, 1], + ); + expect( + session.subscribeFilters.expand((filter) => filter.tags['#h']!).toSet(), + channelIds.toSet(), + ); + }); + + test( + 'refreshing an unchanged channel set issues zero new live REQs', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + final initialSubscribeCount = session.totalSubscribeCount; + + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + + expect(session.totalSubscribeCount, initialSubscribeCount); + expect(session.unsubscribeCount, 0); + expect(session.subscribeFilters, hasLength(1)); + }, + ); + + test('changing a live chunk replaces only that chunk', () async { + final channelIds = [for (var i = 0; i < 129; i++) _generatedChannelId(i)]; + final addedId = _generatedChannelId(999); + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + session.memberships = [ + for (final id in [...channelIds.take(128), addedId]) + _membership(id, myPk), + ]; + session.metadata = [ + for (final id in [...channelIds.take(128), addedId]) + _meta(id: id, name: id), + ]; + await container.read(channelsProvider.notifier).refresh(); + await _waitUntil(() => session.totalSubscribeCount == 3); + + expect(session.activeSubscriptionCount, 2); + expect(session.unsubscribeCount, 1); + expect(session.activeChannels, {...channelIds.take(128), addedId}); + }); + + test( + 'front-sorting membership insertion retains coverage while replacement waits', + () async { + final channelIds = [ + for (var i = 1; i <= 129; i++) _generatedChannelId(i), + ]; + final addedId = _generatedChannelId(0); + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + session.memberships.add(_membership(addedId, myPk)); + session.metadata.add(_meta(id: addedId, name: addedId)); + session.pauseNextSubscribe(); + await container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + try { + expect(session.activeChannels, channelIds.toSet()); + expect(session.unsubscribeCount, 0); + session.emit(_liveMessage(channelIds.last)); + expect( + container + .read(channelsProvider) + .requireValue + .firstWhere((channel) => channel.id == channelIds.last) + .lastMessageAt + ?.millisecondsSinceEpoch, + 20000, + ); + } finally { + session.resumePausedSubscribe(); + } + await _waitUntil(() => session.unsubscribeCount == 2); + expect(session.totalSubscribeCount, 4); + expect(session.activeSubscriptionCount, 2); + expect(session.activeChannels, {...channelIds, addedId}); + }, + ); + + test( + 'failed replacement keeps desired coverage and ignores departed channels', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'a'), + _meta(id: _channelB, name: 'b'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships = [ + _membership(_channelB, myPk), + _membership(_channelD, myPk), + ]; + session.metadata = [ + _meta(id: _channelB, name: 'b'), + _meta(id: _channelD, name: 'd'), + ]; + session.subscribeFailures = 1; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(session.activeChannels, {_channelA, _channelB}); + expect(session.unsubscribeCount, 0); + final requests = session.membershipRequestCount; + session.emit(_liveMessage(_channelA)); + await _settle(); + expect(session.membershipRequestCount, requests); + session.emit(_liveMessage(_channelB)); + expect( + container + .read(channelsProvider) + .requireValue + .firstWhere((channel) => channel.id == _channelB) + .lastMessageAt + ?.millisecondsSinceEpoch, + 20000, + ); + + await container.read(channelsProvider.notifier).refresh(); + await _waitUntil(() => session.unsubscribeCount == 1); + expect(session.activeChannels, {_channelB, _channelD}); + expect(session.activeSubscriptionCount, 1); + }, + ); + + test( + 'terminal closure preserves fallback until reconnect retries coverage', + () async { + final channelIds = [ + for (var i = 1; i <= 129; i++) _generatedChannelId(i), + ]; + final addedId = _generatedChannelId(0); + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + session.memberships.add(_membership(addedId, myPk)); + session.metadata.add(_meta(id: addedId, name: addedId)); + session.subscribeFailures = 1; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(session.activeChannels, containsAll(channelIds)); + + session.closeSubscriptionContaining( + channelIds.last, + 'restricted: terminal closure', + ); + await _settle(); + // The rejected chunk itself is gone, but the previously retained + // fallback must still cover its original first 128 channels. + expect(session.activeChannels, channelIds.take(128).toSet()); + expect(session.activeChannels, isNot(contains(addedId))); + session.setStatus(SessionStatus.disconnected); + session.setStatus(SessionStatus.connected); + await _waitUntil(() => session.activeChannels.contains(addedId)); + + expect(session.activeChannels, containsAll({...channelIds, addedId})); + expect(session.activeSubscriptionCount, 2); + }, + ); + + test( + 'partial replacement churn keeps complete coverage with bounded fallbacks', + () async { + var channelIds = [for (var i = 100; i < 356; i++) _generatedChannelId(i)]; + final session = _FakeRelaySession( + memberships: [for (final id in channelIds) _membership(id, myPk)], + metadata: [for (final id in channelIds) _meta(id: id, name: id)], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 2); + + for (var cycle = 0; cycle < 7; cycle++) { + final addedId = _generatedChannelId(99 - cycle); + channelIds = [addedId, ...channelIds.take(255)]; + session.memberships = [ + for (final id in channelIds) _membership(id, myPk), + ]; + session.metadata = [ + for (final id in channelIds) _meta(id: id, name: id), + ]; + session.successfulSubscribesBeforeFailure = 1; + session.subscribeFailures = 1; + await container.read(channelsProvider.notifier).refresh(); + await _settle(); + expect(session.activeChannels, containsAll(channelIds)); + expect(session.activeSubscriptionCount, lessThanOrEqualTo(3)); + } + }, + ); + + test( + 'retired replacement cleans up even when the next generation disconnects', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'a')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships.add(_membership(_channelB, myPk)); + session.metadata.add(_meta(id: _channelB, name: 'b')); + session.pauseNextSubscribe(); + await container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + try { + expect(session.activeChannels, {_channelA}); + session.memberships = []; + session.metadata = []; + await container.read(channelsProvider.notifier).refresh(); + session.setStatus(SessionStatus.disconnected); + } finally { + session.resumePausedSubscribe(); + } + await _waitUntil(() => session.unsubscribeCount == 2); + expect(session.activeChannels, isEmpty); + expect(session.activeSubscriptionCount, 0); + }, + ); + + test('disposal retires retained and in-flight replacement chunks', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'a')], + ); + final container = _buildContainer(session: session); + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships.add(_membership(_channelB, myPk)); + session.metadata.add(_meta(id: _channelB, name: 'b')); + session.pauseNextSubscribe(); + await container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + container.dispose(); + session.resumePausedSubscribe(); + await _waitUntil(() => session.unsubscribeCount == 2); + expect(session.activeSubscriptionCount, 0); + }); + + test('empty channel refresh removes every retained live chunk', () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk), _membership(_channelB, myPk)], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + await _waitUntil(() => session.activeSubscriptionCount == 1); + session.memberships = []; + session.metadata = []; + + await container.read(channelsProvider.notifier).refresh(); + await _waitUntil(() => session.activeSubscriptionCount == 0); + + expect(session.activeChannels, isEmpty); + expect(session.unsubscribeCount, 1); + }); + + test( + 'community switch retires a detached old-scope live subscription', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + session.pauseNextSubscribe(); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + expect( + (await container.read(channelsProvider.future)).single.id, + _channelA, + ); + await session.nextSubscribeStarted; + + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'random')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://new-community.example'); + await container.read(channelsProvider.future); + + session.resumePausedSubscribe(); + await _waitUntil(() => session.activeChannels.contains(_channelB)); + + expect(session.activeChannels, {_channelB}); + expect(session.activeSubscriptionCount, 1); + expect(session.unsubscribeCount, 1); + }, + ); +} + +NostrEvent _liveMessage(String channelId) => NostrEvent( + id: 'live-$channelId', + pubkey: 'alice', + createdAt: 20, + kind: EventKind.streamMessageV2, + tags: [ + ['h', channelId], + ], + content: 'live update', + sig: 'sig', +); diff --git a/mobile/test/features/channels/channels_provider_terminal_cases.dart b/mobile/test/features/channels/channels_provider_terminal_cases.dart new file mode 100644 index 00000000000..dfbc41db602 --- /dev/null +++ b/mobile/test/features/channels/channels_provider_terminal_cases.dart @@ -0,0 +1,254 @@ +part of 'channels_provider_test.dart'; + +void _testWithClock(String name, void Function(FakeAsync) body) { + test(name, () => fakeAsync(body)); +} + +void _terminalSubscriptionTests() { + for (final beforeReady in [true, false]) { + _testWithClock( + 'persistent terminal rejection ${beforeReady ? 'before' : 'after'} ' + 'readiness cannot amplify refreshes or retries', + (clock) { + final session = _TerminalRelaySession(beforeReady: beforeReady); + final logs = []; + final originalDebugPrint = debugPrint; + debugPrint = (message, {wrapWidth}) { + if (message != null) logs.add(message); + }; + final container = _buildContainer(session: session); + container.read(channelsProvider); + try { + clock.flushMicrotasks(); + // The finite snapshot is usable even before live readiness settles. + expect( + container.read(channelsProvider).requireValue.single.id, + _channelA, + ); + expect(session.totalSubscribeCount, 1); + final initialMemberships = session.membershipRequestCount; + clock.elapse(const Duration(milliseconds: 2)); + final historyAfterClose = session.historyFilters.length; + final batchesAfterClose = session.queryBatches.length; + final logsAfterClose = logs.length; + + // Virtual time permits many immediate retries on the broken path. + for (var i = 0; i < 20; i++) { + clock.elapse(const Duration(milliseconds: 10)); + } + expect(session.rejectionCount, 1); + expect(session.membershipRequestCount, initialMemberships); + expect(session.historyFilters.length, historyAfterClose); + expect(session.queryBatches.length, batchesAfterClose); + expect(session.totalSubscribeCount, 1); + expect(logs.length, logsAfterClose); + expect(clock.periodicTimerCount, 1); + expect(clock.nonPeriodicTimerCount, 0); + + // Ordinary polling may refresh memberships, but never re-admits an + // unchanged terminal filter, creates retry timers, or repeats logs. + final pollStart = session.membershipRequestCount; + for (var i = 0; i < 3; i++) { + clock.elapse(const Duration(seconds: 60)); + expect(session.totalSubscribeCount, 1); + expect(session.activeSubscriptionCount, 0); + expect(logs.length, logsAfterClose); + expect(clock.periodicTimerCount, 1); + expect(clock.nonPeriodicTimerCount, 0); + expect( + container.read(channelsProvider).requireValue.single.id, + _channelA, + ); + } + expect(session.membershipRequestCount - pollStart, 6); + + // A pull-to-refresh still isn't evidence that admission changed. + unawaited(container.read(channelsProvider.notifier).refresh()); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + expect(session.totalSubscribeCount, 1); + expect(logs.length, logsAfterClose); + } finally { + container.dispose(); + debugPrint = originalDebugPrint; + } + clock.elapse(const Duration(seconds: 60)); + expect(clock.pendingTimers, isEmpty); + expect(session.totalSubscribeCount, 1); + }, + ); + } + + for (final beforeReady in [true, false]) { + _testWithClock( + 'disposal fences pending terminal closure ${beforeReady ? 'before' : 'after'} readiness', + (clock) { + final session = _TerminalRelaySession(beforeReady: beforeReady); + final container = _buildContainer(session: session); + container.read(channelsProvider); + clock.flushMicrotasks(); + final requests = session.membershipRequestCount; + container.dispose(); + clock.elapse(const Duration(minutes: 3)); + expect(session.totalSubscribeCount, 1); + expect(session.membershipRequestCount, requests); + expect(session.activeSubscriptionCount, 0); + expect(clock.pendingTimers, isEmpty); + }, + ); + } + + _testWithClock('terminal rejection retries only after a new connection', ( + clock, + ) { + final session = _TerminalRelaySession(); + final container = _buildContainer(session: session); + try { + container.read(channelsProvider); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + final oldClosed = session.closedCallbacks.single; + for (var attempt = 2; attempt <= 4; attempt++) { + session.setStatus(SessionStatus.disconnected); + clock.flushMicrotasks(); + session.setStatus(SessionStatus.connected); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + clock.elapse(const Duration(milliseconds: 20)); + expect(session.totalSubscribeCount, attempt); + expect(session.rejectionCount, attempt); + expect(session.activeSubscriptionCount, 0); + expect( + container.read(channelsProvider).requireValue.single.id, + _channelA, + ); + } + session.reject = false; + session.setStatus(SessionStatus.disconnected); + clock.flushMicrotasks(); + session.setStatus(SessionStatus.connected); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 5); + expect(session.activeChannels, {_channelA}); + oldClosed(); + clock.flushMicrotasks(); + expect(session.activeChannels, {_channelA}); + expect(session.totalSubscribeCount, 5); + } finally { + container.dispose(); + } + clock.flushMicrotasks(); + }); + + _testWithClock( + 'changed membership admits a new filter, not a rejected old one', + (clock) { + final session = _TerminalRelaySession(); + final container = _buildContainer(session: session); + try { + container.read(channelsProvider); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + session.reject = false; + session.memberships.add(_membership(_channelB, 'me')); + session.metadata.add(_meta(id: _channelB, name: 'b')); + unawaited(container.read(channelsProvider.notifier).refresh()); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 2); + expect(session.activeChannels, {_channelA, _channelB}); + // Removing then rejoining is a meaningful transition even if it returns + // to an earlier filter; obsolete quarantine keys must not leak forever. + session.memberships.removeLast(); + unawaited(container.read(channelsProvider.notifier).refresh()); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 3); + expect(session.activeChannels, {_channelA}); + } finally { + container.dispose(); + } + clock.flushMicrotasks(); + }, + ); + + for (final switchIdentity in [true, false]) { + _testWithClock( + 'terminal quarantine does not cross ${switchIdentity ? 'identity' : 'community'} scope', + (clock) { + final session = _TerminalRelaySession(); + final container = _buildContainer(session: session); + try { + container.read(channelsProvider); + clock.flushMicrotasks(); + clock.elapse(const Duration(milliseconds: 2)); + session.reject = false; + if (switchIdentity) { + session.memberships = [_membership(_channelA, _otherPk)]; + container.read(_testPubkeyProvider.notifier).set(_otherPk); + } else { + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://new-community.example'); + } + container.read(channelsProvider); + clock.flushMicrotasks(); + expect(session.totalSubscribeCount, 2); + expect(session.activeChannels, {_channelA}); + } finally { + container.dispose(); + } + clock.flushMicrotasks(); + }, + ); + } +} + +/// Reject every attempted subscription, including every replacement, as a +/// terminal relay CLOSED. A timer models the wire turn and prevents a broken +/// immediate retry loop from hanging the test's microtask drain. +class _TerminalRelaySession extends _FakeRelaySession { + _TerminalRelaySession({this.beforeReady = false}) + : super( + memberships: [_membership(_channelA, 'me')], + metadata: [_meta(id: _channelA, name: 'a')], + ); + + final bool beforeReady; + bool reject = true; + int rejectionCount = 0; + final List closedCallbacks = []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + final unsubscribe = await super.subscribe( + filter, + onEvent, + onClosed: onClosed, + ); + if (!reject) return unsubscribe; + final key = _subscriptions.keys.last; + final closed = Completer(); + final timer = Timer(const Duration(milliseconds: 1), () { + final subscription = _subscriptions.remove(key); + if (subscription != null) { + subscribeFilters.remove(subscription.$1); + rejectionCount++; + void notify() => onClosed?.call('restricted: persistent rejection'); + closedCallbacks.add(notify); + notify(); + } + closed.complete(); + }); + if (beforeReady) { + await closed.future; + throw StateError('restricted: persistent rejection'); + } + return () { + timer.cancel(); + unsubscribe(); + }; + } +} diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 04f7cd917ba..3bb1be5beb9 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,12 +8,15 @@ import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; +part 'channels_provider_live_cases.dart'; +part 'channels_provider_terminal_cases.dart'; + /// Tests for [ChannelsNotifier] in the pure-Nostr world. /// /// The provider loads membership-backed channels first: /// 1. paginated kind:39002 memberships tagged `#p:` /// 2. kind:39000 metadata for those channel ids -/// then layers per-channel live subscriptions on the `#h` tag. Browse channels +/// then layers chunked live subscriptions on the `#h` tag. Browse channels /// separately triggers paginated kind:39000 open-channel discovery. /// /// Tests stub out the relay session by overriding [relaySessionProvider] with @@ -545,13 +549,15 @@ void main() { .retryDirectory(); await session.nextSubscribeStarted; - // Record every list emitted from the switch onward. The live-subscription - // queue is serialized, so community B's own subscribe waits behind the - // parked one. That makes the observable defect an emission of community - // A's channel into the current scope, not just a wrong final state. + await staleRefresh; + await _settle(); + + // The old scope's finite snapshot is allowed to publish before its live + // subscription becomes ready. From the actual switch onward, however, + // neither that snapshot nor its detached live work may republish. final emitted = >[]; container.listen(channelsProvider, (previous, next) { - final value = next.value; + final value = next.asData?.value; if (value != null) { emitted.add(value.map((channel) => channel.id).toList()); } @@ -562,9 +568,9 @@ void main() { container .read(relayConfigProvider.notifier) .update(baseUrl: 'https://community-b.example'); + await container.read(channelsProvider.future); session.resumePausedSubscribe(); - await staleRefresh; await _settle(); expect( @@ -1535,6 +1541,7 @@ void main() { expect(channels.map((channel) => channel.id), [_channelA, _channelB]); expect(channels.first.isMember, isTrue); expect(channels.last.isMember, isFalse); + await _waitUntil(() => session.subscribeFilters.length == 1); expect(session.subscribeFilters, hasLength(1)); }); @@ -1574,38 +1581,8 @@ void main() { }, ); - test( - 'subscribes per-channel with #h tags (only joined, non-archived)', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - _membership(_channelD, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - // channelD metadata missing -> won't appear in channel list - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - - // One subscription per joined, non-archived channel. - expect(session.subscribeFilters, hasLength(2)); - expect( - session.subscribeFilters.map((f) => f.tags['#h']?.single).toSet(), - {_channelA, _channelB}, - ); - for (final filter in session.subscribeFilters) { - expect(filter.kinds, EventKind.channelEventKinds); - expect(filter.limit, 0); - } - }, - ); + _liveSubscriptionTests(); + _terminalSubscriptionTests(); test('retains channel-list member snapshots for immediate reuse', () async { final joinedAt = DateTime.fromMillisecondsSinceEpoch(1000, isUtc: true); @@ -1626,174 +1603,6 @@ void main() { expect(members.every((member) => member.joinedAt == joinedAt), isTrue); }); - test( - 'refreshing an unchanged channel set issues zero new live REQs', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - final initialSubscribeCount = session.totalSubscribeCount; - - await container.read(channelsProvider.notifier).refresh(); - - expect(session.totalSubscribeCount, initialSubscribeCount); - expect(session.unsubscribeCount, 0); - expect(session.subscribeFilters, hasLength(2)); - }, - ); - - test( - 'live subscription diff only removes and adds changed channels', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - session.memberships = [ - _membership(_channelB, myPk), - _membership(_channelD, myPk), - ]; - session.metadata = [ - _meta(id: _channelB, name: 'random'), - _meta(id: _channelD, name: 'support'), - ]; - - await container.read(channelsProvider.notifier).refresh(); - - expect(session.totalSubscribeCount, 3); - expect(session.unsubscribeCount, 1); - expect( - session.subscribeFilters - .map((filter) => filter.tags['#h']!.single) - .toSet(), - {_channelB, _channelD}, - ); - }, - ); - - test( - 'empty channel refresh removes every retained live subscription', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - session.memberships = []; - session.metadata = []; - - await container.read(channelsProvider.notifier).refresh(); - - expect(session.activeChannels, isEmpty); - expect(session.activeSubscriptionCount, 0); - expect(session.unsubscribeCount, 2); - }, - ); - - test( - 'overlapping refreshes retain one live subscription per desired channel', - () async { - final session = _FakeRelaySession( - memberships: [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - ], - metadata: [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - ], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - session.pauseNextSubscribe(); - session.memberships = [ - _membership(_channelA, myPk), - _membership(_channelB, myPk), - _membership(_channelD, myPk), - ]; - session.metadata = [ - _meta(id: _channelA, name: 'general'), - _meta(id: _channelB, name: 'random'), - _meta(id: _channelD, name: 'support'), - ]; - - final firstRefresh = container.read(channelsProvider.notifier).refresh(); - await session.nextSubscribeStarted; - final secondRefresh = container.read(channelsProvider.notifier).refresh(); - session.resumePausedSubscribe(); - await Future.wait([firstRefresh, secondRefresh]); - - expect(session.activeChannels, {_channelA, _channelB, _channelD}); - expect(session.activeSubscriptionCount, 3); - }, - ); - - test( - 'community switch replaces retained live subscriptions on the new relay', - () async { - final session = _FakeRelaySession( - memberships: [_membership(_channelA, myPk)], - metadata: [_meta(id: _channelA, name: 'general')], - ); - final container = _buildContainer(session: session); - addTearDown(container.dispose); - - await container.read(channelsProvider.future); - expect(session.activeChannels, {_channelA}); - - session.setStatus(SessionStatus.disconnected); - session.memberships = [_membership(_channelB, myPk)]; - session.metadata = [_meta(id: _channelB, name: 'random')]; - container - .read(relayConfigProvider.notifier) - .update(baseUrl: 'https://new-community.example'); - await Future.delayed(Duration.zero); - session.setStatus(SessionStatus.connected); - await container.read(channelsProvider.future); - await _waitUntil( - () => - session.activeChannels.length == 1 && - session.activeChannels.contains(_channelB), - ); - - expect(session.activeChannels, {_channelB}); - expect(session.activeSubscriptionCount, 1); - expect(session.unsubscribeCount, 1); - }, - ); - test('live channel events update channel lastMessageAt', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -2196,6 +2005,7 @@ void main() { final initial = await container.read(channelsProvider.future); expect(initial.single.name, 'general'); + await _waitUntil(() => session.subscribeFilters.length == 1); expect(session.subscribeFilters, hasLength(1)); session.setStatus(SessionStatus.reconnecting); @@ -2296,6 +2106,7 @@ void main() { ); // And one live subscription on the resulting channel. + await _waitUntil(() => session.subscribeFilters.length == 1); expect(session.subscribeFilters, hasLength(1)); }); } @@ -2305,6 +2116,9 @@ const _channelB = '22222222-2222-4222-8222-222222222222'; const _channelD = '44444444-4444-4444-8444-444444444444'; const _otherPk = 'someone-else'; +String _generatedChannelId(int index) => + '${index.toString().padLeft(8, '0')}-0000-4000-8000-000000000000'; + /// Build a kind:39002 membership event tagged with the channel id and member. NostrEvent _membership( String channelId, @@ -2450,7 +2264,11 @@ class _FakeRelaySession extends RelaySessionNotifier { final List directoryQueryFilters = []; final List membershipQueryFilters = []; final List subscribeFilters = []; - final Map _subscriptions = {}; + final Map< + int, + (NostrFilter, void Function(NostrEvent), void Function(String message)?) + > + _subscriptions = {}; int _nextSubscriptionKey = 0; Completer? _pausedSubscribe; Completer? _subscribeStarted; @@ -2470,9 +2288,12 @@ class _FakeRelaySession extends RelaySessionNotifier { Completer? _claimedUnreadCatchUp; int unsubscribeCount = 0; int totalSubscribeCount = 0; + int subscribeFailures = 0; + int successfulSubscribesBeforeFailure = 0; Set get activeChannels => { - for (final (filter, _) in _subscriptions.values) ?filter.tags['#h']?.single, + for (final (filter, _, _) in _subscriptions.values) + ...filter.tags['#h'] ?? const [], }; int get activeSubscriptionCount => _subscriptions.length; @@ -2850,8 +2671,16 @@ class _FakeRelaySession extends RelaySessionNotifier { _pausedSubscribe = null; _subscribeStarted = null; } + if (subscribeFailures > 0 && successfulSubscribesBeforeFailure == 0) { + subscribeFailures--; + subscribeFilters.remove(filter); + throw StateError('live subscription failed'); + } + if (successfulSubscribesBeforeFailure > 0) { + successfulSubscribesBeforeFailure--; + } final subscriptionKey = ++_nextSubscriptionKey; - _subscriptions[subscriptionKey] = (filter, onEvent); + _subscriptions[subscriptionKey] = (filter, onEvent, onClosed); return () { final subscription = _subscriptions.remove(subscriptionKey); if (subscription == null) return; @@ -2860,13 +2689,22 @@ class _FakeRelaySession extends RelaySessionNotifier { }; } + void closeSubscriptionContaining(String channelId, String message) { + final entry = _subscriptions.entries.singleWhere( + (entry) => entry.value.$1.tags['#h']?.contains(channelId) ?? false, + ); + _subscriptions.remove(entry.key); + subscribeFilters.remove(entry.value.$1); + entry.value.$3?.call(message); + } + void setStatus(SessionStatus status) { state = SessionState(status: status); } /// Emit a live event to all subscribers. void emit(NostrEvent event) { - for (final (_, listener) in List.of(_subscriptions.values)) { + for (final (_, listener, _) in List.of(_subscriptions.values)) { listener(event); } } diff --git a/mobile/test/features/channels/message_content_custom_emoji_test.dart b/mobile/test/features/channels/message_content_custom_emoji_test.dart new file mode 100644 index 00000000000..3059af9a93e --- /dev/null +++ b/mobile/test/features/channels/message_content_custom_emoji_test.dart @@ -0,0 +1,121 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +Widget _testable(String content, {List> tags = const []}) { + return ProviderScope( + overrides: [ + customEmojiListProvider.overrideWithValue([ + const CustomEmoji( + shortcode: 'wave', + url: 'https://example.com/wave.png', + ), + for (var i = 0; i < 2500; i++) + CustomEmoji( + shortcode: 'unused_$i', + url: 'https://example.com/$i.png', + ), + ]), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MessageContent( + content: content, + tags: tags, + channelNames: const {'test': 'test-channel'}, + ), + ), + ), + ); +} + +void main() { + testWidgets('message wiring excludes unrelated emoji from the regex', ( + tester, + ) async { + await tester.pumpWidget(_testable('**hello** :unknown:')); + final markdown = tester.widget(find.byType(GptMarkdown)); + final component = markdown.inlineComponents! + .whereType() + .single; + expect(component.exp.hasMatch(':unused_2499:'), isFalse); + expect(component.exp.hasMatch(':wave:'), isFalse); + expect(find.byType(CustomEmojiImage), findsNothing); + final text = tester + .widgetList(find.byType(RichText)) + .map((widget) => widget.text.toPlainText()) + .join(); + expect(text, contains('hello')); + expect(text, contains(':unknown:')); + expect(text, isNot(contains('**hello**'))); + }); + + testWidgets('referenced event emoji stays available with tag URL priority', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + ':wave:', + tags: const [ + ['emoji', 'wave', 'https://example.com/event-wave.png'], + ], + ), + ); + final image = tester.widget( + find.byType(CustomEmojiImage), + ); + expect(image.shortcode, 'wave'); + expect(image.url, 'https://example.com/event-wave.png'); + final markdown = tester.widget(find.byType(GptMarkdown)); + final component = markdown.inlineComponents! + .whereType() + .single; + expect(component.exp.hasMatch(':unused_2499:'), isFalse); + expect(component.exp.hasMatch(':wave:'), isTrue); + }); + + testWidgets('content edits rebuild the scoped matcher', (tester) async { + await tester.pumpWidget(_testable('plain message')); + expect(find.byType(CustomEmojiImage), findsNothing); + + await tester.pumpWidget(_testable('edited :wave:')); + expect( + tester.widget(find.byType(CustomEmojiImage)).shortcode, + 'wave', + ); + + await tester.pumpWidget(_testable('edited :unused_2499:')); + expect( + tester.widget(find.byType(CustomEmojiImage)).shortcode, + 'unused_2499', + ); + final markdown = tester.widget(find.byType(GptMarkdown)); + final component = markdown.inlineComponents! + .whereType() + .single; + expect(component.exp.hasMatch(':wave:'), isFalse); + + await tester.pumpWidget(_testable('edited :unknown:')); + expect(find.byType(CustomEmojiImage), findsNothing); + }); + + testWidgets('code keeps literal emoji while adjacent known tokens render', ( + tester, + ) async { + await tester.pumpWidget(_testable('`:wave:` :unknown:wave:')); + expect(find.byType(CustomEmojiImage), findsOneWidget); + final text = tester + .widgetList(find.byType(RichText)) + .map((widget) => widget.text.toPlainText()) + .join(); + expect(text, contains(':wave:')); + expect(text, contains(':unknown')); + }); +} diff --git a/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart b/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart new file mode 100644 index 00000000000..ae831d64c4d --- /dev/null +++ b/mobile/test/shared/custom_emoji/custom_emoji_render_test.dart @@ -0,0 +1,109 @@ +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; + +const _palette = [ + CustomEmoji(shortcode: 'wave', url: 'https://example.com/wave.png'), + CustomEmoji(shortcode: 'wave_long', url: 'https://example.com/long.png'), + CustomEmoji(shortcode: 'party-parrot', url: 'https://example.com/parrot.png'), +]; + +void main() { + test('pattern is bounded by referenced emoji, not the community palette', () { + final largePalette = [ + ..._palette, + for (var i = 0; i < 2500; i++) + CustomEmoji(shortcode: 'unused_$i', url: 'https://example.com/$i.png'), + ]; + final small = CustomEmojiMd(_palette, content: 'hello :wave:'); + final large = CustomEmojiMd(largePalette, content: 'hello :wave:'); + expect(large.exp.pattern, small.exp.pattern); + expect(large.exp.hasMatch(':wave:'), isTrue); + expect(large.exp.hasMatch(':wave_long:'), isFalse); + expect(large.exp.hasMatch(':unused_2499:'), isFalse); + }); + + test( + 'no references and unknown references produce a nonmatching pattern', + () { + for (final content in [ + 'hello world', + ':unknown:', + 'https://example.com', + ]) { + final matcher = CustomEmojiMd(_palette, content: content); + expect(matcher.exp.allMatches(content), isEmpty); + expect(matcher.exp.hasMatch(':wave:'), isFalse); + } + expect( + CustomEmojiMd(const [], content: ':wave:').exp.hasMatch(':wave:'), + isFalse, + ); + }, + ); + + test('selection preserves the original matcher across token boundaries', () { + final original = RegExp( + ':(?:${_palette.map((e) => RegExp.escape(e.shortcode)).join('|')}):', + caseSensitive: false, + ); + for (final content in [ + ':wave:', + ':WAVE: :Wave_Long: :PARTY-PARROT:', + ':unknown:wave:', + ':wave:unknown:wave_long:', + ':wave::wave_long:', + ':::wave::: :wave_long:! (:party-parrot:)', + ':wave_longer: :unknown: no match', + '`code :wave:` **bold :wave_long:**', + ]) { + final selected = CustomEmojiMd(_palette, content: content); + expect( + selected.exp.allMatches(content).map((m) => m.group(0)).toList(), + original.allMatches(content).map((m) => m.group(0)).toList(), + reason: content, + ); + } + }); + + testWidgets('known tokens keep their URL and size; unknowns remain text', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final context = tester.element(find.byType(SizedBox)); + final matcher = CustomEmojiMd( + _palette, + content: ':WAVE: :unknown:', + size: 32, + ); + final known = matcher.span(context, ':WAVE:', GptMarkdownConfig()); + expect(known, isA()); + final image = (known as WidgetSpan).child as CustomEmojiImage; + expect(image.shortcode, 'wave'); + expect(image.url, 'https://example.com/wave.png'); + expect(image.size, 32); + final unknown = matcher.span(context, ':unknown:', GptMarkdownConfig()); + expect(unknown, isA()); + expect((unknown as TextSpan).text, ':unknown:'); + }); + + testWidgets('selection uses the current content and current palette URL', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final context = tester.element(find.byType(SizedBox)); + final initial = CustomEmojiMd(_palette, content: ':wave:'); + final updated = CustomEmojiMd(const [ + CustomEmoji(shortcode: 'wave_long', url: 'https://example.com/new.png'), + ], content: ':wave_long:'); + expect(initial.exp.hasMatch(':wave_long:'), isFalse); + expect(updated.exp.hasMatch(':wave:'), isFalse); + final span = updated.span(context, ':wave_long:', GptMarkdownConfig()); + expect( + ((span as WidgetSpan).child as CustomEmojiImage).url, + 'https://example.com/new.png', + ); + }); +} diff --git a/mobile/test/shared/relay/relay_rate_limit_gate_test.dart b/mobile/test/shared/relay/relay_rate_limit_gate_test.dart index 811d439572d..9043982d148 100644 --- a/mobile/test/shared/relay/relay_rate_limit_gate_test.dart +++ b/mobile/test/shared/relay/relay_rate_limit_gate_test.dart @@ -4,11 +4,10 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('uses the default for absent and non-positive hints', () { - var now = DateTime.utc(2026); + test('uses the default only for an absent hint', () { final timers = <_ManualTimer>[]; final gate = RelayRateLimitGate( - now: () => now, + now: () => DateTime.utc(2026), timerFactory: (duration, callback) { final timer = _ManualTimer(duration, callback); timers.add(timer); @@ -17,12 +16,66 @@ void main() { ); gate.activate(null); + + expect(gate.isActive, isTrue); expect(gate.remainingMs(), 10000); expect(timers.single.duration, const Duration(seconds: 10)); + }); + + test('an explicit non-positive hint opens no window', () async { + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime.utc(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + // The relay sends `retry in 0s` to mean "retry immediately". + gate.activate(0); + + expect(gate.isActive, isFalse); + expect(gate.remainingMs(), 0); + expect(timers, isEmpty); + await gate.wait().timeout(const Duration(seconds: 1)); + + gate.activate(-5); + + expect(gate.isActive, isFalse); + expect(gate.remainingMs(), 0); + expect(timers, isEmpty); + await gate.wait().timeout(const Duration(seconds: 1)); + }); - now = now.add(const Duration(seconds: 11)); + test('an immediate hint mid-window leaves the window intact', () async { + var now = DateTime.utc(2026); + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => now, + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + gate.activate(30); + final wait = gate.wait(); + final armedTimer = timers.single; + + now = now.add(const Duration(seconds: 5)); gate.activate(0); - expect(timers.last.duration, const Duration(seconds: 10)); + + expect(gate.isActive, isTrue); + expect(gate.remainingMs(), 25000); + expect(timers, hasLength(1)); + expect(armedTimer.isActive, isTrue); + + armedTimer.fire(); + await wait; + expect(gate.isActive, isFalse); }); test('clamps large hints to five minutes', () { diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index c6ae6e9bd8a..d896250536d 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1036,6 +1036,42 @@ void main() { unsubscribe(); }); + test('rate-limited live CLOSED honors an immediate retry hint', () async { + final retryTimers = <_ManualTimer>[]; + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final session = RelaySessionNotifier( + rateLimitGate: gate, + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + retryTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'rate-limited: quota exceeded; retry in 0s', + ]); + + expect(retryTimers.single.duration, const Duration(seconds: 1)); + expect(gateTimers, isEmpty); + expect(gate.isActive, isFalse); + unsubscribe(); + }); + test( 'rate-limited CLOSED retry does not survive a superseded connection', () async { From 2c99ee7af5a20d239e95ec5368407887449795c9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 28 Aug 2026 17:45:42 -0400 Subject: [PATCH 096/101] fix(desktop): resolve bundled sidecar on cheap path and bound login-shell spawns (#6904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why PR #6330 split agent harness/runtime detection into a cheap (cache-only) path and a forced (spawning) path. Two regressions followed, both surfacing as every harness showing "(not installed)" / "CLI missing" across the agent create/edit picker, Agents > Agent defaults, and Settings > Agents — blocking agent create/edit until the user clicked Install in Settings > Agents. ## Root cause One underlying bug, two victims: - **Boot false-negative.** The resolve cache is in-memory, so it starts cold on every launch. `resolve_command_cached` (the cheap path) consulted only the Buzz-managed shim dirs plus that cold cache, and `buzz_managed_command_path`'s allowlist structurally excludes `buzz-agent`. The bundled sidecar could therefore never resolve on the cheap path until a forced pass warmed the cache, so cheap-path surfaces rendered all-missing at boot. App setup never warms the cache. - **"Check again" hang.** `run_in_login_shell` used an untimeouted `Command::output()`; a wedged login shell froze the whole forced pipeline, leaving "Check again" spinning forever. ## What - `resolve_command_cached` now also calls `resolve_workspace_command`, resolving the bundled sidecar via a filesystem stat (no spawn) — the same class of work the managed-shim check already performs. `buzz-agent` can no longer report missing, even inside the boot warm window. - New `discovery/bounded_command.rs` runs any discovery child under a hard wall-clock deadline, polling with `try_wait` rather than blocking on `wait()`. Stdout and stderr are piped to two drain threads whose buffers share an aggregate `CAPTURE_LIMIT`; a breach fails closed (kill the tree, return `None`), so a noisy or hostile probe can force neither unbounded memory nor disk fill. Tree teardown runs on every exit path — timeout, error, cap breach, *and* success — because a login-shell rc file or auth CLI can legitimately background a descendant that would otherwise outlive discovery. Ownership is deliberately asymmetric: - **Unix:** the child leads its own process group (`process_group(0)`); teardown is `SIGTERM` → bounded grace → `SIGKILL` on the group. A descendant that leaves the group (`setsid`/`setpgid`) while holding a pipe is not owned and may survive one probe, but can never hang or unbound the helper: the Unix drains read nonblocking and end on `WouldBlock` once teardown sets the stop flag, so the join returns promptly without waiting on an escaped writer's EOF. - **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a kill-on-close Job Object while frozen, then resumed. The job owns the root before any descendant can exist and is created without breakaway, so no writer can escape — a hard whole-tree guarantee, and closing the job reaps the tree even after the root has exited. Any failure to create, assign, or resume is fail-closed: the child is terminated and reaped and the spawn returns `None` (discovery treats it as command-not-found) rather than running unowned. - Each login-shell candidate is bounded by a 10s timeout via that helper, falling through to the next candidate on timeout instead of aborting the resolve. The login-shell path cache is generation-aware: a probe that loses to a concurrent refresh or lands mid-refresh returns the authoritative cached value (or re-probes under the new generation) rather than its own rejected local result, so a losing thread can never settle the UI with a PATH-missing catalog while the cache holds a fresh success. - Warm the ACP runtime catalog once at `AppShell` mount and gate the cheap-path surfaces on that pass. A module-level boot-warm state (`idle` → `pending` → `settled`/`failed`, deduped per launch) lets `useAcpRuntimesQuery` present a cold catalog as *loading* while the first forced pass runs and as a *retryable error* (carrying the probe's real reason) if it fails, instead of blessing "every harness not installed" as authoritative. A non-empty catalog always wins, so a revalidation or later failure never blanks a good list; the gate only overlays once the warm has started, so onboarding (which renders before the warm) is unaffected. Deduping per launch also fixes the previous per-remount re-fire. ## Verification Unix teardown and the drain contract are runtime-proven by `#[ignore]`-free tests that record a backgrounded descendant's real PID and assert the helper returns promptly on both the success and timeout paths without blocking on that writer. The generation-aware login-shell cache is covered by deterministic tests through a `cfg(test)` injectable probe seam that assert the function's return value under both concurrent-refresh interleavings — the losing caller returns the peer's committed success, and a mid-probe refresh forces a re-probe to the fresh value. The Windows ownership contract has no CI lane, so `bounded_command.rs` carries two `#[ignore]`-gated tests (spawn/assign race, looped; and the timeout path) for a sanctioned run on a Windows host. The boot-warm gate is covered by unit tests for the pure overlay and the `startBootWarm` failure → retry → settle lifecycle. Origin: [Buzz thread](buzz://message?channel=5ef5d5bb-643f-4b87-bbf4-e8b64585ffeb&id=a4b1c4485de4d35cff0f914d4f4211c796f44f670e76de2ef9431f7e882c906e) Fixes #6872 Related #6662 --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/managed_agents/discovery.rs | 103 +- .../discovery/bounded_command.rs | 999 ++++++++++++++++++ .../managed_agents/discovery/login_shell.rs | 322 +++++- .../tests/managed_path_resolution.rs | 27 + .../src/managed_agents/process_lifecycle.rs | 75 +- .../src/app/useAppShellLifecycleEffects.ts | 18 + .../features/agents/acpRuntimesQuery.test.mjs | 196 +++- .../src/features/agents/acpRuntimesQuery.ts | 168 ++- desktop/src/features/agents/hooks.ts | 21 +- .../agents/ui/AgentDefaultsEditor.tsx | 23 +- .../agents/ui/AgentDefinitionDialog.tsx | 2 +- .../features/agents/ui/AgentHarnessField.tsx | 5 +- .../agents/ui/HarnessCatalogRetryNotice.tsx | 24 + 14 files changed, 1850 insertions(+), 135 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs create mode 100644 desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 050c3af89db..f41fa2d6e39 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -63,7 +63,7 @@ user-idle = { version = "0.6", default-features = false } plist = "1" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] } keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true } user-idle = { version = "0.6", default-features = false } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 78592357c9b..1ee7e6e5562 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,8 +1,7 @@ -use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -use std::time::{Duration, Instant}; +use std::time::Duration; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, @@ -10,6 +9,7 @@ use crate::managed_agents::{ HarnessSource, }; mod auth_status_cache; +mod bounded_command; mod login_shell; mod presets; mod runtime_metadata; @@ -593,6 +593,16 @@ pub fn resolve_command_cached(command: &str) -> Option { if let Some(managed) = resolve_buzz_managed_command(command) { return Some(managed); } + // Bundled sidecars (e.g. `buzz-agent`) ship next to the app executable, so + // `resolve_workspace_command` finds them with a filesystem stat and no + // login-shell spawn — the same class of work the managed-shim check above + // already performs. Without this the cheap path could never see the sidecar + // until a forced discovery warmed the resolve cache, so `buzz-agent` (which + // cannot legitimately be missing) reported "not installed" at every cold + // launch across the create/edit and agent-defaults surfaces. + if let Some(workspace) = resolve_workspace_command(command) { + return Some(workspace); + } resolve_cache() .lock() .ok() @@ -822,10 +832,9 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { /// Run a CLI auth probe with a 10-second process-level timeout. /// -/// Spawns the probe CLI as a child process. Stdout and stderr are drained on -/// background threads to prevent pipe-buffer deadlock. On timeout the child is -/// killed and `Unknown` is returned; no orphaned threads or processes are left -/// behind. Returns `Unknown` on timeout. +/// On timeout or spawn failure the child is killed and `Unknown` is returned; +/// no orphaned threads or processes are left behind (see +/// [`bounded_command::output_with_timeout`]). fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { use crate::managed_agents::readiness::cli_probe; @@ -836,81 +845,17 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { if let Some(ref path) = augmented_path { command.env("PATH", path); } - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - crate::util::configure_no_window(&mut command); - - let mut child = match command.spawn() { - Ok(c) => c, - Err(_) => return AuthStatus::Unknown, + // Window suppression is owned by `output_with_timeout`'s spawn + // (`BOUNDED_CREATION_FLAGS` carries `CREATE_NO_WINDOW`); a + // `configure_no_window` call here would be clobbered by that later + // `creation_flags` set, so it is deliberately omitted. + + let Some(output) = bounded_command::output_with_timeout(command, Duration::from_secs(10)) + else { + return AuthStatus::Unknown; }; - // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - - let stdout_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_end(&mut buf); - } - }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_end(&mut buf); - } - buf - }); - - // Save PID for kill-on-timeout before moving child into the wait thread. - let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let _ = tx.send(child.wait()); - }); - - // 10-second timeout for auth probes. - let deadline = Instant::now() + Duration::from_secs(10); - let exit_status = loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(not(unix))] - let _ = child_pid; - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - match rx.recv_timeout(Duration::from_millis(100).min(remaining)) { - Ok(Ok(status)) => break status, - Ok(Err(_)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - } - }; - - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let stderr_bytes = stderr_thread.join().unwrap_or_default(); - - match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) { + match cli_probe::classify_probe_output(&output.stderr, output.status.success()) { cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { diff --git a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs new file mode 100644 index 00000000000..18286d62b1e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs @@ -0,0 +1,999 @@ +//! Run a child process to completion under a hard wall-clock deadline. +//! +//! Every spawn on the discovery path — the CLI auth probes and the login-shell +//! PATH lookups — must return in bounded time no matter how the child behaves. +//! A login shell that blocks on an interactive prompt, a child that traps +//! `SIGTERM`, or a forked descendant that keeps a pipe open must not be able to +//! stall discovery; that stall is what left "Check again" spinning forever. + +use std::io::{ErrorKind, Read}; +use std::process::{ChildStderr, ChildStdout, Command, ExitStatus, Output, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// Poll interval while waiting for the child to exit. +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Idle backoff for a nonblocking Unix drain that has no bytes available and +/// has not yet been told to stop. Short so a running child's output is pulled +/// promptly and the post-teardown join returns quickly. +#[cfg(unix)] +const DRAIN_IDLE_POLL: Duration = Duration::from_millis(5); + +/// Maximum bytes retained across stdout + stderr for one bounded probe. +/// +/// Discovery output is tiny — a version string, an auth-status word, a PATH +/// lookup. A probe that emits more than this is noisy or hostile. The ceiling +/// is enforced *in the drain sink* (see [`spawn_drain`]): each stream is pulled +/// on its own thread into a capped buffer, the limit is checked the moment a +/// bounded read crosses it, and the probe is failed closed — so an over-cap +/// payload is never retained in memory (and, since output goes to pipes not +/// temp files, never written to disk). The ceiling is *aggregate*, not +/// per-stream, so a probe cannot double it by splitting output across stdout +/// and stderr. +const CAPTURE_LIMIT: u64 = 1 << 20; // 1 MiB + +/// Grace period between the initial `SIGTERM` and the escalating `SIGKILL` for a +/// timed-out process group. Long enough for a well-behaved child to flush and +/// exit cleanly, short enough that a signal-ignoring one is reaped promptly. +#[cfg(unix)] +const KILL_GRACE: Duration = Duration::from_millis(500); + +/// Freeze the child so the Job Object can take ownership before any child code +/// runs (see [`BoundedChild::spawn`]). +#[cfg(windows)] +const CREATE_SUSPENDED: u32 = 0x0000_0004; + +/// Suppress the console window a GUI-spawned console child would otherwise +/// flash — the same suppression [`crate::util::configure_no_window`] applies to +/// non-bounded spawns. +#[cfg(windows)] +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// The exact creation flags every bounded child is spawned with. +/// +/// `Command::creation_flags` *replaces* rather than accumulates (std ORs only +/// `CREATE_UNICODE_ENVIRONMENT` afterward), and [`BoundedChild::spawn`] is the +/// last writer before spawn, so a caller's earlier `configure_no_window` is +/// wiped. This constant therefore has to carry every flag a bounded child +/// needs, and owning both here keeps the window-suppression contract in one +/// place instead of split between the caller and the helper. +#[cfg(windows)] +const BOUNDED_CREATION_FLAGS: u32 = CREATE_SUSPENDED | CREATE_NO_WINDOW; + +/// Compile-time guard: the bounded flags must always carry *both* bits. A +/// future edit that drops `CREATE_NO_WINDOW` (reintroducing the console-flash +/// regression) or `CREATE_SUSPENDED` (reopening the spawn-to-assign race) fails +/// the build on Windows rather than shipping silently. +#[cfg(windows)] +const _: () = { + assert!(BOUNDED_CREATION_FLAGS & CREATE_SUSPENDED == CREATE_SUSPENDED); + assert!(BOUNDED_CREATION_FLAGS & CREATE_NO_WINDOW == CREATE_NO_WINDOW); +}; + +/// A spawned child plus ownership of its descendant tree, torn down on *every* +/// exit path — timeout, error, or successful exit. The two platforms establish +/// ownership differently, and the guarantee is deliberately asymmetric — the +/// adjudicated design, not an oversight: +/// +/// - **Unix:** the child leads its own process group (`process_group(0)`), so +/// `killpg` reaches every descendant that has not left the group. A +/// `setsid`/`setpgid` escapee holding a pipe is *not* owned and may survive +/// one probe, yet never hangs the helper (see [`output_with_timeout`]). +/// - **Windows:** the child is spawned `CREATE_SUSPENDED`, assigned to a +/// kill-on-close Job Object while frozen, then resumed. The job owns the root +/// before any descendant can exist and is created without breakaway, so no +/// writer can escape it — a hard whole-tree guarantee. Closing that job reaps +/// the whole tree *even after the root has exited* — the distinction that +/// makes `taskkill /T ` (a live-root lookup) unfit for the success path. +/// This mirrors the Job Object discipline the harness uses to reap its 24 +/// agent workers (`process_lifecycle.rs`). +struct BoundedChild { + child: std::process::Child, + /// The kill-on-close job that owns the whole tree. Taken and dropped by + /// `kill_tree` so the reap happens exactly once. Spawn is fail-closed: if + /// the job cannot be created, assigned, or the child resumed, the child is + /// terminated and `spawn` returns `None` rather than running unowned. + #[cfg(windows)] + job: Option, +} + +impl BoundedChild { + /// Spawn `command`, establishing tree ownership before the child can run. + /// Returns `None` if the spawn fails or — on Windows — if the job cannot be + /// created, assigned, or the frozen child resumed; in every such case the + /// child is terminated and reaped before returning, so no unowned process + /// survives. + fn spawn(mut command: Command) -> Option { + // Run the child in its own process group so the whole tree can be torn + // down as a unit, not just a direct child that may have forked workers. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + + // Spawn frozen so the Job Object can take ownership before any child + // code runs and forks a descendant that would escape the job. The flags + // are set here as the last writer before spawn; `Command::creation_flags` + // replaces rather than ORs, so `BOUNDED_CREATION_FLAGS` must itself carry + // `CREATE_NO_WINDOW` — a caller's earlier `configure_no_window` would be + // clobbered otherwise, flashing a console window on GUI discovery. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(BOUNDED_CREATION_FLAGS); + } + + // `mut` is used only on the Windows fail-closed path (kill/wait on the + // frozen child); Unix moves the child unmodified into `Self`. + #[cfg_attr(not(windows), allow(unused_mut))] + let mut child = command.spawn().ok()?; + + #[cfg(windows)] + let job = { + // Assign the frozen child to a kill-on-close job, then resume it. + // Any failure is fail-closed: terminate + reap the still-owned + // child and abort the spawn, never run it unowned to the deadline. + let Some(job) = crate::managed_agents::create_job_for_child(child.id()) else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + if !crate::managed_agents::resume_process(child.id()) { + // Dropping the job kills the still-suspended child via + // kill-on-close; reap it so no zombie lingers. + drop(job); + let _ = child.wait(); + return None; + } + job + }; + + Some(Self { + child, + #[cfg(windows)] + job: Some(job), + }) + } + + fn try_wait(&mut self) -> std::io::Result> { + self.child.try_wait() + } + + /// Timeout teardown: a graceful `SIGTERM` to the group and a bounded grace + /// period for a clean flush on Unix, then the unconditional forced kill. + /// Windows has no group signal, so it goes straight to the forced kill. + fn terminate_timed_out(&mut self) { + #[cfg(unix)] + { + // SAFETY: `killpg` on the group led by the child; an ignored result + // is intentional — the group may already be gone (ESRCH). + unsafe { + libc::killpg(self.child.id() as i32, libc::SIGTERM); + } + std::thread::sleep(KILL_GRACE); + } + self.kill_tree(); + } + + /// Forcibly reap the whole tree. Idempotent and safe on an already-exited + /// tree. Runs on every exit path — including success, because a login shell + /// or auth CLI can background a descendant that outlives the leader while + /// still holding the captured-output descriptors. + fn kill_tree(&mut self) { + #[cfg(unix)] + // SAFETY: `killpg` on the group led by the child; ignored result is + // intentional — `ESRCH` on a dead group is the success case. + unsafe { + libc::killpg(self.child.id() as i32, libc::SIGKILL); + } + #[cfg(windows)] + // Closing the kill-on-close job reaps every descendant, even once the + // root has exited — which `taskkill /T ` cannot. `spawn` is + // fail-closed, so the job is always present until this first take; + // a later take is a no-op (the tree is already reaped). + if let Some(job) = self.job.take() { + drop(job); + } + } + + /// Reap the direct child so no zombie lingers after the tree is killed. + fn reap(&mut self) { + let _ = self.child.wait(); + } + + /// Take the captured stdout pipe. `Some` because [`output_with_timeout`] + /// configures `Stdio::piped()` before spawn. + fn take_stdout(&mut self) -> Option { + self.child.stdout.take() + } + + /// Take the captured stderr pipe. + fn take_stderr(&mut self) -> Option { + self.child.stderr.take() + } +} + +/// Set a file descriptor nonblocking so a read on it returns `WouldBlock` +/// instead of parking when no bytes are available. Returns `false` on any +/// `fcntl` failure, which the caller treats as fail-closed. +#[cfg(unix)] +fn set_nonblocking(f: &F) -> bool { + let fd = f.as_raw_fd(); + // SAFETY: `fd` is owned by `f` for the duration of this call; `F_GETFL` / + // `F_SETFL` read and set the descriptor's flags without transferring + // ownership or touching any other resource. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags < 0 { + return false; + } + libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) == 0 + } +} + +/// Drain one child stream on its own thread into a buffer capped by the shared +/// aggregate budget, so the sink itself — not a post-hoc size sample — enforces +/// [`CAPTURE_LIMIT`]. +/// +/// Continuous draining keeps the pipe buffer from filling, so the child can +/// never block on a full pipe while we poll it. Retention is bounded: `total` +/// reserves a disjoint byte range per chunk across both streams, so the sum of +/// both buffers never exceeds the aggregate cap. The moment a read crosses the +/// cap, `overflow` is set and the drain returns immediately — it does not keep +/// reading, so a writer that keeps the pipe continuously readable cannot spin +/// this loop forever (it must cross the finite cap). A read error other than +/// `Interrupted`/`WouldBlock` returns `Err`, which the caller treats as +/// fail-closed. +/// +/// **Bounded completion differs by platform, because tree ownership does.** +/// - **Unix:** the read end is nonblocking (see [`set_nonblocking`]). A killed +/// in-group writer's descriptors close, so the read reaches EOF (`Ok(0)`) and +/// the thread returns normally. But `kill_tree` is a `killpg` on the child's +/// group, which does *not* reach a descendant that left the group via +/// `setsid`/`setpgid` while retaining the pipe; that writer keeps the write +/// end open and EOF never comes. So once teardown has set `stop`, a +/// `WouldBlock` (nothing more buffered) ends the drain rather than waiting on +/// that escaped writer forever. This is what makes bounded return hold +/// *without* depending on every inherited writer exiting — the correction to +/// the round-8 blocking-EOF design. +/// - **Windows:** the read blocks to EOF. That is sound because the whole tree +/// is owned by a kill-on-close Job Object created without +/// `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, so no descendant can escape the job; job +/// close reaps every writer and the read reaches EOF. `stop` is unused there. +fn spawn_drain( + mut reader: R, + total: Arc, + overflow: Arc, + stop: Arc, +) -> JoinHandle>> { + // `stop` gates only the nonblocking Unix drain; the Windows path blocks to + // the job-close EOF and never consults it. + #[cfg(windows)] + let _ = &stop; + std::thread::spawn(move || { + let mut buf = Vec::new(); + let mut chunk = [0u8; 8192]; + loop { + match reader.read(&mut chunk) { + Ok(0) => return Ok(buf), + Ok(n) => { + // Atomically reserve [prev, prev + n) of the shared budget; + // `prev` is unique per call, so the two streams keep + // disjoint ranges and their retained bytes sum to <= cap. + let prev = total.fetch_add(n as u64, Ordering::Relaxed); + if prev.saturating_add(n as u64) > CAPTURE_LIMIT { + overflow.store(true, Ordering::Relaxed); + let keep = CAPTURE_LIMIT.saturating_sub(prev).min(n as u64) as usize; + buf.extend_from_slice(&chunk[..keep]); + // Overflow: the result is already fail-closed, so nothing + // still in the pipe is worth preserving. Return NOW rather + // than draining to EOF — this is what bounds the `Ok(n)` + // path against a writer that keeps the pipe continuously + // readable, which would otherwise never reach the + // `WouldBlock`/`stop` check below and hang the join. It is + // safe to stop draining: the poll loop sees `overflow` and + // kills the tree, and a writer that then blocks on a full + // pipe dies to `killpg`/job-close. Do NOT "fix" that + // blocked-writer case by resuming an unbounded drain here. + return Ok(buf); + } + buf.extend_from_slice(&chunk[..n]); + } + Err(e) if e.kind() == ErrorKind::Interrupted => continue, + // Nonblocking read (Unix only): no bytes available right now. + // After teardown, an escaped out-of-group writer is the only + // thing that could still hold the pipe open, so stop draining it + // rather than block the join forever; otherwise back off and + // retry so a running child's later output is still captured. + #[cfg(unix)] + Err(e) if e.kind() == ErrorKind::WouldBlock => { + if stop.load(Ordering::Relaxed) { + return Ok(buf); + } + std::thread::sleep(DRAIN_IDLE_POLL); + } + Err(e) => return Err(e), + } + } + }) +} + +/// Run `command` to completion, bounded by `timeout`. +/// +/// Returns `Some(output)` when the child exits within the deadline, `None` when +/// it fails to spawn, exceeds the deadline, or breaches the capture ceiling. +/// Guarantees a bounded return regardless of child cooperation: +/// +/// - **Sink-enforced capture bound.** Stdout and stderr are piped to two drain +/// threads that read into buffers capped by a shared aggregate budget +/// ([`spawn_drain`]); nothing over [`CAPTURE_LIMIT`] is ever retained. On a +/// breach the poll loop fails closed — kill the tree, return `None` — so a +/// noisy or hostile probe cannot force unbounded memory (and, with pipes +/// rather than temp files, cannot fill the disk either). Continuous draining +/// also keeps the pipe buffer from filling, so the child can never block on a +/// full pipe while we poll. +/// - **Bounded drain completion without depending on writer death.** Tree +/// teardown runs on *every* exit path before the drains are joined — +/// [`BoundedChild::kill_tree`] on timeout, error, cap breach, *and* success. +/// But teardown alone does not guarantee EOF on Unix: `kill_tree` is a +/// `killpg` on the child's process group, and a descendant that left the +/// group (`setsid`/`setpgid`) while retaining the pipe survives it and keeps +/// the write end open. So the drains do not rely on EOF from every writer: +/// the Unix reads are nonblocking, and after teardown sets the shared `stop` +/// flag a `WouldBlock` (no more buffered bytes) ends each drain. An escaped +/// writer is allowed to survive; the join still returns promptly. On Windows +/// the reads block to EOF, which is sound because the kill-on-close Job Object +/// is created without breakaway, so no writer can escape the job. This is the +/// correction to the round-8 design, whose blocking Unix reads could hang the +/// join forever on a group-escaping writer. +/// - **No wait hang.** The child is polled with [`Child::try_wait`] against the +/// deadline rather than blocked on with `wait()`. +/// - **Tree termination on every exit path.** [`BoundedChild`] tears the tree +/// down whether the child times out, errors, breaches the cap, *or exits +/// successfully* — a login-shell rc file or auth CLI can legitimately +/// background a descendant (`worker &`) that would outlive discovery. +/// Ownership is a hard whole-tree guarantee on Windows but only the child's +/// process group on Unix (the group-escapee case bounded by the drain rule +/// above) — the adjudicated asymmetry. The timeout path additionally sends a +/// graceful `SIGTERM` and a grace period before the kill. +pub(crate) fn output_with_timeout(mut command: Command, timeout: Duration) -> Option { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = BoundedChild::spawn(command)?; + + let stdout_pipe = child.take_stdout(); + let stderr_pipe = child.take_stderr(); + + // Unix: make the parent read ends nonblocking so a drain can be told to stop + // (post-teardown) instead of parking forever on a group-escaping writer that + // still holds the pipe. Fail closed if the fd cannot be reconfigured — the + // child is still fully owned here, so cleanup is just kill + reap. + #[cfg(unix)] + { + let stdout_ok = match stdout_pipe.as_ref() { + Some(p) => set_nonblocking(p), + None => true, + }; + let stderr_ok = match stderr_pipe.as_ref() { + Some(p) => set_nonblocking(p), + None => true, + }; + if !(stdout_ok && stderr_ok) { + child.kill_tree(); + child.reap(); + return None; + } + } + + // Shared drain state: one aggregate byte budget across both streams, an + // overflow flag the poll loop watches so a streaming producer that never + // exits is failed closed the moment it crosses the cap, and a stop flag that + // teardown raises to end the nonblocking Unix drains. + let total = Arc::new(AtomicU64::new(0)); + let overflow = Arc::new(AtomicBool::new(false)); + let stop = Arc::new(AtomicBool::new(false)); + let stdout_drain = + stdout_pipe.map(|s| spawn_drain(s, total.clone(), overflow.clone(), stop.clone())); + let stderr_drain = + stderr_pipe.map(|s| spawn_drain(s, total.clone(), overflow.clone(), stop.clone())); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break Some(status), + Ok(None) => { + if Instant::now() >= deadline { + child.terminate_timed_out(); + break None; + } + // Fail closed on a capture breach *while the child runs*: the + // drain kept nothing over the cap; teardown below ends the + // drains so the join cannot hang. + if overflow.load(Ordering::Relaxed) { + child.kill_tree(); + break None; + } + std::thread::sleep(POLL_INTERVAL); + } + Err(_) => { + child.kill_tree(); + break None; + } + } + }; + + // Tree down on every path (timeout/error/overflow killed it above; a clean + // exit may still have backgrounded a descendant holding the pipe). Kill is + // idempotent, so calling it here on the success path is safe. Then raise + // `stop`: a killed in-group writer's pipe reaches EOF and ends its drain on + // its own, but a group-escaping writer never will — `stop` ends that drain + // on the next `WouldBlock` so the joins below return promptly. + child.kill_tree(); + child.reap(); + stop.store(true, Ordering::Relaxed); + + let stdout = join_drain(stdout_drain); + let stderr = join_drain(stderr_drain); + + // Fail closed if the child exited within the deadline but overran the cap in + // a final burst, or if either drain hit a read error (join_drain -> None). + let (status, stdout, stderr) = (status?, stdout?, stderr?); + if overflow.load(Ordering::Relaxed) { + return None; + } + + Some(Output { + status, + stdout, + stderr, + }) +} + +/// Join a drain thread, returning its captured bytes. `None` (fail closed) if +/// the stream was absent, the thread panicked, or the read errored. +fn join_drain(drain: Option>>>) -> Option> { + match drain { + Some(handle) => handle.join().ok()?.ok(), + None => Some(Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + /// Drive `output_with_timeout` on its own thread under an independent + /// wall-clock `bound` — the real outer bound, unreachable by an inline `elapsed()` assertion if the helper hangs. + /// The raw result lets the Windows sites fold transcripts into the expiry panic. + #[cfg(any(unix, windows))] + fn run_watchdogged_raw( + cmd: Command, + timeout: Duration, + bound: Duration, + ) -> Result, mpsc::RecvTimeoutError> { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(output_with_timeout(cmd, timeout)); + }); + rx.recv_timeout(bound) + } + #[cfg(unix)] + fn run_watchdogged(cmd: Command, timeout: Duration, bound: Duration) -> Option { + run_watchdogged_raw(cmd, timeout, bound) + .unwrap_or_else(|_| panic!("output_with_timeout did not return within {bound:?}")) + } + + /// True while a Unix process (or a reaped-but-not-waited zombie under this + /// test process) still exists. `kill(pid, 0)` probes existence without + /// signalling. Descendants reparent to init on exit, so a survivor stays + /// probeable; once `kill_tree` reaps it, the pid is gone (ESRCH). + #[cfg(unix)] + fn pid_alive(pid: i32) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + #[cfg(unix)] + #[test] + fn returns_output_for_fast_command() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "printf hi; printf oops 1>&2"]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("a fast command must complete within the timeout"); + assert!(out.status.success()); + assert_eq!(out.stdout, b"hi"); + assert_eq!(out.stderr, b"oops"); + } + + // Adversarial: a child that traps and ignores SIGTERM. The old + // wait-thread + lone-SIGTERM helper never returned for this input; the + // process-group SIGKILL escalation must reap it inside the grace period. + // The watchdog thread is the real bound — the helper hanging fails the + // test rather than hanging it. + #[cfg(unix)] + #[test] + fn kills_sigterm_ignoring_child_within_bound() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "trap '' TERM; while :; do sleep 1; done"]); + let result = run_watchdogged(cmd, Duration::from_millis(200), Duration::from_secs(5)); + assert!(result.is_none(), "a timed-out child must yield None"); + } + + // Adversarial (success path): the direct child exits 0 but backgrounds a + // descendant that keeps writing to the inherited stdout/stderr forever. + // Two guarantees under test: (1) the drain returns rather than blocking on + // the descendant, and (2) `kill_tree` reaps that descendant before + // returning, so no survivor keeps consuming CPU after discovery reports + // success. This is the pass-2 leak Thufir proved with `(yes) & exit 0`. + #[cfg(unix)] + #[test] + fn reaps_backgrounded_descendant_on_success() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + // Background a real child process (`sleep`), record ITS pid via `$!` + // (not `$$`, which in a subshell is the invoking shell), then exit 0. + // The leader waits until the pid is recorded so the test can read it + // deterministically even though the success path kills the group at + // once. `$!` is the pass-2 `(yes) & exit 0` survivor, made observable. + let script = format!( + "sleep 30 & echo $! > '{pid_path}'; \ + until [ -s '{pid_path}' ]; do :; done; printf done; exit 0" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("the direct child exits, so this must return its output"); + assert!(out.status.success()); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("descendant must have recorded its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + // Give the reaped group a moment to fully disappear, then assert dead. + std::thread::sleep(Duration::from_millis(200)); + assert!( + !pid_alive(descendant_pid), + "backgrounded descendant {descendant_pid} must be reaped on success, but it survived" + ); + } + + // Adversarial (timeout path): a SIGTERM-ignoring leader that backgrounds a + // descendant, both looping forever. The leader's process group is killed on + // timeout, so the descendant (same group) must die too. The descendant is a + // real child process whose PID is recorded via `$!`, so the test proves the + // actual descendant — not the already-reaped leader — reaches ESRCH. + #[cfg(unix)] + #[test] + fn reaps_descendant_on_timeout() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + let script = format!( + "trap '' TERM; sleep 300 & echo $! > '{pid_path}'; \ + while :; do sleep 1; done" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + assert!(result.is_none(), "a timed-out tree must yield None"); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("descendant must have written its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + std::thread::sleep(Duration::from_millis(200)); + assert!( + !pid_alive(descendant_pid), + "backgrounded descendant {descendant_pid} must be group-killed on timeout, but it survived" + ); + } + + // Deterministic seam regression (Thufir's finding): a drain fed a reader + // that stays continuously readable — every `read` returns `Ok(8192)`, never + // `WouldBlock` — must still complete, because the `stop`/`WouldBlock` check + // alone never fires on such a reader. The bound comes from the `Ok(n)` path + // returning the instant the aggregate cap is crossed. No real process and no + // scheduler timing: the reader is a pure in-test `Read` impl, so this pins + // the control flow rather than relying on a descendant eventually blocking. + // With the round-9-initial code (which kept reading after overflow) the + // drain never returns and the join below hangs past the watchdog. + #[test] + fn overflow_bounds_a_continuously_readable_drain() { + /// A reader that is always ready with a full 8192-byte chunk. It never + /// returns 0 (EOF) or `WouldBlock`, so only the overflow return can end + /// a drain reading it. + struct AlwaysReady; + impl Read for AlwaysReady { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + for b in buf.iter_mut() { + *b = b'x'; + } + Ok(buf.len()) + } + } + + let total = Arc::new(AtomicU64::new(0)); + let overflow = Arc::new(AtomicBool::new(false)); + // `stop` set from the start: a correct drain must NOT depend on it here, + // since a continuously-ready reader never hits the `WouldBlock` arm that + // consults it. The overflow return is the only thing that can bound it. + let stop = Arc::new(AtomicBool::new(true)); + let drain = spawn_drain(AlwaysReady, total.clone(), overflow.clone(), stop); + + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(drain.join()); + }); + let joined = rx + .recv_timeout(Duration::from_secs(2)) + .expect("a continuously-readable drain must be bounded by the capture cap"); + let buf = joined + .expect("drain thread must not panic") + .expect("drain read must not error"); + assert!( + overflow.load(Ordering::Relaxed), + "the drain must have tripped overflow" + ); + assert!( + buf.len() as u64 <= CAPTURE_LIMIT, + "retained bytes {} must not exceed the cap {CAPTURE_LIMIT}", + buf.len() + ); + } + + // Adversarial (group escape): the leader backgrounds a descendant that + // calls `setsid()` — leaving the leader's process group while retaining the + // inherited stdout — then sleeps 300s; the leader itself loops forever, so + // the helper times out. `kill_tree` is a `killpg` on the leader's group and + // cannot reach the escaped descendant, so its pipe write end stays open and + // never reaches EOF. The helper must still return within the outer watchdog + // and fail closed: the nonblocking drains stop on `WouldBlock` after + // teardown rather than blocking on that surviving writer. This is the exact + // primitive Thufir reproduced against the round-8 blocking-read design; with + // blocking reads the drain join hangs forever and `run_watchdogged` panics. + // + // Non-vacuous: the descendant is asserted *alive* after the helper returns, + // proving it genuinely escaped the `killpg` (so it was still holding the + // pipe at join time) — the return therefore came from the stop path, not + // from an EOF the kill happened to produce. The test then reaps it. + #[cfg(unix)] + #[test] + fn returns_when_escaped_descendant_retains_pipe() { + let pid_file = tempfile::NamedTempFile::new().expect("temp file for descendant pid"); + let pid_path = pid_file + .path() + .to_str() + .expect("utf-8 temp path") + .to_string(); + // The perl descendant `setsid()`s out of the leader's group, records its + // PID, writes a few bytes to the retained stdout, then sleeps. The + // leader waits until the PID is recorded (so the test can read it) and + // then loops forever, forcing the timeout path. + let script = format!( + "perl -MPOSIX -e 'POSIX::setsid() or die; open(my $f,\">\",$ARGV[0]) or die; \ + print $f $$; close $f; print \"x\" x 4096; sleep 300;' '{pid_path}' & \ + until [ -s '{pid_path}' ]; do :; done; while :; do sleep 1; done" + ); + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", &script]); + let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + assert!( + result.is_none(), + "a timed-out probe must fail closed even when an escaped writer holds the pipe" + ); + + let descendant_pid: i32 = std::fs::read_to_string(&pid_path) + .expect("escaped descendant must have recorded its PID") + .trim() + .parse() + .expect("descendant PID must be numeric"); + assert!( + pid_alive(descendant_pid), + "descendant {descendant_pid} was expected to survive the group kill (proving it escaped)" + ); + // Reap the escaped writer so the test leaves nothing behind. + unsafe { + libc::kill(descendant_pid, libc::SIGKILL); + } + } + + // Adversarial (capture bound): a producer that streams zero bytes + // *indefinitely* — it never exits and never stops writing on its own, so + // the only thing that can end the probe is the in-flight ceiling check + // tripping `overflow`, killing the tree, and failing closed (None). + // + // The discriminator is `timeout >> bound`: the deadline is 60s but the + // watchdog fails the test at 10s, so a return within the bound proves the + // *cap* ended the probe, not the timeout. Neuter the overflow check and the + // helper runs until the 60s deadline, blowing the 10s watchdog. Pipe + // backpressure cannot end it either: the drains pull continuously, so `cat` + // would keep writing forever. Retention stays bounded by construction — + // `spawn_drain` reserves a disjoint byte range per chunk against the shared + // budget and discards everything past `CAPTURE_LIMIT` — so no over-cap + // payload is ever materialized even though the producer is infinite. + #[cfg(unix)] + #[test] + fn fails_closed_when_capture_exceeds_limit() { + let mut cmd = Command::new("/bin/sh"); + cmd.args(["-c", "exec cat /dev/zero"]); + let result = run_watchdogged(cmd, Duration::from_secs(60), Duration::from_secs(10)); + assert!( + result.is_none(), + "an unbounded producer must fail closed on the capture cap, well before the deadline" + ); + } + + // The complement of the bound: output at or under the ceiling still returns + // in full, so the limit rejects only genuine overruns. + #[cfg(unix)] + #[test] + fn returns_full_output_at_capture_limit() { + let mut cmd = Command::new("/bin/sh"); + // Comfortably under 1 MiB, emitted in one burst then a clean exit. + cmd.args(["-c", "head -c 4096 /dev/zero"]); + let out = run_watchdogged(cmd, Duration::from_secs(5), Duration::from_secs(10)) + .expect("output under the limit must be returned"); + assert!(out.status.success()); + assert_eq!(out.stdout.len(), 4096); + } + + // ---- Windows tree-ownership verification (Will's box) ---------------- + // + // No CI lane executes Windows tests for this helper, so these are + // `#[ignore]`-gated for a sanctioned local run on a real Windows machine: + // + // cargo test -p buzz-desktop --lib bounded_command -- --ignored --nocapture + // + // Both assert on the actual PowerShell-recorded descendant PID (not the + // already-exited root), so neutering the Job Object ownership leaves that + // PID alive and fails the test — the mutation is observable. + + /// True while a Windows process still exists. Opens with the minimal + /// query right and reads its exit code: `STILL_ACTIVE` (259) means running, + /// any other code means exited. A failed open means the PID is gone. + #[cfg(windows)] + fn pid_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle.is_null() { + return false; + } + let mut code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut code); + CloseHandle(handle); + ok != 0 && code == STILL_ACTIVE as u32 + } + } + + /// Read a PID that a probe wrote to `path`, retrying briefly since the + /// descendant records it asynchronously. Dumps `logs` on failure so a remote + /// run diagnoses itself instead of panicking blind. + #[cfg(windows)] + fn read_recorded_pid(path: &str, logs: &[&str]) -> u32 { + for _ in 0..200 { + if let Ok(text) = std::fs::read_to_string(path) { + if let Ok(pid) = text.trim().parse::() { + return pid; + } + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!( + "descendant never recorded its PID at {path}\n{}", + dump_logs(logs) + ); + } + + /// Write a PowerShell payload to `path` as a `.ps1` file. Invoking these via + /// `powershell -File` avoids the Rust-std → cmd.exe → powershell quoting + /// gauntlet that silently mangled the inline `-Command` fixtures (the root + /// exited without its payload ever running), so the payload reaches + /// PowerShell verbatim. + #[cfg(windows)] + fn write_ps1(path: &std::path::Path, body: &str) { + std::fs::write(path, body).expect("write .ps1 payload"); + } + + /// Collect the named transcript files (each written by the fixture's + /// PowerShell) into one string for a self-diagnosing assert message. Missing + /// files are reported as such rather than skipped. + #[cfg(windows)] + fn dump_logs(paths: &[&str]) -> String { + let mut out = String::from("---- fixture transcripts ----\n"); + for p in paths { + out.push_str(&format!("[{p}]\n")); + match std::fs::read_to_string(p) { + Ok(text) if text.is_empty() => out.push_str("(empty)\n"), + Ok(text) => { + out.push_str(&text); + if !text.ends_with('\n') { + out.push('\n'); + } + } + Err(e) => out.push_str(&format!("(unreadable: {e})\n")), + } + } + out + } + + // Success path, run in a loop to hammer the spawn/assign race. A PowerShell + // root (no cmd.exe anywhere) launches a hidden, detached PowerShell + // descendant via `Start-Process -WindowStyle Hidden`; the descendant records + // its own PID and sleeps. The root then waits synchronously until the PID + // file is non-empty before exiting 0 — without that wait the root would exit + // in the same tick, the success path would close the kill-on-close job + // immediately, and the descendant would be reaped mid-cold-start before it + // could record its PID, starving the test of its evidence. The descendant is + // still born inside the job (suspend → assign → resume, no breakaway), so the + // reaping guarantee under test is unchanged; only the delivery mechanism (a + // `.ps1` via `-File`, not a mangled inline `-Command`) is fixed. Every assert + // dumps the PowerShell transcripts so a remote failure is self-diagnosing. + #[cfg(windows)] + #[test] + #[ignore = "requires a Windows host; run manually with --ignored"] + fn reaps_backgrounded_descendant_on_success_windows() { + for iteration in 0..25 { + let dir = tempfile::tempdir().expect("temp dir for fixture scripts"); + let pid_path = dir.path().join("descendant.pid"); + let child_ps1 = dir.path().join("child.ps1"); + let root_ps1 = dir.path().join("root.ps1"); + let root_log = dir.path().join("root.log"); + let child_log = dir.path().join("child.log"); + let pid_s = pid_path.to_str().expect("utf-8 pid path"); + let root_log_s = root_log.to_str().expect("utf-8 root log"); + let child_log_s = child_log.to_str().expect("utf-8 child log"); + + write_ps1( + &child_ps1, + &format!( + "$PID | Set-Content -Encoding ascii -Path '{pid_s}'\n\ + Add-Content -Path '{child_log_s}' -Value \"descendant $PID started\"\n\ + Start-Sleep -Seconds 30\n" + ), + ); + write_ps1( + &root_ps1, + &format!( + "Add-Content -Path '{root_log_s}' -Value \"root $PID launching descendant\"\n\ + Start-Process -FilePath 'powershell' -WindowStyle Hidden -ArgumentList \ + '-NoProfile','-ExecutionPolicy','Bypass','-File','{child}'\n\ + $deadline = (Get-Date).AddSeconds(15)\n\ + while (((-not (Test-Path '{pid_s}')) -or ((Get-Item '{pid_s}').Length -eq 0)) \ + -and (Get-Date) -lt $deadline) {{ Start-Sleep -Milliseconds 50 }}\n\ + Add-Content -Path '{root_log_s}' -Value \"root observed pid file, exiting\"\n\ + exit 0\n", + child = child_ps1.to_str().expect("utf-8 child path"), + ), + ); + + let mut cmd = Command::new("powershell"); + cmd.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + root_ps1.to_str().expect("utf-8 root path"), + ]); + let out = run_watchdogged_raw(cmd, Duration::from_secs(20), Duration::from_secs(40)) + .ok() + .flatten() + .unwrap_or_else(|| { + panic!( + "iteration {iteration}: root exits, so this must return output\n{}", + dump_logs(&[root_log_s, child_log_s]) + ) + }); + assert!( + out.status.success(), + "iteration {iteration}: root must exit 0\nstdout={}\nstderr={}\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + dump_logs(&[root_log_s, child_log_s]) + ); + + let descendant_pid = read_recorded_pid(pid_s, &[root_log_s, child_log_s]); + std::thread::sleep(Duration::from_millis(300)); + assert!( + !pid_alive(descendant_pid), + "iteration {iteration}: descendant {descendant_pid} must be reaped on success, but it survived\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + } + } + + // Timeout path: a PowerShell root launches a hidden, detached PowerShell + // descendant (records its PID, sleeps 300s), waits synchronously until the + // PID file is non-empty, then enters its own 300s block so the helper's + // deadline fires inside it. The helper must time out and close the job, + // reaping both. The synchronous wait is the evidence — the descendant's PID + // is recorded before the root reaches the block the deadline fires in, so the + // reap cannot kill it mid-cold-start and starve the assert. Same `.ps1` + // delivery as the success fixture (no cmd tokenizer), and every assert dumps + // the transcripts. + #[cfg(windows)] + #[test] + #[ignore = "requires a Windows host; run manually with --ignored"] + fn reaps_descendant_on_timeout_windows() { + let dir = tempfile::tempdir().expect("temp dir for fixture scripts"); + let pid_path = dir.path().join("descendant.pid"); + let child_ps1 = dir.path().join("child.ps1"); + let root_ps1 = dir.path().join("root.ps1"); + let root_log = dir.path().join("root.log"); + let child_log = dir.path().join("child.log"); + let pid_s = pid_path.to_str().expect("utf-8 pid path"); + let root_log_s = root_log.to_str().expect("utf-8 root log"); + let child_log_s = child_log.to_str().expect("utf-8 child log"); + + write_ps1( + &child_ps1, + &format!( + "$PID | Set-Content -Encoding ascii -Path '{pid_s}'\n\ + Add-Content -Path '{child_log_s}' -Value \"descendant $PID started\"\n\ + Start-Sleep -Seconds 300\n" + ), + ); + write_ps1( + &root_ps1, + &format!( + "Add-Content -Path '{root_log_s}' -Value \"root $PID launching descendant\"\n\ + Start-Process -FilePath 'powershell' -WindowStyle Hidden -ArgumentList \ + '-NoProfile','-ExecutionPolicy','Bypass','-File','{child}'\n\ + $deadline = (Get-Date).AddSeconds(15)\n\ + while (((-not (Test-Path '{pid_s}')) -or ((Get-Item '{pid_s}').Length -eq 0)) \ + -and (Get-Date) -lt $deadline) {{ Start-Sleep -Milliseconds 50 }}\n\ + Add-Content -Path '{root_log_s}' -Value \"root observed pid file, blocking\"\n\ + Start-Sleep -Seconds 300\n", + child = child_ps1.to_str().expect("utf-8 child path"), + ), + ); + + let mut cmd = Command::new("powershell"); + cmd.args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + root_ps1.to_str().expect("utf-8 root path"), + ]); + let result = run_watchdogged_raw(cmd, Duration::from_secs(20), Duration::from_secs(40)) + .unwrap_or_else(|_| { + panic!( + "watchdog expired — output_with_timeout hung on the timeout path\n{}", + dump_logs(&[root_log_s, child_log_s]) + ) + }); + assert!( + result.is_none(), + "a timed-out tree must yield None\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + + let descendant_pid = read_recorded_pid(pid_s, &[root_log_s, child_log_s]); + std::thread::sleep(Duration::from_millis(300)); + assert!( + !pid_alive(descendant_pid), + "descendant {descendant_pid} must be job-killed on timeout, but it survived\n{}", + dump_logs(&[root_log_s, child_log_s]) + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs index d8f8e603546..c9109184d5b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/login_shell.rs @@ -6,9 +6,15 @@ use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::Duration; use super::is_executable_file; +/// Per-candidate wall-clock bound for a login-shell spawn. Matches the auth +/// probe's 10s discipline: long enough for a healthy interactive shell to +/// source its rc files, short enough that a wedged shell can't stall discovery. +const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(10); + /// Test-only spawn counter lives beside `discovery.rs`; import it here so the /// spawn-record call site stays byte-identical to the pre-extraction source. #[cfg(test)] @@ -34,14 +40,25 @@ pub(crate) fn login_shell_candidates() -> Vec { /// Run a command in a login shell (tries zsh then bash on Unix, Git Bash on Windows). /// Returns trimmed stdout if the command succeeds with non-empty output. +/// +/// Each candidate shell is bounded by [`LOGIN_SHELL_TIMEOUT`]: a shell whose +/// startup blocks (an interactive prompt in `.zshrc`, a stalled network mount, +/// a credential helper waiting on input) is killed and treated as a miss so the +/// loop falls through to the next candidate rather than hanging the whole +/// discovery. Without this bound a single slow login shell froze the forced +/// pipeline indefinitely, which is what left "Check again" spinning forever. fn run_in_login_shell(args: &[&str]) -> Option { #[cfg(test)] login_shell_spawn_probe::record(); for shell in login_shell_candidates() { let mut cmd = Command::new(&shell); cmd.args(args); - crate::util::configure_no_window(&mut cmd); - let Ok(output) = cmd.output() else { + // Window suppression is owned by `output_with_timeout`'s spawn + // (`BOUNDED_CREATION_FLAGS` carries `CREATE_NO_WINDOW`); a + // `configure_no_window` call here would be clobbered by that later + // `creation_flags` set, so it is deliberately omitted. + let Some(output) = super::bounded_command::output_with_timeout(cmd, LOGIN_SHELL_TIMEOUT) + else { continue; }; if !output.status.success() { @@ -72,10 +89,25 @@ enum LoginShellPath { Probed(Option), } -fn path_cache() -> &'static std::sync::Mutex { +/// Cache plus a monotonic generation counter. `refresh_login_shell_path` bumps +/// the generation and resets the state together; a probe records the generation +/// it started under and may only publish its result while that generation is +/// still current. This stops a slow, pre-refresh probe from committing a stale +/// (often false-negative) PATH over the fresh value a post-refresh probe wrote. +struct PathCache { + generation: u64, + state: LoginShellPath, +} + +fn path_cache() -> &'static std::sync::Mutex { use std::sync::{Mutex, OnceLock}; - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(LoginShellPath::Uninit)) + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| { + Mutex::new(PathCache { + generation: 0, + state: LoginShellPath::Uninit, + }) + }) } fn fetch_login_shell_path_inner() -> Option { @@ -103,45 +135,112 @@ fn fetch_login_shell_path_inner() -> Option { /// to invalidate the cache so the next call re-fetches — e.g. after the user /// installs Node.js mid-session and clicks Retry. /// -/// The lock is never held while the login shell spawns: we check for a cached -/// value, release the lock, run the shell, then re-lock to write. Two concurrent -/// callers may both run the shell (last-writer-wins is fine — both produce the -/// same result), but neither blocks a concurrent agent spawn on the Mutex. +/// The lock is never held while the login shell spawns: we read the cached +/// value and the current generation, release the lock, run the shell, then +/// re-lock to publish. Publication is generation-guarded so a probe that +/// started before a [`refresh_login_shell_path`] can never overwrite the fresh +/// value: if the generation moved while the probe ran, its result is discarded. +/// Within one generation two callers may both probe; a failure/timeout result +/// (`None`) never clobbers an already-committed success, so a slow timeout can't +/// undo a peer's fresh PATH. +/// +/// The caller never returns its own local probe result: after publishing it +/// returns the value now in the cache. This closes two divergences where a +/// caller's own result contradicted the authoritative cache: +/// - same-generation timeout-vs-success — a peer committed a success while +/// our probe timed out (`None`); we return the peer's success, not `None`; +/// - a pre-refresh probe whose writeback was generation-rejected — its local +/// value is stale, so we re-probe under the new generation instead. pub fn login_shell_path() -> Option { - // Fast path: return cached result without spawning a shell. - { - let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - if let LoginShellPath::Probed(ref result) = *guard { - return result.clone(); + loop { + // Fast path: return the cached result and capture the generation the + // probe will run under, all under a single lock. + let generation = { + let guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if let LoginShellPath::Probed(ref result) = guard.state { + return result.clone(); + } + guard.generation + }; + + // Slow path: spawn shell outside any lock. + let result = probe_login_shell_path(); + + // Publish under our generation, then return whatever value is now + // authoritative. `None` means a refresh invalidated our generation + // mid-probe and no fresh value is cached yet, so our `result` is stale + // by definition — discard it and re-probe under the new generation. + // + // Termination: another lap requires another [`refresh_login_shell_path`] + // to land during a probe. Refreshes come only from discrete human + // actions (install/retry/Doctor re-run) and one-shot boot warm, so the + // loop cannot spin unbounded. + if let Some(committed) = publish_probe_result(generation, result) { + return committed; } } +} - // Slow path: spawn shell outside any lock. - let result = fetch_login_shell_path_inner(); +/// Real login-shell probe. A `cfg(test)` seam lets the race tests inject +/// deterministic probe results (and side effects) without spawning shells. +#[cfg(not(test))] +fn probe_login_shell_path() -> Option { + fetch_login_shell_path_inner() +} - // Write back; last-writer-wins is safe here. - { - let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Probed(result.clone()); +#[cfg(test)] +fn probe_login_shell_path() -> Option { + match path_cache_race_tests::take_injected_probe() { + Some(injected) => injected(), + None => fetch_login_shell_path_inner(), } +} - result +/// Commit a probe's `result` under the generation it started with, then report +/// the value the caller should return. +/// +/// A probe whose generation is stale (a [`refresh_login_shell_path`] ran while +/// it was probing) does not commit. Within a live generation a failure/timeout +/// (`None`) never overwrites an already-committed success. This is the sole +/// writer of a probed value, so the two race outcomes are decided here. +/// +/// Returns `Some(v)` — the now-cached probed value the caller must return +/// (its own commit, or a peer's success that superseded it) — or `None` when +/// the cache is `Uninit` because a refresh landed mid-probe, signalling the +/// caller to re-probe under the new generation. Commit and re-read happen under +/// one lock so no refresh can slip between them. +fn publish_probe_result(generation: u64, result: Option) -> Option> { + let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); + if guard.generation == generation { + let keep_committed_success = + result.is_none() && matches!(guard.state, LoginShellPath::Probed(Some(_))); + if !keep_committed_success { + guard.state = LoginShellPath::Probed(result); + } + } + match guard.state { + LoginShellPath::Probed(ref v) => Some(v.clone()), + LoginShellPath::Uninit => None, + } } /// Invalidate the login-shell PATH cache so the next [`login_shell_path`] call /// re-fetches from a fresh login shell. /// /// Called before every install/retry operation and on Doctor Re-run so a -/// newly-installed tool becomes visible without restarting the app. +/// newly-installed tool becomes visible without restarting the app. Bumping the +/// generation revokes any in-flight probe's writeback, so a shell that started +/// before this refresh cannot recache its now-stale result. pub(crate) fn refresh_login_shell_path() { let mut guard = path_cache().lock().unwrap_or_else(|e| e.into_inner()); - *guard = LoginShellPath::Uninit; + guard.generation = guard.generation.wrapping_add(1); + guard.state = LoginShellPath::Uninit; } #[cfg(test)] pub(crate) fn is_login_shell_path_uninit() -> bool { matches!( - *path_cache().lock().unwrap_or_else(|e| e.into_inner()), + path_cache().lock().unwrap_or_else(|e| e.into_inner()).state, LoginShellPath::Uninit ) } @@ -234,3 +333,178 @@ pub(crate) fn parse_semver_tag(s: &str) -> Option<(u64, u64, u64)> { let patch = patch_str.split('-').next()?.parse::().ok()?; Some((major, minor, patch)) } + +#[cfg(test)] +mod path_cache_race_tests { + use super::*; + use std::collections::VecDeque; + use std::sync::{Mutex, OnceLock}; + + /// A deterministic stand-in for one login-shell spawn. Returning it lets a + /// test drive `login_shell_path`'s slow path without a real shell, and run + /// side effects (a peer commit, a mid-probe refresh) at the exact moment a + /// probe would be executing. + pub(super) type InjectedProbe = Box Option + Send>; + + fn probe_queue() -> &'static Mutex> { + static Q: OnceLock>> = OnceLock::new(); + Q.get_or_init(|| Mutex::new(VecDeque::new())) + } + + /// Consumed by the `cfg(test)` `probe_login_shell_path` seam: each slow-path + /// probe pops the next injected result, falling back to the real shell when + /// the queue is empty (so unrelated cache tests still exercise real probing). + pub(super) fn take_injected_probe() -> Option { + probe_queue() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .pop_front() + } + + fn inject_probes(probes: Vec) { + let mut q = probe_queue().lock().unwrap_or_else(|e| e.into_inner()); + q.clear(); + q.extend(probes); + } + + fn cached_probe() -> Option> { + match path_cache().lock().unwrap_or_else(|e| e.into_inner()).state { + LoginShellPath::Uninit => None, + LoginShellPath::Probed(ref v) => Some(v.clone()), + } + } + + fn generation() -> u64 { + path_cache() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .generation + } + + /// A probe that started before a refresh must not recache its stale result. + /// Models the P1 interleaving: probe A captures generation G; a forced + /// refresh bumps to G+1 and (via probe B) commits a fresh PATH; then A + /// finishes late and tries to publish. A publishes a non-empty *success* + /// (`/stale/bin`), which the same-generation `None`-over-`Some` rule would + /// accept — so only the generation guard can reject it. This keeps the test + /// non-vacuous: delete the generation comparison and stale overwrites fresh. + #[test] + fn stale_probe_cannot_commit_after_refresh() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + + // Probe A starts here. + let gen_a = generation(); + + // A forced refresh invalidates the cache; probe B (new generation) then + // commits a fresh PATH. + refresh_login_shell_path(); + let gen_b = generation(); + assert_ne!(gen_a, gen_b, "refresh must bump the generation"); + publish_probe_result(gen_b, Some("/fresh/bin".to_string())); + + // Probe A finishes late and tries to publish a *stale success* under + // its old generation. Only the generation guard can reject this — the + // same-generation success-retention rule would let a `Some` through. + publish_probe_result(gen_a, Some("/stale/bin".to_string())); + + assert_eq!( + cached_probe(), + Some(Some("/fresh/bin".to_string())), + "a pre-refresh probe must not overwrite the post-refresh fresh PATH" + ); + + // Restore the shared cache so sibling tests re-probe a real PATH rather + // than reading this fixture value. + refresh_login_shell_path(); + } + + /// Within one generation a slow failure/timeout must not clobber a peer's + /// already-committed success. Two cold callers race under generation G: the + /// success lands first, the timeout (`None`) lands second and is dropped. + #[test] + fn timeout_does_not_clobber_committed_success() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + let gen = generation(); + + // Caller 1 succeeds. + publish_probe_result(gen, Some("/usr/local/bin".to_string())); + // Caller 2 times out later in the same generation. + publish_probe_result(gen, None); + + assert_eq!( + cached_probe(), + Some(Some("/usr/local/bin".to_string())), + "a same-generation timeout must not overwrite a committed success" + ); + + // Restore the shared cache so sibling tests re-probe a real PATH rather + // than reading this fixture value. + refresh_login_shell_path(); + } + + /// P1 #2, divergence (a): same-generation timeout-vs-success. A caller + /// whose own probe times out (`None`) must still return the success a peer + /// committed under the same generation — never its own `None`, which would + /// let a forced discovery on this thread settle a PATH-missing UI while the + /// authoritative cache holds the peer's success. + /// + /// Injected probe: commit the peer's `/peer/bin` success, then return `None` + /// (this caller's timeout). Non-vacuous for the "return authoritative value" + /// rule: return the local result instead and this yields `None`. + #[test] + fn caller_returns_peer_success_not_own_timeout() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + let gen = generation(); + + inject_probes(vec![Box::new(move || { + // A peer probe finishes first and commits a success under gen. + publish_probe_result(gen, Some("/peer/bin".to_string())); + // Our probe then times out. + None + })]); + + assert_eq!( + login_shell_path(), + Some("/peer/bin".to_string()), + "a timed-out caller must return the peer's committed success, not its own None" + ); + + refresh_login_shell_path(); + } + + /// P1 #2, divergence (b): a pre-refresh probe whose writeback is + /// generation-rejected must not return its stale local value; the caller + /// re-probes under the new generation and returns the fresh result. + /// + /// First injected probe refreshes mid-flight (bumping the generation) and + /// returns a stale `/stale/bin`; publication is rejected, so the caller + /// loops and the second probe returns the fresh `/fresh/bin`. Non-vacuous + /// for the re-probe rule: return the stale local value on a rejected commit + /// instead and this yields `/stale/bin`. + #[test] + fn caller_reprobes_after_midprobe_refresh() { + let _guard = crate::managed_agents::lock_path_mutex(); + refresh_login_shell_path(); + + inject_probes(vec![ + Box::new(|| { + // A forced refresh lands while this probe runs, invalidating the + // generation it started under; its result is stale by definition. + refresh_login_shell_path(); + Some("/stale/bin".to_string()) + }), + Box::new(|| Some("/fresh/bin".to_string())), + ]); + + assert_eq!( + login_shell_path(), + Some("/fresh/bin".to_string()), + "a generation-rejected probe must re-probe, never return its stale local value" + ); + + refresh_login_shell_path(); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 5369b6321b7..aab5cd45298 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -187,3 +187,30 @@ fn cheap_discovery_never_spawns_login_shell_even_when_cold() { "the forced path must probe the absent command via login shell at least once, got {forced}" ); } + +/// Regression: `resolve_command_cached` (the cheap discovery path) must find a +/// bundled sidecar sitting next to the executable via a filesystem stat, even +/// with a cold resolve cache. Before the fix it consulted only the managed-shim +/// dirs + cache, so `buzz-agent` reported "not installed" at every cold launch. +/// Here the path form exercises the same `resolve_workspace_command` stat the +/// cheap path now shares. +#[cfg(unix)] +#[test] +fn cheap_path_resolves_workspace_sidecar_without_cache() { + use crate::managed_agents::discovery::resolve_command_cached; + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-sidecar-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let bin = dir.join("buzz-agent"); + std::fs::write(&bin, "#!/bin/sh\n").expect("write sidecar"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + + assert_eq!( + resolve_command_cached(bin.to_str().expect("utf8 path")), + Some(bin.clone()), + "cheap path must resolve a bundled sidecar by path with a cold cache" + ); + + let _ = std::fs::remove_dir_all(dir); +} diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 479d6ec913e..8e27ba1031d 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -45,20 +45,19 @@ impl Drop for JobHandle { /// the caller can fall back to `Child::kill()` — a degraded teardown beats a /// failed spawn. /// -/// Assignment happens immediately after spawn, on the same parent thread. The -/// child (buzz-acp) does spawn its 24 workers before it connects to the relay, -/// so the window between our spawn and our assignment is NOT structurally empty. -/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject` -/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init -/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so -/// the assign reliably wins before any worker exists. Once assigned, Windows -/// places every subsequently-spawned descendant in the job automatically. +/// For the harness spawn path ([`finish_spawn`]) assignment happens immediately +/// after a normal spawn. The child (buzz-acp) must init tokio, parse its config, +/// and spawn 24 children (tens-to-hundreds of ms) before any descendant exists, +/// so the microsecond `OpenProcess` + `AssignProcessToJobObject` reliably wins +/// that race. Once assigned, Windows places every subsequently-spawned +/// descendant in the job automatically. /// -/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight -/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread` -/// (materially more unsafe Win32) to close a microsecond race, so it is -/// deliberately not used here. -fn create_job_for_child(pid: u32) -> Option { +/// The discovery path (`bounded_command`) runs arbitrary probe commands that +/// can background a descendant and exit in the same tick, so it cannot rely on +/// assign-latency. It spawns with `CREATE_SUSPENDED`, assigns the frozen child +/// here, then calls [`resume_process`] — no descendant can exist until the job +/// owns the root, closing the race by construction. +pub(crate) fn create_job_for_child(pid: u32) -> Option { use std::ptr::null; use windows_sys::Win32::Foundation::{CloseHandle, FALSE}; use windows_sys::Win32::System::JobObjects::{ @@ -105,6 +104,56 @@ fn create_job_for_child(pid: u32) -> Option { } } +/// Resume a process spawned with `CREATE_SUSPENDED` by resuming every thread it +/// owns. A fresh `CREATE_SUSPENDED` process has exactly one thread suspended at +/// its entry point; resuming it lets the process run. We enumerate via a +/// ToolHelp thread snapshot filtered to `pid` rather than tracking the initial +/// thread id (`std::process::Command` does not expose it), and resume each so +/// the walk is correct even in the pathological multi-thread case. +/// +/// Returns `true` only if at least one owned thread was resumed. `false` means +/// no thread could be resumed — the caller must treat the child as unusable and +/// tear it down, since a still-suspended root would otherwise hang to the +/// deadline. +pub(crate) fn resume_process(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32, + }; + use windows_sys::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}; + + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0); + if snapshot == INVALID_HANDLE_VALUE { + return false; + } + + let mut entry: THREADENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + + let mut resumed_any = false; + let mut has_entry = Thread32First(snapshot, &mut entry); + while has_entry != 0 { + if entry.th32OwnerProcessID == pid { + let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID); + if !thread.is_null() { + // ResumeThread returns u32::MAX on failure; any other value + // is the thread's previous suspend count. + if ResumeThread(thread) != u32::MAX { + resumed_any = true; + } + CloseHandle(thread); + } + } + entry.dwSize = std::mem::size_of::() as u32; + has_entry = Thread32Next(snapshot, &mut entry); + } + + CloseHandle(snapshot); + resumed_any + } +} + /// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest /// equivalent to the Unix process-group kill. Used on the after-restart path /// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index 71db0523695..fcdc29fc5fd 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { startBootWarm } from "@/features/agents/acpRuntimesQuery"; import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { useForegroundQueryRefresh } from "@/features/workflows/hooks"; import { relayClient } from "@/shared/api/relayClient"; @@ -23,6 +25,22 @@ export function useAppShellLifecycleEffects({ useRelayResumeTriggers(); useForegroundQueryRefresh(); + // Warm the ACP runtime catalog once at app launch. The shared runtime-catalog + // cache is in-memory only, so it starts cold every boot; the cheap discovery + // path reports every harness as "(not installed)" until a forced pass warms + // it. The create/edit picker and Agents > Agent defaults surfaces read that + // cheap path, so without this warm they render all-missing (and block agent + // save) until the user visits Settings > Agents — the accidental workaround. + // `startBootWarm` drives the module-level boot-warm gate (once per launch, so + // this remounting effect never re-fires the probe) which makes those cheap + // surfaces show loading/retryable-error instead of blessing the cold catalog, + // and swallows the probe's own errors so a failure leaves the last good + // catalog in place without an unhandled rejection. + const queryClient = useQueryClient(); + React.useEffect(() => { + void startBootWarm(queryClient); + }, [queryClient]); + // Prevent webview file:/// navigation on file drop outside the composer. // Scoped to file drags only (text drag-and-drop into inputs still works). // Composer's onDrop fires first (React synthetic before window bubble). diff --git a/desktop/src/features/agents/acpRuntimesQuery.test.mjs b/desktop/src/features/agents/acpRuntimesQuery.test.mjs index c51dea05b8f..e9320bb6e99 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.test.mjs +++ b/desktop/src/features/agents/acpRuntimesQuery.test.mjs @@ -179,12 +179,15 @@ globalThis.__TAURI_INTERNALS__ = { import React from "react"; import { createRoot } from "react-dom/client"; import { act } from "react"; -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query"; import { acpRuntimesQueryKey, + applyBootWarmGate, + getBootWarmSnapshot, refreshAcpRuntimes, + startBootWarm, useAcpRuntimesQueryForced, } from "./acpRuntimesQuery.ts"; import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery.ts"; @@ -230,6 +233,144 @@ afterEach(() => { discoverHandler = () => Promise.resolve([]); }); +// Runs FIRST so the process-global boot-warm gate is observed from `idle`. +// Covers Carl's ask: the cheap/forced race (a cold cheap catalog must read as +// loading, not authoritative, while the first forced pass is in flight) and the +// failure state (a failed forced pass must surface a retryable error carrying +// the real reason, not a silent empty catalog), plus recovery on retry. +describe("boot-warm gate drives cheap consumers through the initial pass", () => { + it("applyBootWarmGate: a non-empty cold catalog is not authoritative while pending or failed", () => { + // The real cold cheap response is NEVER empty: discovery always emits the + // known runtimes as not_installed/cli_missing rows plus presets. Model that + // wire shape so the gate is exercised against the payload it exists to + // gate, not a `[]` that never occurs in production. + const coldCatalog = { + data: [ + rawEntry("codex", "unknown"), + rawEntry("goose", "unknown"), + rawEntry("claude-code", "unknown"), + ], + error: null, + isLoading: false, + isPending: false, + isFetching: false, + isError: false, + }; + // A consumer maps `isLoading -> "loading"`, `isError -> "error"`, else + // `"ready"`. "Ready" is what blesses the cold rows as authoritative — the + // exact P2 defect. Assert neither pending nor failed reads as ready. + const readsAsReady = (q) => !q.isLoading && !q.isError; + + const pending = applyBootWarmGate(coldCatalog, { + status: "pending", + error: null, + }); + assert.equal(pending.isLoading, true); + assert.equal(pending.isPending, true); + assert.equal( + readsAsReady(pending), + false, + "pending must not read as ready", + ); + // The catalog rows are preserved so a consumer reading `data ?? []` keeps + // them; only the lifecycle flags are overlaid. + assert.equal(pending.data.length, 3); + + const reason = new Error("PATH probe timed out"); + const failed = applyBootWarmGate(coldCatalog, { + status: "failed", + error: reason, + }); + assert.equal(failed.isError, true); + assert.equal(failed.error, reason); + assert.equal(readsAsReady(failed), false, "failed must not read as ready"); + assert.equal(failed.data.length, 3); + + // idle/settled pass through untouched: onboarding renders before the warm + // starts (idle) and the warmed hot path (settled) must both read as ready. + for (const status of ["idle", "settled"]) { + const passed = applyBootWarmGate(coldCatalog, { status, error: null }); + assert.equal(passed.isLoading, false); + assert.equal(passed.isError, false); + assert.equal(readsAsReady(passed), true, `${status} must read as ready`); + } + }); + + it("applyBootWarmGate: a warmed non-empty catalog reads as ready once settled", () => { + const warm = { + data: [rawEntry("codex", "logged_in")], + error: null, + isLoading: false, + isPending: false, + isFetching: false, + isError: false, + }; + const settled = applyBootWarmGate(warm, { status: "settled", error: null }); + assert.equal(settled.isLoading, false); + assert.equal(settled.isError, false); + assert.equal(settled.data.length, 1); + }); + + it("applyBootWarmGate: failed reads as a retryable error with the real reason", () => { + const cold = { + data: [], + error: null, + isLoading: true, + isPending: true, + isFetching: true, + isError: false, + }; + const reason = new Error("PATH probe timed out"); + const failed = applyBootWarmGate(cold, { status: "failed", error: reason }); + assert.equal(failed.isError, true); + assert.equal(failed.error, reason); + assert.equal(failed.isLoading, false, "a failed warm is not still loading"); + }); + + it("startBootWarm: failure marks the gate failed, a retry settles it", async () => { + assert.equal( + getBootWarmSnapshot().status, + "idle", + "gate must start idle before any warm", + ); + + const queryClient = makeQueryClient(); + queryClient.mount(); + + // 1. First forced pass fails: the gate goes `failed` and captures the + // reason, so cold cheap surfaces can show a retryable error. + let failForced = true; + discoverHandler = (args) => + args?.force === true && failForced + ? Promise.reject(new Error("discovery boom")) + : Promise.resolve([]); + await startBootWarm(queryClient); + assert.equal(getBootWarmSnapshot().status, "failed"); + assert.equal(getBootWarmSnapshot().error?.message, "discovery boom"); + + // 2. A retry that succeeds settles the gate and clears the error, so cheap + // consumers stop overlaying and render the warmed catalog. + failForced = false; + discoverHandler = () => Promise.resolve([rawEntry("codex", "logged_in")]); + await startBootWarm(queryClient); + assert.equal(getBootWarmSnapshot().status, "settled"); + assert.equal(getBootWarmSnapshot().error, null); + + // 3. Once settled, further boot warms are no-ops (fixes the per-remount + // re-fire): no additional forced probe fires. + const before = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + await startBootWarm(queryClient); + const after = calls.filter( + (c) => c.command === "discover_acp_providers" && c.args?.force === true, + ).length; + assert.equal(after, before, "a settled gate must not re-fire the probe"); + + queryClient.unmount(); + }); +}); + describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () => { it("runs a distinct force:true probe and writes it into the shared cache", async () => { const queryClient = makeQueryClient(); @@ -275,6 +416,59 @@ describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () = queryClient.unmount(); }); + + it("an in-flight cheap query cannot clobber the forced result after refresh", async () => { + // Carl's settle-order finding: a cheap query in flight on the shared key + // must not land its (older) result after the forced catalog is written. + // `refreshAcpRuntimes` cancels the shared-key query before settling; this + // proves the cancel is load-bearing by holding a real cheap observer + // fetching, running the forced refresh, then resolving the cheap request + // late — its result must not overwrite the forced catalog, and the gate + // must settle on the forced state. (Removing the `cancelQueries` call makes + // the late cheap result win and fails this test.) + const queryClient = makeQueryClient(); + queryClient.mount(); + + // Seed a pre-existing cold catalog, then start a mounted cheap observer that + // refetches and is held pending — the real in-flight shape. + queryClient.setQueryData(acpRuntimesQueryKey, [ + rawEntry("codex", "unknown"), + ]); + const cheap = deferred(); + discoverHandler = (args) => { + if (args?.force === false) return cheap.promise; + return Promise.resolve([rawEntry("codex", "logged_in")]); + }; + const observer = new QueryObserver(queryClient, { + queryKey: acpRuntimesQueryKey, + queryFn: () => discoverAcpRuntimes(), + staleTime: 0, + }); + const unsubscribe = observer.subscribe(() => {}); + await new Promise((r) => setImmediate(r)); + + // Forced refresh completes and settles while the cheap observer is fetching. + await refreshAcpRuntimes(queryClient); + + // The cheap request resolves afterward; its result must be dropped. + cheap.resolve([rawEntry("codex", "unknown")]); + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); + + assert.equal( + queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status, + "logged_in", + "shared cache must remain the forced result after a late cheap resolution", + ); + assert.equal( + getBootWarmSnapshot().status, + "settled", + "the gate must settle on the forced catalog, not the stale cheap state", + ); + + unsubscribe(); + queryClient.unmount(); + }); }); describe("useAcpRuntimesQueryForced surfaces forced-probe failures", () => { diff --git a/desktop/src/features/agents/acpRuntimesQuery.ts b/desktop/src/features/agents/acpRuntimesQuery.ts index 0e76e25ee76..16f82a0aa95 100644 --- a/desktop/src/features/agents/acpRuntimesQuery.ts +++ b/desktop/src/features/agents/acpRuntimesQuery.ts @@ -19,6 +19,153 @@ export const acpRuntimesQueryKey = ["acp-runtimes"] as const; */ export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const; +/** + * Boot-warm gate for the *initial* forced discovery pass. + * + * The shared runtime catalog is in-memory only, so it starts cold every launch: + * the cheap discovery path reports every harness `(not installed)` until a + * forced pass warms it. Without a gate, the create/edit picker and Agents > + * Agent defaults surfaces read that cheap path and present the cold catalog as + * *authoritative* — blessing every harness as unavailable and blocking save — + * during the 20–65s boot probe, and forever if that probe fails. + * + * This module-level state lets cheap consumers (`useAcpRuntimesQuery`) treat the + * catalog as still-loading while the first forced pass is in flight and as a + * retryable error if it failed, instead of authoritative. It is process-global + * (one launch), so `startBootWarm` runs the warm exactly once no matter how many + * times `AppShell` mounts — that also fixes the per-remount re-fire. + * + * The seam that protects onboarding (which renders before `AppShell` fires the + * warm): the gate only overlays loading/error once the warm has *started* + * (`pending`/`failed`). While `idle` — no warm yet, e.g. the onboarding flow — + * cheap consumers behave exactly as before. A successful forced refresh from any + * surface settles the gate, so onboarding's own forced warm clears it too. + */ +export type AcpBootWarmStatus = "idle" | "pending" | "settled" | "failed"; + +/** + * A stable snapshot object for `useSyncExternalStore`: `getSnapshot` must return + * a referentially-stable value between changes, so the object is rebuilt only in + * `setBootWarm`, never per read. + */ +let bootWarmSnapshot: { status: AcpBootWarmStatus; error: Error | null } = { + status: "idle", + error: null, +}; +const bootWarmListeners = new Set<() => void>(); + +function setBootWarm(status: AcpBootWarmStatus, error: Error | null) { + if (bootWarmSnapshot.status === status && bootWarmSnapshot.error === error) { + return; + } + bootWarmSnapshot = { status, error }; + for (const listener of bootWarmListeners) listener(); +} + +export function subscribeBootWarm(listener: () => void) { + bootWarmListeners.add(listener); + return () => { + bootWarmListeners.delete(listener); + }; +} + +export function getBootWarmSnapshot() { + return bootWarmSnapshot; +} + +/** + * Overlay the launch boot-warm gate onto a cheap-path query result so cheap + * consumers never present a cold catalog as authoritative. Pure so it can be + * unit-tested without a mounted hook. + * + * The cheap backend response is *never* empty on a cold cache — discovery + * always emits the full set of known runtimes (as `not_installed`/`cli_missing` + * rows) plus presets. Gating on `data.length` would therefore be a no-op for the + * exact payload this exists to gate, so the gate keys on the boot-warm state + * instead and always preserves `query.data`: + * + * - `pending` (first forced pass in flight) reads as loading, so a cold catalog + * is presented as still-loading rather than a settled "everything + * unavailable" list — even though those cold rows are non-empty. + * - `failed` (forced pass rejected) reads as a retryable error carrying the + * probe's real reason. + * - `idle`/`settled` pass the query through unchanged, so onboarding (which + * renders before the warm starts) and the warmed hot path are untouched. + * + * `query.data` is preserved on every branch: overlaying only the lifecycle + * flags means a consumer that reads `data ?? []` keeps its rows while a + * status-driven consumer correctly treats them as not-yet-authoritative. + */ +export function applyBootWarmGate< + Q extends { + data?: unknown[]; + error: Error | null; + isLoading: boolean; + isPending: boolean; + isFetching: boolean; + isError: boolean; + }, +>(query: Q, bootWarm: { status: AcpBootWarmStatus; error: Error | null }): Q { + if (bootWarm.status === "pending") { + return { ...query, isLoading: true, isPending: true, isFetching: true }; + } + if (bootWarm.status === "failed") { + return { + ...query, + isError: true, + error: bootWarm.error ?? query.error, + isLoading: false, + }; + } + return query; +} + +/** + * Run the initial forced discovery pass once per launch and drive the boot-warm + * gate. `AppShell` calls this on mount; the `pending`/`settled` short-circuit + * makes remounts no-ops (fixing the re-fire) while still retrying after a prior + * failure. Success is recorded by `refreshAcpRuntimes` itself (any forced + * success settles the gate); this only has to mark its own failure. + */ +export async function startBootWarm( + queryClient: ReturnType, +) { + const status: AcpBootWarmStatus = bootWarmSnapshot.status; + if (status === "pending" || status === "settled") { + return; + } + setBootWarm("pending", null); + const result = await refreshAcpRuntimes(queryClient); + // A concurrent forced success may have already settled the gate; only mark + // failed if this pass is still the pending one and it returned no catalog. + if (result === undefined && bootWarmSnapshot.status === "pending") { + setBootWarm("failed", lastForcedError); + } +} + +/** + * A stable callback that re-runs the boot warm after it failed, for the retry + * affordance the cheap-path surfaces (create/edit picker, Agent defaults) show + * when the gate is in its `failed` state. `startBootWarm` is the retry + * primitive: from `failed` it transitions back through `pending` (so the + * surface shows loading again) to `settled` on success or `failed` with a fresh + * reason on another rejection. It no-ops while `pending`/`settled`, so a + * double-click cannot stack probes. + */ +export function useRetryBootWarm() { + const queryClient = useQueryClient(); + return React.useCallback(() => { + void startBootWarm(queryClient); + }, [queryClient]); +} + +/** + * The error from the most recent failed forced probe, surfaced through the + * boot-warm `failed` state so a cold catalog shows a real reason rather than a + * silent empty list. Cleared on the next forced success. + */ +let lastForcedError: Error | null = null; + /** * Run a forced (full re-discovery) refresh and write the result into the shared * runtime-catalog cache. @@ -48,13 +195,20 @@ export async function refreshAcpRuntimes( staleTime: 0, gcTime: 0, }); - queryClient.setQueryData(acpRuntimesQueryKey, result); - // A hot-surface cheap fetch may already be in flight on the shared key; cancel - // it so its (older, cached) result cannot land after and clobber the fresh - // forced catalog we just wrote. + // Cancel and *await* the in-flight cheap query on the shared key BEFORE + // writing the forced result. `cancelQueries` defaults to `revert: true`, so + // cancellation restores the cheap query's pre-fetch state; doing it after + // `setQueryData` would let that revert land last and clobber the fresh + // forced catalog, and the gate would then settle on the stale state. With + // the cancel awaited first, our `setQueryData` is the final write. await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey }); + queryClient.setQueryData(acpRuntimesQueryKey, result); + // Any forced success proves the catalog is warm: settle the boot-warm gate + // and clear the last error, so cheap consumers stop overlaying loading/error. + lastForcedError = null; + setBootWarm("settled", null); return result; - } catch { + } catch (error) { // The forced probe rejected. `fetchQuery` has already recorded the error in // the forced key's query state, where `useAcpRuntimesQueryForced` projects // it into the hook's returned `error`/`isError`. Swallow the rejection here @@ -63,7 +217,9 @@ export async function refreshAcpRuntimes( // paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an // unhandled rejection, and a new call site can never reintroduce one. The // shared cache is left untouched so consumers keep the last good catalog - // alongside the surfaced error. + // alongside the surfaced error. Record the error so a failed boot warm can + // surface a real reason on the cheap-path surfaces (via the boot-warm gate). + lastForcedError = error instanceof Error ? error : new Error(String(error)); return undefined; } } diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index b7dd7667334..3daf4fa78cc 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -52,9 +52,15 @@ import { import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks"; import { acpRuntimesQueryKey, + applyBootWarmGate, + getBootWarmSnapshot, refreshAcpRuntimes, + subscribeBootWarm, +} from "@/features/agents/acpRuntimesQuery"; +export { + useAcpRuntimesQueryForced, + useRetryBootWarm, } from "@/features/agents/acpRuntimesQuery"; -export { useAcpRuntimesQueryForced } from "@/features/agents/acpRuntimesQuery"; import { createPersona, deletePersona, @@ -218,12 +224,23 @@ function invalidateManagedAgentQueriesInBackground( * probe pipeline. */ export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { - return useQuery({ + const query = useQuery({ enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, queryFn: () => discoverAcpRuntimes(), staleTime: 30 * 60_000, }); + // Overlay the launch boot-warm gate so cheap consumers never present a cold + // catalog as authoritative: until the first forced pass settles, an un-warmed + // catalog reads as loading (`pending`) or a retryable error (`failed`) rather + // than "every harness not installed". `applyBootWarmGate` preserves an + // already-good list and passes through untouched while idle/settled. + const bootWarm = React.useSyncExternalStore( + subscribeBootWarm, + getBootWarmSnapshot, + getBootWarmSnapshot, + ); + return applyBootWarmGate(query, bootWarm); } export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { diff --git a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx index 69e8b3a5b43..3564f31cb50 100644 --- a/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx +++ b/desktop/src/features/agents/ui/AgentDefaultsEditor.tsx @@ -33,6 +33,7 @@ import { sortPersonaRuntimes, } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; +import { HarnessCatalogRetryNotice } from "@/features/agents/ui/HarnessCatalogRetryNotice"; import { AgentConfigFields, EMPTY_GLOBAL_CONFIG, @@ -171,10 +172,12 @@ export function AgentDefaultsEditor({ [sortedRuntimes], ); const configSurfaceLoading = isLoading || runtimesQuery.isLoading; - const configSurfaceError = - loadError || + // The runtime catalog failing to warm is retryable in-place (re-run the boot + // probe); a global-config load failure is not, so it keeps the restart copy. + const runtimeCatalogError = runtimesQuery.isError || - (!configSurfaceLoading && sortedRuntimes.length === 0); + (!configSurfaceLoading && !loadError && sortedRuntimes.length === 0); + const configSurfaceError = loadError || runtimeCatalogError; function handleConfigChange(next: GlobalAgentConfig) { configRef.current = next; @@ -268,10 +271,16 @@ export function AgentDefaultsEditor({ Loading… ) : configSurfaceError ? ( -
- - Couldn't load agent defaults. Restart the app to try again. -
+ runtimeCatalogError && !loadError ? ( +
+ +
+ ) : ( +
+ + Couldn't load agent defaults. Restart the app to try again. +
+ ) ) : ( <>
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 06f41667b09..81033f7d928 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -823,6 +823,7 @@ export function AgentDefinitionDialog({ > {aiConfigurationMode === "custom" ? ( ) : null} - {llmProviderFieldVisible && aiConfigurationMode === "custom" ? (
void; options: PersonaDropdownOption[]; @@ -34,7 +37,7 @@ export function AgentHarnessField({ placeholder={placeholder} value={value} /> - {warning} + {catalogStatus === "error" ? : warning}
); } diff --git a/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx b/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx new file mode 100644 index 00000000000..34efb54c94d --- /dev/null +++ b/desktop/src/features/agents/ui/HarnessCatalogRetryNotice.tsx @@ -0,0 +1,24 @@ +import { AlertCircle } from "lucide-react"; + +import { useRetryBootWarm } from "@/features/agents/hooks"; +import { Button } from "@/shared/ui/button"; + +/** + * Inline error affordance shown when the launch runtime-catalog warm failed + * (the boot-warm gate's `failed` state). Unlike a global-config load failure — + * which is not retryable and keeps the "restart the app" copy — a failed + * harness probe re-runs in place via `useRetryBootWarm`, so the create/edit + * picker and Agent defaults surfaces both render this instead of a dead end. + */ +export function HarnessCatalogRetryNotice() { + const retryBootWarm = useRetryBootWarm(); + return ( +
+ + Couldn't detect agent harnesses. + +
+ ); +} From 8dbc65d9e2c80d9d8516e17b751c46e0568100e6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 28 Aug 2026 17:11:04 -0700 Subject: [PATCH 097/101] fix(composer): polish automatic mentions (#6956) **Category:** improvement **User Impact:** Automatic mentions are easier to turn off and now behave consistently across conversations, settings, drafts, and repeated agent mentions. **Problem:** People found the new automatic mention behavior hard to control: turning it off in Settings did not reliably affect the composer, removing a mention could require also disabling the feature, and root/thread composers could inherit or restore surprising state. Other reported rough edges included only one of several mentioned agents becoming automatic, synthetic mentions leaking into drafts, restored mentions corrupting adjacent text, controls remaining visible in archived channels, and unclear picker feedback. See the [original feedback thread](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=c949ec399274fbb0d6633da3f95712e843a67dcada214f63d72f6975b406604b). **Solution:** Polish the existing feature around the problems people encountered, keeping automatic mentions controllable and scoped to the active conversation. | Reported issue | UX fix | | --- | --- | | Turning automatic mentions off in Settings did not reliably update the composer. | The global setting and composer control stay synchronized, and disabling the feature does not clear typed text. | | Removing an automatic mention could require both deleting the mention and turning off the feature. | Removing or unchecking an agent excludes that agent for the current conversation, while explicitly re-adding the agent can restore automatic mention behavior. | | Root and thread composers could share or restore surprising selections. | Each root or thread composer keeps its own automatic audience and restores it when the user returns. A request to enable automatic mentions only in agent threads was considered; this PR keeps them available at the channel root but prevents state from leaking between the two. | | Mentioning multiple agents could leave only one saved as automatic. | Multi-agent selections remain represented in the automatic audience and restored mention chips. | | Automatic mention prefixes could be saved as if the user typed them. | Synthetic prefixes stay out of persisted drafts while authored text is preserved. | | Restored mentions could lose their separator and corrupt continued typing. | Restored multi-word mentions retain their trailing space and place the caret after it. | | Archived channels showed automatic-mention state beside a disabled composer. | Disabled composers hide automatic-mention controls while preserving the draft and restoring state when re-enabled. | | Confirmation and picker behavior made the feature feel difficult to inspect or adjust. | Confirmations dismiss with removed agents, remain open while hovered, and expose the setting before it changes; pin icons, contrast, scope copy, animation, and keyboard toggling are also clarified. | | Agent suggestions and membership state could shift during directory refreshes. | Suggestions and membership labels stay stable during refreshes, while send-time authorization still revalidates access. | ## Changes
File changes **desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs** Adds coverage for the channel-roster eligibility rules used by agent mention autocomplete. **desktop/src/features/agents/lib/agentAutocompleteEligibility.ts** Aligns agent autocomplete eligibility with channel membership so available agents and their labels stay trustworthy. **desktop/src/features/channels/ui/MembersSidebar.tsx** Uses the shared member-pubkey logic when presenting and acting on channel members. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs** Covers preference changes that must remain stable while composer controls are toggled. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts** Keeps the automatic-mention preference as durable user intent rather than transient composer state. **desktop/src/features/messages/lib/mentionMemberPubkeys.ts** Centralizes which member identities count as mentionable in the current channel. **desktop/src/features/messages/lib/persistentAgentAudience.test.mjs** Expands lifecycle coverage for persistent agent audiences, explicit exclusions, and restored mentions. **desktop/src/features/messages/lib/persistentAgentAudience.ts** Models automatic, explicit, and excluded agent audiences separately so user choices survive updates without leaking across composers. **desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs** Verifies implicit automatic mentions are removed without damaging surrounding separators or authored content. **desktop/src/features/messages/lib/stripImplicitAgentMentions.ts** Strips presentation-only automatic mentions before draft persistence while preserving whitespace and authored text. **desktop/src/features/messages/lib/useMentions.ts** Routes mention insertion and removal through the composer-local audience lifecycle. **desktop/src/features/messages/lib/useRichTextEditor.ts** Preserves mention-chip structure and caret placement when automatic mentions are restored. **desktop/src/features/messages/ui/ComposerAddressControls.test.mjs** Updates control-state expectations for disabled automatic mentions and restored pin affordances. **desktop/src/features/messages/ui/ComposerAddressControls.tsx** Makes automatic-mention state, disabled presentation, and pin controls visually explicit. **desktop/src/features/messages/ui/MentionAutocomplete.test.mjs** Adds coverage for roster labels, pin state, and picker behavior after mention selection. **desktop/src/features/messages/ui/MentionAutocomplete.tsx** Keeps the shortcut picker open for repeated selection and restores visible automatic-mention pin indicators. **desktop/src/features/messages/ui/MessageComposer.tsx** Scopes automatic mention state to each root or thread composer and coordinates restoration, draft persistence, and sending. **desktop/src/features/messages/ui/MessageComposerToolbar.tsx** Passes the effective automatic-mention state into the toolbar presentation. **desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs** Updates keyboard interaction coverage for toggling agents in place. **desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts** Restores automatic mention chips after lifecycle changes without moving or duplicating authored content. **desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs** Substantially expands coverage for toggles, exclusions, synchronization, and picker dismissal rules. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts** Keeps the picker usable across repeated choices and preserves explicit per-agent intent while settings change. **desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts** Makes the keyboard shortcut toggle the highlighted automatic audience choice without replacing unrelated selections. **desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts** Owns composer-local automatic-mention lifecycle behavior, including restoration, exclusions, deletion, and disabled-state handling. **desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs** Adds focused picker lifecycle coverage for selection, hover, and dismissal behavior. **desktop/src/features/messages/ui/useComposerMentionPicker.ts** Prevents premature picker dismissal while the user is interacting with its controls. **desktop/src/features/messages/ui/useDraftPersistSnapshot.ts** Persists only user-authored draft content rather than implicit automatic mention decorations. **desktop/src/shared/lib/keyboard-shortcuts.ts** Updates the automatic-mention shortcut description to match its toggle behavior. **desktop/src/testing/e2eBridge.ts** Extends the desktop test bridge with the state needed to exercise roster and automatic-mention transitions. **desktop/tests/e2e/mentions.spec.ts** Covers roster-based labels, managed-agent invitation, revocation, and recovery behavior in the complete mention flow. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Adds end-to-end coverage for root/thread isolation, preference synchronization, manual exclusions, draft hygiene, restored chips, separators, hover behavior, and disabled presentation.
## Reproduction Steps 1. Open a channel with at least two available agents and enable automatic mentions from the composer mention control. 2. Select multiple agents, remove or uncheck one, and confirm subsequent composer updates keep that agent excluded while the others remain automatic. 3. Open a thread, choose a different automatic audience there, and switch between the thread and root composer; confirm each composer retains only its own choices. 4. Disable automatic mentions and confirm the draft text remains unchanged while automatic chips and controls show the disabled state; re-enable the setting and confirm eligible automatic chips return. 5. Delete an automatic mention chip, then explicitly add the agent again; confirm it immediately returns as an automatic mention without disturbing spaces or the caret, including for a multi-word name. 6. Reload with a saved draft and confirm implicit automatic mentions were not persisted as authored draft text. 7. Use the automatic-mention keyboard shortcut and picker repeatedly; confirm the picker remains open for additional choices and the highlighted agent toggles in place. ## Validation Validated at `34d208b47d64a9816f88e10a46bcfd479e917d75` after rebasing onto `origin/main` (`69096c9a8`): - Desktop unit tests: 5,731 passed, 0 failed. - Desktop TypeScript typecheck: passed. - Desktop E2E build: passed; emitted only existing chunk and dynamic-import warnings. - `pnpm check`: exited successfully; 4 warnings and 5 informational findings are in unrelated files introduced by current main. ## Screenshots/Demos The behavioral changes are covered by the focused desktop E2E scenarios above. Screenshots can be attached from the screenshot-producing automatic-mention E2E after the PR is created. --------- Signed-off-by: Taylor Ho Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Carl Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> --- .../lib/agentAutocompleteEligibility.test.mjs | 10 + .../lib/agentAutocompleteEligibility.ts | 13 + .../features/channels/ui/MembersSidebar.tsx | 9 +- .../autoPinMentionedAgentsPreference.test.mjs | 14 + .../lib/autoPinMentionedAgentsPreference.ts | 3 + .../lib/buildMentionCandidates.test.mjs | 1 + .../messages/lib/buildMentionCandidates.ts | 15 +- .../messages/lib/mentionHighlightExtension.ts | 7 +- .../messages/lib/mentionMemberPubkeys.ts | 19 + .../lib/persistentAgentAudience.test.mjs | 51 +- .../messages/lib/persistentAgentAudience.ts | 53 +- .../lib/stripImplicitAgentMentions.test.mjs | 45 ++ .../lib/stripImplicitAgentMentions.ts | 15 + .../src/features/messages/lib/useMentions.ts | 31 +- .../messages/lib/useRichTextEditor.ts | 28 +- .../ui/ComposerAddressControls.test.mjs | 8 +- .../messages/ui/ComposerAddressControls.tsx | 15 +- .../messages/ui/MentionAutocomplete.test.mjs | 61 +- .../messages/ui/MentionAutocomplete.tsx | 37 +- .../features/messages/ui/MessageComposer.tsx | 88 ++- .../messages/ui/MessageComposerToolbar.tsx | 3 + .../ui/composerAgentKeyboard.test.mjs | 17 +- .../ui/useAddressedAgentMentionRestore.ts | 66 ++ .../ui/useAgentAddressLockPicker.test.mjs | 255 ++++++- .../messages/ui/useAgentAddressLockPicker.ts | 149 +++- .../messages/ui/useAlwaysAddressShortcut.ts | 9 +- .../messages/ui/useAutoPinMentionedAgents.ts | 146 +++- .../ui/useComposerMentionPicker.test.mjs | 94 +++ .../messages/ui/useComposerMentionPicker.ts | 37 +- .../messages/ui/useDraftPersistSnapshot.ts | 16 +- .../ui/useImplicitAgentMentionProvenance.ts | 54 ++ desktop/src/shared/lib/keyboard-shortcuts.ts | 2 +- desktop/src/testing/e2eBridge.ts | 11 +- desktop/tests/e2e/mentions.spec.ts | 134 +++- .../e2e/persistent-agent-audience.spec.ts | 646 +++++++++++++++++- 35 files changed, 1921 insertions(+), 241 deletions(-) create mode 100644 desktop/src/features/messages/lib/mentionMemberPubkeys.ts create mode 100644 desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs create mode 100644 desktop/src/features/messages/lib/stripImplicitAgentMentions.ts create mode 100644 desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts create mode 100644 desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs create mode 100644 desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 21880eca2ff..5398b28f055 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -8,6 +8,7 @@ import { getAgentMentionAdmission, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, isAgentMentionChannelType, relayAgentCanRespondInChannel, @@ -42,6 +43,15 @@ function makeAgent(overrides = {}) { }; } +test("isAgentDirectoryReady: requires successful cached directory evidence", () => { + assert.equal(isAgentDirectoryReady({ data: [], error: null }), true); + assert.equal(isAgentDirectoryReady({ data: undefined, error: null }), false); + assert.equal( + isAgentDirectoryReady({ data: [], error: new Error("offline") }), + false, + ); +}); + test("getSharedChannelIds: includes only active joined channels", () => { assert.deepEqual( getSharedChannelIds([ diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index 4e1c787f92e..e3c82cfff4f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -1,6 +1,19 @@ import type { Channel, RelayAgent } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; +export function isAgentDirectoryReady({ + data, + error, +}: { + data: unknown; + error: unknown; +}) { + // A successful cached directory remains suitable for autocomplete during a + // refetch. Sending still re-fetches and fails closed at its authorization + // boundary, so suggestions are hints rather than permission to send. + return data !== undefined && error === null; +} + export function getSharedChannelIds(channels: readonly Channel[] | undefined) { return new Set( (channels ?? []) diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 562f890c0f5..9e15cdc6359 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -12,6 +12,7 @@ import { coalesceAgentAutocompleteCandidates, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentIdentityInAllowedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { isOtherSetupAgent } from "@/features/agents/lib/otherSetupAgent"; @@ -184,12 +185,8 @@ export function MembersSidebar({ relayAgentsQuery, } = useClassifiedMembers(rawMembers, currentPubkey); const agentDirectoriesReady = - managedAgentsQuery.data !== undefined && - managedAgentsQuery.error === null && - !managedAgentsQuery.isFetching && - relayAgentsQuery.data !== undefined && - relayAgentsQuery.error === null && - !relayAgentsQuery.isFetching; + isAgentDirectoryReady(managedAgentsQuery) && + isAgentDirectoryReady(relayAgentsQuery); const activeMembers = React.useMemo( () => [...people, ...bots].sort((left, right) => diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index b30008109c1..06a24b5b681 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -8,6 +8,7 @@ globalThis.localStorage = { }; const preference = await import("./autoPinMentionedAgentsPreference.ts"); +const persistentAudience = await import("./persistentAgentAudience.ts"); test("defaults missing and invalid values to one-time agent mentions", () => { assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); @@ -31,3 +32,16 @@ test("persists changes to the post-mention pinning preference", () => { "false", ); }); + +test("turning off automatic mentions clears active conversation audiences", () => { + const scope = `${"1".repeat(64)}:channel-a:channel`; + persistentAudience.setPersistentAgentAudience(scope, ["a".repeat(64)]); + preference.setKeepMentionedAgentsPinned(true); + + preference.setKeepMentionedAgentsPinned(false); + + assert.deepEqual( + persistentAudience.getPersistentAgentAudienceSnapshot().audiences, + {}, + ); +}); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 8f8e0b12d65..27a792f3353 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { resetPersistentAgentAudienceStore } from "./persistentAgentAudience"; + export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; @@ -37,6 +39,7 @@ export function getKeepMentionedAgentsPinned(): boolean { } export function setKeepMentionedAgentsPinned(value: boolean): void { + if (!value) resetPersistentAgentAudienceStore(); if (value === keepMentionedAgentsPinned) return; keepMentionedAgentsPinned = value; try { diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs index afb2a289216..250e40e0a38 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs +++ b/desktop/src/features/messages/lib/buildMentionCandidates.test.mjs @@ -23,6 +23,7 @@ function input(overrides = {}) { managedAgents: [], memberPubkeys: new Set(), members: [], + mentionChannelId: null, mentionableAgentPubkeys: new Set(), personaNameByPubkey: new Map(), profiles: undefined, diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 1d9771317af..45bdf591e0c 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -36,6 +36,7 @@ export type BuildMentionCandidatesInput = { managedAgents: readonly ManagedAgent[] | undefined; memberPubkeys: ReadonlySet; members: readonly ChannelMember[] | undefined; + mentionChannelId: string | null; mentionableAgentPubkeys: ReadonlySet; personaNameByPubkey: ReadonlyMap; profiles: UserProfileLookup | undefined; @@ -66,6 +67,7 @@ export function buildMentionCandidates({ managedAgents, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -167,7 +169,13 @@ export function buildMentionCandidates({ kind: "identity", pubkey, displayName: agent.name, - isMember: false, + // Prefer the active channel's signed roster. The relay-agent directory + // is filtered by access policy, so its channel ids can legitimately omit + // a room where this identity is already a member. + isMember: + memberPubkeys.has(pubkey) || + (mentionChannelId !== null && + agent.channelIds.includes(mentionChannelId)), personaId: managedAgentPersonaIdsByPubkey.get(pubkey) ?? (activePersonaById.has(pubkey) ? pubkey : undefined), @@ -177,11 +185,12 @@ export function buildMentionCandidates({ }); } for (const agent of managedAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); addCandidate({ kind: "identity", - pubkey: agent.pubkey, + pubkey, displayName: agent.name, - isMember: false, + isMember: memberPubkeys.has(pubkey), isAgent: true, isActiveAgent: agent.status === "running" || agent.status === "deployed", isManagedAgent: true, diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index e55c79c9a38..2d75bcacc52 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -431,7 +431,12 @@ export const MentionHighlightExtension = Extension.create({ applying = false; } } - setDomCaretAtPos(view, view.state.selection.from); + // Highlight refreshes can land after the user has moved focus to + // a popover. Keep settlement armed for the next keystroke, but + // never drag DOM selection back into an unfocused composer. + if (view.hasFocus()) { + setDomCaretAtPos(view, view.state.selection.from); + } }, destroy() { settlement.cancel(); diff --git a/desktop/src/features/messages/lib/mentionMemberPubkeys.ts b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts new file mode 100644 index 00000000000..ae1a3e73816 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionMemberPubkeys.ts @@ -0,0 +1,19 @@ +import type { Channel, ChannelMember } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; + +/** Merge the dedicated roster with the active channel's signed projection. */ +export function getMentionMemberPubkeys( + channelId: string | null, + channels: readonly Channel[] | undefined, + members: ChannelMember[] | undefined, +): Set { + const pubkeys = new Set( + members ? channelMemberPubkeySet(members) : undefined, + ); + const activeChannel = channels?.find((channel) => channel.id === channelId); + for (const pubkey of activeChannel?.memberPubkeys ?? []) { + pubkeys.add(normalizePubkey(pubkey)); + } + return pubkeys; +} diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs index 64d11cd6312..dc3ca942416 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs +++ b/desktop/src/features/messages/lib/persistentAgentAudience.test.mjs @@ -134,20 +134,29 @@ test("reset clears every audience for refresh and community boundaries", async ( assert.deepEqual(currentAudiences(store), {}); }); -test("channel and thread composers share the channel audience scope", async () => { +test("channel and thread composers have independent audience scopes", async () => { const store = await loadStore(8); const channelScope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", + composerKey: "channel-a", }); const threadScope = store.getPersistentAgentAudienceScope({ ownerPubkey: ownerA, channelId: "channel-a", - threadRootId: "root", + composerKey: "thread:root", + }); + const otherThreadScope = store.getPersistentAgentAudienceScope({ + ownerPubkey: ownerA, + channelId: "channel-a", + composerKey: "thread:other-root", }); assert.equal(channelScope, `${ownerA}:channel-a:channel`); - assert.equal(threadScope, channelScope); + assert.equal(threadScope, `${ownerA}:channel-a:thread:root`); + assert.equal(otherThreadScope, `${ownerA}:channel-a:thread:other-root`); + assert.notEqual(threadScope, channelScope); + assert.notEqual(otherThreadScope, threadScope); }); test("delayed promotion cannot overwrite a newer audience choice", async () => { @@ -191,6 +200,42 @@ test("stale auto-pin Undo cannot remove a newer explicit choice", async () => { assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); }); +test("explicitly excluded agents are not auto-promoted again", async () => { + const store = await loadStore(12); + const scope = `${ownerA}:channel-a:channel`; + store.setPersistentAgentAudience(scope, [agentA]); + store.excludePersistentAgentAudienceMember(scope, agentA); + + const promotion = store.promotePersistentAgentAudienceIfUnchanged({ + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + pubkeys: [agentA], + scope, + }); + + assert.equal(promotion, null); + assert.deepEqual(currentAudiences(store), { [scope]: [] }); + + store.addPersistentAgentAudienceMember(scope, agentA); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + +test("explicit re-selection reinstates an excluded agent", async () => { + const store = await loadStore(13); + const scope = `${ownerA}:channel-a:channel`; + store.setPersistentAgentAudience(scope, [agentA]); + store.excludePersistentAgentAudienceMember(scope, agentA); + + const promotion = store.promotePersistentAgentAudienceIfUnchanged({ + expectedRevision: store.getPersistentAgentAudienceRevision(scope), + reinstateExcluded: true, + pubkeys: [agentA], + scope, + }); + + assert.deepEqual(promotion?.promotedPubkeys, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + test("promotion reports only newly added agents for transactional Undo", async () => { const store = await loadStore(11); const scope = `${ownerA}:channel-a:channel`; diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 018a7da489a..78819e0c735 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -4,6 +4,7 @@ export const MAX_IN_MEMORY_AGENT_AUDIENCES = 200; const listeners = new Set<() => void>(); const revisions = new Map(); +const excludedPubkeysByScope = new Map>(); let revisionClock = 0; let defaultRevision = 0; let audiences: Record = {}; @@ -16,7 +17,7 @@ export type PersistentAgentAudienceSnapshot = Readonly<{ type PersistentAgentAudienceScopeInput = { ownerPubkey: string; channelId: string; - threadRootId?: string | null; + composerKey?: string | null; }; function normalizePubkeys(pubkeys: Iterable): string[] { @@ -46,17 +47,22 @@ function emit(): void { export function getPersistentAgentAudienceScope({ ownerPubkey, channelId, + composerKey, }: PersistentAgentAudienceScopeInput): string | null { const owner = ownerPubkey.trim().toLowerCase(); if (!/^[0-9a-f]{64}$/.test(owner) || !channelId) return null; - // Thread composers intentionally share their parent channel's audience. - return `${owner}:${channelId}:channel`; + const composer = + composerKey?.trim() && composerKey.trim() !== channelId + ? composerKey.trim() + : "channel"; + return `${owner}:${channelId}:${composer}`; } export function resetPersistentAgentAudienceStore(): void { revisionClock += 1; defaultRevision = revisionClock; revisions.clear(); + excludedPubkeysByScope.clear(); audiences = {}; emit(); } @@ -83,6 +89,11 @@ export function setPersistentAgentAudience( const nextAudiences = { ...audiences }; delete nextAudiences[scope]; audiences = boundAudiences({ ...nextAudiences, [scope]: normalized }); + for (const excludedScope of excludedPubkeysByScope.keys()) { + if (!Object.hasOwn(audiences, excludedScope)) { + excludedPubkeysByScope.delete(excludedScope); + } + } for (const revisedScope of revisions.keys()) { if (!Object.hasOwn(audiences, revisedScope)) revisions.delete(revisedScope); } @@ -97,19 +108,29 @@ export function getPersistentAgentAudienceRevision(scope: string): number { export function promotePersistentAgentAudienceIfUnchanged({ expectedRevision, + reinstateExcluded = false, pubkeys, scope, }: { expectedRevision: number; + reinstateExcluded?: boolean; pubkeys: Iterable; scope: string; }): { promotedPubkeys: string[]; revision: number } | null { if (getPersistentAgentAudienceRevision(scope) !== expectedRevision) return null; - const promotedPubkeys = normalizePubkeys(pubkeys).filter( - (pubkey) => !(audiences[scope] ?? []).includes(pubkey), + const normalizedPubkeys = normalizePubkeys(pubkeys); + const promotedPubkeys = normalizedPubkeys.filter( + (pubkey) => + !(audiences[scope] ?? []).includes(pubkey) && + (reinstateExcluded || !excludedPubkeysByScope.get(scope)?.has(pubkey)), ); if (promotedPubkeys.length === 0) return null; + if (reinstateExcluded) { + const excluded = excludedPubkeysByScope.get(scope); + for (const pubkey of promotedPubkeys) excluded?.delete(pubkey); + if (excluded?.size === 0) excludedPubkeysByScope.delete(scope); + } setPersistentAgentAudience(scope, [ ...(audiences[scope] ?? []), ...promotedPubkeys, @@ -143,7 +164,22 @@ export function addPersistentAgentAudienceMember( scope: string, pubkey: string, ): void { - setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), pubkey]); + const normalized = normalizePubkeys([pubkey])[0]; + if (!normalized) return; + excludedPubkeysByScope.get(scope)?.delete(normalized); + setPersistentAgentAudience(scope, [...(audiences[scope] ?? []), normalized]); +} + +export function excludePersistentAgentAudienceMember( + scope: string, + pubkey: string, +): void { + const normalized = normalizePubkeys([pubkey])[0]; + if (!scope || !normalized) return; + const excluded = excludedPubkeysByScope.get(scope) ?? new Set(); + excluded.add(normalized); + excludedPubkeysByScope.set(scope, excluded); + removePersistentAgentAudienceMember(scope, normalized); } export function removePersistentAgentAudienceMember( @@ -179,6 +215,7 @@ export function usePersistentAgentAudience(scope: string | null): { pubkeys: readonly string[]; addPubkey: (pubkey: string) => void; removePubkey: (pubkey: string) => void; + excludePubkey: (pubkey: string) => void; clear: () => void; } { const state = React.useSyncExternalStore( @@ -197,6 +234,10 @@ export function usePersistentAgentAudience(scope: string | null): { (pubkey) => removePersistentAgentAudienceMember(resolvedScope, pubkey), [resolvedScope], ), + excludePubkey: React.useCallback( + (pubkey) => excludePersistentAgentAudienceMember(resolvedScope, pubkey), + [resolvedScope], + ), clear: React.useCallback( () => setPersistentAgentAudience(resolvedScope, []), [resolvedScope], diff --git a/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs b/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs new file mode 100644 index 00000000000..d1c2a11cf01 --- /dev/null +++ b/desktop/src/features/messages/lib/stripImplicitAgentMentions.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { stripImplicitAgentMentionPrefix } from "./stripImplicitAgentMentions.ts"; + +test("removes the exact synthesized leading prefix", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Morgarita draft text", "@Morgarita "), + "draft text", + ); +}); + +test("removes the complete captured prefix for multiple agents", () => { + assert.equal( + stripImplicitAgentMentionPrefix( + "@Morgarita @Vogue draft text", + "@Morgarita @Vogue ", + ), + "draft text", + ); +}); + +test("removes an implicit-only mention when markdown drops its separator", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Morgarita", "@Morgarita "), + "", + ); +}); + +test("preserves an identical authored mention after the synthesized prefix", () => { + assert.equal( + stripImplicitAgentMentionPrefix( + "@Morgarita @Morgarita authored duplicate", + "@Morgarita ", + ), + "@Morgarita authored duplicate", + ); +}); + +test("preserves content when the captured prefix does not match exactly", () => { + assert.equal( + stripImplicitAgentMentionPrefix("@Alice ask @Morgarita", "@Morgarita "), + "@Alice ask @Morgarita", + ); +}); diff --git a/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts b/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts new file mode 100644 index 00000000000..0ac80ca751e --- /dev/null +++ b/desktop/src/features/messages/lib/stripImplicitAgentMentions.ts @@ -0,0 +1,15 @@ +/** + * Removes the exact leading prefix synthesized by automatic agent addressing. + * The captured prefix is provenance: an identical authored mention immediately + * after it must remain draft content. + */ +export function stripImplicitAgentMentionPrefix( + content: string, + implicitPrefix: string, +): string { + if (!implicitPrefix) return content; + if (content.startsWith(implicitPrefix)) { + return content.slice(implicitPrefix.length); + } + return content === implicitPrefix.trimEnd() ? "" : content; +} diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0d2e5ef6ac1..de6e6da6c8f 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -18,6 +18,7 @@ import { getAgentIdentityPubkeys, getMentionableAgentPubkeys, getSharedChannelIds, + isAgentDirectoryReady, isAgentMentionChannelType, rememberSelectedAgentPubkeys, uniqueAutocompleteLabels, @@ -32,7 +33,6 @@ import type { ChannelMember, ChannelType } from "@/shared/api/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { normalizePubkey } from "@/shared/lib/pubkey"; -import { channelMemberPubkeySet } from "@/shared/lib/rosterDerivations"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; import { useActiveAgentPubkeys } from "./useActiveAgentPubkeys"; import { useDefaultAgentSuggestion } from "./useDefaultAgentSuggestion"; @@ -50,6 +50,7 @@ import { } from "./useMentionSelection"; import { rankMentionCandidates } from "./mentionRanking"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping"; +import { getMentionMemberPubkeys } from "./mentionMemberPubkeys"; import { appendUniqueName, buildTeamMentionCandidates, @@ -99,14 +100,8 @@ export function useMentions( const channelsQuery = useChannelsQuery(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); - const managedAgentDirectoryReady = - managedAgentsQuery.data !== undefined && - managedAgentsQuery.error === null && - !managedAgentsQuery.isFetching; - const relayAgentDirectoryReady = - relayAgentsQuery.data !== undefined && - relayAgentsQuery.error === null && - !relayAgentsQuery.isFetching; + const managedAgentDirectoryReady = isAgentDirectoryReady(managedAgentsQuery); + const relayAgentDirectoryReady = isAgentDirectoryReady(relayAgentsQuery); const agentDirectoriesReady = managedAgentDirectoryReady && relayAgentDirectoryReady; const canSearchGlobalUsers = canSearchGlobalPeople && agentDirectoriesReady; @@ -228,11 +223,9 @@ export function useMentions( () => new Set(activePersonas.map((persona) => persona.id)), [activePersonas], ); - // Identity-cached (shared with the timeline's roster derivations) — the - // Set is built once per distinct roster instead of per consumer. const memberPubkeys = React.useMemo( - () => (members ? channelMemberPubkeySet(members) : new Set()), - [members], + () => getMentionMemberPubkeys(channelId, channelsQuery.data, members), + [channelId, channelsQuery.data, members], ); const agentIdentityPubkeys = React.useMemo( () => @@ -260,6 +253,7 @@ export function useMentions( managedAgents: managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -283,6 +277,7 @@ export function useMentions( managedAgentsQuery.data, memberPubkeys, members, + mentionChannelId, mentionableAgentPubkeys, personaNameByPubkey, profiles, @@ -535,11 +530,11 @@ export function useMentions( appendUniqueName(current, trimmedName), ); if (options?.isAgent) { - setSelectedAgentMentionNames((current) => { - const next = appendUniqueName(current, trimmedName); - selectedAgentMentionNamesRef.current = next; - return next; - }); + selectedAgentMentionNamesRef.current = appendUniqueName( + selectedAgentMentionNamesRef.current, + trimmedName, + ); + setSelectedAgentMentionNames(selectedAgentMentionNamesRef.current); } }, [], diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index fa9644fa61b..e3e17071fad 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -74,6 +74,8 @@ export type AutocompleteEdit = { insertText: string; /** Keep the current selection mapped through this edit instead of moving it to the insertion. */ preserveSelection?: boolean; + /** Skip asynchronous DOM caret reassertion when focus may move elsewhere. */ + reassertMentionCaret?: boolean; /** * When set, the replaced range becomes a CustomEmojiNode for this * shortcode (followed by `insertText`, which carries the trailing space) @@ -162,6 +164,7 @@ export function useRichTextEditor({ onLinkSelectionChange, onLinkShortcut, }: RichTextEditorOptions) { + const addressedAgentMentionNamesRef = React.useRef([]); const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; @@ -648,11 +651,30 @@ export function useRichTextEditor({ syncMentionHighlightFromProps( editor, mentionNames, - agentMentionNames, + [ + ...new Set([ + ...(agentMentionNames ?? []), + ...addressedAgentMentionNamesRef.current, + ]), + ], channelNames, ); }, [editor, mentionNames, agentMentionNames, channelNames]); + const syncAddressedAgentMentionNames = React.useCallback( + (names: readonly string[]) => { + addressedAgentMentionNamesRef.current = names; + if (!editor) return; + syncMentionHighlightFromProps( + editor, + mentionNames, + [...new Set([...(agentMentionNames ?? []), ...names])], + channelNames, + ); + }, + [agentMentionNames, channelNames, editor, mentionNames], + ); + // Custom-emoji set changes: re-resolve the `src` attr on any existing // node in the doc (e.g. an emoji's image was just published). React.useEffect(() => { @@ -777,6 +799,7 @@ export function useRichTextEditor({ text: string, customEmojiShortcode?: string, preserveSelection = false, + reassertMentionCaret = !preserveSelection, ) => { if (!editor) return; const projection = buildPlainTextProjection(editor.state.doc); @@ -825,7 +848,7 @@ export function useRichTextEditor({ settleAutocompleteMentionInsert(editor, tr, text, !preserveSelection); editor.view.dispatch(tr); editor.view.focus(); - if (!preserveSelection) reassertMentionCaretAfterFocus(editor.view); + if (reassertMentionCaret) reassertMentionCaretAfterFocus(editor.view); }, [editor, customEmojiWiring.resolveUrl], ); @@ -917,6 +940,7 @@ export function useRichTextEditor({ focusPreserve, getPlainTextAndCursor, replacePlainTextRange, + syncAddressedAgentMentionNames, getLinkSelectionInfo, applyLink, removeLink, diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 4fe40da4b37..5dfc851bfd2 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -111,10 +111,16 @@ test("mention control expands with automatically mentioned agents", async () => ); } const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); + const removeChrome = remove.querySelector("span.absolute"); assert.match( - remove.querySelector("span.absolute")?.className ?? "", + removeChrome?.className ?? "", /group-hover\/address:opacity-100/, ); + assert.match(removeChrome?.className ?? "", /(?:^|\s)bg-foreground(?:\s|$)/); + assert.doesNotMatch( + removeChrome?.className ?? "", + /(?:^|\s)bg-foreground\/80(?:\s|$)/, + ); fireEvent.click(remove); assert.deepEqual(removed, ["agent-pubkey"]); view.rerender(renderButton([])); diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index f141a4d2fdc..5ebcc436581 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -129,6 +129,7 @@ export function ComposerMentionButton({ confirmationTitle, disabled, onConfirmationDismiss, + onConfirmationHoverChange, onConfirmationTurnOff, onCaptureSelection, onOpen, @@ -140,6 +141,7 @@ export function ComposerMentionButton({ confirmationTitle?: string | null; disabled: boolean; onConfirmationDismiss?: () => void; + onConfirmationHoverChange?: (hovered: boolean) => void; onConfirmationTurnOff?: () => void; onCaptureSelection: () => void; onOpen: () => void; @@ -216,11 +218,11 @@ export function ComposerMentionButton({ className="flex items-center gap-1 overflow-hidden" data-testid="composer-address-locks" exit={{ opacity: 0, width: 0 }} - initial={false} + initial={shouldReduceMotion ? false : { opacity: 0, width: 0 }} transition={ shouldReduceMotion ? { duration: 0 } - : { duration: 0.18, ease: "easeOut" } + : { duration: 0.12, ease: "easeOut" } } > @@ -228,7 +230,7 @@ export function ComposerMentionButton({ - + - Stop automatically mentioning {agent.displayName} + Don't automatically mention {agent.displayName} in this + conversation ))} @@ -289,6 +292,8 @@ export function ComposerMentionButton({ data-testid="composer-auto-pin-confirmation" onCloseAutoFocus={(event) => event.preventDefault()} onOpenAutoFocus={(event) => event.preventDefault()} + onPointerEnter={() => onConfirmationHoverChange?.(true)} + onPointerLeave={() => onConfirmationHoverChange?.(false)} side="right" sideOffset={8} style={{ width: "max-content" }} diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs index 012c962e0f0..be3ec6938ef 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs +++ b/desktop/src/features/messages/ui/MentionAutocomplete.test.mjs @@ -77,6 +77,11 @@ test("agent rows offer automatic mention controls", async () => { }); assert.equal(action.getAttribute("aria-pressed"), "false"); assert.equal(action.getAttribute("data-state"), "off"); + const inactivePin = action.querySelector( + '[data-testid="mention-auto-pin-icon"]', + ); + assert.match(inactivePin?.getAttribute("class") ?? "", /\blucide-pin\b/); + assert.equal(inactivePin?.getAttribute("fill"), "none"); fireEvent.click(action); assert.deepEqual(toggled, [suggestion]); assert.deepEqual(selected, [suggestion]); @@ -88,10 +93,14 @@ test("agent rows offer automatic mention controls", async () => { }), ); const selectedAction = view.getByRole("button", { - name: "Stop automatically mentioning Agent Ada", + name: "Don't automatically mention Agent Ada in this conversation", }); assert.equal(selectedAction.getAttribute("aria-pressed"), "true"); assert.equal(selectedAction.getAttribute("data-state"), "on"); + const activePin = selectedAction.querySelector( + '[data-testid="mention-auto-pin-icon"]', + ); + assert.equal(activePin?.getAttribute("fill"), "currentColor"); fireEvent.click(selectedAction); assert.deepEqual(toggled, [suggestion, suggestion]); }); @@ -168,6 +177,56 @@ test("options expand in place without replacing the people list", async () => { assert.ok(view.getByRole("button", { name: "Mention Agent Ada" })); }); +test("automatic selection loads the setting once, then updates it in place", async () => { + const React = await import("react"); + const { render } = await import("@testing-library/react"); + const { MentionAutocomplete } = await import("./MentionAutocomplete.tsx"); + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const props = { + suggestions: [suggestion], + selectedIndex: 0, + onSelect: () => {}, + keepMentionedAgentsPinned: false, + onKeepMentionedAgentsPinnedChange: () => {}, + }; + const view = render( + React.createElement(MentionAutocomplete, { + ...props, + openOptionsRequest: 0, + }), + ); + + view.rerender( + React.createElement(MentionAutocomplete, { + ...props, + openOptionsRequest: 1, + }), + ); + assert.equal( + view.getByRole("button", { name: "Options" }).getAttribute("aria-expanded"), + "true", + ); + const toggle = view.getByRole("switch", { + name: "Automatically mention agents", + }); + const settings = view.getByTestId("mention-options-settings"); + assert.equal(toggle.getAttribute("data-state"), "unchecked"); + + view.rerender( + React.createElement(MentionAutocomplete, { + ...props, + keepMentionedAgentsPinned: true, + openOptionsRequest: 2, + }), + ); + assert.equal(view.getByTestId("mention-options-settings"), settings); + assert.equal(toggle.getAttribute("data-state"), "checked"); +}); + test("clicking outside dismisses the tray without intercepting its trigger", async () => { const React = await import("react"); const { fireEvent, render } = await import("@testing-library/react"); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 9287c4f0e13..a242898c2e6 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { AtSign, Bot, ChevronRight, Users } from "lucide-react"; +import { Bot, ChevronRight, Pin, Users } from "lucide-react"; import { OtherSetupAgentMarker } from "@/features/agents/ui/OtherSetupAgentMarker"; import { motion } from "motion/react"; import type { TeamMentionMember } from "@/features/messages/lib/mentionCandidates"; @@ -44,6 +44,7 @@ type MentionAutocompleteProps = { keepMentionedAgentsPinned?: boolean; onKeepMentionedAgentsPinnedChange?: (value: boolean) => void; openOptionsRequest?: number; + onOptionsRevealComplete?: (request: number) => void; onDismiss?: () => void; position?: "above" | "below"; }; @@ -65,6 +66,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ keepMentionedAgentsPinned = true, onKeepMentionedAgentsPinnedChange, openOptionsRequest = 0, + onOptionsRevealComplete, onDismiss, position = "above", }: MentionAutocompleteProps) { @@ -74,6 +76,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ const optionsId = React.useId(); const keepPinnedSwitchId = React.useId(); const [optionsOpen, setOptionsOpen] = React.useState(false); + const handledOptionsRequestRef = React.useRef(0); const alwaysAddressShortcut = getPlatformKeysById("always-address-agent"); React.useEffect(() => { @@ -90,10 +93,17 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ }, [suggestions.length]); React.useEffect(() => { - if (openOptionsRequest > 0) { - setOptionsOpen(true); + if (openOptionsRequest <= handledOptionsRequestRef.current) return; + handledOptionsRequestRef.current = openOptionsRequest; + + // The first request waits for the entrance to finish. Once visible, apply + // later requests in place so toggling a pin cannot replay that entrance. + if (optionsOpen) { + onOptionsRevealComplete?.(openOptionsRequest); + return; } - }, [openOptionsRequest]); + setOptionsOpen(true); + }, [onOptionsRevealComplete, openOptionsRequest, optionsOpen]); React.useEffect(() => { if (!onDismiss) return; @@ -173,8 +183,14 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ { + if (openOptionsRequest > 0) { + onOptionsRevealComplete?.(openOptionsRequest); + } + }} transition={{ duration: 0.16, ease: "easeOut" }} >
@@ -390,7 +406,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ - @@ -416,11 +437,11 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ > {isAlwaysAddressed - ? "Stop automatically mentioning" + ? "Don't automatically mention in this conversation" : "Automatically mention"} {alwaysAddressShortcut ? ( - + {(alwaysAddressShortcut.includes("+") ? alwaysAddressShortcut.split("+") : Array.from(alwaysAddressShortcut) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 5c0dced5ca3..0cc8650795e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -64,9 +64,11 @@ import { useComposerAttachmentSpoilers } from "./useComposerAttachmentSpoilers"; import { useComposerContentState } from "./useComposerContentState"; import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; +import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; +import { useAddressedAgentMentionRestore } from "./useAddressedAgentMentionRestore"; import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit"; import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -131,10 +133,13 @@ function MessageComposerImpl({ ? getPersistentAgentAudienceScope({ ownerPubkey, channelId, + composerKey: effectiveDraftKey, }) : null; const effectiveDraftKeyRef = React.useRef(effectiveDraftKey); effectiveDraftKeyRef.current = effectiveDraftKey; + const implicitAgentMentionProvenance = + useImplicitAgentMentionProvenance(effectiveDraftKey); const preEditSnapshotRef = React.useRef<{ content: string; pendingImeta: ImetaMedia[]; @@ -204,6 +209,7 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + getImplicitAgentMentionPrefix: implicitAgentMentionProvenance.getPrefix, }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { @@ -310,10 +316,13 @@ function MessageComposerImpl({ const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); const addressPulse = useAddressMentionPulse(); const { + completeOptionsReveal: completeMentionOptionsReveal, confirmationTitle: autoPinConfirmationTitle, dismissConfirmation: dismissAutoPinConfirmation, + openOptionsRequest: openMentionOptionsRequest, promoteExplicitlyAddressedAgents, promoteMentionedAgents, + setConfirmationHovered: setAutoPinConfirmationHovered, turnOffConfirmation: turnOffAutoPinConfirmation, } = useAutoPinMentionedAgents({ audienceScope, @@ -321,26 +330,13 @@ function MessageComposerImpl({ getDisplayName: mentions.getMentionDisplayName, onPulse: addressPulse.pulseOne, onTurnOff: () => setKeepMentionedAgentsPinned(false), + onTurnOn: () => setKeepMentionedAgentsPinned(true), + }); + const addressedMentionRestore = useAddressedAgentMentionRestore({ + audiencePubkeys: persistentAudience.pubkeys, + channelId, + enabled: keepMentionedAgentsPinned, }); - const restoreAddressedAgentMentionsRef = React.useRef< - ( - pubkeys?: readonly string[], - allowedUnpinnedPubkeys?: readonly string[], - ) => string - >(() => ""); - const restoreAddressedAgentMentionsFrameRef = React.useRef( - null, - ); - const channelIdRef = React.useRef(channelId); - channelIdRef.current = channelId; - React.useEffect( - () => () => { - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - }, - [], - ); const mentionSendFlow = useMentionSendFlow({ channelId, channelLinks, @@ -350,23 +346,11 @@ function MessageComposerImpl({ drafts, emojiAutocomplete, mentions, - onAddressedAgentsComposerCleared: (pubkeys) => - restoreAddressedAgentMentionsRef.current(pubkeys), + onAddressedAgentsComposerCleared: + addressedMentionRestore.onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed: addressPulse.shakeMany, - onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; - const sentChannelId = channelId; - if (restoreAddressedAgentMentionsFrameRef.current !== null) { - cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); - } - restoreAddressedAgentMentionsFrameRef.current = requestAnimationFrame( - () => { - restoreAddressedAgentMentionsFrameRef.current = null; - if (channelIdRef.current !== sentChannelId) return; - restoreAddressedAgentMentionsRef.current(pubkeys, newlyPinnedPubkeys); - }, - ); - }, + onAddressedAgentsSendSucceeded: + addressedMentionRestore.onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText, @@ -445,6 +429,7 @@ function MessageComposerImpl({ edit.insertText, edit.customEmojiShortcode, edit.preserveSelection, + edit.reassertMentionCaret, ); }, [richText.replacePlainTextRange], @@ -467,16 +452,24 @@ function MessageComposerImpl({ promoteExplicitlyAddressedAgents({ pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }), - onAutoPinAgentMention: (suggestion) => { + onAutoPinAgentMention: (suggestion, options) => { promoteMentionedAgents({ + ...options, pubkeys: suggestion.pubkey ? [suggestion.pubkey] : [], }); }, + onImplicitPrefixInserted: implicitAgentMentionProvenance.add, + onImplicitPrefixRemoved: implicitAgentMentionProvenance.remove, onPulseAddressLock: addressPulse.pulseOne, profiles, richText, }); - restoreAddressedAgentMentionsRef.current = restoreAddressedAgentMentions; + addressedMentionRestore.restoreAddressedAgentMentionsRef.current = + restoreAddressedAgentMentions; + React.useLayoutEffect(() => { + if (!audienceScope || editTarget != null) return; + restoreAddressedAgentMentions(); + }, [audienceScope, editTarget, restoreAddressedAgentMentions]); syncAddressedAgentsFromTextRef.current = syncAddressedAgentsFromText; const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { @@ -536,21 +529,17 @@ function MessageComposerImpl({ }, [richText.editor, mentions.clearMentions, customEmoji], ); - const openMentionPicker = useComposerMentionPicker({ + const mentionPicker = useComposerMentionPicker({ mentions, + onTurnOffAutoPinConfirmation: turnOffAutoPinConfirmation, richText, setIsEmojiPickerOpen, }); - const openMentionSettings = React.useCallback( - () => openMentionPicker(false), - [openMentionPicker], - ); const handleAlwaysAddressShortcut = useAlwaysAddressShortcut({ enabled: Boolean(audienceScope && editTarget == null), lockedAgent: lockedAgents[0], mentions, - onOpenPicker: openMentionPicker, - onSelect: selectMentionSuggestion, + onOpenPicker: mentionPicker.openMentionPicker, onToggle: toggleAlwaysAddressAgent, }); const submitMessage = React.useCallback(async () => { @@ -872,11 +861,13 @@ function MessageComposerImpl({
void; onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationHoverChange?: (hovered: boolean) => void; onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; @@ -187,6 +189,7 @@ export const MessageComposerToolbar = React.memo( confirmationTitle={autoPinConfirmationTitle} disabled={composerDisabled} onConfirmationDismiss={onAutoPinConfirmationDismiss} + onConfirmationHoverChange={onAutoPinConfirmationHoverChange} onConfirmationTurnOff={onAutoPinConfirmationTurnOff} onCaptureSelection={onCaptureSelection} onOpen={onOpenMentionPicker} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index b95528dd939..b0272500572 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,12 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection in place", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -78,7 +77,6 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection" suggestions: [suggestion], }, onOpenPicker: () => {}, - onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, @@ -86,16 +84,13 @@ test("primary+Shift+M addresses the default agent or toggles the tray selection" act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion]); + assert.deepEqual(toggled, [suggestion, suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion]); - assert.deepEqual(selected, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion, suggestion, suggestion]); }); test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { @@ -126,7 +121,6 @@ test("primary+Shift+M removes the current locked agent before choosing a new def suggestions: [], }, onOpenPicker: () => {}, - onSelect: () => {}, onToggle: (value) => toggled.push(value), }), ); @@ -137,7 +131,7 @@ test("primary+Shift+M removes the current locked agent before choosing a new def altKey: false, code: "KeyM", ctrlKey: !isMacPlatform(), - key: "m", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -169,7 +163,6 @@ test("primary+Shift+M opens the picker when no default agent is ready", async () onOpenPicker: () => { opened += 1; }, - onSelect: () => {}, onToggle: () => {}, }), ); @@ -180,7 +173,7 @@ test("primary+Shift+M opens the picker when no default agent is ready", async () altKey: false, code: "KeyM", ctrlKey: !isMacPlatform(), - key: "m", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, diff --git a/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts new file mode 100644 index 00000000000..9bc88ea6b47 --- /dev/null +++ b/desktop/src/features/messages/ui/useAddressedAgentMentionRestore.ts @@ -0,0 +1,66 @@ +import * as React from "react"; + +type RestoreAddressedAgentMentions = ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys?: readonly string[], +) => string; + +export function useAddressedAgentMentionRestore({ + audiencePubkeys, + channelId, + enabled, +}: { + audiencePubkeys: readonly string[]; + channelId: string | null; + enabled: boolean; +}) { + const restoreAddressedAgentMentionsRef = + React.useRef(() => ""); + const restoreFrameRef = React.useRef(null); + const channelIdRef = React.useRef(channelId); + channelIdRef.current = channelId; + + React.useEffect( + () => () => { + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + }, + [], + ); + + const onAddressedAgentsComposerCleared = React.useCallback( + (pubkeys: readonly string[]) => + restoreAddressedAgentMentionsRef.current(pubkeys), + [], + ); + const onAddressedAgentsSendSucceeded = React.useCallback( + (pubkeys: readonly string[], newlyPinnedPubkeys: readonly string[]) => { + const currentAudience = new Set(audiencePubkeys); + const confirmedPinnedPubkeys = newlyPinnedPubkeys.filter((pubkey) => + currentAudience.has(pubkey), + ); + if (!enabled || confirmedPinnedPubkeys.length === 0) return; + + const sentChannelId = channelId; + if (restoreFrameRef.current !== null) { + cancelAnimationFrame(restoreFrameRef.current); + } + restoreFrameRef.current = requestAnimationFrame(() => { + restoreFrameRef.current = null; + if (channelIdRef.current !== sentChannelId) return; + restoreAddressedAgentMentionsRef.current( + pubkeys, + confirmedPinnedPubkeys, + ); + }); + }, + [audiencePubkeys, channelId, enabled], + ); + + return { + onAddressedAgentsComposerCleared, + onAddressedAgentsSendSucceeded, + restoreAddressedAgentMentionsRef, + }; +} diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 95e0af9e06b..8ed155234fa 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -30,6 +30,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds ); const appliedEdits = []; const addedPubkeys = []; + const openPickerCalls = []; const pulsedPubkeys = []; let cancelCount = 0; const text = "@"; @@ -47,6 +48,7 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds getMentionDisplayName: () => "Agent Ada", isInlineMentionSelection: () => false, isMentionOpen: true, + openMentionPicker: (...args) => openPickerCalls.push(args), registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; @@ -82,9 +84,11 @@ test("always addressing an agent keeps autocomplete open, inserts the chip, adds replaceToOffset: 0, insertText: "@Agent Ada ", preserveSelection: true, + reassertMentionCaret: false, }, ]); assert.equal(cancelCount, 0); + assert.deepEqual(openPickerCalls, [[text.length, "preserve"]]); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -301,8 +305,8 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn audience, audienceScope: "channel-scope", mentions, - onAutoPinAgentMention: (suggestion) => - autoPinnedSuggestions.push(suggestion), + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -322,7 +326,9 @@ test("selecting an agent from a typed query immediately auto-addresses it", asyn insertText: "@Agent Ada ", }, ]); - assert.deepEqual(autoPinnedSuggestions, [suggestion]); + assert.deepEqual(autoPinnedSuggestions, [ + [suggestion, { reinstateExcluded: true }], + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, []); assert.equal(result.current.announcement, ""); @@ -375,11 +381,149 @@ test("selecting a human mention never changes automatic addressing", async () => assert.deepEqual(autoPinnedSuggestions, []); }); -test("removing the last agent chip clears its automatic address", async () => { +test("restoring a multi-word automatic mention into an empty composer focuses after its trailing space", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "claude code", + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(registeredMentions, [ + ["claude code", "agent-pubkey", { isAgent: true }], + ]); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@claude code ", + preserveSelection: true, + }, + ]); + assert.equal(focusEndCount, 1); +}); + +test("restoring before authored text preserves its selection", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Morgarita", + registerMentionPubkey: () => {}, + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ text: "draft text", cursor: 10 }), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Morgarita ", + preserveSelection: true, + }, + ]); + assert.equal(focusEndCount, 0); +}); + +test("restoring an existing automatic mention re-registers its agent chip", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + const syncedAddressedNames = []; + let focusEndCount = 0; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { + pubkeys: ["agent-pubkey"], + addPubkey: () => {}, + }, + audienceScope: "thread-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "claude code", + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + richText: { + focusEnd: () => { + focusEndCount += 1; + }, + getPlainTextAndCursor: () => ({ + text: "@claude code ", + cursor: 13, + }), + syncAddressedAgentMentionNames: (names) => + syncedAddressedNames.push(names), + }, + }), + ); + + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(registeredMentions, [ + ["claude code", "agent-pubkey", { isAgent: true }], + ]); + assert.deepEqual(appliedEdits, []); + assert.equal(focusEndCount, 0); + assert.deepEqual(syncedAddressedNames.at(-1), ["claude code"]); +}); + +test("deleting the last automatic agent mention explicitly excludes its address", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const excludedPubkeys = []; const removedPubkeys = []; const mentionRefsByText = { "@Agent Ada first @Agent Ada second": [ @@ -396,6 +540,7 @@ test("removing the last agent chip clears its automatic address", async () => { applyAutocompleteEdit: () => {}, audience: { pubkeys: ["agent-pubkey", "existing-lock"], + excludePubkey: (pubkey) => excludedPubkeys.push(pubkey), removePubkey: (pubkey) => removedPubkeys.push(pubkey), }, audienceScope: "channel-scope", @@ -418,20 +563,23 @@ test("removing the last agent chip clears its automatic address", async () => { assert.deepEqual(removedPubkeys, []); act(() => result.current.syncAddressedAgentsFromText("")); - assert.deepEqual(removedPubkeys, ["agent-pubkey"]); + assert.deepEqual(excludedPubkeys, ["agent-pubkey"]); + assert.deepEqual(removedPubkeys, []); }); -test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { +test("deleting human mentions is ignored while deleting a restored automatic agent mention excludes its address", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const excludedPubkeys = []; const removedPubkeys = []; const { result } = renderHook(() => useAgentAddressLockPicker({ applyAutocompleteEdit: () => {}, audience: { pubkeys: ["existing-lock"], + excludePubkey: (pubkey) => excludedPubkeys.push(pubkey), removePubkey: (pubkey) => removedPubkeys.push(pubkey), }, audienceScope: "channel-scope", @@ -462,9 +610,11 @@ test("removing human mentions is ignored while removing a restored agent chip cl result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), ); act(() => result.current.syncAddressedAgentsFromText("@Alice")); - assert.deepEqual(removedPubkeys, ["existing-lock"]); + assert.deepEqual(excludedPubkeys, ["existing-lock"]); + assert.deepEqual(removedPubkeys, []); act(() => result.current.syncAddressedAgentsFromText("")); - assert.deepEqual(removedPubkeys, ["existing-lock"]); + assert.deepEqual(excludedPubkeys, ["existing-lock"]); + assert.deepEqual(removedPubkeys, []); }); test("selecting an agent from the explicit picker auto-addresses it", async () => { @@ -529,13 +679,14 @@ test("selecting an agent from the explicit picker auto-addresses it", async () = ); }); -test("selecting an explicitly unpinned agent inserts a mention until send", async () => { +test("repeatedly selecting an explicitly unpinned agent keeps its mentions manual", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); const appliedEdits = []; const addedPubkeys = []; + const autoPinnedSuggestions = []; const removedPubkeys = []; const pulsedPubkeys = []; const mentions = { @@ -545,7 +696,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ], getMentionDisplayName: () => "Agent Ada", registerMentionPubkey: () => {}, - isInlineMentionSelection: () => false, + isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 0, replaceToOffset: 0, @@ -570,6 +721,8 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn }, audienceScope: "channel-scope", mentions, + onAutoPinAgentMention: (suggestion, options) => + autoPinnedSuggestions.push([suggestion, options]), onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), richText, }), @@ -577,7 +730,14 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 11, + insertText: "", + }, + ]); + appliedEdits.length = 0; rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ @@ -586,6 +746,13 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn isAgent: true, }); }); + act(() => { + result.current.selectMentionSuggestion({ + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }); + }); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(appliedEdits, [ @@ -594,11 +761,77 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn replaceToOffset: 0, insertText: "@Agent Ada ", }, + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + }, ]); assert.deepEqual(addedPubkeys, []); + assert.deepEqual(autoPinnedSuggestions, [ + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: false }, + ], + [ + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { reinstateExcluded: false }, + ], + ]); assert.deepEqual(pulsedPubkeys, []); }); +test("restoring after an agent rename keeps the existing automatic mention", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const registeredMentions = []; + const oldName = "OldName"; + const newName = "NewName"; + const { result, rerender } = renderHook( + ({ displayName }) => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: ["agent-pubkey"], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [ + { displayName: oldName, pubkey: "agent-pubkey", isAgent: true }, + ], + getMentionDisplayName: () => displayName, + registerMentionPubkey: (...args) => registeredMentions.push(args), + }, + onPulseAddressLock: () => {}, + profiles: {}, + richText: { + getPlainTextAndCursor: () => ({ + text: `@${oldName} authored draft`, + cursor: 23, + }), + }, + }), + { initialProps: { displayName: oldName } }, + ); + + rerender({ displayName: newName }); + act(() => result.current.restoreAddressedAgentMentions()); + + assert.deepEqual(appliedEdits, []); + assert.deepEqual(registeredMentions, [ + [newName, "agent-pubkey", { isAgent: true }], + ]); +}); + test("an addressed agent keeps its resolved name while mention state clears during send", async () => { const { renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 17883900ecb..4a9ca5f15c1 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { getMentionOffsets } from "@/features/messages/lib/hasMention"; +import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; import type { usePersistentAgentAudience } from "@/features/messages/lib/persistentAgentAudience"; import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; import type { @@ -59,6 +60,8 @@ export function useAgentAddressLockPicker({ mentions, onAddressAgentMention, onAutoPinAgentMention, + onImplicitPrefixInserted, + onImplicitPrefixRemoved, onPulseAddressLock, profiles, richText, @@ -68,7 +71,16 @@ export function useAgentAddressLockPicker({ audienceScope: string | null; mentions: UseMentionsResult; onAddressAgentMention?: (suggestion: MentionSuggestion) => void; - onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: ( + suggestion: MentionSuggestion, + options: { reinstateExcluded: boolean }, + ) => void; + /** Records generated mention provenance at the insertion boundary. */ + onImplicitPrefixInserted?: ( + mentions: readonly { pubkey: string; prefix: string }[], + ) => void; + /** Removes generated mention provenance by its stable identity. */ + onImplicitPrefixRemoved?: (pubkey: string) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -83,6 +95,11 @@ export function useAgentAddressLockPicker({ unpinnedAudienceScopeRef.current = audienceScope; unpinnedAgentPubkeysRef.current.clear(); } + React.useEffect(() => { + for (const pubkey of lockedAgentPubkeys) { + unpinnedAgentPubkeysRef.current.delete(pubkey); + } + }, [lockedAgentPubkeys]); const lockedAgentNamesRef = React.useRef(new Map()); const visibleAgentMentionPubkeysRef = React.useRef(new Set()); const mentionSyncScopeRef = React.useRef(audienceScope); @@ -115,6 +132,11 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); + React.useLayoutEffect(() => { + richText.syncAddressedAgentMentionNames?.( + lockedAgents.map((agent) => agent.displayName), + ); + }, [lockedAgents, richText.syncAddressedAgentMentionNames]); const trackMentionAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -138,16 +160,20 @@ export function useAgentAddressLockPicker({ !presentAgentPubkeys.has(pubkey) && lockedAgentPubkeys.has(pubkey) ) { - audience.removePubkey(pubkey); + const excludePubkey = audience.excludePubkey ?? audience.removePubkey; + onImplicitPrefixRemoved?.(pubkey); + excludePubkey(pubkey); } } visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ + audience.excludePubkey, audience.removePubkey, audienceScope, lockedAgentPubkeys, mentions.getDraftMentionRefs, + onImplicitPrefixRemoved, ], ); @@ -156,9 +182,37 @@ export function useAgentAddressLockPicker({ const normalized = normalizePubkey(pubkey); if (!audienceScope || !normalized) return; unpinnedAgentPubkeysRef.current.add(normalized); - audience.removePubkey(normalized); + const excludePubkey = audience.excludePubkey ?? audience.removePubkey; + excludePubkey(normalized); + const displayName = lockedAgents.find( + (agent) => agent.pubkey === normalized, + )?.displayName; + if (displayName) { + const text = richText.getPlainTextAndCursor().text; + const implicitPrefix = `@${displayName}${text === `@${displayName}` ? "" : " "}`; + const strippedText = stripImplicitAgentMentionPrefix( + text, + implicitPrefix, + ); + if (strippedText !== text) { + onImplicitPrefixRemoved?.(normalized); + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: text.length - strippedText.length, + insertText: "", + }); + } + } }, - [audience.removePubkey, audienceScope], + [ + applyAutocompleteEdit, + audience.excludePubkey, + audience.removePubkey, + audienceScope, + lockedAgents, + onImplicitPrefixRemoved, + richText.getPlainTextAndCursor, + ], ); const removeAddressedAgentMentions = React.useCallback( (pubkey: string) => { @@ -199,11 +253,14 @@ export function useAgentAddressLockPicker({ }); const { text } = richText.getPlainTextAndCursor(); if (getMentionOffsets(text, suggestion.displayName).length === 0) { + const insertedText = `@${suggestion.displayName} `; + onImplicitPrefixInserted?.([{ pubkey, prefix: insertedText }]); applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, - insertText: `@${suggestion.displayName} `, - preserveSelection: true, + insertText: insertedText, + preserveSelection: text.length > 0, + reassertMentionCaret: false, }); } trackMentionAddressedAgent(pubkey); @@ -216,24 +273,31 @@ export function useAgentAddressLockPicker({ setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { + if (mentions.isMentionOpen) { const { text, cursor } = richText.getPlainTextAndCursor(); - const activeMention = detectPrefixQuery("@", text, cursor, [ - suggestion.displayName.toLowerCase(), - ]); - const queryStart = Math.max( - 0, - Math.min( - activeMention?.startIndex ?? mentions.mentionStartIndex, - text.length, - ), - ); - applyAutocompleteEdit({ - replaceFromOffset: queryStart, - replaceToOffset: Math.max(queryStart, Math.min(cursor, text.length)), - insertText: "", - }); - mentions.openMentionPicker(queryStart, "preserve"); + if (mentions.isInlineMentionSelection()) { + const activeMention = detectPrefixQuery("@", text, cursor, [ + suggestion.displayName.toLowerCase(), + ]); + const queryStart = Math.max( + 0, + Math.min( + activeMention?.startIndex ?? mentions.mentionStartIndex, + text.length, + ), + ); + applyAutocompleteEdit({ + replaceFromOffset: queryStart, + replaceToOffset: Math.max( + queryStart, + Math.min(cursor, text.length), + ), + insertText: "", + }); + mentions.openMentionPicker(queryStart, "preserve"); + } else { + mentions.openMentionPicker(cursor, "preserve"); + } } }, [ @@ -247,6 +311,7 @@ export function useAgentAddressLockPicker({ mentions.openMentionPicker, mentions.registerMentionPubkey, onAddressAgentMention, + onImplicitPrefixInserted, onPulseAddressLock, removeAddressedAgentMentions, richText.getPlainTextAndCursor, @@ -264,9 +329,10 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); - if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); trackMentionAddressedAgent(pubkey); - onAutoPinAgentMention?.(suggestion); + onAutoPinAgentMention?.(suggestion, { + reinstateExcluded: !wasUnpinned, + }); return; } @@ -335,8 +401,24 @@ export function useAgentAddressLockPicker({ return { pubkey, displayName }; }); const { text } = richText.getPlainTextAndCursor(); + // A profile can rename an agent while this draft is off-screen. Mention + // refs retain the identity of its already-inserted automatic prefix, so + // use that identity as well as the current display name when deciding + // whether restoration is needed. + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); for (const agent of targetAgents) { - if (getMentionOffsets(text, agent.displayName).length > 0) { + if ( + presentAgentPubkeys.has(agent.pubkey) || + getMentionOffsets(text, agent.displayName).length > 0 + ) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); visibleAgentMentionPubkeysRef.current.add(agent.pubkey); } } @@ -344,6 +426,7 @@ export function useAgentAddressLockPicker({ (agent) => (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || allowedUnpinned.has(agent.pubkey)) && + !presentAgentPubkeys.has(agent.pubkey) && getMentionOffsets(text, agent.displayName).length === 0, ); if (missingAgents.length === 0) return text; @@ -356,20 +439,34 @@ export function useAgentAddressLockPicker({ const insertedText = `${missingAgents .map((agent) => `@${agent.displayName}`) .join(" ")} `; + onImplicitPrefixInserted?.( + missingAgents.map((agent) => ({ + pubkey: agent.pubkey, + prefix: `@${agent.displayName} `, + })), + ); applyAutocompleteEdit({ replaceFromOffset: 0, replaceToOffset: 0, insertText: insertedText, preserveSelection: true, }); + // A restored empty composer has no authored caret to preserve. Move it + // to the real document end after insertion so WebKit places it after + // the trailing space, without routing the multi-word name back through + // autocomplete settlement (which would lose its mention decoration). + if (text.length === 0) richText.focusEnd(); return `${insertedText}${text}`; }, [ applyAutocompleteEdit, audience.pubkeys, + mentions.getDraftMentionRefs, mentions.getMentionDisplayName, mentions.registerMentionPubkey, + onImplicitPrefixInserted, profiles, + richText.focusEnd, richText.getPlainTextAndCursor, ], ); diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index ed92e7b3f0f..cccb1b40869 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -9,14 +9,12 @@ export function useAlwaysAddressShortcut({ lockedAgent, mentions, onOpenPicker, - onSelect, onToggle, }: { enabled: boolean; lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; - onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { const { @@ -48,11 +46,7 @@ export function useAlwaysAddressShortcut({ if (!isMentionOpen) onOpenPicker(false); return true; } - if (isMentionOpen) { - onSelect(suggestion); - } else { - onToggle(suggestion); - } + onToggle(suggestion); return true; }, [ @@ -62,7 +56,6 @@ export function useAlwaysAddressShortcut({ lockedAgent, mentionSelectedIndex, onOpenPicker, - onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index d0557567fdd..550cdef9832 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -4,6 +4,7 @@ import { getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, + usePersistentAgentAudience, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -16,12 +17,19 @@ type Confirmation = { title: string; }; +type PendingPreferenceChange = { + confirmation?: Confirmation; + enabled: boolean; + request: number; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; onPulse: (pubkey: string) => void; onTurnOff: () => void; + onTurnOn: () => void; }; export function useAutoPinMentionedAgents({ @@ -30,19 +38,101 @@ export function useAutoPinMentionedAgents({ getDisplayName, onPulse, onTurnOff, + onTurnOn, }: Options) { + const { pubkeys: currentAudiencePubkeys } = + usePersistentAgentAudience(audienceScope); const [confirmation, setConfirmation] = React.useState( null, ); + const [confirmationHovered, setConfirmationHovered] = React.useState(false); + const [openOptionsRequest, setOpenOptionsRequest] = React.useState(0); + const nextOptionsRequestRef = React.useRef(0); + const pendingPreferenceChangeRef = + React.useRef(null); + const onTurnOffRef = React.useRef(onTurnOff); + const onTurnOnRef = React.useRef(onTurnOn); + onTurnOffRef.current = onTurnOff; + onTurnOnRef.current = onTurnOn; React.useEffect(() => { - if (!confirmation) return; + if (pendingPreferenceChangeRef.current?.enabled === enabled) { + pendingPreferenceChangeRef.current = null; + } + }, [enabled]); + + React.useEffect( + () => () => { + const pending = pendingPreferenceChangeRef.current; + pendingPreferenceChangeRef.current = null; + if (!pending) return; + if (pending.enabled) { + onTurnOnRef.current(); + } else { + onTurnOffRef.current(); + } + }, + [], + ); + + const requestPreferenceChange = React.useCallback( + (preferenceEnabled: boolean, pendingConfirmation?: Confirmation) => { + const request = nextOptionsRequestRef.current + 1; + nextOptionsRequestRef.current = request; + pendingPreferenceChangeRef.current = { + confirmation: pendingConfirmation, + enabled: preferenceEnabled, + request, + }; + setOpenOptionsRequest(request); + }, + [], + ); + + const completeOptionsReveal = React.useCallback((request: number) => { + const pending = pendingPreferenceChangeRef.current; + if (!pending || pending.request !== request) return; + pendingPreferenceChangeRef.current = null; + if (pending.enabled) { + onTurnOnRef.current(); + return; + } + if (pending.confirmation) { + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: pending.confirmation.expectedRevision, + pubkeys: pending.confirmation.pubkeys, + scope: pending.confirmation.scope, + }); + } + onTurnOffRef.current(); + }, []); + + const clearConfirmation = React.useCallback(() => { + setConfirmationHovered(false); + setConfirmation(null); + }, []); + + React.useEffect(() => { + if (!confirmation || confirmationHovered) return; const timeout = window.setTimeout( - () => setConfirmation(null), + clearConfirmation, CONFIRMATION_DURATION_MS, ); return () => window.clearTimeout(timeout); - }, [confirmation]); + }, [clearConfirmation, confirmation, confirmationHovered]); + + const currentAudiencePubkeySet = React.useMemo( + () => new Set(currentAudiencePubkeys.map(normalizePubkey).filter(Boolean)), + [currentAudiencePubkeys], + ); + const confirmationIsCurrent = + confirmation?.scope === audienceScope && + confirmation.pubkeys.every((pubkey) => + currentAudiencePubkeySet.has(pubkey), + ); + React.useEffect(() => { + if (confirmation && !confirmationIsCurrent) clearConfirmation(); + }, [clearConfirmation, confirmation, confirmationIsCurrent]); const promoteAgents = React.useCallback( ({ @@ -50,10 +140,12 @@ export function useAutoPinMentionedAgents({ ? getPersistentAgentAudienceRevision(audienceScope) : 0, pubkeys, + reinstateExcluded, requirePreference, }: { expectedRevision?: number; pubkeys: readonly string[]; + reinstateExcluded: boolean; requirePreference: boolean; }) => { if (!audienceScope || (requirePreference && !enabled)) return; @@ -62,6 +154,7 @@ export function useAutoPinMentionedAgents({ ].filter(Boolean); const promotion = promotePersistentAgentAudienceIfUnchanged({ expectedRevision, + reinstateExcluded, pubkeys: normalizedPubkeys, scope: audienceScope, }); @@ -78,6 +171,7 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; + setConfirmationHovered(false); setConfirmation({ expectedRevision: revision, pubkeys: promotedPubkeys, @@ -88,37 +182,45 @@ export function useAutoPinMentionedAgents({ [audienceScope, enabled, getDisplayName, onPulse], ); const promoteMentionedAgents = React.useCallback( - (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => - promoteAgents({ ...promotion, requirePreference: true }), + (promotion: { + expectedRevision?: number; + pubkeys: readonly string[]; + reinstateExcluded?: boolean; + }) => + promoteAgents({ + ...promotion, + reinstateExcluded: promotion.reinstateExcluded ?? false, + requirePreference: true, + }), [promoteAgents], ); const promoteExplicitlyAddressedAgents = React.useCallback( - (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => - promoteAgents({ ...promotion, requirePreference: false }), - [promoteAgents], + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => { + promoteAgents({ + ...promotion, + reinstateExcluded: true, + requirePreference: false, + }); + requestPreferenceChange(true); + }, + [promoteAgents, requestPreferenceChange], ); - const dismissConfirmation = React.useCallback( - () => setConfirmation(null), - [], - ); + const dismissConfirmation = clearConfirmation; const turnOffConfirmation = React.useCallback(() => { if (!confirmation) return; - setConfirmation(null); - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: confirmation.expectedRevision, - pubkeys: confirmation.pubkeys, - scope: confirmation.scope, - }); - onTurnOff(); - }, [confirmation, onTurnOff]); + clearConfirmation(); + requestPreferenceChange(false, confirmation); + }, [clearConfirmation, confirmation, requestPreferenceChange]); return { - confirmationTitle: - confirmation?.scope === audienceScope ? confirmation.title : null, + confirmationTitle: confirmationIsCurrent ? confirmation.title : null, + completeOptionsReveal, dismissConfirmation, + openOptionsRequest, promoteExplicitlyAddressedAgents, promoteMentionedAgents, + setConfirmationHovered, turnOffConfirmation, }; } diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs b/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs new file mode 100644 index 00000000000..747df2ee7ee --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +test("turning off automatic mentions opens a closed mention picker first", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useComposerMentionPicker } = await import( + "./useComposerMentionPicker.ts" + ); + const openCalls = []; + let turnOffCount = 0; + const { result } = renderHook(() => + useComposerMentionPicker({ + mentions: { + cancelMentionAutocomplete: () => {}, + isMentionOpen: false, + openMentionPicker: (...args) => openCalls.push(args), + updateMentionQuery: () => {}, + }, + onTurnOffAutoPinConfirmation: () => { + turnOffCount += 1; + }, + richText: { + editor: {}, + focus: () => {}, + getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }), + }, + setIsEmojiPickerOpen: () => {}, + }), + ); + + act(() => result.current.turnOff()); + + assert.deepEqual(openCalls, [[4, "first-agent"]]); + assert.equal(turnOffCount, 1); +}); + +test("turning off automatic mentions refreshes an open picker without closing it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useComposerMentionPicker } = await import( + "./useComposerMentionPicker.ts" + ); + let pickerMutationCount = 0; + let turnOffCount = 0; + const { result } = renderHook(() => + useComposerMentionPicker({ + mentions: { + cancelMentionAutocomplete: () => { + pickerMutationCount += 1; + }, + isMentionOpen: true, + openMentionPicker: () => { + pickerMutationCount += 1; + }, + updateMentionQuery: () => {}, + }, + onTurnOffAutoPinConfirmation: () => { + turnOffCount += 1; + }, + richText: { + editor: {}, + focus: () => {}, + getPlainTextAndCursor: () => ({ cursor: 4, text: "ping" }), + }, + setIsEmojiPickerOpen: () => {}, + }), + ); + + act(() => result.current.turnOff()); + + assert.equal(pickerMutationCount, 1); + assert.equal(turnOffCount, 1); +}); diff --git a/desktop/src/features/messages/ui/useComposerMentionPicker.ts b/desktop/src/features/messages/ui/useComposerMentionPicker.ts index 1a13aa98586..331200e71f5 100644 --- a/desktop/src/features/messages/ui/useComposerMentionPicker.ts +++ b/desktop/src/features/messages/ui/useComposerMentionPicker.ts @@ -5,21 +5,23 @@ import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTex export function useComposerMentionPicker({ mentions, + onTurnOffAutoPinConfirmation, richText, setIsEmojiPickerOpen, }: { mentions: UseMentionsResult; + onTurnOffAutoPinConfirmation: () => void; richText: UseRichTextEditorResult; setIsEmojiPickerOpen: (open: boolean) => void; }) { const { cancelMentionAutocomplete, isMentionOpen, - openMentionPicker, + openMentionPicker: setMentionPickerOpen, updateMentionQuery, } = mentions; const { editor, focus, getPlainTextAndCursor } = richText; - return React.useCallback( + const openMentionPicker = React.useCallback( (insertTrigger = true) => { if (!editor) return; const { text, cursor } = getPlainTextAndCursor(); @@ -30,7 +32,7 @@ export function useComposerMentionPicker({ focus(); return; } - openMentionPicker(cursor, "first-agent"); + setMentionPickerOpen(cursor, "first-agent"); setIsEmojiPickerOpen(false); focus(); return; @@ -55,9 +57,36 @@ export function useComposerMentionPicker({ focus, getPlainTextAndCursor, isMentionOpen, - openMentionPicker, + setMentionPickerOpen, setIsEmojiPickerOpen, updateMentionQuery, ], ); + const openMentionSettings = React.useCallback( + () => openMentionPicker(false), + [openMentionPicker], + ); + const revealMentionSettings = React.useCallback(() => { + if (!editor) return; + const { cursor } = getPlainTextAndCursor(); + setMentionPickerOpen(cursor, "first-agent"); + setIsEmojiPickerOpen(false); + focus(); + }, [ + editor, + focus, + getPlainTextAndCursor, + setIsEmojiPickerOpen, + setMentionPickerOpen, + ]); + const turnOffAutoPinFromConfirmation = React.useCallback(() => { + revealMentionSettings(); + onTurnOffAutoPinConfirmation(); + }, [onTurnOffAutoPinConfirmation, revealMentionSettings]); + + return { + openMentionPicker, + openMentionSettings, + turnOff: turnOffAutoPinFromConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 694bf6a2a55..857f4bf0e05 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -1,5 +1,7 @@ import * as React from "react"; +import { stripImplicitAgentMentionPrefix } from "@/features/messages/lib/stripImplicitAgentMentions"; + import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; import { @@ -59,6 +61,8 @@ type UseDraftPersistLifecycleParams = { * closure to capture the latest text before the effect fires. */ syncComposerContentFromEditor: () => string; + /** Exact editor prefix inserted by automatic addressing, including separator. */ + getImplicitAgentMentionPrefix?: () => string; }; type UseDraftPersistLifecycleResult = { @@ -120,7 +124,17 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, + getImplicitAgentMentionPrefix, }: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { + const persistedContent = React.useCallback( + (content: string) => + stripImplicitAgentMentionPrefix( + content, + getImplicitAgentMentionPrefix?.() ?? "", + ), + [getImplicitAgentMentionPrefix], + ); + const pendingImetaForPersistRef = React.useRef([]); const emptyContentIsAuthoritativeRef = React.useRef(false); const isRestoringContentRef = React.useRef(false); @@ -192,7 +206,7 @@ export function useDraftPersistLifecycle({ } const content = emptyContentIsAuthoritativeRef.current ? "" - : syncComposerContentFromEditor(); + : persistedContent(syncComposerContentFromEditor()); persistDraft( effectiveDraftKey, content, diff --git a/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts new file mode 100644 index 00000000000..c426ee1df76 --- /dev/null +++ b/desktop/src/features/messages/ui/useImplicitAgentMentionProvenance.ts @@ -0,0 +1,54 @@ +import * as React from "react"; + +import { trimMapToSize } from "@/shared/lib/trimMapToSize"; + +type GeneratedMention = { pubkey: string; prefix: string }; + +/** Tracks the leading mentions that automatic addressing inserted per draft. */ +export function useImplicitAgentMentionProvenance( + effectiveDraftKey: string | null | undefined, +) { + const byDraftRef = React.useRef(new Map()); + + const getPrefix = React.useCallback(() => { + if (!effectiveDraftKey) return ""; + return ( + byDraftRef.current + .get(effectiveDraftKey) + ?.map((fragment) => fragment.prefix) + .join("") ?? "" + ); + }, [effectiveDraftKey]); + + const add = React.useCallback( + (insertedFragments: readonly GeneratedMention[]) => { + if (!effectiveDraftKey) return; + const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; + const knownPubkeys = new Set( + fragments.map((fragment) => fragment.pubkey), + ); + byDraftRef.current.set(effectiveDraftKey, [ + ...insertedFragments.filter( + (fragment) => !knownPubkeys.has(fragment.pubkey), + ), + ...fragments, + ]); + trimMapToSize(byDraftRef.current, 200); + }, + [effectiveDraftKey], + ); + + const remove = React.useCallback( + (pubkey: string) => { + if (!effectiveDraftKey) return; + const fragments = byDraftRef.current.get(effectiveDraftKey) ?? []; + byDraftRef.current.set( + effectiveDraftKey, + fragments.filter((fragment) => fragment.pubkey !== pubkey), + ); + }, + [effectiveDraftKey], + ); + + return { add, getPrefix, remove }; +} diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index d8e1550549f..30e79362461 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,7 +166,7 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Address the default agent, or select the highlighted agent", + description: "Address the default agent, or toggle the highlighted agent", keys: "⇧⌘M", keysWindows: "Ctrl+Shift+M", category: "Messages", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 814bd86223e..9c12ebef4fe 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1411,7 +1411,16 @@ declare global { }) => unknown; __BUZZ_E2E_SEED_MOCK_REMINDERS__?: (reminders: RelayEvent[]) => void; __BUZZ_E2E_QUERY_CLIENT__?: { - invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown; + invalidateQueries: (filters: { + queryKey: readonly unknown[]; + exact?: boolean; + }) => unknown; + getQueryState: (queryKey: readonly unknown[]) => + | { + fetchStatus: "fetching" | "paused" | "idle"; + status: "pending" | "error" | "success"; + } + | undefined; }; __BUZZ_E2E_MD_PARSE_COUNT__?: () => number; /** diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index c5a4522ef55..88985fedecd 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -425,10 +425,10 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect( page.getByTestId(`composer-address-lock-${relayPubkey}`), ).toHaveCount(0); - await input.fill("local"); + await input.pressSequentially("local"); await page.getByTestId("send-message").click(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "local")) + .poll(() => readOutgoingMentionPubkeys(page, "@carl local")) .toEqual([managedPubkey]); await expect(input).toHaveText("@carl "); @@ -448,7 +448,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect( page.getByTestId(`composer-address-lock-${managedPubkey}`), ).toHaveCount(0); - await input.fill("remote"); + await input.pressSequentially("remote"); await page.getByTestId("send-message").click(); const sendWithoutInviting = page.getByRole("button", { name: "Do nothing" }); try { @@ -458,7 +458,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as // In-channel selections send immediately without opening the prompt. } await expect - .poll(() => readOutgoingMentionPubkeys(page, "remote")) + .poll(() => readOutgoingMentionPubkeys(page, "@carl remote")) .toEqual([relayPubkey]); await page.getByTestId("channel-members-trigger").click(); @@ -1673,30 +1673,60 @@ test("forum sends revalidate relay-agent authorization before signing", async ({ .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); }); -test("relay-only allowlisted agents are visible in channel mentions", async ({ +test("managed agents use the channel roster for membership labels", async ({ page, }) => { await installMockBridge(page, { - relayAgents: [ + managedAgents: [ { - pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, - name: "quinn", - respondTo: "allowlist", - respondToAllowlist: [MOCK_VIEWER_PUBKEY], - channelNames: ["general"], + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + name: "carl", + status: "running", }, ], }); await page.goto("/"); await page.getByTestId("channel-general").click(); - await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate( + (channelId) => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState([ + "channels", + channelId, + "members", + ])?.status, + GENERAL_CHANNEL_ID, + ), + ) + .toBe("success"); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + exact: true, + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: IN_CHANNEL_MANAGED_AGENT_PUBKEY, + }, + ); const input = page.getByTestId("message-input"); - await input.fill("@quinn"); + await input.fill("@carl"); - const dropdown = autocomplete(page); - await expect(dropdown.getByText("quinn")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); + const carlRow = autocomplete(page).locator("button", { hasText: "carl" }); + await expect(carlRow).toBeVisible(); + await expect(carlRow.getByText("agent")).toBeVisible(); + await expect(carlRow.getByText("not in channel")).toHaveCount(0); }); test("relay-agent directory errors fail closed and recover after a fresh fetch", async ({ @@ -1734,7 +1764,27 @@ test("relay-agent directory errors fail closed and recover after a fresh fetch", queryKey: ["relay-agents"], }); }); - await expect(autocomplete(page).getByText("quinn")).toHaveCount(0); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("fetching"); + await expect(autocomplete(page).getByText("quinn")).toBeVisible({ + timeout: 200, + }); + await expect + .poll(async () => + page.evaluate( + () => + window.__BUZZ_E2E_QUERY_CLIENT__?.getQueryState(["relay-agents"]) + ?.fetchStatus, + ), + ) + .toBe("idle"); await expect(autocomplete(page).getByText("quinn")).toBeVisible(); }); @@ -1917,6 +1967,56 @@ test("targeted revocation before send causes no agent side effects", async ({ } }); +test("selected relay agents are invited as bots before sending", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const input = page.getByTestId("message-input"); + await input.fill("@quinn"); + const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); + await expect(quinnRow).toBeVisible(); + await expect(quinnRow.getByText("not in channel")).toHaveCount(0); + await quinnRow.click(); + await page.keyboard.type("hello"); + + const baselinePayloadCount = (await readCommandPayloadLog(page)).length; + await page.getByTestId("send-message").click(); + const inviteButton = page.getByRole("button", { + name: "Invite", + exact: true, + }); + await expect(inviteButton).toBeVisible(); + await inviteButton.click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) + .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + const sendCommands = (await readCommandPayloadLog(page)).slice( + baselinePayloadCount, + ); + const addCommand = sendCommands.find( + (entry) => entry.command === "add_channel_members", + ); + expect(addCommand?.payload).toMatchObject({ + channelId: GENERAL_CHANNEL_ID, + pubkeys: [ALLOWLIST_RELAY_AGENT_PUBKEY], + role: "bot", + }); +}); + test("selected relay agents revoked after the invite prompt cause no side effects", async ({ page, }) => { diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 0b957992e2b..643031454ac 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -5,6 +5,7 @@ import { installMockBridge } from "../helpers/bridge"; const SHOTS = "test-results/persistent-agent-audience"; const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; @@ -84,11 +85,11 @@ async function readComposerCaret(input: Locator) { }); } -async function pressPrimaryShift(page: Page, key: "M") { +async function pressPrimaryShiftM(page: Page) { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); - await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+${key}`); + await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); } async function readOutgoingMentionPubkeys(page: Page, content: string) { @@ -156,6 +157,7 @@ async function emitMockMessage( async function installAudienceFixtures( page: Page, options: { + agentAName?: string; deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; @@ -171,12 +173,13 @@ async function installAudienceFixtures( usersBatchDelayMs?: number; } = {}, ) { + const { agentAName = "Morgarita", ...bridgeOptions } = options; await installMockBridge(page, { - ...options, + ...bridgeOptions, managedAgents: [ { pubkey: AGENT_A, - name: "Morgarita", + name: agentAName, status: "running", channelNames: ["general"], }, @@ -285,6 +288,104 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); +test("keeps the composer and global automatic mention settings synchronized", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + await composer.getByTestId("message-insert-mention").click(); + const optionsTrigger = composer.getByTestId("mention-options-trigger"); + await expect(optionsTrigger).toHaveAttribute("aria-expanded", "false"); + await composer + .getByTestId("mention-autocomplete") + .getByRole("button", { name: "Automatically mention Morgarita" }) + .click(); + await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); + const composerToggle = composer.getByTestId( + "mention-keep-agents-pinned-toggle", + ); + await expect(composerToggle).toHaveAttribute("data-state", "unchecked"); + await expect(composerToggle).toHaveAttribute("data-state", "checked", { + timeout: 1_500, + }); + + await page + .getByTestId("composer-auto-pin-confirmation") + .getByRole("button", { name: "Turn off" }) + .click(); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); + await expect(composerToggle).toHaveAttribute("data-state", "checked"); + await expect(composerToggle).toHaveAttribute("data-state", "unchecked", { + timeout: 1_500, + }); + + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); + const settingsToggle = page + .getByTestId("settings-automatic-agent-mentions") + .getByRole("switch", { name: "Automatically mention agents" }); + await expect(settingsToggle).toHaveAttribute("data-state", "unchecked"); + + await page.getByTestId("settings-back-to-app").click(); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await composer.getByTestId("message-insert-mention").click(); + await composer.getByTestId("mention-options-trigger").click(); + await expect( + composer.getByTestId("mention-keep-agents-pinned-toggle"), + ).toHaveAttribute("data-state", "unchecked"); + await expect( + composer.getByRole("button", { + name: "Automatically mention Morgarita", + }), + ).toHaveAttribute("aria-pressed", "false"); +}); + +test("hides automatic mention state while disabled without clearing the draft", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + await input.type("draft text"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.getByTestId("channel-management-archive").click(); + await expect(page.getByTestId("channel-management-unarchive")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + + await expect(input).toHaveAttribute("contenteditable", "false"); + await expect(input).toHaveText("@Morgarita draft text"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await page.getByTestId("channel-management-unarchive").click(); + await expect(page.getByTestId("channel-management-archive")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + + await expect(input).toHaveAttribute("contenteditable", "true"); + await expect(input).toHaveText("@Morgarita draft text"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); +}); + test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -346,7 +447,7 @@ test("disabling automatic mentions leaves the composer empty after send", async .toContain(AGENT_A); }); -test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then toggles the highlighted agent in place", async ({ page, }) => { await keepMentionedAgentsPinned(page); @@ -356,21 +457,21 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveText("alice"); await expect( page.getByTestId("composer-auto-pin-confirmation"), ).toContainText("alice will be mentioned automatically"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); await expect( composer .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Stop automatically mentioning / }), + .getByRole("button", { name: /^Don't automatically mention / }), ).toHaveCount(1); await input.fill("@Vog"); @@ -378,9 +479,9 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); - await expect(menu).toHaveCount(0); + await expect(menu).toBeVisible(); await expect(input).toHaveText("@Vogue "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_B}`), @@ -391,7 +492,7 @@ test("primary+Shift+M addresses the default agent, then selects the highlighted await expect( composer .getByTestId("composer-address-locks") - .getByRole("button", { name: /^Stop automatically mentioning / }), + .getByRole("button", { name: /^Don't automatically mention / }), ).toHaveCount(1); }); @@ -407,13 +508,13 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async await input.press("ArrowLeft"); await input.press("ArrowLeft"); await expect.poll(() => readComposerCaret(input)).toBe(8); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); await expect.poll(() => readComposerCaret(input)).toBe(15); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@Vogue draft text"); }); @@ -468,12 +569,14 @@ test("the mention button opens settings and can undo an address", async ({ await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); await expect( menu.getByRole("button", { - name: "Stop automatically mentioning Morgarita", + name: "Don't automatically mention Morgarita in this conversation", }), ).toHaveAttribute("aria-pressed", "true"); await menu - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByRole("button", { + name: "Don't automatically mention Morgarita in this conversation", + }) .click(); await expect(input).toHaveText("draft text"); await expect( @@ -724,44 +827,513 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ ); await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( + "aria-expanded", + "true", + ); + await expect( + composer.getByTestId("mention-keep-agents-pinned-toggle"), + ).toHaveAttribute("data-state", "unchecked"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); await expect(autoPinConfirmation).toHaveCount(0); - await composer.getByTestId("message-insert-mention").click(); + await input.press("Escape"); + await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); +}); + +test("the auto-pin popover remains open while hovered", async ({ page }) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await composer.getByTestId("mention-options-trigger").click(); - await expect( - composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toHaveAttribute("data-state", "unchecked"); + await input.press("Tab"); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await autoPinConfirmation.hover(); + await page.waitForTimeout(4_250); + await expect(autoPinConfirmation).toBeVisible(); await input.press("Escape"); - await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + await expect(autoPinConfirmation).toHaveCount(0); }); -test("channel automatic mentions carry into threads and stay synchronized", async ({ +test("removing the mention chip dismisses the auto-pin popover", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - await openThread(page); - const channelAutomaticMention = channelComposer(page).getByTestId( - `composer-address-lock-${AGENT_A}`, + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", ); - const threadAutomaticMention = threadComposer(page).getByTestId( - `composer-address-lock-${AGENT_A}`, + await expect(autoPinConfirmation).toBeVisible(); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); - await expect(channelAutomaticMention).toBeVisible(); - await expect(threadAutomaticMention).toBeVisible(); + await input.press(selectAllShortcut); + await input.press("Backspace"); + + await expect(input).toHaveText(""); + await expect(autoPinConfirmation).toHaveCount(0); +}); + +test("automatic mentions are scoped to their channel or thread composer", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); - await threadComposer(page) + await openThread(page); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await automaticallyMention(threadComposer(page), "Vogue"); + await openGeneral(page); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); + + await openThread(page); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toBeVisible(); + + await openThread(page, "mock-general-alice"); + await expect( + threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + ).toHaveCount(0); +}); + +test("a thread automatic mention preserves an explicitly unpinned root agent", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page, { agentAName: "claude code" }); + await openGeneral(page); + + const rootComposer = channelComposer(page); + const rootInput = rootComposer.getByTestId("message-input"); + await automaticallyMention(rootComposer, "claude code"); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await rootComposer .getByTestId(`composer-address-lock-remove-${AGENT_A}`) .click(); - await expect(threadAutomaticMention).toHaveCount(0); - await expect(channelAutomaticMention).toHaveCount(0); + await expect(rootInput).toHaveText(""); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await rootInput.fill("@cla"); + await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible(); + await rootInput.press("Tab"); + await rootInput.type("one time"); + await rootInput.press("Enter"); + await expect(rootInput).toHaveText(""); + await expect( + rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await openThread(page); + const activeThreadComposer = threadComposer(page); + const threadInput = activeThreadComposer.getByTestId("message-input"); + await threadInput.fill("@cla"); + await expect( + activeThreadComposer.getByTestId("mention-autocomplete"), + ).toBeVisible(); + await threadInput.press("Tab"); + await expect( + activeThreadComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await threadInput.type("thread message"); + await threadInput.press("Enter"); + + await openGeneral(page); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + const restoredRootInput = channelComposer(page).getByTestId("message-input"); + await restoredRootInput.fill("@cla"); + await expect( + channelComposer(page).getByTestId("mention-autocomplete"), + ).toBeVisible(); + await restoredRootInput.press("Tab"); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await restoredRootInput.type("one time"); + await restoredRootInput.press("Enter"); + + await expect(restoredRootInput).toHaveText(""); + await expect( + channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText(""); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.type("one time"); + await input.press("Enter"); + + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); +}); + +test("re-adding a deleted automatic mention restores its automatic mention state immediately", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + await input.type("re-added"); + await input.press("Enter"); + + await expect(input).toHaveText("@Morgarita "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); +}); + +test("implicit automatic mentions stay out of persisted drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + const input = channelComposer(page).getByTestId("message-input"); + await input.type("draft text"); + + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await input.pressSequentially(" continues"); + await expect(input).toHaveText("@Morgarita draft text continues"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[channelId]; + if (draft?.channelId === channelId) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("draft text continues"); +}); + +test("an authored duplicate leading mention survives draft restoration", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await automaticallyMention(channelComposer(page), "Morgarita"); + const input = channelComposer(page).getByTestId("message-input"); + await input.pressSequentially("@Morgarita authored duplicate"); + + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); + // Exact typed mentions now resolve on Space, so both the automatic prefix and + // the authored duplicate retain mention identity after restoration. + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[channelId]; + if (draft?.channelId === channelId) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita authored duplicate"); +}); + +test("typed deletion preserves an identical authored mention in drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + await expect(input).toHaveText(""); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + + await input.pressSequentially("@Morgarita manual after typed deletion"); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita manual after typed deletion"); +}); + +test("removing an automatic mention preserves an identical authored mention in drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + + await automaticallyMention(composer, "Morgarita"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await input.pressSequentially("@Morgarita manual after removal"); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita manual after removal"); +}); + +test("multiple automatic mentions stay out of persisted drafts", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await automaticallyMention(composer, "Morgarita"); + await automaticallyMention(composer, "Vogue"); + await input.pressSequentially("draft text"); + + await openThread(page); + await openGeneral(page); + await expect(input).toHaveText("@Vogue @Morgarita draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const draft = ( + JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record + )[channelId]; + if (draft) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("draft text"); +}); + +test("re-enabling an automatic mention preserves an authored duplicate after draft restoration", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + + await automaticallyMention(composer, "Morgarita"); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(input).toHaveText(""); + + await automaticallyMention(composer, "Morgarita"); + await input.pressSequentially("@Morgarita authored duplicate"); + await openThread(page); + await openGeneral(page); + + await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect + .poll(() => + page.evaluate((channelId) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[channelId]; + if (draft?.channelId === channelId) return draft.content ?? ""; + } + return ""; + }, CHANNEL_ID), + ) + .toBe("@Morgarita authored duplicate"); +}); + +test("a restored multi-word automatic mention remains a chip with the caret after its space", async ({ + page, +}) => { + await installAudienceFixtures(page, { agentAName: "claude code" }); + await openGeneral(page); + const originalComposer = channelComposer(page); + await automaticallyMention(originalComposer, "claude code"); + const originalInput = originalComposer.getByTestId("message-input"); + await originalInput.pressSequentially("hello"); + await originalInput.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@claude code hello")) + .toContain(AGENT_A); + await expect(originalInput).toHaveText("@claude code "); + + await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + const expectedContent = "@claude code "; + await expect(input).toHaveText(expectedContent); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + await expect( + composer.getByRole("button", { name: "Manage automatic agent mentions" }), + ).toBeVisible(); + await page.waitForTimeout(500); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect(input).toBeFocused(); + await expect + .poll(() => readComposerCaret(input)) + .toBe(expectedContent.length); + + await input.pressSequentially("follow-up"); + await expect(input).toHaveText("@claude code follow-up"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); }); test("reduced motion removes addressed agents without spatial animation", async ({ @@ -785,7 +1357,7 @@ test("reduced motion removes addressed agents without spatial animation", async await expect(removeButton).toHaveCSS("transform", "none"); await removeButton.click(); - await expect(input).toHaveText("@Morgarita "); + await expect(input).toHaveText(""); await expect(removeButton).toHaveCount(0); }); @@ -832,7 +1404,7 @@ test("captures the lightweight auto-pin popover", async ({ page }) => { const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "M"); + await pressPrimaryShiftM(page); await expect(input).toHaveText("@alice draft text"); const addressControl = composer From 00e61eafa917d296104006576b7a2ddbfd58bb5a Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Fri, 28 Aug 2026 23:23:25 -0400 Subject: [PATCH 098/101] fix(desktop): surface channel history load failures (#7013) ## Summary A failed initial channel-history request no longer appears as an authoritative empty channel. The timeline now shows an announced error with a Retry action, while cached messages remain visible when a later refresh fails; successful empty channels continue to use their normal intro state. ### Related issue None found. ### Testing - Full desktop unit suite (`pnpm test`) - Desktop TypeScript check (`pnpm exec tsc --noEmit`) - Biome checks for changed files - Repository file-size ratchet - Full pre-push desktop checks and tests - Desktop app launched successfully against local Postgres and Redis for manual testing No screenshot is included because the new UI is only shown after a terminal relay-history failure; the regression test pins the error/empty/list precedence directly. --------- Signed-off-by: Thomas Petersen --- .../channels/ui/ChannelPane.helpers.ts | 17 +++ .../src/features/channels/ui/ChannelPane.tsx | 22 ++-- .../features/channels/ui/ChannelPane.types.ts | 3 + .../features/channels/ui/ChannelScreen.tsx | 33 ++---- .../lib/projectChannelWindow.test.mjs | 71 ++++++++++++ .../messages/lib/projectChannelWindow.ts | 2 +- .../lib/timelineLoadingState.test.mjs | 107 +++++++++++++++++- .../messages/lib/timelineLoadingState.ts | 38 ++++++- .../messages/lib/timelineSnapshot.test.mjs | 24 ++++ .../features/messages/lib/timelineSnapshot.ts | 10 +- .../features/messages/ui/MessageTimeline.tsx | 38 +++++-- .../messages/ui/MessageTimelineErrorCard.tsx | 36 ++++++ 12 files changed, 350 insertions(+), 51 deletions(-) create mode 100644 desktop/src/features/messages/ui/MessageTimelineErrorCard.tsx diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index 4dc0f0f247b..a93eed6837a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -1,6 +1,7 @@ import { getChannelDetail } from "@/features/channels/lib/channelDescription"; import { isEphemeralChannel } from "@/features/channels/lib/ephemeralChannel"; import type { TimelineMessage } from "@/features/messages/types"; +import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { Channel } from "@/shared/api/types"; import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; @@ -106,3 +107,19 @@ export function mentionsKnownAgent( knownAgentPubkeys.has(pubkey.toLowerCase()), ); } + +export function selectThreadComposerBotTypingPubkeys( + entries: TypingIndicatorEntry[], + threadHeadId: string | null, +) { + if (!threadHeadId) return []; + return entries + .filter((entry) => entry.threadHeadId === threadHeadId) + .map((entry) => entry.pubkey) + .filter( + (pubkey, index, all) => + all.findIndex( + (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), + ) === index, + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 38ebd9234a4..d53e8dbbdaa 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -52,6 +52,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent, + selectThreadComposerBotTypingPubkeys, shouldPrioritizeIdleAuxiliary, shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; @@ -103,7 +104,9 @@ export const ChannelPane = React.memo(function ChannelPane({ isJoining = false, isSinglePanelView = false, isSending, + isTimelineError = false, isTimelineLoading, + onRetryTimeline, entranceMessageId = null, onEntranceMessageComplete, welcomeKickoffStage = null, @@ -334,18 +337,11 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; - const threadComposerBotTypingPubkeys = React.useMemo(() => { - if (!openThreadHeadId) return []; - return botTypingEntries - .filter((entry) => entry.threadHeadId === openThreadHeadId) - .map((entry) => entry.pubkey) - .filter( - (pubkey, index, all) => - all.findIndex( - (candidate) => candidate.toLowerCase() === pubkey.toLowerCase(), - ) === index, - ); - }, [botTypingEntries, openThreadHeadId]); + const threadComposerBotTypingPubkeys = React.useMemo( + () => + selectThreadComposerBotTypingPubkeys(botTypingEntries, openThreadHeadId), + [botTypingEntries, openThreadHeadId], + ); const hasThreadComposerBotActivity = threadComposerBotTypingPubkeys.length > 0; const directMessageIntro = React.useMemo( @@ -662,7 +658,9 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No messages yet" : "No channel selected" } + isError={isTimelineError} isLoading={isHuddleTranscript ? false : isTimelineLoading} + onRetry={onRetryTimeline} entranceMessageId={entranceMessageId} onEntranceMessageComplete={onEntranceMessageComplete} mainEntries={mainTimelineEntries} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5bce6a71ff7..1fe5bf751b8 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -59,7 +59,10 @@ export type ChannelPaneProps = { isJoining?: boolean; isSinglePanelView?: boolean; isSending: boolean; + /** Terminal channel-history failure. Cached messages remain visible when present. */ + isTimelineError?: boolean; isTimelineLoading: boolean; + onRetryTimeline?: () => void; /** Newly-created message that should receive the one-shot conversation arrival motion. */ entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9c2d25dd7cf..f7a5b480122 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -50,10 +50,7 @@ import { isThreadReply, } from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; -import { - resolveTimelineLoadingLatch, - selectTimelineLoadingState, -} from "@/features/messages/lib/timelineLoadingState"; +import { resolveTimelineQueryLoadingState } from "@/features/messages/lib/timelineLoadingState"; import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages"; import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel"; import { useThreadReplies } from "@/features/messages/useThreadReplies"; @@ -616,27 +613,21 @@ export function ChannelScreen({ setThreadScrollTargetId, }); const settledChannelIdRef = React.useRef(null); - const hasSettledThisChannel = - activeChannelId !== null && settledChannelIdRef.current === activeChannelId; - const timelineLoadingNow = - activeChannel !== null && - activeChannel.channelType !== "forum" && - selectTimelineLoadingState( + const { settledChannelId, isLoading: isTimelineLoading } = + resolveTimelineQueryLoadingState( + settledChannelIdRef.current, + activeChannelId, { + isEnabled: + activeChannel !== null && activeChannel.channelType !== "forum", isPending: messagesQuery.isPending, isFetching: messagesQuery.isFetching, isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, + isError: messagesQuery.isError, }, - hasSettledThisChannel || - (activeChannelId !== null && - hasPersistedHydratedChannel(queryClient, activeChannelId)), - ); - const { settledChannelId, isLoading: isTimelineLoading } = - resolveTimelineLoadingLatch( - settledChannelIdRef.current, - activeChannelId, - timelineLoadingNow, + activeChannelId !== null && + hasPersistedHydratedChannel(queryClient, activeChannelId), ); settledChannelIdRef.current = settledChannelId; const { welcomeKickoffStage, welcomeKickoffSettingUp } = @@ -890,8 +881,8 @@ export function ChannelScreen({ isFollowingThread={isNotifiedForEffectiveThread} isSending={sendMessageMutation.isPending} isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} + isTimelineError={messagesQuery.isError} isTimelineLoading={isTimelineLoading} + onRetryTimeline={() => void messagesQuery.refetch()} messages={timelineMessages} threadSummaries={threadSummaries} huddleThreadRepliesError={huddleThreadRepliesError} onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 618f3fc9912..9594b937879 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -416,3 +416,74 @@ test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fe unsubscribe(); } }); + +test("test_subscription_refresh_preserves_cold_history_error", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const channelId = "cold-failure"; + const queryKey = channelMessagesKey(channelId); + const observer = new QueryObserver(client, { + queryKey, + queryFn: async () => { + throw new Error("history unavailable"); + }, + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + await observer.refetch(); + assert.equal(observer.getCurrentResult().status, "error"); + assert.equal(observer.getCurrentResult().data, undefined); + + await assert.rejects( + refreshChannelWindowMessages(client, channelId), + /history unavailable/, + ); + assert.equal(observer.getCurrentResult().status, "error"); + assert.equal(observer.getCurrentResult().data, undefined); + } finally { + unsubscribe(); + } +}); + +test("test_refresh_failure_retains_cached_rows_and_success_clears_error", async () => { + const harness = createHarness(); + let shouldFail = true; + const refreshed = event("refreshed", 110); + const observer = new QueryObserver(harness.client, { + queryKey: harness.messagesKey, + queryFn: async ({ signal }) => { + if (shouldFail) { + throw new Error("history unavailable"); + } + const previousMessages = + harness.client.getQueryData(harness.messagesKey) ?? []; + return reconcileFetchedChannelWindow( + harness.client, + harness.channelId, + wirePage([refreshed, event("initial", 100)]), + previousMessages, + signal, + ); + }, + staleTime: Number.POSITIVE_INFINITY, + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + await assert.rejects( + refreshChannelWindowMessages(harness.client, harness.channelId), + /history unavailable/, + ); + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(contents(harness), ["initial"]); + + shouldFail = false; + await refreshChannelWindowMessages(harness.client, harness.channelId); + assert.equal(observer.getCurrentResult().status, "success"); + assert.deepEqual(contents(harness), ["initial", "refreshed"]); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/projectChannelWindow.ts b/desktop/src/features/messages/lib/projectChannelWindow.ts index b16187ce18c..8531c867f2b 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.ts +++ b/desktop/src/features/messages/lib/projectChannelWindow.ts @@ -47,7 +47,7 @@ export async function refreshChannelWindowMessages( } await queryClient.invalidateQueries( { queryKey, exact: true, refetchType: "active" }, - { cancelRefetch: !seeded }, + { cancelRefetch: !seeded, throwOnError: true }, ); projectChannelWindowMessages(queryClient, channelId); } diff --git a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs index fe6960f74c3..ed414365be7 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.test.mjs +++ b/desktop/src/features/messages/lib/timelineLoadingState.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { selectTimelineLoadingState } from "./timelineLoadingState.ts"; +import { + resolveTimelineLoadingLatch, + resolveTimelineQueryLoadingState, + selectTimelineLoadingState, +} from "./timelineLoadingState.ts"; const settled = { isPending: false, @@ -10,6 +15,27 @@ const settled = { dataLength: null, }; +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function resolveQueryLoading(result, settledChannelId = null) { + return resolveTimelineQueryLoadingState(settledChannelId, "chan-a", { + isEnabled: true, + isPending: result.isPending, + isFetching: result.isFetching, + isPlaceholderData: result.isPlaceholderData, + dataLength: result.data?.length ?? null, + isError: result.isError, + }); +} + test("pending first fetch with no cache is loading", () => { assert.equal( selectTimelineLoadingState({ ...settled, isPending: true }), @@ -120,8 +146,6 @@ test("settled channel with rows mid-refetch is not loading", () => { ); }); -import { resolveTimelineLoadingLatch } from "./timelineLoadingState.ts"; - test("latch: loading on first entry to a channel", () => { const r = resolveTimelineLoadingLatch(null, "chan-a", true); assert.equal(r.isLoading, true); @@ -158,3 +182,80 @@ test("latch: no active channel passes loadingNow through untouched", () => { false, ); }); + +test("query wiring: cold error retry stays loading until successful empty result", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const first = deferred(); + const retry = deferred(); + let attempt = 0; + const observer = new QueryObserver(client, { + queryKey: ["messages", "chan-a"], + queryFn: () => [first.promise, retry.promise][attempt++], + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + first.reject(new Error("history unavailable")); + await new Promise((resolve) => setImmediate(resolve)); + let loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(loading, { settledChannelId: null, isLoading: false }); + + const retryResult = observer.refetch(); + loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "pending"); + assert.deepEqual(loading, { settledChannelId: null, isLoading: true }); + + retry.resolve([]); + await retryResult; + loading = resolveQueryLoading(observer.getCurrentResult()); + assert.equal(observer.getCurrentResult().status, "success"); + assert.deepEqual(loading, { + settledChannelId: "chan-a", + isLoading: false, + }); + } finally { + unsubscribe(); + } +}); + +test("query wiring: repeated cold retry failure never settles as empty", async () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const first = deferred(); + const retry = deferred(); + let attempt = 0; + const observer = new QueryObserver(client, { + queryKey: ["messages", "chan-a"], + queryFn: () => [first.promise, retry.promise][attempt++], + }); + const unsubscribe = observer.subscribe(() => {}); + + try { + first.reject(new Error("history unavailable")); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: false, + }); + + const retryResult = observer.refetch(); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: true, + }); + + retry.reject(new Error("still unavailable")); + await retryResult; + assert.equal(observer.getCurrentResult().status, "error"); + assert.deepEqual(resolveQueryLoading(observer.getCurrentResult()), { + settledChannelId: null, + isLoading: false, + }); + } finally { + unsubscribe(); + } +}); diff --git a/desktop/src/features/messages/lib/timelineLoadingState.ts b/desktop/src/features/messages/lib/timelineLoadingState.ts index ea46168d254..bb6f0bc96c9 100644 --- a/desktop/src/features/messages/lib/timelineLoadingState.ts +++ b/desktop/src/features/messages/lib/timelineLoadingState.ts @@ -15,6 +15,11 @@ export type TimelineQueryStatus = { dataLength: number | null; }; +export type TimelineQueryLoadingStatus = TimelineQueryStatus & { + isEnabled: boolean; + isError: boolean; +}; + export function selectTimelineLoadingState( status: TimelineQueryStatus, hasSettled = true, @@ -50,6 +55,7 @@ export function resolveTimelineLoadingLatch( settledChannelId: string | null, activeChannelId: string | null, loadingNow: boolean, + canSettle = true, ): { settledChannelId: string | null; isLoading: boolean } { if (activeChannelId === null) { return { settledChannelId, isLoading: loadingNow }; @@ -58,9 +64,37 @@ export function resolveTimelineLoadingLatch( // Already settled for this channel — stay loaded through refetch blips. return { settledChannelId, isLoading: false }; } - if (!loadingNow) { + if (!loadingNow && canSettle) { // First settle for this channel; latch it. return { settledChannelId: activeChannelId, isLoading: false }; } - return { settledChannelId, isLoading: true }; + return { settledChannelId, isLoading: loadingNow }; +} + +/** + * Production coordinator from the messages query state to the channel loading + * latch. Keeping the error guard here prevents a cold terminal failure from + * being recorded as an authoritative empty result before Retry starts. + */ +export function resolveTimelineQueryLoadingState( + settledChannelId: string | null, + activeChannelId: string | null, + status: TimelineQueryLoadingStatus, + hasPersistedHydratedChannel = false, +): { settledChannelId: string | null; isLoading: boolean } { + const hasSettledThisChannel = + activeChannelId !== null && settledChannelId === activeChannelId; + const loadingNow = + status.isEnabled && + selectTimelineLoadingState( + status, + hasSettledThisChannel || hasPersistedHydratedChannel, + ); + + return resolveTimelineLoadingLatch( + settledChannelId, + activeChannelId, + loadingNow, + !status.isError, + ); } diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs index a8afb823ecb..3787f8281a2 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs +++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs @@ -590,6 +590,30 @@ test("timeline-body-surface: empty only when live and deferred rows are empty", ); }); +test("timeline-body-surface: terminal history failure never paints false-empty", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 0, + isError: true, + isLoading: false, + liveCount: 0, + }), + "error", + ); +}); + +test("timeline-body-surface: cached rows stay visible after a refetch failure", () => { + assert.equal( + selectTimelineBodySurface({ + deferredCount: 2, + isError: true, + isLoading: false, + liveCount: 2, + }), + "list", + ); +}); + test("deferred-snapshot: stale when channel ids diverge during channel switch", () => { assert.equal( isDeferredTimelineSnapshotStale({ diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts index 4e23fb22453..656e25d1b63 100644 --- a/desktop/src/features/messages/lib/timelineSnapshot.ts +++ b/desktop/src/features/messages/lib/timelineSnapshot.ts @@ -181,16 +181,18 @@ export function selectDeferredListRenderState( return "pending"; } -export type TimelineBodySurface = "skeleton" | "empty" | "list"; +export type TimelineBodySurface = "skeleton" | "error" | "empty" | "list"; export function selectTimelineBodySurface({ deferredCount, preserveSettledEmptyIntro = false, + isError = false, isLoading, liveCount, }: { deferredCount: number; preserveSettledEmptyIntro?: boolean; + isError?: boolean; isLoading: boolean; liveCount: number; }): TimelineBodySurface { @@ -199,6 +201,12 @@ export function selectTimelineBodySurface({ } const renderState = selectDeferredListRenderState(deferredCount, liveCount); + if (renderState === "list") { + return "list"; + } + if (isError && liveCount === 0) { + return "error"; + } if (renderState === "pending") { // Preserve a channel/DM intro across a new append only when this channel // already committed an authoritative empty timeline. On first load, the diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index fa8bb4e9f6d..88ec8080aab 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -19,6 +19,7 @@ import { TooltipProvider } from "@/shared/ui/tooltip"; import { useCommittedEmptyTimeline } from "./useCommittedEmptyTimeline"; import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock"; +import { MessageTimelineErrorCard } from "./MessageTimelineErrorCard"; import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton"; import { TimelineMessageList } from "./TimelineMessageList"; import type { TimelineVirtualizerApi } from "./TimelineMessageList"; @@ -52,7 +53,9 @@ type MessageTimelineProps = { displayName: string; participants: DirectMessageIntroParticipant[]; } | null; + isError?: boolean; isLoading?: boolean; + onRetry?: () => void; entranceMessageId?: string | null; onEntranceMessageComplete?: (messageId: string) => void; emptyTitle?: string; @@ -163,7 +166,9 @@ const MessageTimelineBase = React.forwardRef< messages, mainEntries, threadSummaries, + isError = false, isLoading = false, + onRetry, entranceMessageId = null, onEntranceMessageComplete, emptyTitle = "No messages yet", @@ -299,10 +304,12 @@ const MessageTimelineBase = React.forwardRef< const timelineBodySurface = selectTimelineBodySurface({ deferredCount: deferredMessages.length, preserveSettledEmptyIntro, + isError, isLoading: timelineIsLoading, liveCount: messages.length, }); const showTimelineSkeleton = timelineBodySurface === "skeleton"; + const showTimelineError = timelineBodySurface === "error"; const [isSemanticallyAtBottom, setIsSemanticallyAtBottom] = React.useState(true); // biome-ignore lint/correctness/useExhaustiveDependencies: reset semantic tail state when the active channel changes @@ -425,15 +432,17 @@ const MessageTimelineBase = React.forwardRef< [onVirtualizerAtBottomStateChange, queueSemanticBottom], ); - const timelineIntroSurface = selectTimelineIntroSurface({ - hasChannelIntro: channelIntro !== null && directMessageIntro === null, - hasDirectMessageIntro: directMessageIntro !== null, - hasReachedChannelStart: - !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && - !isHoldingPrepend && - (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), - isSkeletonVisible: showTimelineSkeleton, - }); + const timelineIntroSurface = showTimelineError + ? null + : selectTimelineIntroSurface({ + hasChannelIntro: channelIntro !== null && directMessageIntro === null, + hasDirectMessageIntro: directMessageIntro !== null, + hasReachedChannelStart: + !isRenderedTimelineBehindHistoryPrepend(deferredMessages, messages) && + !isHoldingPrepend && + (messages.length === 0 || (!hasOlderMessages && !isFetchingOlder)), + isSkeletonVisible: showTimelineSkeleton, + }); const showDirectMessageIntro = timelineIntroSurface === "direct-message-intro"; const showChannelIntro = timelineIntroSurface === "channel-intro"; @@ -765,7 +774,10 @@ const MessageTimelineBase = React.forwardRef< showChannelIntroOnly ? "pt-[var(--channel-top-chrome-height,4.5rem)]" : channelChrome.contentPadding, - (showIntro || showGenericEmpty || showMessageList) && + (showIntro || + showTimelineError || + showGenericEmpty || + showMessageList) && "min-h-full", )} ref={contentRef} @@ -784,7 +796,8 @@ const MessageTimelineBase = React.forwardRef< className={cn( "flex min-h-[18rem] min-w-0 flex-col gap-2", useTimelineVirtualizer && "min-h-0 flex-1", - (showIntro || showGenericEmpty) && "min-h-full", + (showIntro || showTimelineError || showGenericEmpty) && + "min-h-full", showMessageList && !showIntro && !useTimelineVirtualizer && @@ -794,6 +807,9 @@ const MessageTimelineBase = React.forwardRef< {showTimelineSkeleton ? ( ) : null} + {showTimelineError ? ( + + ) : null} {activeDirectMessageIntro ? (
void; +}) { + return ( +
+

+ Couldn't load messages +

+

+ The channel history didn't load. Check your connection and try + again. +

+ {onRetry ? ( + + ) : null} +
+ ); +} From eed74bde2f4797714335ac10c56c0b0244c1def4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 29 Aug 2026 19:14:28 -0400 Subject: [PATCH 099/101] feat(buzz-agent): surface stop reason and silent-turn WARN in telemetry (#7038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two telemetry additions to make a known silent-death failure mode visible in harness logs. **1. `stop` field on `llm: call completed` INFO** (`crates/buzz-agent/src/llm.rs`) The `ProviderStop` value was already parsed and stored on `LlmResponse` but never emitted in the log line. Without it, "model chose `end_turn`" vs "gateway truncated/refused" is indistinguishable from telemetry alone. **2. WARN on silent-turn signature** (`crates/buzz-agent/src/agent.rs`) Emits a `WARN` when a turn produces no publish, no visible assistant text, and either near-zero or absent output tokens. The WARN logic is extracted into `warn_if_silent_turn` (pure synchronous function) so the seam is testable without the async run loop. Three independent gates before the WARN fires: 1. **`!buzz_reply_call_seen`** — no publish attempt in any round, tracked unconditionally via the existing `is_buzz_reply_call` matcher. Read-only tool calls do NOT suppress the WARN; a turn that ran tools but never published and died at 3 tokens is still a silent death. 2. **`text_is_empty`** — no visible assistant text in the final round. A terse reply like "OK" (≤12 tokens, non-empty) is not a silent death. 3. **Token check** — two distinct WARN messages: - `Some(t) where t <= 12`: near-zero token count, the observed failure signature (2–12 tokens) - `None` usage: provider omitted token counts entirely, separately diagnostic Tests use a scoped `tracing_subscriber` layer (same pattern as the existing stall-warn tests in `llm.rs`) to exercise the WARN seam directly: - Canonical signature (no publish, no text, 4 tokens) → 1 WARN - Non-empty assistant text → 0 WARNs - Publish seen → 0 WARNs - `None` usage (no publish, no text) → 1 WARN ## Why Recurring silent-death incident in a specific agent×channel combination: sessions die with 1 LLM call, 2–12 output tokens, no tool calls, no message, no error — recorded as a "successful" turn. The harness log shows the token count but not the `stop_reason`, leaving the root cause undiagnosable without request-level tracing. The observed shape also includes tool-step-then-3-token-death (one tool call, then silence) — the publish-aware gate catches both shapes. Context thread: buzz://message?channel=91fd9ca1-cf04-4ef7-b18f-aa2aee55692b&id=e3f1693f2e29f26a0c840f8054d592270c1504beacdc1d9c2063d8ab82960a06 ## Scope Logging and telemetry only. No behavior change, no retry-logic change, no stop-reason mapping change. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-agent/src/agent.rs | 238 ++++++++++++++++++++++++++++++++- crates/buzz-agent/src/llm.rs | 1 + 2 files changed, 238 insertions(+), 1 deletion(-) diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 0743a75667b..5125b280747 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -68,6 +68,12 @@ fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize { /// [`Config::require_reply`](crate::config::Config::require_reply). const MAX_REPLY_NAGS: u32 = 2; +/// Output-token upper bound (inclusive) for the silent-death signature: the +/// observed failure emits 2–12 tokens. Turns with `output_tokens <= 12` +/// and no prior tool call are flagged. Legitimate one-sentence replies land +/// well above this value even in the most terse case. +const SILENT_TURN_TOKEN_THRESHOLD: u64 = 12; + /// Server label on the synthetic reply-guard objection. /// /// Not a real MCP server. It rides the same tool-result path as `_Stop` hook @@ -348,6 +354,11 @@ impl RunCtx<'_> { // // Named for what it proves: a *recognized attempt* to publish, not a // successful publish. See `is_buzz_reply_call`. + // Tracks whether a publish-shaped tool call was seen this turn, updated + // unconditionally (not gated on `require_reply`) so the silent-turn + // diagnostic has a turn-level view regardless of config. A turn that + // ran read-only tools and then died at 3 tokens IS a silent death; + // only a genuine publish should suppress the WARN. let mut buzz_reply_call_seen = false; let mut reply_nags = 0u32; // Per-`run()` reactive context-recovery budget. Per-turn, not @@ -696,12 +707,33 @@ impl RunCtx<'_> { "provider: stop=tool_use but zero tool_calls".into(), )); } + // Capture before response.text is moved into history. + let text_is_empty = response.text.trim().is_empty(); self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: Vec::new(), reasoning_details: response.reasoning_details.clone(), }); let stop = map_stop(response.stop); + // Diagnostic: warn when no publish was seen across the whole + // turn, the final response has no visible text, and the + // token count looks silent. Two independent gates: + // 1. `!buzz_reply_call_seen` — no publish attempt in any + // round (read-only tool calls do NOT suppress: a turn + // that ran tools but never published then died at 3 + // tokens is still a silent death). + // 2. `text_is_empty` — model emitted no visible text + // (a terse reply like "OK" is not silent). + // 3. token count or usage-absent check. + // Fires before the `_Stop` hook so the warning appears in + // the log even if the hook rejects the stop and the loop + // continues. Does not alter control flow. + warn_if_silent_turn( + buzz_reply_call_seen, + text_is_empty, + response.output_tokens, + response.stop, + ); // Only gate genuine end_turn — don't override max_tokens/refusal. if stop == StopReason::EndTurn { if stop_rejections >= self.cfg.stop_max_rejections { @@ -746,7 +778,10 @@ impl RunCtx<'_> { } // Deliberately after truncation: a publish-shaped call that was // discarded never runs, so it must not suppress the reminder. - if self.cfg.require_reply && !buzz_reply_call_seen { + // Updated unconditionally (not gated on `require_reply`) so the + // silent-turn diagnostic has a publish-aware turn-level signal + // regardless of config. + if !buzz_reply_call_seen { buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); } self.history.push(HistoryItem::Assistant { @@ -1286,10 +1321,69 @@ fn map_stop(p: ProviderStop) -> StopReason { } } +/// Returns `true` when a reported output-token count is at or below the +/// silent-death threshold. The observed failure signature is 2–12 tokens. +/// +/// Takes a bare `u64` — the caller handles `None` usage separately (a +/// provider that omits token counts is a distinct diagnostic case, not +/// automatically "near-zero"). +/// +/// Extracted as a pure function so it can be tested without standing up an +/// async agent loop. +fn is_silent_turn(output_tokens: u64) -> bool { + output_tokens <= SILENT_TURN_TOKEN_THRESHOLD +} + +/// Emits the silent-turn diagnostic WARN when the turn produced no publish, +/// no visible text, and either near-zero or absent output tokens. +/// +/// `buzz_reply_call_seen` is the publish-aware gate (from +/// `is_buzz_reply_call`), updated unconditionally regardless of +/// `require_reply`. Read-only tool calls do NOT suppress the WARN — a turn +/// that ran tools but never published and then died at 3 tokens is a silent +/// death. +/// +/// Two distinct WARN shapes: +/// - Near-zero token count (`output_tokens <= 12`): canonical silent-death. +/// - Unknown usage (`None`) with no publish and no text: separately +/// diagnostic; does not assert near-zero since the count is unknown. +/// +/// Extracted as a free function so the WARN seam can be exercised by a +/// scoped tracing subscriber without standing up the full async run loop. +fn warn_if_silent_turn( + buzz_reply_call_seen: bool, + text_is_empty: bool, + output_tokens: Option, + stop: ProviderStop, +) { + if buzz_reply_call_seen || !text_is_empty { + return; + } + match output_tokens { + Some(t) if is_silent_turn(t) => { + tracing::warn!( + stop = ?stop, + output_tokens = t, + "agent: turn ended with no publish attempt and near-zero output tokens — possible silent model/gateway early-stop" + ); + } + None => { + tracing::warn!( + stop = ?stop, + "agent: turn ended with no publish attempt and no usage reported — cannot confirm output size" + ); + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tracing_subscriber::layer::SubscriberExt; /// `truncate_history` cannot serve as the context-window fallback: it is /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body @@ -1635,4 +1729,146 @@ mod tests { "three identical rounds must remain consistently proven" ); } + + // ── is_silent_turn (pure predicate) ───────────────────────────────────── + + /// Counts WARN events emitted by `warn_if_silent_turn` calls inside `f`. + /// + /// Identifies silent-turn WARNs by target (`buzz_agent::agent`) + WARN + /// level + presence of the `stop` field, which is unique to these two + /// WARNs in this module. Using the target avoids parsing message strings, + /// which are routed through `record_debug` as `Display`-formatted values + /// and are not reliably interceptable via `record_str` across tracing + /// versions. + fn count_silent_turn_warnings(f: impl FnOnce()) -> usize { + struct Capture { + count: Arc, + } + struct Visitor { + saw_stop: bool, + } + impl tracing::field::Visit for Visitor { + fn record_debug(&mut self, field: &tracing::field::Field, _: &dyn std::fmt::Debug) { + if field.name() == "stop" { + self.saw_stop = true; + } + } + } + impl tracing_subscriber::Layer for Capture { + fn on_event( + &self, + event: &tracing::Event<'_>, + _: tracing_subscriber::layer::Context<'_, S>, + ) { + if *event.metadata().level() != tracing::Level::WARN { + return; + } + if event.metadata().target() != "buzz_agent::agent" { + return; + } + let mut v = Visitor { saw_stop: false }; + event.record(&mut v); + if v.saw_stop { + self.count.fetch_add(1, Ordering::SeqCst); + } + } + } + let count = Arc::new(AtomicUsize::new(0)); + let sub = tracing_subscriber::registry().with(Capture { + count: count.clone(), + }); + tracing::subscriber::with_default(sub, f); + count.load(Ordering::SeqCst) + } + + /// Predicate: values within the observed failure range (2–12) fire. + /// Pair (0, 12) catches an always-false mutation and an off-by-one at 12. + #[test] + fn is_silent_turn_fires_at_and_below_threshold() { + assert!( + is_silent_turn(0), + "zero output tokens must be a silent turn" + ); + assert!( + is_silent_turn(SILENT_TURN_TOKEN_THRESHOLD), + "exactly at threshold (12) must be a silent turn — 12 is in the observed range" + ); + } + + /// One above the threshold must NOT fire, catching `<` vs `<=` and + /// always-true mutations. + #[test] + fn is_silent_turn_silent_above_threshold() { + assert!( + !is_silent_turn(SILENT_TURN_TOKEN_THRESHOLD + 1), + "one above threshold (13) must not be a silent turn" + ); + } + + // ── warn_if_silent_turn (WARN seam) ─────────────────────────────────── + + /// The canonical silent-death signature — no publish, no text, ≤12 tokens + /// — must emit exactly one WARN. Deleting the WARN call, weakening the + /// token check, or hardcoding `buzz_reply_call_seen = true` are all caught. + #[test] + fn warn_if_silent_turn_fires_for_canonical_signature() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish seen + true, // no text + Some(4), // 4 tokens — in the 2–12 range + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 1, "canonical silent-death must emit exactly one WARN"); + } + + /// A turn that ends with non-empty assistant text is NOT a silent death + /// even if token count is low — a terse reply like "OK" is legitimate. + /// Deleting the `text_is_empty` gate would cause this to fail. + #[test] + fn warn_if_silent_turn_silent_for_nonempty_text() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish + false, // text IS present + Some(3), // low tokens — would fire without the text gate + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 0, "a turn with non-empty assistant text must not WARN"); + } + + /// A turn that published (buzz_reply_call_seen = true) then ended with a + /// short final completion must not trigger the WARN. This is the normal + /// publish-then-wrap pattern. Deleting the `buzz_reply_call_seen` gate + /// would cause this to fail. + #[test] + fn warn_if_silent_turn_silent_after_publish() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + true, // publish seen + true, // no text in final round + Some(0), // zero tokens — would fire without the publish gate + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 0, "a turn where a publish ran must not WARN"); + } + + /// Unknown usage (None) with no publish and no text emits the distinct + /// "no usage reported" WARN. Mutating the None arm to fall through to + /// `_ => {}` would cause this. + #[test] + fn warn_if_silent_turn_fires_distinct_warn_for_none_usage() { + let n = count_silent_turn_warnings(|| { + warn_if_silent_turn( + false, // no publish + true, // no text + None, // provider omitted usage + ProviderStop::EndTurn, + ); + }); + assert_eq!(n, 1, "unknown-usage silent turn must emit exactly one WARN"); + } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 55c85bb0c5a..1d46c16e163 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -229,6 +229,7 @@ impl Llm { input_tokens = ?response.input_tokens, cached_input_tokens = ?response.cached_input_tokens, output_tokens = ?response.output_tokens, + stop = ?response.stop, "llm: call completed" ); } From c3132c3ee982d194cd0198ad07b57ec8bd726e4e Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 31 Aug 2026 12:14:35 +1000 Subject: [PATCH 100/101] feat(desktop): use segmented controls for channel creation (#6845) ## Summary - replace the two-option Type and Visibility dropdowns in the Create channel dialog with single-click segmented controls - keep Expires after as a dropdown and preserve the existing dropdown controls in edit and management dialogs - update channel creation end-to-end coverage for the direct controls ## Before Default Ongoing/Public state: ![Create channel before - default Ongoing and Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-default.png) Type dropdown open, showing the extra selection click: ![Create channel before - Type dropdown open](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/before-type-dropdown.png) ## After Default Ongoing/Public state: ![Create channel after - default Ongoing and Public](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-default-reversed.png) Temporary/Private state with the Expires after row visible: ![Create channel after - Temporary and Private](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6845/after-temporary-private-reversed.png) ## Verification - Biome and TypeScript checks pass - 5,508 desktop unit tests pass - 88 channel smoke tests pass - source guards pass --------- Signed-off-by: Matt Toohey Co-authored-by: Claude Fable 5 --- .../ui/ChannelPermissionsSettings.tsx | 121 +++++++++++------- .../channels/ui/ChannelTypeSettings.tsx | 61 +++++++-- .../sidebar/lib/useCreateChannelForm.ts | 6 - .../sidebar/ui/CreateChannelFormFields.tsx | 16 ++- desktop/src/shared/ui/segmented-control.tsx | 4 + desktop/tests/e2e/channels.spec.ts | 41 +++--- .../welcome-agent-modal-screenshots.spec.ts | 2 - 7 files changed, 157 insertions(+), 94 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx index eb7ee4739c8..8914af29331 100644 --- a/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelPermissionsSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Globe, Lock } from "lucide-react"; import type { ChannelVisibility } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; @@ -10,17 +10,25 @@ import { DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { cn } from "@/shared/lib/cn"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; + +const VISIBILITY_OPTIONS = [ + { value: "private", label: "Private", Icon: Lock }, + { value: "open", label: "Public", Icon: Globe }, +] as const; export function ChannelPermissionsSettings({ disabled, onVisibilityChange, testIdPrefix, visibility, + variant = "dropdown", }: { disabled?: boolean; onVisibilityChange: (visibility: ChannelVisibility) => void; testIdPrefix: string; visibility: ChannelVisibility; + variant?: "dropdown" | "segmented"; }) { const visibilityLabel = visibility === "private" ? "Private" : "Public"; @@ -28,57 +36,76 @@ export function ChannelPermissionsSettings({
- Visibility - - - - - event.preventDefault()} - style={{ - minWidth: "var(--radix-dropdown-menu-trigger-width)", - }} - > - - onVisibilityChange( - nextVisibility === "private" ? "private" : "open", - ) - } - value={visibility} - > - + Visibility + + {variant === "segmented" ? ( + + ) : ( + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + onVisibilityChange( + nextVisibility === "private" ? "private" : "open", + ) + } + value={visibility} > - Private - - - - + + Public + + + Private + + + + + )}
); } diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 82fd1c9f7a1..3f7a2d907ed 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -1,4 +1,4 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, ClockFading, Hash } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { @@ -11,6 +11,7 @@ import { } from "@/features/channels/lib/ephemeralChannel"; import { useIsProjectHomeChannel } from "@/features/projects/lib/projectHomeChannel"; import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -19,9 +20,15 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; import { EditableInfoFieldRow } from "./ChannelManagementSheetRows"; import { ChannelTypePicker } from "./ChannelTypePicker"; +const CHANNEL_TYPE_OPTIONS = [ + { value: "temporary", label: "Temporary", Icon: ClockFading }, + { value: "ongoing", label: "Ongoing", Icon: Hash }, +] as const; + const EPHEMERAL_TIMEOUT_OPTIONS = [ { label: "30 minutes", seconds: 30 * 60 }, { label: "1 hour", seconds: 60 * 60 }, @@ -76,6 +83,7 @@ export function ChannelTypeSettings({ temporary, testIdPrefix, ttlSeconds, + variant = "dropdown", }: { channelId?: string | null; disabled?: boolean; @@ -87,6 +95,7 @@ export function ChannelTypeSettings({ temporary: boolean; testIdPrefix: string; ttlSeconds: number; + variant?: "dropdown" | "segmented"; }) { const projectHome = useIsProjectHomeChannel(channelId); const lifecycle = channelLifecycle({ projectHome, temporary }); @@ -116,18 +125,39 @@ export function ChannelTypeSettings({ className="flex items-center justify-between gap-3 px-3 py-3" data-testid={`${testIdPrefix}-channel-type-row`} > - {label} - onTemporaryChange(next === "temporary")} - onOpenChange={onOpenChange} - open={open} - testId={`${testIdPrefix}-channel-type`} - /> + + {label} + + {variant === "segmented" ? ( + onTemporaryChange(value === "temporary")} + optionTestIdPrefix={`${testIdPrefix}-channel-type-option`} + options={CHANNEL_TYPE_OPTIONS} + testId={`${testIdPrefix}-channel-type`} + value={temporary ? "temporary" : "ongoing"} + /> + ) : ( + + onTemporaryChange(next === "temporary") + } + onOpenChange={onOpenChange} + open={open} + testId={`${testIdPrefix}-channel-type`} + /> + )}
{temporary && !projectHome ? ( @@ -144,7 +174,10 @@ export function ChannelTypeSettings({ data-testid={`${testIdPrefix}-ephemeral-settings`} >