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 01/21] 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 8fdea4b3c0..bbfcd8ecfe 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 ff8a0e7703..9099beff69 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 7c41f6bfe2..9cbb4444ab 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 0000000000..f84c534d30 --- /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 31559777d2..461f29e7fa 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 97a03fdad5..8ec82beebb 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 c0ad03d936..dc7c0f4b5a 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 71a5eb3806..428aa4d2a7 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 17ca7a7bb3..b1548c6937 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 bfaf2ba200..bdb26c1930 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 969bf67ca6..71db052369 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 6254afd8c7..68df9bc05c 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 e0a1001788..f30353af72 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 5493a47b1e..c565ee0f7b 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 8b457a7adf..a3c1e7f172 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 0000000000..9fc62331f8 --- /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 0000000000..0927b40b2c --- /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 a188f51da0..0000000000 --- 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 d4a183c2ff..0000000000 --- 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 14ec110add..618f3fc991 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 81ef3de42d..b16187ce18 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 421799d373..bfe66afcff 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 48896c5c51..53399cd2f3 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 bb1c2909b6..25a6b68986 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 f174d50408..d46d27df9e 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 0000000000..9431fced04 --- /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 4abe03ee09..965e498b41 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 e4028f0171..3252a025c0 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 0000000000..4bf082786b --- /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 af573373d3..7868e577da 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 ed94e6b176..2637b94a80 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 42f1d82835..aec9d19542 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 02/21] 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 c0e55184b1..135224d01d 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 70c7bf10a8..59d68e4057 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 52d91dea3e..6c6a53f91b 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 03/21] 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 59d68e4057..562f890c0f 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 0d5e0f453f..2c858474a4 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 6c6a53f91b..0e27f771f9 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 04/21] 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 9eb24a970c..84934d1826 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 202ec96869..25d3cbaf9a 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 05/21] 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 b956b672d0..984d69ff89 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 1a6403fb24..0c99eaf16d 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 9be4548249..e443357ae8 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 5fd2593c79..875a17aedf 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 4816b3ddc3..165d8a1ac2 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 f1b618b0d6..e44e6e097b 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 25d73b28ca..ad1acf87c3 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 06/21] 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 | 447 ++-- 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, 3246 insertions(+), 247 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 7f0eb97d36..120acca3c9 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 0000000000..a99822ede5 --- /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 d50ed8c46e..5f1881facd 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 0000000000..9d1bb63371 --- /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 8754842702..3e0f6982b4 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 8517fa70b9..b298c192d0 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 deb6869cb2..2258a493c9 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); - // 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 - .map((e) => e.getTagValue('d')) - .whereType() - .toSet() - .toList(); - _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), + // 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, ); - // 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. + // Step 1: find the channels I'm a member of via kind:39002. + final memberships = await _fenced( + fence, + _fetchChannelMemberships(session, myPk), + ); + final memberChannelIds = memberships + .map((e) => e.getTagValue('d')) + .whereType() + .toSet(); + _cacheMemberSnapshots(memberships, replaceAll: true); + + // 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; + } + + // 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); - } - } + // 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 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; - } - } - - 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 563a46c9e8..514629b1f7 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 9cd955ce52..68fa315236 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 fa5d3a0e11..ea6c38cdfd 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 e295132360..8b4c903b56 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 07/21] 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 0000000000..7db052bc3e --- /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 e2cab7ddc8..e910f565b4 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 2bf8ab3a11..ca2a644c9f 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 81ab5a4b39..1fb19432f1 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 f23b68b146..80eefc5b98 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 f6e6a25f34..e6d3e9330d 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 0000000000..cdfc16fe81 --- /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 08/21] 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 461f29e7fa..5de2d32b80 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 dc7c0f4b5a..627e632643 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 768b2ad7db..9693f1563a 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 dfa9c84060..ef3cc8d4cd 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 1e9ccb3839..3371658a3b 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 b4f0df6fc0..9055a2a48a 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 3252a025c0..9002d751c8 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 09/21] 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 df9af09e67..fa8bb4e9f6 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 10/21] 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 05267ed7c1..491da241e8 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 0bbdeaa980..43df76f681 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 65878e64d8..1ef121fff4 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 af42ebd34b..3f93df5b97 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 f613dea711..8a95831808 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 0000000000..abccc693ad --- /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 0000000000..d6541363d2 --- /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 e020bd50bf..5ea5552152 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 5f7b11db29..04cb2ff8cd 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 0fc68f7f3a..3f2485cc1f 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 5f1881facd..55fec3f08e 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 b6ce235f65..f126f1210c 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 0000000000..0878b78e6b --- /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 0000000000..0c87c10b50 --- /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 0000000000..022a730009 --- /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 0000000000..b93175bf73 --- /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 0000000000..7c9ce4d970 --- /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 0000000000..77f2b557c4 --- /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 0000000000..220462ab53 --- /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 0000000000..3ad51acb83 --- /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 0000000000..ff8e7f5dc7 --- /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 0000000000..3760b45661 --- /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 0000000000..fb60dbaaf2 --- /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 0000000000..efb6900460 --- /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 0000000000..4cc495eecb --- /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 0000000000..1712c0c99b --- /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 0000000000..12e4e3efd7 --- /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 6fa351d630..38231d0f0d 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 0000000000..939984dbf5 --- /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 0000000000..f37c113c9f --- /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 514629b1f7..0e489ed486 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 88965e251f..a0ee79a51d 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 eb4194eb15..5fd934f251 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 8ab89f9e96..7f989b29db 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 bd2c8fae32..ce0e0d3bf5 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 41efcecb1e..f44321746e 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 0000000000..e1395a78b6 --- /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 7831c1b32b..40b6f81b79 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 c04bf952ae..fd8d3fafcd 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 3161a70362..cbe59bcd33 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 0000000000..527871f11f --- /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 98039ff2ab..c041e4399f 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 787e66d577..9c46e3bec3 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 7b40739c4c..0a5379cf33 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 fed46a8d60..14551f76fd 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 d20b4dcf99..42d0b54181 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 0000000000..0282ea62bb --- /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 0000000000..59a296f2b9 --- /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 0000000000..656a1dfaee --- /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 0000000000..c2ab6f4dec --- /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 0000000000..ef84d4549e --- /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 d83c73b98d..0d4e95a80c 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 cd1eac39ac..6c51dc9cc5 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 96ad80aa00..f770e1ccb1 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 4178dbc76a..195dcc25ad 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 68fa315236..44f8714c64 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 0000000000..6305926632 --- /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 0000000000..1c92ac9fdc --- /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 0000000000..d100e0f5ae --- /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 0000000000..62c5455dc1 --- /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 0000000000..2cffbb0891 --- /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 0000000000..6eea4c9915 --- /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 4b4f7ed192..5638656b71 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 63be8d81e3..a22c8c58f8 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 8b4c903b56..8e8c4f81ec 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 2a197222ab..b8e3464c2e 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 9242c5b4bc..018aeca586 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 773f957a4b..e3b66ec601 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 435423905f..8d8e02ee8a 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 cfaab6d4f1..4631cf58da 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 8f1f40a8b1..e7a4d534c8 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 11/21] 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 | 194 +++++++- .../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, 1439 insertions(+), 38 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 5ea5552152..655095045e 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 34d359040b..e0879eb022 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 2258a493c9..03f29c58a1 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 391a4f1a49..a7f509de13 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 b3b1e58144..a665dded1b 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,14 +242,14 @@ class InviteJoinNotifier extends Notifier { pubkey: keys.public, nsec: keys.nsec, sensitiveActionPolicy: SensitiveActionPolicy.disabledByUser, + starterSetupIncomplete: true, ); await ref .read(authProvider.notifier) .authenticateWithCommunity(community); - state = state.copyWith( - status: InviteJoinStatus.success, - communityName: community.name, - ); + _pendingStarterSetupCommunity = community; + state = state.copyWith(isStarterSetupRecovery: true); + await startStarterSetupRecovery(); } catch (error) { final requiresFreshInvite = _requiresFreshInvite(error); state = state.copyWith( @@ -178,7 +260,90 @@ class InviteJoinNotifier extends Notifier { } } + /// 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) { + Object visibleError = error; + try { + await _saveStarterSetupState(community, incomplete: true); + } catch (storageError) { + visibleError = storageError; + } + state = state.copyWith( + status: InviteJoinStatus.error, + 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 b0e0cb6f7b..deec5ba7ef 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 833d48b207..83ded086f2 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 409de10e39..6763f953ee 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 5700bfae96..04b8c16671 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 ea6c38cdfd..04f7cd917b 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 d1e2e63384..e7170c2afe 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 ea6bc226c2..a26308b284 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 2e52f79490..83f7dc70c9 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 0000000000..876429b7d9 --- /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 e00f3cb17d..5c5b337a45 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 12/21] 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 0000000000..4fb72329b7 --- /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 0000000000..5ff853720b --- /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 c4776564e3..7a21f0dfbe 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 e5513247d5..717e62153e 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 193695f0cd..8c476b2863 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 bccc163ed4..dbdb2ed234 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 760ef58073..7ac3930b84 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 68df9bc05c..aac93d7f8d 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 1aaad0e609..410f2d8672 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 0000000000..2e9455a1e5 --- /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 20c561e798..8dd22bb94a 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 61e9211480..1a35666478 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 0dc4b0e4d6..39e8a6688d 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 0000000000..83e1343db6 --- /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 0000000000..f75bc04b70 --- /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 c32c809a88..b794ce87be 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 0000000000..bda71513c0 --- /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 f9c57f6656..465a0a6b61 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 9192c64952..373ef80f45 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 a3c1e7f172..d28f292608 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 87ec604464..ac1ed2d8c3 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 65c7a68fec..7aebf86b2f 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 11163aa8c7..4fcf0f067a 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 517b9afb00..e1bc409802 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 ea478aff4e..b6cad59567 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 adb525ae74..af3997f815 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 fbbe4b9f67..288b381291 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 9a93086cf8..d24c274413 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 13/21] 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 9099beff69..69250c4b53 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 bd3fefb125..f408ef2afd 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 0000000000..4ae3924932 --- /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 dbdb2ed234..4086a4e1c1 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 aac93d7f8d..240a9ad70c 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 2a90971dde..5a3a7c4041 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 0000000000..47beb1c7a8 --- /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 ebc1b35ae7..6dc1190910 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 0000000000..bad72b3339 --- /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 0000000000..0ce446daec --- /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 a0374fbe2b..5334d58875 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 3bfd934947..4e23fb2245 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 d2650c84ac..01fbcda7ff 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 0000000000..f70143f87b --- /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 0000000000..a13d46286e --- /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 25a6b68986..4d602348f9 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 59f3264b02..11344ea574 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 9a465d3da0..f667fcd2a2 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 0000000000..8d3777201a --- /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 0000000000..d76bb709ec --- /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 0000000000..291600e736 --- /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 14/21] 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 3695275762..57212aeafe 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 9587c312ca..59b15c78ba 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 fbbcdf026c..781ac8a196 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 e443357ae8..9d0c382ac3 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 8136ff9f08..6cd15c69b4 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 ad1acf87c3..8f0300af16 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 18/21] 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 69250c4b53..be15c75587 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 4fcf0f067a..9c490edb07 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 16ca3900ec..9a4bf7f07c 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 1b7451cbcc..2385be0f6c 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 0000000000..a7ef2f10c6 --- /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 34cf071e77..b37ce56dd1 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 19/21] 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 5de2d32b80..1e221b6bd1 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 0000000000..1bb5174868 --- /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 428aa4d2a7..613040b809 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 0000000000..bcf0b209ff --- /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 48ce8d2db6..8556424f87 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 3f8655a3aa..198e0fe91c 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 58e4b4a960..8ac97402aa 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 0000000000..6639d85b0f --- /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 0000000000..50eb5ef672 --- /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 39caec253a..78f6e30606 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) ? (