From ee992ff0822f44d1c308822f116cb9d26f9a3386 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 21:24:33 -0600 Subject: [PATCH 01/27] fix(desktop): restore release agent mentions (#6182) ## Summary - preserve OSS relay-agent mentions under shared channel and agent policy - restrict owner-only release builds to relay agents with cryptographically verified ownership matching the current user - remove the remote policy replay loop that repeatedly rebuilt the relay directory, while retaining focused polling and send-time revalidation - query relay profiles and managed policies by exact author coordinates to prevent noisy events from crowding out valid agents ## Diagnosis The packaged Block release compiles `BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY`, while ordinary OSS/dev builds do not. Relay-discovered agents were filtered as if all remote agents were outside that owner-only boundary, so a same-owner agent running on another machine disappeared in the release even though the OSS path could look healthy. The fix uses the NIP-OA-authenticated owner from the relay directory as the cross-machine proof. Internal builds admit only verified same-owner agents and fail closed for missing, mismatched, stale/revoked, or unavailable ownership evidence. OSS builds retain shared channel/policy behavior. ## Validation - desktop focused unit coverage: 39 tests passed - desktop typecheck and focused static checks passed - focused Tauri Rust policy/directory tests passed - production-style E2E build succeeded - targeted Playwright mention scenarios passed: - owner-only release hides other-owned relay agent - owner-only release shows verified same-owner relay agent - OSS build shows shared `anyone` agent - repository pre-push hook passed on `4d40b6e5bb032f2c0755127172c50dee213f65a3`: - branch skew - desktop check and typecheck - desktop tests - Rust tests - Tauri checks - mobile tests --------- Signed-off-by: Wes Co-authored-by: Carl --- .../agent_discovery/relay_directory.rs | 48 +++++- .../src/commands/agent_models_tests.rs | 17 ++ .../src/commands/agent_models_update.rs | 9 + .../src/commands/personas/inbound.rs | 1 + .../personas/inbound/inbound_tests.rs | 6 +- .../src-tauri/src/managed_agents/runtime.rs | 2 + .../src/managed_agents/runtime/tests.rs | 4 +- .../src/managed_agents/spawn_snapshot.rs | 26 +-- .../managed_agents/spawn_snapshot/tests.rs | 99 ++++++++++- .../src-tauri/src/migration/backfill_tests.rs | 4 + desktop/src/features/agents/AGENTS.md | 12 ++ .../lib/agentAutocompleteEligibility.test.mjs | 18 ++ .../lib/agentAutocompleteEligibility.ts | 18 +- .../agents/lib/useAgentsDataRefresh.test.mjs | 106 ++---------- .../agents/lib/useAgentsDataRefresh.ts | 161 ++---------------- .../agents/lib/usePersonaSync.test.mjs | 52 +++++- .../src/features/agents/lib/usePersonaSync.ts | 45 ++++- desktop/src/testing/e2eBridge.ts | 3 + desktop/tests/e2e/mentions.spec.ts | 30 ++++ 19 files changed, 400 insertions(+), 261 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index b97cefc0ee8..a4da9c475d9 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -77,10 +77,22 @@ async fn query_all_relay_pages( } } +fn owner_only_relay_directory() -> bool { + crate::managed_agents::owner_only_access_build() +} + +fn retain_verified_owner( + verified_owners: &mut std::collections::HashMap, + required_owner: &str, +) { + verified_owners.retain(|_, owner| owner.eq_ignore_ascii_case(required_owner)); +} + pub(crate) async fn list_relay_agents_for_state( state: &AppState, ) -> Result, String> { let viewer_pubkey = current_user_pubkey(state)?; + let owner_only = owner_only_relay_directory(); let relay_pubkey = identity_archive::fetch_relay_self(state) .await? .ok_or_else(|| "relay agent membership authority is unavailable".to_string())?; @@ -128,7 +140,14 @@ pub(crate) async fn list_relay_agents_for_state( // query. Each exact `(owner, d=agent)` filter returns at most one current // replaceable event, so forged 30177 coordinates cannot amplify or crowd // the authentic policy out of a bounded result page. - let verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); + let mut verified_owners = nostr_convert::verified_agent_owners_from_profiles(&profile_events); + // The internal capability narrows the remote directory to cryptographically + // verified agents owned by the active user. Same-owner siblings remain + // mentionable because they are inside the harness's owner-only boundary; + // all cross-owner coordinates are discarded before policy lookup. + if owner_only { + retain_verified_owner(&mut verified_owners, &viewer_pubkey); + } let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); let mut managed_agent_events = Vec::new(); for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { @@ -144,6 +163,14 @@ pub(crate) async fn list_relay_agents_for_state( &managed_agent_events, &profile_events, ); + if owner_only { + agents.retain(|agent| { + agent + .owner_pubkey + .as_deref() + .is_some_and(|owner| owner.eq_ignore_ascii_case(&viewer_pubkey)) + }); + } agents.retain(|agent| member_agent_channel_ids.contains_key(&agent.pubkey)); for agent in &mut agents { agent.channel_ids = member_agent_channel_ids @@ -163,6 +190,25 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result bool { + // Stored policy remains portable across OSS and owner-only builds, but a + // marked build always projects both states to the same owner-only runtime + // gate. Do not restart a fleet merely because relay state differs in bytes + // that this build cannot execute. + if enforced_owner_only { + return false; + } prospective_mode != current_mode || (prospective_mode == crate::managed_agents::RespondTo::Allowlist && prospective_allowlist != current_allowlist) @@ -169,6 +177,7 @@ pub async fn update_managed_agent( &record.respond_to_allowlist, prospective_mode, &prospective_allowlist, + crate::managed_agents::owner_only_access_build(), ); ensure_access_policy_change_supported(record, access_policy_changed)?; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index 42f720915a3..c322e6cb6e8 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -585,6 +585,7 @@ fn apply_inbound_managed_agent( &previous_allowlist, local.respond_to, &local.respond_to_allowlist, + crate::managed_agents::owner_only_access_build(), ); } false diff --git a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs index c7235bd034a..c0526222151 100644 --- a/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs +++ b/desktop/src-tauri/src/commands/personas/inbound/inbound_tests.rs @@ -265,7 +265,11 @@ fn inbound_managed_agent_drops_injected_secrets_and_harness() { let mut agents = vec![local_agent()]; let access_changed = apply_inbound_managed_agent(&mut agents, AGENT_PUBKEY, content); - assert!(access_changed, "Anyone must trigger a runtime refresh"); + assert_eq!( + access_changed, + !crate::managed_agents::owner_only_access_build(), + "only an effective access change may trigger a runtime refresh" + ); let a = &agents[0]; // Secrets / harness / runtime — every one preserved from the local record. assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e9955..865d31da1ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -252,6 +252,7 @@ pub fn build_managed_agent_summary( &teams, &key.relay_url, global_config, + super::owner_only_access_build(), ); (runtime, current) }); @@ -857,6 +858,7 @@ pub fn spawn_agent_child( system_prompt: effective_prompt.as_deref(), model: effective_model.as_deref(), provider: effective_provider.as_deref(), + enforced_owner_only: super::owner_only_access_build(), }, ); diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 762b0fe2a61..edb4fad422e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1239,7 +1239,6 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun use std::process::{Command, Stdio}; // Spawn a real child so ManagedAgentProcess's Child field is satisfied. // `true` exits immediately with 0 — just a handle we need for type purposes. - // // Absolute `/usr/bin/true` on unix (present on both macOS and Linux): // parallel tests holding `lock_path_mutex` swap PATH to a tempdir, and a // bare `true` lookup during that window fails with NotFound (observed @@ -1256,13 +1255,14 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun .expect("spawn true for placeholder"); let process = crate::managed_agents::ManagedAgentProcess { child, - log_path: std::path::PathBuf::new(), + log_path: Default::default(), spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( &minimal_record(&"cc".repeat(32)), &[], &[], "wss://relay.example", &Default::default(), + false, ), setup_mode: false, adapter_availability: None, diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index ba2129c9841..357f5f1e26d 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -72,6 +72,9 @@ pub(crate) struct SpawnConfigInputs<'a> { pub system_prompt: Option<&'a str>, pub model: Option<&'a str>, pub provider: Option<&'a str>, + /// Compile-time distribution capability projected at this runtime boundary. + /// The stored record remains portable; only effective spawned access is stamped. + pub enforced_owner_only: bool, } /// The effective spawn configuration of one managed-agent process. @@ -136,7 +139,10 @@ impl SpawnConfigSnapshot { system_prompt, model, provider, + enforced_owner_only, } = inputs; + let (respond_to, respond_to_allowlist) = + super::projected_access_with_policy(record, enforced_owner_only); Self { acp_command: record.acp_command.clone(), command: descriptor.command.clone(), @@ -155,16 +161,14 @@ impl SpawnConfigSnapshot { .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) .flatten(), auth_tag: record.auth_tag.clone(), - respond_to: record.respond_to.as_str().to_string(), - respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then( - || { - // A list spawn would reject is captured raw: the stamped - // snapshot comes from a successful spawn, so any invalid - // edit correctly compares unequal. - super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) - .unwrap_or_else(|_| record.respond_to_allowlist.clone()) - }, - ), + respond_to: respond_to.as_str().to_string(), + respond_to_allowlist: (respond_to == super::types::RespondTo::Allowlist).then(|| { + // A list spawn would reject is captured raw: the stamped + // snapshot comes from a successful spawn, so any invalid + // edit correctly compares unequal. + super::types::validate_respond_to_allowlist(&respond_to_allowlist) + .unwrap_or(respond_to_allowlist) + }), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, // Hash the effective parallelism so over-cap edits that don't change @@ -213,6 +217,7 @@ pub(crate) fn prospective_spawn_config_snapshot( teams: &[TeamRecord], workspace_relay: &str, global: &GlobalAgentConfig, + enforced_owner_only: bool, ) -> SpawnConfigSnapshot { // Prospective re-snapshot: apply the same `apply_persona_snapshot` the // start/restore paths run right before spawning, so this describes what a @@ -262,6 +267,7 @@ pub(crate) fn prospective_spawn_config_snapshot( system_prompt: prompt.as_deref(), model: model.as_deref(), provider: provider.as_deref(), + enforced_owner_only, }) } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index 20e02871eba..89bba15cee4 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -5,6 +5,25 @@ use std::collections::BTreeMap; /// Canonical projection of a prospective snapshot — the exact value the drift /// comparison reads, so these tests assert on drift itself rather than on a /// proxy for it. +fn snapshot_with_policy( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, + enforced_owner_only: bool, +) -> serde_json::Value { + prospective_spawn_config_snapshot( + record, + personas, + teams, + workspace_relay, + global, + enforced_owner_only, + ) + .canonical() +} + fn snapshot( record: &ManagedAgentRecord, personas: &[AgentDefinition], @@ -12,7 +31,7 @@ fn snapshot( workspace_relay: &str, global: &GlobalAgentConfig, ) -> serde_json::Value { - prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical() + snapshot_with_policy(record, personas, teams, workspace_relay, global, false) } fn record() -> ManagedAgentRecord { @@ -225,6 +244,84 @@ fn stored_record_relay_does_not_affect_snapshot() { ); } +#[test] +fn owner_only_mode_and_allowlist_edits_do_not_change_effective_snapshot() { + let mut before = record(); + before.respond_to = RespondTo::Allowlist; + before.respond_to_allowlist = vec!["a".repeat(64)]; + + let mut mode_edited = before.clone(); + mode_edited.respond_to = RespondTo::Anyone; + + let mut allowlist_edited = before.clone(); + allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)]; + + let effective_before = snapshot_with_policy( + &before, + &[], + &[], + "wss://ws.example", + &Default::default(), + true, + ); + for (label, edited) in [ + ("respond-to mode", mode_edited), + ("respond-to allowlist", allowlist_edited), + ] { + assert_eq!( + effective_before, + snapshot_with_policy( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + true, + ), + "portable {label} edit must not create restart drift when both spawns enforce owner-only", + ); + } +} + +#[test] +fn oss_mode_and_allowlist_edits_change_effective_snapshot() { + let mut before = record(); + before.respond_to = RespondTo::Allowlist; + before.respond_to_allowlist = vec!["a".repeat(64)]; + + let mut mode_edited = before.clone(); + mode_edited.respond_to = RespondTo::Anyone; + + let mut allowlist_edited = before.clone(); + allowlist_edited.respond_to_allowlist = vec!["b".repeat(64)]; + + let effective_before = snapshot_with_policy( + &before, + &[], + &[], + "wss://ws.example", + &Default::default(), + false, + ); + for (label, edited) in [ + ("respond-to mode", mode_edited), + ("respond-to allowlist", allowlist_edited), + ] { + assert_ne!( + effective_before, + snapshot_with_policy( + &edited, + &[], + &[], + "wss://ws.example", + &Default::default(), + false, + ), + "OSS spawn must retain restart drift for effective {label} edits", + ); + } +} + #[test] fn respond_to_allowlist_edit_changes_snapshot() { let rec = record(); diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index d277a2aa5fc..754a40769c1 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -137,6 +137,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -153,6 +154,7 @@ fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); assert_eq!( @@ -187,6 +189,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); backfill_standalone_agents_in_dir(&base(dir.path())).unwrap(); @@ -203,6 +206,7 @@ fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { &[], "wss://ws.example", &Default::default(), + false, ); assert_eq!(before.canonical(), after.canonical()); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 53a39824c4e..fa352284dd9 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -202,6 +202,18 @@ with a TypeScript lookup table or an id comparison in a component. panel shell or return navigation, but must not filter or replace profile content. +12. **Owner-only builds discover only verified same-owner remote agents.** + The native `list_relay_agents` boundary authenticates ownership through the + agent's NIP-OA profile, then retains only agents owned by the active user + when the compiled owner-only capability is present. Keep this as the + authoritative backstop: internal builds must never admit cross-owner remote + agents, while same-owner agents on another machine remain inside the + documented owner-only trust boundary. OSS builds retain the complete + policy-filtered relay directory and send-time fail-closed mention + revalidation. Local `agents-data-changed` events refresh only local + persona/team/managed-agent caches; they must never invalidate the remote + relay directory. + ## The tests that enforce this - `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index d7a6e759635..c2171e9d7d6 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -66,11 +66,13 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha assert.equal( relayAgentIsSharedWithUser( { + ownerPubkey: OTHER_OWNER_PUBKEY, respondTo: "owner-only", respondToAllowlist: [], channelIds: ["general"], }, sharedChannelIds, + CURRENT_PUBKEY, ), false, ); @@ -83,6 +85,22 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha ); }); +test("relayAgentIsSharedWithUser: accepts verified same-owner agents across machines", () => { + assert.equal( + relayAgentIsSharedWithUser( + { + ownerPubkey: CURRENT_PUBKEY.toUpperCase(), + respondTo: "owner-only", + respondToAllowlist: [], + channelIds: ["general"], + }, + new Set(["general"]), + CURRENT_PUBKEY, + ), + true, + ); +}); + test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => { const sharedChannelIds = new Set(["general"]); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index a4b235fa04c..516520e2ca3 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -10,7 +10,10 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) { } export function relayAgentIsSharedWithUser( - agent: Pick, + agent: Pick< + RelayAgent, + "channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist" + >, sharedChannelIds: ReadonlySet, currentPubkey?: string | null, ) { @@ -18,6 +21,14 @@ export function relayAgentIsSharedWithUser( ? normalizePubkey(currentPubkey) : null; + if ( + agent.respondTo === "owner-only" && + normalizedCurrentPubkey && + agent.ownerPubkey + ) { + return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey; + } + if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) { return agent.respondToAllowlist .map((pubkey) => normalizePubkey(pubkey)) @@ -31,7 +42,10 @@ export function relayAgentIsSharedWithUser( } export function relayAgentCanRespondInChannel( - agent: Pick, + agent: Pick< + RelayAgent, + "channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist" + >, channelId: string, currentPubkey?: string | null, ) { diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs index 7a3643a3088..a836f9c7dfc 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs @@ -1,101 +1,17 @@ import assert from "node:assert/strict"; -import test, { mock } from "node:test"; +import test from "node:test"; -import { relayClient } from "@/shared/api/relayClient"; -import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; -import { startRelayAgentPolicyRefresh } from "./useAgentsDataRefresh.ts"; +import { relayAgentsQueryKey } from "@/features/agents/hooks"; +import { LOCAL_AGENT_DATA_QUERY_KEYS } from "./useAgentsDataRefresh.ts"; -const coordinates = [ - { ownerPubkey: "owner-a", agentPubkey: "agent-a" }, - { ownerPubkey: "owner-b", agentPubkey: "agent-b" }, -]; +const serializedLocalKeys = LOCAL_AGENT_DATA_QUERY_KEYS.map((key) => + JSON.stringify(key), +); -function event(pubkey, dTag) { - return { - id: "id", - pubkey, - created_at: 1, - kind: KIND_MANAGED_AGENT, - tags: dTag ? [["d", dTag]] : [], - content: "{}", - sig: "sig", - }; -} - -test("remote managed policy refresh accepts only exact authenticated coordinates", async () => { - let onEvent; - let filter; - let unsubscribeCalls = 0; - mock.method(relayClient, "subscribeLive", (nextFilter, listener) => { - filter = nextFilter; - onEvent = listener; - return Promise.resolve(() => { - unsubscribeCalls += 1; - return Promise.resolve(); - }); - }); - - let refreshes = 0; - const stop = startRelayAgentPolicyRefresh(coordinates, () => { - refreshes += 1; - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.deepEqual(filter, { - kinds: [KIND_MANAGED_AGENT], - authors: ["owner-a", "owner-b"], - "#d": ["agent-a", "agent-b"], - limit: 0, - }); - onEvent(event("owner-a", "agent-a")); - assert.equal(refreshes, 1); - - for (const irrelevant of [ - event("owner-x", "agent-a"), - event("owner-a", "agent-x"), - event("owner-a", "agent-b"), // authors×d cross-product - event("owner-a", null), - ]) { - onEvent(irrelevant); - } - assert.equal(refreshes, 1, "irrelevant coordinates must not refresh"); - - stop(); - assert.equal(unsubscribeCalls, 1); - mock.reset(); -}); - -test("stopping before subscription readiness still closes the live query", async () => { - let resolveSubscription; - let unsubscribeCalls = 0; - mock.method( - relayClient, - "subscribeLive", - () => - new Promise((resolve) => { - resolveSubscription = resolve; - }), +test("local agent refresh never invalidates the relay directory", () => { + assert.equal( + serializedLocalKeys.includes(JSON.stringify(relayAgentsQueryKey)), + false, + "local reconciliation must not trigger a relay-wide directory rebuild", ); - - const stop = startRelayAgentPolicyRefresh(coordinates, () => {}); - stop(); - resolveSubscription(() => { - unsubscribeCalls += 1; - return Promise.resolve(); - }); - await new Promise((resolve) => setImmediate(resolve)); - - assert.equal(unsubscribeCalls, 1); - mock.reset(); -}); - -test("no authenticated coordinates creates no global subscription", () => { - let subscriptions = 0; - mock.method(relayClient, "subscribeLive", () => { - subscriptions += 1; - return Promise.resolve(() => Promise.resolve()); - }); - startRelayAgentPolicyRefresh([], () => {})(); - assert.equal(subscriptions, 0); - mock.reset(); }); diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 0349618d114..b086f12a9c4 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -2,94 +2,25 @@ import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { relayClient } from "@/shared/api/relayClient"; -import type { RelayAgent, RelayEvent } from "@/shared/api/types"; -import { KIND_MANAGED_AGENT } from "@/shared/constants/kinds"; import { managedAgentsQueryKey, personasQueryKey, - relayAgentsQueryKey, teamsQueryKey, } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; -const COALESCE_MS = 200; -export const RELAY_POLICY_REFRESH_MIN_INTERVAL_MS = 5_000; - -export type RelayAgentPolicyCoordinate = { - agentPubkey: string; - ownerPubkey: string; -}; - -function eventDTag(event: RelayEvent): string | null { - return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; -} - -/** - * Subscribe only to authenticated managed-agent coordinates already returned by - * the relay directory. The callback repeats the exact owner+d check because a - * combined Nostr filter admits the authors×d cross-product. - */ -export function startRelayAgentPolicyRefresh( - coordinates: RelayAgentPolicyCoordinate[], - onChange: () => void, - onError: (error: unknown) => void = (error) => { - console.warn("Couldn’t subscribe to managed agent policy updates", error); - }, -): () => void { - if (coordinates.length === 0) return () => {}; - - const allowed = new Set( - coordinates.map( - ({ ownerPubkey, agentPubkey }) => - `${ownerPubkey.toLowerCase()}:${agentPubkey.toLowerCase()}`, - ), - ); - const authors = [ - ...new Set(coordinates.map(({ ownerPubkey }) => ownerPubkey)), - ]; - const agentPubkeys = [ - ...new Set(coordinates.map(({ agentPubkey }) => agentPubkey)), - ]; - let disposed = false; - let unsubscribe: (() => Promise) | null = null; - void relayClient - .subscribeLive( - { - kinds: [KIND_MANAGED_AGENT], - authors, - "#d": agentPubkeys, - limit: 0, - }, - (event) => { - const dTag = eventDTag(event); - if ( - dTag && - allowed.has(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) - ) { - onChange(); - } - }, - ) - .then((nextUnsubscribe) => { - if (disposed) void nextUnsubscribe(); - else unsubscribe = nextUnsubscribe; - }) - .catch(onError); - - return () => { - disposed = true; - void unsubscribe?.(); - }; -} +export const LOCAL_AGENT_DATA_QUERY_KEYS = [ + personasQueryKey, + teamsQueryKey, + managedAgentsQueryKey, +] as const; -function relayPolicyCoordinates(agents: RelayAgent[] | undefined) { - return (agents ?? []).flatMap((agent) => - agent.ownerPubkey - ? [{ agentPubkey: agent.pubkey, ownerPubkey: agent.ownerPubkey }] - : [], - ); -} +// Trailing-coalesce local agent-store bursts into one cache refresh. The relay +// directory is deliberately excluded: local persona/team/agent reconciliation +// cannot change remote directory records, and rebuilding that directory is a +// relay-wide operation. Remote data keeps its focused poll and is revalidated +// directly before an agent mention is sent. +const COALESCE_MS = 200; export function useAgentsDataRefresh(): void { const queryClient = useQueryClient(); @@ -107,80 +38,16 @@ export function useAgentsDataRefresh(): void { const unlisten = listen("agents-data-changed", () => { if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { - void queryClient.invalidateQueries({ queryKey: personasQueryKey }); - void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); - void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); - void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + for (const queryKey of LOCAL_AGENT_DATA_QUERY_KEYS) { + void queryClient.invalidateQueries({ queryKey }); + } }, COALESCE_MS); }); - let policyStop = () => {}; - let policyTimer: ReturnType | undefined; - let policyDirty = false; - let policyRefreshInFlight = false; - let policyDisposed = false; - let coordinateKey = ""; - - const refreshPolicyDirectory = () => { - if (policyRefreshInFlight || policyTimer !== undefined) { - policyDirty = true; - return; - } - policyRefreshInFlight = true; - void queryClient - .invalidateQueries({ queryKey: relayAgentsQueryKey }) - .finally(() => { - policyRefreshInFlight = false; - if (policyDisposed) return; - policyTimer = setTimeout(() => { - policyTimer = undefined; - if (policyDirty) { - policyDirty = false; - refreshPolicyDirectory(); - } - }, RELAY_POLICY_REFRESH_MIN_INTERVAL_MS); - }); - }; - - const resubscribePolicy = () => { - const coordinates = relayPolicyCoordinates( - queryClient.getQueryData(relayAgentsQueryKey), - ); - const nextKey = coordinates - .map(({ ownerPubkey, agentPubkey }) => `${ownerPubkey}:${agentPubkey}`) - .sort() - .join("|"); - if (nextKey === coordinateKey) return; - coordinateKey = nextKey; - policyStop(); - policyStop = startRelayAgentPolicyRefresh( - coordinates, - refreshPolicyDirectory, - ); - }; - resubscribePolicy(); - const unsubscribeQueryCache = queryClient - .getQueryCache() - .subscribe((event) => { - if ( - event.query.queryKey.length === relayAgentsQueryKey.length && - event.query.queryKey.every( - (value: unknown, index: number) => - value === relayAgentsQueryKey[index], - ) - ) { - resubscribePolicy(); - } - }); - return () => { - policyDisposed = true; if (timer !== undefined) clearTimeout(timer); - if (policyTimer !== undefined) clearTimeout(policyTimer); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); - unsubscribeQueryCache(); - policyStop(); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/lib/usePersonaSync.test.mjs b/desktop/src/features/agents/lib/usePersonaSync.test.mjs index a67b68cc7fd..cfc0d901c3a 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.test.mjs +++ b/desktop/src/features/agents/lib/usePersonaSync.test.mjs @@ -8,7 +8,10 @@ import { KIND_PERSONA, KIND_TEAM, } from "@/shared/constants/kinds"; -import { startPersonaSync } from "./usePersonaSync.ts"; +import { + coalesceManagedAgentBackfill, + startPersonaSync, +} from "./usePersonaSync.ts"; const EXPECTED_KINDS = [ KIND_PERSONA, @@ -17,6 +20,53 @@ const EXPECTED_KINDS = [ KIND_DELETION, ]; +function event({ + id, + kind = KIND_MANAGED_AGENT, + createdAt, + pubkey = "owner-pubkey", + dTag = "agent-pubkey", +}) { + return { + id, + pubkey, + created_at: createdAt, + kind, + tags: dTag ? [["d", dTag]] : [], + content: "{}", + sig: "sig", + }; +} + +test("startup backfill keeps only the newest managed-agent head per coordinate", () => { + const persona = event({ + id: "persona", + kind: KIND_PERSONA, + createdAt: 1, + dTag: "persona-id", + }); + const otherAgent = event({ + id: "other-agent", + createdAt: 2, + dTag: "other-agent", + }); + const oldest = event({ id: "oldest", createdAt: 1 }); + const sameSecondLoser = event({ id: "f", createdAt: 3 }); + const newest = event({ id: "a", createdAt: 3 }); + + assert.deepEqual( + coalesceManagedAgentBackfill([ + oldest, + persona, + newest, + otherAgent, + sameSecondLoser, + ]).map(({ id }) => id), + ["persona", "a", "other-agent"], + "NIP-33 uses newest created_at and lowest id on a tie", + ); +}); + // Regression guard for the fresh-start backfill gap (F3): a device that comes // online AFTER another published gets zero history from a live-only `limit: 0` // subscription, because reconnect-replay's since-cursor is undefined until the diff --git a/desktop/src/features/agents/lib/usePersonaSync.ts b/desktop/src/features/agents/lib/usePersonaSync.ts index 66ed679ad95..57d33089a9b 100644 --- a/desktop/src/features/agents/lib/usePersonaSync.ts +++ b/desktop/src/features/agents/lib/usePersonaSync.ts @@ -20,6 +20,48 @@ const PERSONA_SYNC_KINDS = [ KIND_DELETION, ]; +function eventDTag(event: RelayEvent): string | null { + return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null; +} + +function eventIsNewer(candidate: RelayEvent, current: RelayEvent): boolean { + return ( + candidate.created_at > current.created_at || + (candidate.created_at === current.created_at && candidate.id < current.id) + ); +} + +/** + * Keep only the NIP-33 head for each managed-agent coordinate in a startup + * backfill. Applying historical policy revisions one by one can stop and start + * the same runtime for every revision; the retained store only needs the final + * head. Other event kinds stay in relay order because persona/team projections + * do not trigger runtime policy transitions and deletion ordering is separate. + */ +export function coalesceManagedAgentBackfill( + events: readonly RelayEvent[], +): RelayEvent[] { + const heads = new Map(); + + for (const event of events) { + if (event.kind !== KIND_MANAGED_AGENT) continue; + const dTag = eventDTag(event); + if (!dTag) continue; + const coordinate = `${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`; + const current = heads.get(coordinate); + if (!current || eventIsNewer(event, current)) heads.set(coordinate, event); + } + + return events.filter((event) => { + if (event.kind !== KIND_MANAGED_AGENT) return true; + const dTag = eventDTag(event); + if (!dTag) return true; + return ( + heads.get(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) === event + ); + }); +} + // Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`: // one-shot backfill of existing heads + tombstones, then a live subscription. // Returns a disposer that closes the live subscription. Extracted from the hook @@ -56,7 +98,8 @@ export function startPersonaSync( .fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 }) .then((events) => { if (onCancelled()) return; - for (const event of events) reconcile(event); + for (const event of coalesceManagedAgentBackfill(events)) + reconcile(event); }) .catch((error) => { console.warn("[usePersonaSync] backfill failed:", error); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4215d183ac2..1239f9522a3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -113,6 +113,7 @@ type MockManagedAgentRuntimeSeed = { type MockRelayAgentSeed = { pubkey: string; + ownerPubkey?: string | null; name: string; agentType?: string; capabilities?: string[]; @@ -851,6 +852,7 @@ type RawSendChannelMessageResponse = { type RawRelayAgent = { pubkey: string; + owner_pubkey?: string | null; name: string; agent_type: string; channels: string[]; @@ -2324,6 +2326,7 @@ function resetMockRelayAgents(config?: E2eConfig) { }); mockRelayAgents.push({ pubkey: seed.pubkey, + owner_pubkey: seed.ownerPubkey ?? null, name: seed.name, agent_type: seed.agentType ?? "goose", channels: channels.map((channel) => channel.name), diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 4efd6dd7a3f..5562920a349 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1454,6 +1454,36 @@ test("owner-only builds hide other-owned relay agents", async ({ page }) => { await expect(autocomplete(page)).toHaveCount(0); }); +test("owner-only builds show verified same-owner relay agents", async ({ + page, +}) => { + await installMockBridge(page, { + ownerOnlyAccessBuild: true, + searchProfiles: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + displayName: "quinn", + ownerPubkey: MOCK_VIEWER_PUBKEY, + isAgent: true, + }, + ], + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + ownerPubkey: MOCK_VIEWER_PUBKEY, + name: "quinn", + respondTo: "owner-only", + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("message-input").fill("@quinn"); + + await expect(autocomplete(page).getByText("quinn")).toBeVisible(); +}); + test("relay-only allowlisted agents stay hidden outside their channel", async ({ page, }) => { From 978e585e8df893fe55aded854de07996b9412678 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 17 Aug 2026 21:54:34 -0600 Subject: [PATCH 02/27] chore(release): release Buzz Desktop version 0.5.16 (#6191) ## Buzz Desktop release v0.5.16 - **Frozen main:** `ee992ff0822f44d1c308822f116cb9d26f9a3386` - **Reviewed candidate:** `a6211b0e285600a6f08d6592261e44ecc4a6917b` - **Previous desktop release:** `desktop-v0.5.15` - **Proposed immutable tag:** `desktop-v0.5.16` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++++------- CHANGELOG.md | 13 +++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index e668efa7b2a..2cfec8d7813 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.15", - "base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2", - "previous_tag": "desktop-v0.5.14", - "previous_base_sha": "1b3dbcaaea882eeea90359c1db02e306d2f4f50a", - "previous_merge_sha": "82f7ed1532f50e0d28afca5580ed522f1c2ef1ca", - "tag": "desktop-v0.5.15", - "commit_count": 18 + "version": "0.5.16", + "base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386", + "previous_tag": "desktop-v0.5.15", + "previous_base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2", + "previous_merge_sha": "c8c8eb58ad5336f21d77e7b02517cd4604a9a7ae", + "tag": "desktop-v0.5.16", + "commit_count": 2 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b701ec2581..3f81baf7730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v0.5.16 + +### Desktop and shared changes + +- fix(desktop): restore release agent mentions ([#6182](https://github.com/block/buzz/pull/6182)) ([`ee992ff0822f44d1c308822f116cb9d26f9a3386`](https://github.com/block/buzz/commit/ee992ff0822f44d1c308822f116cb9d26f9a3386)) +- test(desktop): cover exact workflow batch limit ([#6168](https://github.com/block/buzz/pull/6168)) ([`f8692fa9b52ddcfeb4b95fb4862109983509f131`](https://github.com/block/buzz/commit/f8692fa9b52ddcfeb4b95fb4862109983509f131)) + +### Other repository changes + +- None + +[Compare desktop-v0.5.15...desktop-v0.5.16](https://github.com/block/buzz/compare/desktop-v0.5.15...desktop-v0.5.16) + ## v0.5.15 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 963fe643d63..29d7d448639 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.15", + "version": "0.5.16", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7d5a3f67dcb..8724145dfd5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.15" +version = "0.5.16" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6d14b04cf4c..1fa31d12d35 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.15" +version = "0.5.16" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index dd7ab08e06d..884cf0624f3 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.15", + "version": "0.5.16", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From f0234f1449ab8a6d52d45a9e1ec19cc675b40fe9 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 06:47:53 -0600 Subject: [PATCH 03/27] =?UTF-8?q?fix(desktop):=20eliminate=20mounted-view?= =?UTF-8?q?=20CPU=20burn=20=E2=80=94=20compositor-safe=20shimmer,=20observ?= =?UTF-8?q?er=20append=20fast=20path,=20poll-tick=20disk=20reads=20(#6198)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Live 0.5.16 review (Royal Court thread) traced the sustained 20–25% WebContent CPU burn in any large mounted channel with a working agent to three defects, in descending impact: 1. **Shimmer animation forces per-frame style + compositing walks.** `.buzz-shimmer` animated `background-position` under `-webkit-background-clip: text`, which WebKit cannot run on the compositor: every frame did a full document style resolve plus a recursive compositing-hierarchy update over the timeline's layer tree. The highlight now lives on an `aria-hidden` overlay child duplicating the label text and animates **opacity only** (compositor-accelerated); a real element is used instead of `::after` generated content so screen readers never see the duplicate text. Visual: the moving sweep becomes a gentle pulse. `prefers-reduced-motion` removes the overlay entirely — static muted label, exactly as before. 2. **Observer journal whole-journal dedup + re-sort per append.** Every one-per-second observer frame rebuilt a dedup Set over up to 3000 retained events and re-sorted the whole journal with a `Date.parse`-per-comparison comparator. In-order batches (the ordinary live path, same condition as the existing incremental transcript fold) now dedup within the batch and concat; out-of-order/replayed arrivals keep the full path. 3. **Redundant disk reads in the 5s agent-list poll.** `build_managed_agent_summary` re-read the global config from disk per call despite receiving it as a parameter, and re-read the teams store per tracked pair — 2N redundant reads per poll tick for N agents. Both are now caller-supplied; one-shot command paths use a `summarize_from_disk` helper. Same stores, same `unwrap_or_default` failure posture, read once. ## Evidence (mechanism attribution, live 0.5.16-block, mounted ~1500-event channel) - True-idle mounted view (working-state UI live): **25.08% mean / 24.15% median** WebContent CPU; post working-state decay: **6.33% / 3.50%**. - Reduce Motion A/B during a live agent turn (isolates the shimmer, working UI still mounted): **19.50% mean → 5.39% mean** (72% collapse). Native 10s samples: `Document::resolveStyle` 379 samples → 1; `updateCompositingLayersAfterStyleChange` 378 → 0; recursive `updateBackingAndHierarchy` 363 → 6. - The shimmer mechanism predates 0.5.16 (CSS unchanged since #3151); current multi-agent workloads exposed and amplified it. The mention regression itself was fixed separately in #6182. ## Testing - `desktop` observer store suites: 55/55 pass, including 5 new tests pinning the fast-path invariants (equal-timestamp seq ordering, duplicate-batch redelivery, intra-batch duplicates, overlapping late arrival takes the slow path, transcript-equals-replay on both paths). - `cargo test --lib managed_agents` (1016 passed) and `--lib commands` (727 passed); `cargo clippy` clean; `pnpm typecheck` + biome clean. - Release gate for the patched build (per Mongo): active-turn mounted CPU must collapse from the ~21–25% baseline with no per-frame style/compositing walk in a native sample — measured on Wes's workspace once a build with this branch is running. Findings and raw samples: `RESEARCH/LIVE_0_5_16_WEBKIT_ATTRIBUTION_2026_08_18.md`, `RESEARCH/RENDERER_STEADY_STATE_LOOP_AUDIT_2026_08_18.md`, `.scratch/live-app-review/` (Carl/Donut/Mongo/Brain, Royal Court thread 01a7fe75). --- *Opened by Brain (agent) via @wesbillman's account on his behalf — coordinated in Buzz channel agent-mention-policy-royal-court, thread 01a7fe75.* --------- Signed-off-by: Wes Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/agent_models.rs | 10 +- .../src/commands/agent_models_update.rs | 11 +- .../src-tauri/src/commands/agent_settings.rs | 23 +--- desktop/src-tauri/src/commands/agents.rs | 91 +++++++------- .../src-tauri/src/managed_agents/runtime.rs | 12 +- .../src/features/agents/observerRelayStore.ts | 36 ++++-- .../observerTranscriptRetention.test.mjs | 113 ++++++++++++++++++ .../src/shared/styles/globals/animations.css | 68 ++++++----- desktop/src/shared/ui/Shimmer.tsx | 5 + 9 files changed, 236 insertions(+), 133 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 85d9da4dfa7..cb809b6c04a 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -18,11 +18,11 @@ use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRoll use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, - find_managed_agent_mut, known_acp_runtime, load_global_agent_config, load_managed_agents, - load_personas, managed_agent_avatar_url, missing_command_message, normalize_agent_args, - resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, - AgentModelInfo, AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest, + current_instance_id, discovery_env_with_baked_floor, find_managed_agent_mut, + known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, + AgentModelsResponse, ManagedAgentRecord, UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, diff --git a/desktop/src-tauri/src/commands/agent_models_update.rs b/desktop/src-tauri/src/commands/agent_models_update.rs index 5f91622b44b..bb045b81a24 100644 --- a/desktop/src-tauri/src/commands/agent_models_update.rs +++ b/desktop/src-tauri/src/commands/agent_models_update.rs @@ -250,16 +250,7 @@ pub async fn update_managed_agent( None }; - let summary = { - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - )? - }; + let summary = { super::super::agents::summarize_from_disk(&app, record, &runtimes)? }; let rollback = name_changed .then(|| AgentUpdateRollback::new(previous_record, record, access_policy_changed)); ( diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index 2317930c1ef..6135c671606 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -4,9 +4,8 @@ use tauri::{AppHandle, Manager, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, find_managed_agent_mut, - load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, - ManagedAgentSummary, + current_instance_id, find_managed_agent_mut, load_managed_agents, save_managed_agents, + sync_managed_agent_processes, ManagedAgentSummary, }, util::now_iso, }; @@ -56,14 +55,7 @@ pub async fn set_managed_agent_start_on_app_launch( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - ) + super::agents::summarize_from_disk(&app, record, &runtimes) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -107,14 +99,7 @@ pub async fn set_managed_agent_auto_restart( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - ) + super::agents::summarize_from_disk(&app, record, &runtimes) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c4fc0cf2f61..d1667a72bb4 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -26,6 +26,28 @@ pub(super) fn workspace_owner_hex(state: &AppState) -> Result { Ok(keys.public_key().to_hex()) } +/// Build a summary from fresh disk state (personas, teams, global config). +/// For one-shot command paths only — the 5s list poll calls +/// `build_managed_agent_summary` directly with stores loaded once per call, +/// not once per record. +pub(super) fn summarize_from_disk( + app: &AppHandle, + record: &ManagedAgentRecord, + runtimes: &std::collections::HashMap< + crate::managed_agents::ManagedAgentRuntimeKey, + crate::managed_agents::ManagedAgentPairRuntime, + >, +) -> Result { + build_managed_agent_summary( + app, + record, + runtimes, + &load_personas(app).unwrap_or_default(), + &load_teams(app).unwrap_or_default(), + &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), + ) +} + /// Retain a freshly authored managed-agent event in the local store, flagged /// for relay sync. MUST be called inside the `managed_agents_store_lock`-held /// body after `save_managed_agents`, NEVER across an `.await`: it acquires @@ -333,18 +355,11 @@ pub(super) async fn start_local_agent_pairs_with_preflight( .managed_agent_processes .lock() .map_err(|e| e.to_string())?; - let personas = load_personas(app).unwrap_or_default(); let record = records .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - build_managed_agent_summary( - app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), - ) + summarize_from_disk(app, record, &runtimes) } pub(super) async fn start_local_agent_with_preflight( @@ -436,6 +451,7 @@ pub(super) async fn start_local_agent_with_preflight( record, &runtimes, &personas, + &load_teams(app).unwrap_or_default(), &crate::managed_agents::load_global_agent_config(app).unwrap_or_default(), ) } @@ -474,14 +490,22 @@ pub async fn list_managed_agents(app: AppHandle) -> Result Err(format!( "agent {pubkey} has unsupported backend kind: {backend:?}" @@ -1171,14 +1167,7 @@ pub async fn stop_managed_agent( .iter() .find(|record| record.pubkey == pubkey) .ok_or_else(|| format!("agent {pubkey} not found"))?; - let personas = load_personas(&app).unwrap_or_default(); - build_managed_agent_summary( - &app, - record, - &runtimes, - &personas, - &crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(), - ) + summarize_from_disk(&app, record, &runtimes) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 865d31da1ed..8b57b161b77 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -133,6 +133,7 @@ pub fn build_managed_agent_summary( record: &ManagedAgentRecord, runtimes: &HashMap, personas: &[crate::managed_agents::types::AgentDefinition], + teams: &[crate::managed_agents::TeamRecord], global_config: &crate::managed_agents::GlobalAgentConfig, ) -> Result { use crate::managed_agents::BackendKind; @@ -195,12 +196,10 @@ pub fn build_managed_agent_summary( let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); - let global_for_summary = - crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( record, personas, - &global_for_summary, + global_config, ); let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg { @@ -242,14 +241,13 @@ pub fn build_managed_agent_summary( // env layering below — the caller loads it once and passes it in, so // list-style callers pay one disk read per call rather than one per record. - // The prospective side is computed only for a tracked pair: it costs a - // teams-store read, and an unstamped agent has nothing to compare against. + // The prospective side is computed only for a tracked pair: an unstamped + // agent has nothing to compare against. let tracked_spawn = pair_key.as_ref().zip(pair_runtime).map(|(key, runtime)| { - let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); let current = crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( record, personas, - &teams, + teams, &key.relay_url, global_config, super::owner_only_access_build(), diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 7ae4d0bfc81..88e8bba7547 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -243,11 +243,26 @@ function appendAgentEvents( : events; if (admissible.length === 0) return null; - const seen = new Set( - current.map( - (event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`, - ), - ); + // Ordinary live path: the harness publishes frames in order once per + // second, so the whole batch lands strictly after the retained tail. In + // that case no admissible event can collide with a retained one (the + // journal is sorted), so dedup only needs to look inside the batch and the + // merged journal is a plain concat — no Set over the full journal and no + // whole-journal re-sort (whose comparator Date.parses per comparison). + // Out-of-order or replayed arrivals take the full dedup + re-sort path. + const currentLast = current.at(-1); + const allAtEnd = + !currentLast || + admissible.every((event) => isObserverEventAfter(event, currentLast)); + + const seen = allAtEnd + ? new Set() + : new Set( + current.map( + (event) => + `${event.timestamp.length}:${event.timestamp}:${event.seq}`, + ), + ); const added: ObserverEvent[] = []; for (const event of admissible) { const eventKey = `${event.timestamp.length}:${event.timestamp}:${event.seq}`; @@ -258,7 +273,9 @@ function appendAgentEvents( if (added.length === 0) return null; const sortedAdded = added.sort(compareObserverEvents); - const sorted = [...current, ...sortedAdded].sort(compareObserverEvents); + const sorted = allAtEnd + ? [...current, ...sortedAdded] + : [...current, ...sortedAdded].sort(compareObserverEvents); const trimmed = sorted.length > MAX_OBSERVER_EVENTS; const final = trimmed ? sorted.slice(sorted.length - OBSERVER_EVENTS_LOW_WATER) @@ -276,14 +293,11 @@ function appendAgentEvents( }); } - // The common live path appends a sorted batch after the retained window. Fold + // The common live path appends a sorted batch after the retained window + // (the same `allAtEnd` that authorized the concat fast-path above). Fold // that batch through the transcript state once without rebuilding history. // Out-of-order arrivals and cap eviction rebuild from the final window so // stateful tool/permission relationships remain correct. - const currentLast = current.at(-1); - const allAtEnd = - !currentLast || - sortedAdded.every((event) => compareObserverEvents(event, currentLast) > 0); if (allAtEnd && !trimmed) { let transcriptState = transcriptByAgent.get(key) ?? createEmptyTranscriptState(); diff --git a/desktop/src/features/agents/observerTranscriptRetention.test.mjs b/desktop/src/features/agents/observerTranscriptRetention.test.mjs index 861940c1193..aa7ab427c92 100644 --- a/desktop/src/features/agents/observerTranscriptRetention.test.mjs +++ b/desktop/src/features/agents/observerTranscriptRetention.test.mjs @@ -303,3 +303,116 @@ describe("live observer journal retention — eviction floor (reconnect replay)" ); }); }); + +describe("live observer journal — in-order append fast path ordering/dedup", () => { + // The common live path (every batch strictly after the retained tail) skips + // the whole-journal dedup Set and re-sort. These pin the observable + // invariants that authorize that skip: identical ordering, dedup, and + // transcript against the general path. + beforeEach(() => { + resetAgentObserverStore(); + }); + + /** An event with an explicit timestamp, for equal-timestamp tie-breaks. */ + function makeEventAt(seq, timestampMs) { + return { + ...makeEvent(seq), + timestamp: new Date(timestampMs).toISOString(), + }; + } + + it("test_equal_timestamp_batch_orders_by_seq", () => { + // A one-second harness frame batches several events sharing a timestamp; + // the tie-break is seq. Deliver them out of seq order in one batch. + const t = 1_760_000_100_000; + syncAgentObserverEvents(AGENT_PUBKEY, [makeEventAt(1, t - 1000)]); + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEventAt(4, t), + makeEventAt(2, t), + makeEventAt(3, t), + ]); + assert.deepEqual( + getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq), + [1, 2, 3, 4], + "equal-timestamp events are retained in seq order", + ); + }); + + it("test_duplicate_batch_redelivery_is_ignored", () => { + // Relay redelivery of the newest batch: every event duplicates the tail, + // so nothing is admitted, nothing is notified. + const batch = [makeEvent(1), makeEvent(2), makeEvent(3)]; + syncAgentObserverEvents(AGENT_PUBKEY, batch); + let notifications = 0; + const unsubscribe = subscribeAgentObserverStore(() => { + notifications += 1; + }); + try { + syncAgentObserverEvents(AGENT_PUBKEY, batch); + } finally { + unsubscribe(); + } + assert.deepEqual( + getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq), + [1, 2, 3], + "a redelivered batch adds nothing", + ); + assert.equal(notifications, 0, "a pure-duplicate batch notifies no one"); + }); + + it("test_batch_with_intra_batch_duplicate_admits_once", () => { + // A batch strictly after the tail still dedups within itself. + syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(1)]); + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(2), + makeEvent(3), + makeEvent(2), + ]); + assert.deepEqual( + getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq), + [1, 2, 3], + "an intra-batch duplicate is admitted exactly once", + ); + }); + + it("test_late_arrival_overlapping_tail_takes_slow_path_and_dedups", () => { + // A replayed window straddling the tail: partly duplicate, partly new, + // partly older-than-tail. Not all-after, so the general path must dedup + // against the whole journal and re-sort. + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(1), + makeEvent(2), + makeEvent(4), + ]); + syncAgentObserverEvents(AGENT_PUBKEY, [ + makeEvent(2), + makeEvent(3), + makeEvent(4), + makeEvent(5), + ]); + const events = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.deepEqual( + events.map((event) => event.seq), + [1, 2, 3, 4, 5], + "overlapping late arrival dedups against the journal and sorts into place", + ); + assert.deepEqual( + getAgentTranscript(AGENT_PUBKEY), + buildTranscript(events), + "the transcript equals a full replay after a mixed-path sequence", + ); + }); + + it("test_fast_path_transcript_equals_full_replay", () => { + // Pure in-order streaming (fast path every time) must produce the same + // derived transcript as a replay of the retained window. + fillSequential(50); + const events = getAgentObserverSnapshot(AGENT_PUBKEY).events; + assert.equal(events.length, 50); + assert.deepEqual( + getAgentTranscript(AGENT_PUBKEY), + buildTranscript(events), + "in-order fast-path appends derive the same transcript as a replay", + ); + }); +}); diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index f8ae456d350..5a0723cdaa0 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -194,7 +194,6 @@ .buzz-shimmer { --buzz-shimmer-duration: 2600ms; - --buzz-shimmer-band: 250%; --buzz-shimmer-highlight: color-mix( in srgb, hsl(var(--background)) 60%, @@ -202,49 +201,58 @@ ); --buzz-shimmer-spread: 2rem; - animation: buzz-shimmer var(--buzz-shimmer-duration) linear infinite; + color: hsl(var(--muted-foreground)); + display: inline-block; + position: relative; +} + +/* + * The highlight lives on an aria-hidden overlay child that duplicates the + * label text and animates ONLY opacity. The previous implementation animated + * background-position under -webkit-background-clip: text, which WebKit + * cannot run on the compositor: every animation frame forced a full document + * style resolve + compositing-hierarchy walk, burning ~20% CPU at rest + * whenever a large timeline was mounted with a working agent. Opacity is + * compositor-accelerated: the layer paints once and the pulse runs outside + * the web process's main thread. A real element (not ::after generated + * content) is used so the duplicate text can be aria-hidden — pseudo-element + * text is inconsistently exposed to screen readers and cannot be hidden. + */ +.buzz-shimmer > .buzz-shimmer-overlay { + animation: buzz-shimmer var(--buzz-shimmer-duration) ease-in-out infinite; background-clip: text; - background-image: - linear-gradient( - 90deg, - transparent calc(50% - var(--buzz-shimmer-spread)), - var(--buzz-shimmer-highlight) 50%, - transparent calc(50% + var(--buzz-shimmer-spread)) - ), - linear-gradient(hsl(var(--muted-foreground)), hsl(var(--muted-foreground))); - background-position: - 100% 0, - 0 0; - background-repeat: no-repeat; - background-size: - var(--buzz-shimmer-band) 100%, - 100% 100%; + background-image: linear-gradient( + 90deg, + transparent calc(50% - var(--buzz-shimmer-spread)), + var(--buzz-shimmer-highlight) 50%, + transparent calc(50% + var(--buzz-shimmer-spread)) + ); color: transparent; - display: inline-block; + inset: 0; + overflow: inherit; + padding: inherit; + position: absolute; + text-overflow: inherit; + white-space: inherit; -webkit-background-clip: text; -webkit-text-fill-color: transparent; } @keyframes buzz-shimmer { - 0% { - background-position: - 100% 0, - 0 0; + 0%, + 100% { + opacity: 0; } - 100% { - background-position: - 0 0, - 0 0; + 50% { + opacity: 1; } } @media (prefers-reduced-motion: reduce) { - .buzz-shimmer { + .buzz-shimmer > .buzz-shimmer-overlay { animation: none; - background: none; - color: hsl(var(--muted-foreground)); - -webkit-text-fill-color: currentcolor; + display: none; } } diff --git a/desktop/src/shared/ui/Shimmer.tsx b/desktop/src/shared/ui/Shimmer.tsx index 0b26ed032f8..252c29dd5ef 100644 --- a/desktop/src/shared/ui/Shimmer.tsx +++ b/desktop/src/shared/ui/Shimmer.tsx @@ -16,6 +16,11 @@ export function Shimmer({ children, className }: ShimmerProps) { } > {children} + {/* Visual-only highlight copy; the sibling text node above is the sole + accessible content. */} + ); } From 6d45f98665004d314468d98e50084996f4046cdf Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 07:36:25 -0600 Subject: [PATCH 04/27] ci: make file-size policy a first-class gate (#6187) ## Summary - make the differential file-size ratchet a first-class repository gate - run the same unfiltered gate from pre-push, `just check`, and CI - remove hidden file-size coupling from Desktop, Web, and Mobile lint commands - isolate ratchet Git subprocesses from hook-exported repository state ## Why The Desktop ratchet grew to govern `desktop/src-tauri/crates/**`, but the pre-push `desktop-check` command remained path-filtered to non-Tauri files. That contract drift allowed a Tauri Rust file-size regression through local validation. The ratchet already computes its own merge-base diff, so duplicating governed paths in Lefthook and CI adds drift risk without meaningful runtime savings. One root gate owns the policy now. ## Testing - `just file-size-check` - oversized untracked probe under `desktop/src-tauri/crates/**` fails with the 1,000-line ceiling - file-size core tests with hook-style `GIT_DIR` / `GIT_WORK_TREE` environment - `lefthook dump` confirms the unfiltered pre-push command - mandatory pre-push suite passed on `3217003db10c84d8a0c5f636ec4c92936777a002` - `git diff --check` Signed-off-by: Wes Co-authored-by: Carl --- .github/workflows/ci.yml | 13 +++---------- AGENTS.md | 24 ++++++++++++------------ Justfile | 19 ++++++++++++++----- desktop/package.json | 2 +- lefthook.yml | 7 +++++++ scripts/check-file-sizes-core.mjs | 6 ++++++ scripts/check-file-sizes-core.test.mjs | 12 +++++++++++- web/package.json | 2 +- 8 files changed, 55 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d1424ea7d..a832c0a0aff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: @@ -50,8 +51,6 @@ jobs: - 'scripts/normative-corpus.json' - 'justfile' desktop: - - 'scripts/check-file-sizes-core.mjs' - - 'scripts/check-file-sizes-core.test.mjs' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - 'desktop/**' @@ -60,13 +59,9 @@ jobs: desktop-rust: - 'desktop/src-tauri/**' web: - - 'scripts/check-file-sizes-core.mjs' - - 'scripts/check-file-sizes-core.test.mjs' - 'web/**' - 'pnpm-lock.yaml' mobile: - - 'scripts/check-file-sizes-core.mjs' - - 'scripts/check-file-sizes-core.test.mjs' - 'mobile/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' @@ -92,8 +87,8 @@ jobs: scripts/test-mobile-release-candidate-publisher.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh - - name: File size ratchet unit tests - run: node --test scripts/check-file-sizes-core.test.mjs + - name: File size policy + run: just file-size-check rust-lint: name: Rust Lint @@ -896,8 +891,6 @@ jobs: with: path: ~/.pub-cache key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} - - name: File size ratchet - run: node mobile/scripts/check-file-sizes.mjs - name: Format check run: cd mobile && dart format --output=none --set-exit-if-changed . - name: Analyze diff --git a/AGENTS.md b/AGENTS.md index d66ecbdc748..3a79c4b38d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,14 +120,14 @@ Run `just test` for integration tests if you touched `buzz-relay`, formatting via `stage_fixed`. Pre-commit runs fix variants in parallel (Rust fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format). Auto-fixable issues are fixed and re-staged; unfixable lint issues block the -commit. **Pre-push hooks** run clippy (workspace + Tauri), desktop TypeScript -typechecking (`tsc --noEmit`), and fast unit tests in parallel (Rust, desktop -JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are -CI-only. Run `just fix-all` to auto-fix all formatting in one shot. Run -`just ci` for the full local gate. Run `just hooks` to -re-install hooks after env changes. Before agents run Git or hooks, activate the -repo's Hermit environment (`. ./bin/activate-hermit`); do not rewrite hook -commands to compensate for an unconfigured shell `PATH`. +commit. **Pre-push hooks** run the repository-wide differential file-size gate, +clippy (workspace + Tauri), desktop TypeScript typechecking (`tsc --noEmit`), +and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) +— no overlap with pre-commit. Builds are CI-only. Run `just fix-all` to auto-fix +all formatting in one shot. Run `just ci` for the full local gate. Run `just +hooks` to re-install hooks after env changes. Before agents run Git or hooks, +activate the repo's Hermit environment (`. ./bin/activate-hermit`); do not +rewrite hook commands to compensate for an unconfigured shell `PATH`. **Commit with `git commit -s`.** The required **DCO Check** fails any PR with a commit missing a `Signed-off-by` trailer, and `just hooks` installs a `commit-msg` hook that adds it to commits you create locally (`git rebase` and `git cherry-pick` still need `--signoff`) — if you build commit commands programmatically, include `-s` every time. To repair a branch that already has unsigned commits: `git rebase --signoff main`, then force-push. @@ -566,10 +566,10 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. - **Keep widgets small and composable.** One public widget per file; push private sub-widgets (`_Foo`) into sibling `part` files under a `/` folder rather than growing the page file. Hard ceiling: - **1000 lines/file**, enforced by `mobile/scripts/check-file-sizes.mjs` via - `just mobile-check` (runs in `just check` + pre-push, mirroring desktop/web). - If the guard trips, **split the file — never bump the limit or add an - override to slip under it.** + **1000 lines/file**, enforced across Desktop, Web, and Mobile by the + repository-level `just file-size-check` gate (`just check`, CI, and every + pre-push). If the guard trips, **split the file — never bump the limit or add + an override to slip under it.** - Feature modules must not import from other feature modules — only from `shared/`. - Use `Grid` tokens for spacing, `Radii` for border radius. diff --git a/Justfile b/Justfile index 5b2ed88c952..ce8647cf77c 100644 --- a/Justfile +++ b/Justfile @@ -91,8 +91,17 @@ build: build-release: cargo build --workspace --release -# Run repo lint and formatting checks -check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check +# Run repo lint, formatting, and repository policy checks +check: fmt-check clippy desktop-check desktop-tauri-fmt-check desktop-tauri-clippy web-check mobile-check file-size-check + +# Run the repository-wide differential file-size ratchet and its policy tests. +# The ratchet inspects only files changed from the merge base, so this stays +# cheap enough to run unconditionally without duplicating path filters. +file-size-check: + node --test scripts/check-file-sizes-core.test.mjs + node desktop/scripts/check-file-sizes.mjs + node web/scripts/check-file-sizes.mjs + node mobile/scripts/check-file-sizes.mjs # Format all Rust code fmt: @@ -120,7 +129,7 @@ desktop-check: # Fix desktop lint and format issues desktop-fix: - cd {{desktop_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes + cd {{desktop_dir}} && pnpm exec biome check --write . # Run desktop TS helper unit tests desktop-test: @@ -641,7 +650,7 @@ web-check: # Fix web lint and format issues web-fix: - cd {{web_dir}} && pnpm exec biome check --write . && pnpm check:file-sizes + cd {{web_dir}} && pnpm exec biome check --write . # Run web TypeScript checks web-typecheck: @@ -673,7 +682,7 @@ mobile-fix: # Run mobile lint and format checks mobile-check: - unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze && node ./scripts/check-file-sizes.mjs + unset GIT_DIR GIT_WORK_TREE; cd {{mobile_dir}} && dart format --output=none --set-exit-if-changed . && flutter analyze # Run mobile tests mobile-test: diff --git a/desktop/package.json b/desktop/package.json index 29d7d448639..073a764a1ba 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -12,7 +12,7 @@ "check:px-text": "node ./scripts/check-px-text.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", + "check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", "preview": "vite preview", diff --git a/lefthook.yml b/lefthook.yml index 5b992f19afe..d3c8bddb83e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,6 +6,9 @@ # changes, though CI's Desktop Core job does. Those commands are pure TS # (biome + tsc + node:test) with no Rust dependency, so the extra trigger # would be spurious locally. +# - The repository-wide `file-size-check` is deliberately unfiltered. Its own +# merge-base diff is the path filter; duplicating its governed roots here is +# the coverage drift this gate is intended to prevent. # - Deletion-only surface changes do not trigger local hooks: lefthook 2.1.x # drops deleted paths from push-file discovery (`extractFiles` existence # check, repository.go). CI's dorny/paths-filter catches deletions. @@ -51,6 +54,10 @@ pre-push: commands: branch-skew: run: ./scripts/check-branch-skew.sh + file-size-check: + # The ratchet computes its own merge-base diff, so path filtering here + # would only duplicate policy and create another place for coverage drift. + run: just file-size-check rust-tests: glob: ["crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"] run: just test-unit diff --git a/scripts/check-file-sizes-core.mjs b/scripts/check-file-sizes-core.mjs index 1365424628d..6c3b8c4bb93 100644 --- a/scripts/check-file-sizes-core.mjs +++ b/scripts/check-file-sizes-core.mjs @@ -3,10 +3,16 @@ import { promises as fs } from "node:fs"; import path from "node:path"; function git(args, cwd, options = {}) { + // Git hooks export repository-local GIT_* variables. Child commands that + // intentionally target `cwd` must not be redirected back to the hook's repo. + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")), + ); return execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, + env, ...options, }); } diff --git a/scripts/check-file-sizes-core.test.mjs b/scripts/check-file-sizes-core.test.mjs index 14dfe4b8daa..9b4b910404d 100644 --- a/scripts/check-file-sizes-core.test.mjs +++ b/scripts/check-file-sizes-core.test.mjs @@ -13,7 +13,17 @@ import { } from "./check-file-sizes-core.mjs"; function git(repo, ...args) { - return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); + // These fixture repositories inherit both hook configuration and Git's + // repository-local environment when this test runs from pre-push. Isolate + // them completely so fixture commits cannot recurse into the real checkout. + const env = Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")), + ); + return execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], { + cwd: repo, + encoding: "utf8", + env, + }).trim(); } test("local base resolution uses the branch merge-base and fails without origin/main", () => { diff --git a/web/package.json b/web/package.json index a074a581dca..d932a612c8c 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,7 @@ "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation", + "check": "biome check . && pnpm check:pubkey-truncation", "format": "biome format --write .", "preview": "vite preview", "test:e2e": "pnpm build && playwright test", From 081910424a5b6f01b283ad632b0718240c6b3cbf Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 07:36:50 -0600 Subject: [PATCH 05/27] fix(desktop): bind presence retry timers (#6213) ## Summary - preserve the WebKit-required `Window` receiver when scheduling and clearing presence subscription retries - add a receiver-sensitive regression test covering both retry creation and disposal ## Impact When subscription opening failed, WebKit rejected the detached timer call before `retryTimer` could be set. The reconciler's `finally` block then immediately started another reconciliation because demand was still unsatisfied and no retry appeared pending. This bypassed the intended exponential backoff and could repeatedly reopen subscription work during startup, so the impact was more than console noise. With the timer receiver fixed, a failed open schedules one bounded retry at a time (1s exponential backoff, capped at 30s), and disposal cancels it correctly. ## Validation - `cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test src/features/presence/lib/presenceSubscriptionReconciler.test.mjs` (11 passed) - `cd desktop && pnpm test` (4,993 passed) - `cd desktop && pnpm typecheck` - `cd desktop && pnpm check` (passes with existing unrelated warnings) - pre-push hook on `578b9a0b5c851b21de0b8746238b04dc69032e78` (desktop check/typecheck/tests, Rust tests, Tauri checks, mobile tests all passed) Signed-off-by: Wes Co-authored-by: Carl --- .../presenceSubscriptionReconciler.test.mjs | 35 +++++++++++++++++++ .../lib/presenceSubscriptionReconciler.ts | 7 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs index f86000dfbf1..19b02498394 100644 --- a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs +++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.test.mjs @@ -75,6 +75,41 @@ test("a stale async open is closed and never installed", async () => { reconciler.dispose(); }); +test("default timers preserve their global receiver when scheduling retries", async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const timers = []; + const cleared = []; + + globalThis.setTimeout = function (callback) { + assert.equal(this, globalThis); + timers.push(callback); + return timers.length; + }; + globalThis.clearTimeout = function (timer) { + assert.equal(this, globalThis); + cleared.push(timer); + }; + + try { + const reconciler = new PresenceSubscriptionReconciler({ + open: async () => { + throw new Error("relay unavailable"); + }, + }); + + reconciler.setAuthors([A]); + await Promise.resolve(); + assert.equal(timers.length, 1); + + reconciler.dispose(); + assert.deepEqual(cleared, [1]); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + test("failed replacement preserves the previous subscription and retries", async () => { const timers = []; const actions = []; diff --git a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts index 2831785ccfb..dccadf85444 100644 --- a/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts +++ b/desktop/src/features/presence/lib/presenceSubscriptionReconciler.ts @@ -41,8 +41,11 @@ export class PresenceSubscriptionReconciler { this.retryDelay = options.retryDelay ?? ((attempt) => Math.min(1000 * 2 ** attempt, 30_000)); - this.setTimer = options.setTimer ?? setTimeout; - this.clearTimer = options.clearTimer ?? clearTimeout; + this.setTimer = + options.setTimer ?? + ((callback, delayMs) => globalThis.setTimeout(callback, delayMs)); + this.clearTimer = + options.clearTimer ?? ((timer) => globalThis.clearTimeout(timer)); } setAuthors(authors: string[]) { From cc8a8b0dcbf5c01311b2ac7e1827ff3e582299f3 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 18 Aug 2026 11:00:52 -0400 Subject: [PATCH 06/27] fix: bump h2 for RUSTSEC-2026-0258 (#6222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary - Bump the locked `h2` version from `0.4.14` to `0.4.16` to clear RUSTSEC-2026-0258, which affects h2 versions through `0.4.15`. - Keep the change lockfile-only; no manifest or product-code changes are included. ## Details - `h2` is now `0.4.16` with checksum `a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27`. - The lockfile was regenerated by `cargo update -p h2 --precise 0.4.16` using Cargo 1.95.0 / rustc 1.95.0, matching `rust-toolchain.toml` and the CI pin. - Cargo's canonical resolver output also rewrites 16 dependency references to existing `windows-sys` package entries: - `anstyle-query 1.1.5`, `anstyle-wincon 3.0.11`, `socket2 0.6.3`, `termina 0.3.3`, and `uds_windows 1.2.1`: `0.61.2` → `0.60.2`. - `dirs-sys 0.5.0` and `nu-ansi-term 0.50.3`: `0.59.0` → `0.60.2`. - `colored 3.1.1`, `errno 0.3.14`, `quinn-udp 0.5.14`, `rustix 0.38.44`, `rustix 1.1.4`, `rustls-platform-verifier 0.7.0`, `seize 0.5.1`, `tempfile 3.27.0`, and `winapi-util 0.1.11`: `0.59.0` → `0.52.0`. - All four referenced `windows-sys` versions (`0.52.0`, `0.59.0`, `0.60.2`, and `0.61.2`) were already present in the baseline lockfile. There are zero new package entries or checksums, and the only package record replacement is `h2 0.4.14` → `0.4.16`. There is no `wasmtime` edge change. ## Validation - `cargo-deny check` passed. - `cargo build --workspace` passed. - `just test-unit` passed: 455 passed, 0 failed, 1 ignored; all nine package suites passed. Signed-off-by: loganj --- Cargo.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2f..16d86d0206f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -128,7 +128,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1712,7 +1712,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2544,7 +2544,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2767,7 +2767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3338,9 +3338,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -6028,7 +6028,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -7501,7 +7501,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8172,7 +8172,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8185,7 +8185,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8244,7 +8244,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8527,7 +8527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -9024,7 +9024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9652,7 +9652,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -9665,7 +9665,7 @@ dependencies = [ "parking_lot", "rustix 1.1.4", "signal-hook", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10435,7 +10435,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11019,7 +11019,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] From 3fdf289b78c40f80abce86575c25b5ed6361d82c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 09:46:26 -0600 Subject: [PATCH 07/27] fix(desktop): bound remote agent mention authorization (#6224) ## Summary - add a targeted native `revalidate_relay_agents(pubkeys, channel_id)` command for send-time authorization - scope membership discovery to the destination channel and selected pubkeys before runtime/profile/policy queries - replace full relay-directory rebuilds with targeted checks before agent side effects and again at publication - preserve managed-agent evidence independently and retain internal owner-only filtering ## Security and trust boundary The targeted command reuses the existing authoritative chain: 1. relay-signed kind:39002 membership scoped by viewer and destination `d` tag 2. agent runtime directory event 3. agent-signed owner profile verification 4. owner-signed managed policy 5. internal-build `owner_only` filtering before policy lookup and on final results Relay-only agents are dropped on any targeted directory failure. Fresh managed-agent evidence remains valid when the unrelated relay directory fails. ## Validation - pre-push gate passed: branch skew, Desktop check/typecheck/tests, Tauri checks, Rust tests, and mobile tests - Desktop unit suite: 4,992 passed - targeted Rust relay-directory tests passed - focused Playwright mention-send acceptance passed - relay-only send emits the expected `p` tag - exactly two targeted `revalidate_relay_agents` calls on the already-member send path: pre-side-effect and pre-publication - no `list_relay_agents` call on that send path - relay failure and revocation remain fail-closed - internal owner-only mode hides other-owned agents - manual local Desktop testing by Wes: mention sends felt materially improved ## Notes This intentionally does not clear the composer early or add an optimistic timeline row. Publication still waits for fresh authorization. --------- Signed-off-by: Wes Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/agent_discovery.rs | 2 +- .../agent_discovery/relay_directory.rs | 55 +++++++++++--- desktop/src-tauri/src/lib.rs | 2 +- .../lib/agentMentionRevalidation.test.mjs | 40 +++++----- .../messages/lib/agentMentionRevalidation.ts | 25 +++--- .../src/features/messages/lib/useMentions.ts | 1 - desktop/src/shared/api/tauriRelayAgents.ts | 37 +++++++++ desktop/src/testing/e2eBridge.ts | 23 ++++++ desktop/tests/e2e/mentions.spec.ts | 76 ++++++++++++++----- 9 files changed, 192 insertions(+), 69 deletions(-) create mode 100644 desktop/src/shared/api/tauriRelayAgents.ts diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index e8e910b6451..95534854a0b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1033,7 +1033,7 @@ pub async fn discover_managed_agent_prereqs( mod relay_directory; #[cfg(test)] use relay_directory::advance_relay_cursor; -pub use relay_directory::list_relay_agents; +pub use relay_directory::{list_relay_agents, revalidate_relay_agents}; #[cfg(test)] mod tests { diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index a4da9c475d9..d00969bf19c 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -90,6 +90,14 @@ fn retain_verified_owner( pub(crate) async fn list_relay_agents_for_state( state: &AppState, +) -> Result, String> { + list_relay_agents_for_selection(state, None, None).await +} + +async fn list_relay_agents_for_selection( + state: &AppState, + requested_pubkeys: Option<&std::collections::HashSet>, + channel_id: Option<&str>, ) -> Result, String> { let viewer_pubkey = current_user_pubkey(state)?; let owner_only = owner_only_relay_directory(); @@ -100,18 +108,22 @@ pub(crate) async fn list_relay_agents_for_state( // Membership is the authoritative and bounded candidate source. Only // channels visible to this identity are read, and only bot-role p-tags can // drive the downstream managed-policy and owner-profile lookups. - let membership_events = query_all_relay_pages( - state, - serde_json::json!({ - "kinds": [39002], - "authors": [&relay_pubkey], - "#p": [&viewer_pubkey], - }), - ) - .await - .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; - let member_agent_channel_ids = + let mut membership_filter = serde_json::json!({ + "kinds": [39002], + "authors": [&relay_pubkey], + "#p": [&viewer_pubkey], + }); + if let Some(channel_id) = channel_id { + membership_filter["#d"] = serde_json::json!([channel_id]); + } + let membership_events = query_all_relay_pages(state, membership_filter) + .await + .map_err(|error| format!("relay agent channel-membership query failed: {error}"))?; + let mut member_agent_channel_ids = nostr_convert::member_agent_channel_ids_from_events(&membership_events, &relay_pubkey); + if let Some(requested_pubkeys) = requested_pubkeys { + member_agent_channel_ids.retain(|pubkey, _| requested_pubkeys.contains(pubkey)); + } let candidate_pubkeys: Vec = member_agent_channel_ids.keys().cloned().collect(); if candidate_pubkeys.is_empty() { return Ok(Vec::new()); @@ -186,6 +198,27 @@ pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, + channel_id: Option, + state: State<'_, AppState>, +) -> Result, String> { + let requested_pubkeys = pubkeys + .into_iter() + .filter_map(|pubkey| nostr::PublicKey::from_hex(&pubkey).ok()) + .map(|pubkey| pubkey.to_hex()) + .collect::>(); + if requested_pubkeys.is_empty() { + return Ok(Vec::new()); + } + list_relay_agents_for_selection(&state, Some(&requested_pubkeys), channel_id.as_deref()).await +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b00d61521bb..cefdccfd69f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -146,7 +146,6 @@ pub fn run() { if webview.label() != "main" { return; } - // Linux/WebKitGTK needs media-stream settings and a // permission-request handler for getUserMedia; no-op // on macOS/Windows. @@ -763,6 +762,7 @@ pub fn run() { get_relay_self, resolve_oa_owner, list_relay_agents, + revalidate_relay_agents, list_managed_agents, list_managed_agent_runtimes, start_managed_agent_runtime, diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs index d5a57278557..338ce79003a 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.test.mjs @@ -19,17 +19,14 @@ function options(refetchOwnerProfiles) { ownerOnly: true, ownerPolicyError: null, refetchManagedAgents: async () => ({ data: [], error: null }), - refetchRelayAgents: async () => ({ - data: [ - { - pubkey: AGENT, - respondTo: "anyone", - respondToAllowlist: [], - channelIds: ["general"], - }, - ], - error: null, - }), + fetchRelayAgents: async () => [ + { + pubkey: AGENT, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: ["general"], + }, + ], refetchOwnerProfiles, }; } @@ -61,10 +58,9 @@ test("fresh managed evidence survives unrelated relay authorization errors", asy data: [{ pubkey: LOCAL_AGENT }], error: null, }), - refetchRelayAgents: async () => ({ - data: undefined, - error: new Error("relay directory unavailable"), - }), + fetchRelayAgents: async () => { + throw new Error("relay directory unavailable"); + }, }); assert.deepEqual(result, [HUMAN, LOCAL_AGENT]); @@ -76,10 +72,9 @@ test("relay-only agents still fail closed when relay discovery fails", async () profiles: { [AGENT]: { ownerPubkey: CURRENT } }, missing: [], })), - refetchRelayAgents: async () => ({ - data: undefined, - error: new Error("relay directory unavailable"), - }), + fetchRelayAgents: async () => { + throw new Error("relay directory unavailable"); + }, }); assert.deepEqual(result, [HUMAN]); @@ -97,10 +92,9 @@ test("mixed evidence preserves only fresh managed agents and humans", async () = data: [{ pubkey: LOCAL_AGENT }], error: null, }), - refetchRelayAgents: async () => ({ - data: undefined, - error: new Error("relay directory unavailable"), - }), + fetchRelayAgents: async () => { + throw new Error("relay directory unavailable"); + }, }); assert.deepEqual(result, [HUMAN, LOCAL_AGENT]); diff --git a/desktop/src/features/messages/lib/agentMentionRevalidation.ts b/desktop/src/features/messages/lib/agentMentionRevalidation.ts index 1e6b3a7d669..0eaf26f401a 100644 --- a/desktop/src/features/messages/lib/agentMentionRevalidation.ts +++ b/desktop/src/features/messages/lib/agentMentionRevalidation.ts @@ -6,6 +6,7 @@ import { } from "@/features/agents/lib/agentAutocompleteEligibility"; import { evictUsersBatchEntries } from "@/features/profile/hooks"; import { getUsersBatch } from "@/shared/api/tauriProfiles"; +import { revalidateRelayAgents } from "@/shared/api/tauriRelayAgents"; import type { ManagedAgent, RelayAgent, @@ -29,7 +30,7 @@ export async function revalidateAgentMentionPubkeys({ ownerOnly, ownerPolicyError, refetchManagedAgents, - refetchRelayAgents, + fetchRelayAgents, refetchOwnerProfiles, }: { pubkeys: readonly string[]; @@ -40,7 +41,7 @@ export async function revalidateAgentMentionPubkeys({ ownerOnly: boolean | undefined; ownerPolicyError: Error | null; refetchManagedAgents: () => Promise>; - refetchRelayAgents: () => Promise>; + fetchRelayAgents: (pubkeys: string[]) => Promise; refetchOwnerProfiles: (pubkeys: string[]) => Promise; }) { const requestedAgentPubkeys = new Set( @@ -50,15 +51,14 @@ export async function revalidateAgentMentionPubkeys({ return [...pubkeys]; } - const [managedResult, relayResult, ownerProfiles] = await Promise.all([ + const [managedResult, relayAgents, ownerProfiles] = await Promise.all([ refetchManagedAgents(), - refetchRelayAgents(), + fetchRelayAgents([...requestedAgentPubkeys]).catch(() => null), ownerOnly ? refetchOwnerProfiles([...requestedAgentPubkeys]).catch(() => null) : Promise.resolve(null), ]); - const relayDirectoryReady = - relayResult.error === null && relayResult.data !== undefined; + const relayDirectoryReady = relayAgents !== null; if ( ownerOnly === undefined || ownerPolicyError !== null || @@ -75,7 +75,7 @@ export async function revalidateAgentMentionPubkeys({ currentPubkey, eligibilityScope, managedAgentPubkeys: managedPubkeys, - relayAgents: relayDirectoryReady ? relayResult.data : [], + relayAgents: relayDirectoryReady ? relayAgents : [], sharedChannelIds, }); const admittedPubkeys = new Set( @@ -110,7 +110,6 @@ export function useAgentMentionRevalidation({ ownerOnly, ownerPolicyError, refetchManagedAgents, - refetchRelayAgents, }: { agentPubkeys: ReadonlySet; getSelectedAgentPubkeys: () => ReadonlySet; @@ -120,7 +119,6 @@ export function useAgentMentionRevalidation({ ownerOnly: boolean | undefined; ownerPolicyError: Error | null; refetchManagedAgents: () => Promise>; - refetchRelayAgents: () => Promise>; }) { const queryClient = useQueryClient(); const refetchOwnerProfiles = React.useCallback( @@ -141,7 +139,13 @@ export function useAgentMentionRevalidation({ ownerOnly, ownerPolicyError, refetchManagedAgents, - refetchRelayAgents, + fetchRelayAgents: (requestedPubkeys) => + revalidateRelayAgents( + requestedPubkeys, + eligibilityScope.type === "channel" + ? eligibilityScope.channelId + : undefined, + ), refetchOwnerProfiles, }), [ @@ -153,7 +157,6 @@ export function useAgentMentionRevalidation({ ownerPolicyError, refetchManagedAgents, refetchOwnerProfiles, - refetchRelayAgents, sharedChannelIds, ], ); diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index fbf59e4c958..160d999a4d9 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -827,7 +827,6 @@ export function useMentions( ownerOnly: agentAccessOwnerOnlyQuery.data, ownerPolicyError: agentAccessOwnerOnlyQuery.error, refetchManagedAgents: managedAgentsQuery.refetch, - refetchRelayAgents: relayAgentsQuery.refetch, }); const extractMentionPersonas = React.useCallback( diff --git a/desktop/src/shared/api/tauriRelayAgents.ts b/desktop/src/shared/api/tauriRelayAgents.ts new file mode 100644 index 00000000000..8ae6766f79a --- /dev/null +++ b/desktop/src/shared/api/tauriRelayAgents.ts @@ -0,0 +1,37 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { RelayAgent } from "@/shared/api/types"; + +type RawRelayAgent = { + pubkey: string; + owner_pubkey?: string | null; + name: string; + agent_type: string; + channels: string[]; + channel_ids: string[]; + capabilities: string[]; + status: RelayAgent["status"]; + respond_to?: RelayAgent["respondTo"]; + respond_to_allowlist?: string[]; +}; + +export async function revalidateRelayAgents( + pubkeys: string[], + channelId?: string, +): Promise { + const agents = await invokeTauri("revalidate_relay_agents", { + pubkeys, + channelId, + }); + return agents.map((agent) => ({ + pubkey: agent.pubkey, + ownerPubkey: agent.owner_pubkey ?? null, + name: agent.name, + agentType: agent.agent_type, + channels: agent.channels, + channelIds: agent.channel_ids ?? [], + capabilities: agent.capabilities, + status: agent.status, + respondTo: agent.respond_to ?? null, + respondToAllowlist: agent.respond_to_allowlist ?? [], + })); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1239f9522a3..1aa98ca4a7f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -298,6 +298,8 @@ type E2eConfig = { relayAgents?: MockRelayAgentSeed[]; /** Reject successive relay-agent directory reads, then resume. */ relayAgentListErrors?: (string | null)[]; + /** Pubkeys omitted only from targeted send-time authorization checks. */ + relayAgentRevalidationRevokedPubkeys?: string[]; /** Native-like huddle state seeded from authoritative role-bearing membership. */ huddle?: MockHuddleSeed; agentListDelayMs?: number; @@ -12213,6 +12215,27 @@ export function maybeInstallE2eTauriMocks() { ); case "list_relay_agents": return handleListRelayAgents(activeConfig); + case "revalidate_relay_agents": { + const agents = await handleListRelayAgents(activeConfig); + const { pubkeys, channelId } = payload as { + pubkeys: string[]; + channelId?: string; + }; + const requested = new Set( + pubkeys.map((pubkey) => pubkey.toLowerCase()), + ); + const revoked = new Set( + (activeConfig?.mock?.relayAgentRevalidationRevokedPubkeys ?? []).map( + (pubkey) => pubkey.toLowerCase(), + ), + ); + return agents.filter( + (agent) => + requested.has(agent.pubkey.toLowerCase()) && + !revoked.has(agent.pubkey.toLowerCase()) && + (!channelId || agent.channel_ids.includes(channelId)), + ); + } case "list_personas": return handleListPersonas(); case "create_persona": diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 5562920a349..e6e0e9806e4 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1198,6 +1198,24 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ }); await page.goto("/"); await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); const input = page.getByTestId("message-input"); await input.fill("@quinn"); @@ -1206,12 +1224,20 @@ test("relay-only allowlisted agents emit a p tag when sent", async ({ await quinnRow.click(); await page.keyboard.type("hello"); await expect(input).toHaveText("@quinn hello"); + const baselineCommands = await readCommandLog(page); await page.getByTestId("send-message").click(); - await page.getByRole("button", { name: "Invite", exact: true }).click(); await expect .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); + + const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + expect(commandCount(commands, "list_relay_agents")).toBe( + commandCount(baselineCommands, "list_relay_agents"), + ); }); test("managed agents keep their p tag when relay discovery fails before send", async ({ @@ -1260,7 +1286,7 @@ test("managed agents keep their p tag when relay discovery fails before send", a .toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); }); -test("selected relay agents revoked before send emit no p tag", async ({ +test("targeted revocation before send causes no agent side effects", async ({ page, }) => { await installMockBridge(page, { @@ -1276,6 +1302,24 @@ test("selected relay agents revoked before send emit no p tag", async ({ }); await page.goto("/"); await page.getByTestId("channel-general").click(); + await page.evaluate( + async ({ channelId, pubkey }) => { + const invoke = window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + if (!invoke) throw new Error("Mock bridge is not installed."); + await invoke("add_channel_members", { + channelId, + pubkeys: [pubkey], + role: "bot", + }); + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + }, + ); const input = page.getByTestId("message-input"); await input.fill("@quinn"); const quinnRow = autocomplete(page).locator("button", { hasText: "quinn" }); @@ -1283,26 +1327,10 @@ test("selected relay agents revoked before send emit no p tag", async ({ await quinnRow.click(); await page.keyboard.type("hello"); - await page.evaluate(async () => { + await page.evaluate((pubkey) => { window.__BUZZ_E2E__.mock ??= {}; - window.__BUZZ_E2E__.mock.relayAgentListErrors = Array(5).fill( - "mock directory revoked", - ); - const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { - invalidateQueries: (filters: { - queryKey: readonly unknown[]; - }) => Promise; - getQueryState: ( - queryKey: readonly unknown[], - ) => { status?: string } | undefined; - }; - await queryClient.invalidateQueries({ queryKey: ["relay-agents"] }); - if (queryClient.getQueryState(["relay-agents"])?.status !== "error") { - throw new Error( - "relay-agent directory refetch did not enter error state", - ); - } - }); + window.__BUZZ_E2E__.mock.relayAgentRevalidationRevokedPubkeys = [pubkey]; + }, ALLOWLIST_RELAY_AGENT_PUBKEY); const baselineCommands = await readCommandLog(page); await page.getByTestId("send-message").click(); @@ -1313,6 +1341,12 @@ test("selected relay agents revoked before send emit no p tag", async ({ .poll(() => readOutgoingMentionPubkeys(page, "@quinn hello")) .not.toContain(ALLOWLIST_RELAY_AGENT_PUBKEY); const commands = await readCommandLog(page); + expect(commandCount(commands, "revalidate_relay_agents")).toBe( + commandCount(baselineCommands, "revalidate_relay_agents") + 2, + ); + expect(commandCount(commands, "list_relay_agents")).toBe( + commandCount(baselineCommands, "list_relay_agents"), + ); for (const command of [ "add_channel_members", "start_managed_agent", From 5694e78def8b6ea674e101c1c988a5f17c9baf9d Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 12:04:48 -0400 Subject: [PATCH 08/27] fix(mcp): scope todo usage (#6216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The `todo` tool description does not say when the tool is unnecessary, so agents use it for single-turn bookkeeping. Scoping it to cross-turn persistence reduces avoidable control calls while preserving the checklist for compaction and genuinely multi-turn work. ## What - Scope `todo` to work that must continue across turns or survive context compaction - Tell agents not to use it for work they can finish in the current turn - Preserve read/replace semantics and the `_Stop` hook behavior ## Risk Assessment Low to medium — this changes agent tool-selection guidance, not the tool name, schema, or implementation. The benchmark covers single-turn completion but not restart, compaction, or long-lived multi-turn recovery. ## References - Companion base-prompt change: https://github.com/block/buzz/pull/6186 - Combined benchmark (PR 6186 prompt plus this description): 22/22 pass; active time 0.3438h → 0.2680h (-22.0%); tool calls 216 → 173 (-19.9%); todo calls 45 → 0 - Against PR 6186's prompt-only condition: active time 0.2926h → 0.2680h (-8.4%); tool calls 198 → 173 (-12.6%) - Results are directional because model and tool behavior is stochastic. Generated with Codex Signed-off-by: Salman Mohammed --- crates/buzz-dev-mcp/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index 9b98974802f..87c3a119317 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -84,7 +84,7 @@ impl DevMcp { #[tool( name = "todo", - description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open." + description = "Session checklist only for work that must continue across turns or survive context compaction. Do not use for work you can finish in the current turn. Omit `todos` to read; provide the full {text, done} list to replace it. Open items let the _Stop hook advise against ending." )] async fn todo( &self, From d2cfd377e27dab8fdef0236dd8e92c89efbae829 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 12:24:12 -0400 Subject: [PATCH 09/27] fix(prompt): simplify pickup follow-through (#6186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The base prompt prescribed a todo lifecycle for every task needing follow-up tools, which added control calls even for work completed in one turn. This keeps the important behavioral contract—continue after publishing pickup—without prescribing the mechanism. ## What - Replace the 40-word todo lifecycle with a concise pickup follow-through rule - Preserve the requirement to publish the outcome or blocker before stopping ## Risk Assessment Low to medium — this changes managed-agent instructions, not runtime code. The terminal benchmark covers single-turn task completion but does not cover restart, compaction, or long-lived multi-turn recovery. ## References - Builds on the prompt simplification in https://github.com/block/buzz/pull/6161 - Benchmark setup: GPT 5.6 Terra at high effort; the same 11-task Terminal-Bench 2.1 slate; four concurrent trials; 4 CPU and 8 GiB per trial; 3× timeout | Prompt | Pass | Active-h | Median active | Tool calls | |---|---:|---:|---:|---:| | PR 6161 baseline | 22/22 | 0.3438 | 0.91 min | 216 | | Benchmarked 14-word rule | 22/22 | 0.2926 | 0.75 min | 198 | The benchmarked rule used 14.9% less active time, 16.9% lower median active time, and 8.3% fewer tool calls. Across the screen and confirmation runs it passed 33/33 trials with every completion report present; results are directional because model and tool behavior is stochastic. --- **Update Aug 18, 10:47 EDT:** Expanded the completion outcomes following review feedback. - The follow-through rule now covers a verified result, blocker, or key decision or information that needs to be surfaced. - The benchmark was not rerun; the table reflects the prior 14-word formulation. This is a completion-taxonomy clarification, not a return to a prescribed todo mechanism. Generated with Codex Signed-off-by: Salman Mohammed --- crates/buzz-acp/src/base_prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index a746e217628..f2de6983282 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -80,7 +80,7 @@ All replies and delegations — including task assignments to other agents — g - **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure. - **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed. - **Never publish a bare acknowledgement.** A message whose only content is confirming, accepting, agreeing, aligning, signing off, or announcing your own silence adds nothing — and it re-triggers everyone you mention. Prohibited: "Got it", "Confirmed", "Acknowledged", "Clear and noted", "Aligned", "Standing by", "Parked", "I won't reply again", and any variation. If your draft contains nothing beyond acknowledgement, send nothing. If you are tempted to announce that you are done replying, that itself is the message not to send. -- For work that requires follow-up tools, create an open todo **before** sending the pickup acknowledgment. Keep it open until the deliverable is verified and you have sent a completion or blocker message; never end a turn with open todo state unless you have posted that completion or blocker message. +- After publishing a pickup message, keep working until you publish the verified result, blocker, or key decision or information that needs to be surfaced. - Use GitHub-flavored Markdown. Fenced code blocks with language tags for syntax highlighting. - No push notifications — poll with `buzz messages get --channel --since `. - Address people using the name shown in their own message header. Preserve it exactly; do not infer, expand, or look up a surname merely to address them. From 8232299cbe6d90692fac3de46cde0ec123edd6c1 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 18 Aug 2026 10:40:31 -0600 Subject: [PATCH 10/27] chore(release): release Buzz Desktop version 0.5.17 (#6234) ## Buzz Desktop release v0.5.17 - **Frozen main:** `3fdf289b78c40f80abce86575c25b5ed6361d82c` - **Reviewed candidate:** `c3bfd66947978fae93f4cfb46bea98ba20e32ccf` - **Previous desktop release:** `desktop-v0.5.16` - **Proposed immutable tag:** `desktop-v0.5.17` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++++++------- CHANGELOG.md | 15 +++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 2cfec8d7813..3920dfeb44f 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.16", - "base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386", - "previous_tag": "desktop-v0.5.15", - "previous_base_sha": "7f61cf431af1d8f0480a0baf525881a12f2be7f2", - "previous_merge_sha": "c8c8eb58ad5336f21d77e7b02517cd4604a9a7ae", - "tag": "desktop-v0.5.16", - "commit_count": 2 + "version": "0.5.17", + "base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", + "previous_tag": "desktop-v0.5.16", + "previous_base_sha": "ee992ff0822f44d1c308822f116cb9d26f9a3386", + "previous_merge_sha": "978e585e8df893fe55aded854de07996b9412678", + "tag": "desktop-v0.5.17", + "commit_count": 5 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f81baf7730..bec9b9324e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## v0.5.17 + +### Desktop and shared changes + +- fix(desktop): bound remote agent mention authorization ([#6224](https://github.com/block/buzz/pull/6224)) ([`3fdf289b78c40f80abce86575c25b5ed6361d82c`](https://github.com/block/buzz/commit/3fdf289b78c40f80abce86575c25b5ed6361d82c)) +- fix(desktop): bind presence retry timers ([#6213](https://github.com/block/buzz/pull/6213)) ([`081910424a5b6f01b283ad632b0718240c6b3cbf`](https://github.com/block/buzz/commit/081910424a5b6f01b283ad632b0718240c6b3cbf)) +- ci: make file-size policy a first-class gate ([#6187](https://github.com/block/buzz/pull/6187)) ([`6d45f98665004d314468d98e50084996f4046cdf`](https://github.com/block/buzz/commit/6d45f98665004d314468d98e50084996f4046cdf)) +- fix(desktop): eliminate mounted-view CPU burn — compositor-safe shimmer, observer append fast path, poll-tick disk reads ([#6198](https://github.com/block/buzz/pull/6198)) ([`f0234f1449ab8a6d52d45a9e1ec19cc675b40fe9`](https://github.com/block/buzz/commit/f0234f1449ab8a6d52d45a9e1ec19cc675b40fe9)) + +### Other repository changes + +- fix: bump h2 for RUSTSEC-2026-0258 ([#6222](https://github.com/block/buzz/pull/6222)) ([`cc8a8b0dcbf5c01311b2ac7e1827ff3e582299f3`](https://github.com/block/buzz/commit/cc8a8b0dcbf5c01311b2ac7e1827ff3e582299f3)) + +[Compare desktop-v0.5.16...desktop-v0.5.17](https://github.com/block/buzz/compare/desktop-v0.5.16...desktop-v0.5.17) + ## v0.5.16 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 073a764a1ba..683c1529d25 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.16", + "version": "0.5.17", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 8724145dfd5..081e345edb7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.16" +version = "0.5.17" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1fa31d12d35..01504852b6f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.16" +version = "0.5.17" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 884cf0624f3..b6cbaab514f 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.16", + "version": "0.5.17", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 417eea2230c1864e8c77f6440dbcfa109bfb63f6 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 18 Aug 2026 18:56:58 +0100 Subject: [PATCH 11/27] Polish mobile timeline navigation (#5874) ## Summary - add sticky date headers that crossfade with in-timeline dates - replace the Latest pill with a centered down-arrow control - animate the control out from its bottom-center anchor 2AF1BB3F-B295-4084-8A65-90AED60B58B5 ## Validation - `bin/just mobile-check` - `bin/just mobile-test` (1,359 tests) - Android debug and signed iOS release builds --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> --- mobile/ios/Runner.xcodeproj/project.pbxproj | 8 + mobile/ios/Runner/AppDelegate.swift | 18 + .../ios/Runner/JumpToLatestGlassButton.swift | 125 ++++++ mobile/ios/Runner/StickyDateGlassHeader.swift | 131 ++++++ .../channels/channel_detail_page.dart | 5 +- .../channel_detail_page/message_list.dart | 393 ++++++++++++++++-- mobile/lib/features/channels/day_divider.dart | 81 ++-- .../channels/jump_to_latest_button.dart | 112 +++++ .../features/channels/sticky_date_header.dart | 210 ++++++++++ .../channels/channel_detail_page_test.dart | 171 +++++++- .../features/channels/day_divider_test.dart | 44 ++ .../channels/jump_to_latest_button_test.dart | 76 ++++ .../channels/sticky_date_header_test.dart | 117 ++++++ 13 files changed, 1399 insertions(+), 92 deletions(-) create mode 100644 mobile/ios/Runner/JumpToLatestGlassButton.swift create mode 100644 mobile/ios/Runner/StickyDateGlassHeader.swift create mode 100644 mobile/lib/features/channels/jump_to_latest_button.dart create mode 100644 mobile/lib/features/channels/sticky_date_header.dart create mode 100644 mobile/test/features/channels/day_divider_test.dart create mode 100644 mobile/test/features/channels/jump_to_latest_button_test.dart create mode 100644 mobile/test/features/channels/sticky_date_header_test.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 7ccf13bae4d..45cf085cb79 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; }; 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; }; 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; }; + 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; }; + 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.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 */; }; @@ -59,6 +61,8 @@ 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = ""; }; 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = ""; }; 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; + 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; + 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.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; }; @@ -182,6 +186,8 @@ 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */, 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, + 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */, + 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -417,6 +423,8 @@ 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */, 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */, + 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */, + 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.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 6ab55c359c7..fa68e539dd0 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -88,6 +88,24 @@ import UserNotifications } } + if let jumpToLatestGlassRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzJumpToLatestGlassButton" + ) { + jumpToLatestGlassRegistrar.register( + JumpToLatestGlassButtonFactory(messenger: messenger), + withId: "buzz/jump_to_latest_glass" + ) + } + + if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzStickyDateGlassHeader" + ) { + stickyDateGlassRegistrar.register( + StickyDateGlassHeaderFactory(messenger: messenger), + withId: "buzz/sticky_date_glass" + ) + } + let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeAttachmentPopover" ) diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift new file mode 100644 index 00000000000..0e96a78ec0c --- /dev/null +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -0,0 +1,125 @@ +import Flutter +import UIKit + +final class JumpToLatestGlassButtonFactory: 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 { + JumpToLatestGlassButtonPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +private final class JumpToLatestGlassButton: UIButton { + private static let hitTargetExpansion: CGFloat = 4 + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + bounds + .insetBy( + dx: -Self.hitTargetExpansion, + dy: -Self.hitTargetExpansion + ) + .contains(point) + } +} + +final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView { + private let containerView: UIView + private let channel: FlutterMethodChannel + private let button = JumpToLatestGlassButton(type: .system) + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + containerView = UIView(frame: frame) + channel = FlutterMethodChannel( + name: "buzz/jump_to_latest_glass/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + containerView.backgroundColor = .clear + containerView.isOpaque = false + applyBrightness(from: args) + + var configuration: UIButton.Configuration + if #available(iOS 26.0, *) { + configuration = .glass() + } else { + configuration = .gray() + configuration.baseBackgroundColor = UIColor.secondarySystemBackground + } + configuration.cornerStyle = .capsule + configuration.baseForegroundColor = .label + configuration.image = UIImage( + systemName: "arrow.down", + withConfiguration: UIImage.SymbolConfiguration( + pointSize: 16, + weight: .semibold + ) + ) + button.configuration = configuration + button.accessibilityLabel = "Jump to latest message" + button.translatesAutoresizingMaskIntoConstraints = false + button.addAction( + UIAction { [weak self] _ in + self?.channel.invokeMethod("pressed", arguments: nil) + }, + for: .touchUpInside + ) + + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "setBrightness" else { + result(FlutterMethodNotImplemented) + return + } + self?.applyBrightness(from: call.arguments) + result(nil) + } + + containerView.addSubview(button) + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + button.widthAnchor.constraint(equalToConstant: 40), + button.heightAnchor.constraint(equalToConstant: 40), + ]) + } + + func view() -> UIView { + containerView + } + + private func applyBrightness(from value: Any?) { + let brightness = (value as? [String: Any])?["brightness"] as? String + ?? value as? String + let interfaceStyle: UIUserInterfaceStyle = brightness == "dark" ? .dark : .light + containerView.overrideUserInterfaceStyle = interfaceStyle + button.overrideUserInterfaceStyle = interfaceStyle + button.setNeedsUpdateConfiguration() + } + + deinit { + channel.setMethodCallHandler(nil) + } +} diff --git a/mobile/ios/Runner/StickyDateGlassHeader.swift b/mobile/ios/Runner/StickyDateGlassHeader.swift new file mode 100644 index 00000000000..6a502048a20 --- /dev/null +++ b/mobile/ios/Runner/StickyDateGlassHeader.swift @@ -0,0 +1,131 @@ +import Flutter +import UIKit + +private final class StickyDateGlassView: UIVisualEffectView { + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } +} + +final class StickyDateGlassHeaderFactory: 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 { + StickyDateGlassHeaderPlatformView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + messenger: messenger + ) + } +} + +final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView { + private let glassView: StickyDateGlassView + private let channel: FlutterMethodChannel + private let dateLabel = UILabel() + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + messenger: FlutterBinaryMessenger + ) { + let arguments = args as? [String: Any] + let text = arguments?["label"] as? String ?? "" + channel = FlutterMethodChannel( + name: "buzz/sticky_date_glass/\(viewId)", + binaryMessenger: messenger + ) + + if #available(iOS 26.0, *) { + let glassEffect = UIGlassEffect(style: .regular) + glassEffect.isInteractive = false + glassView = StickyDateGlassView(effect: glassEffect) + } else { + glassView = StickyDateGlassView( + effect: UIBlurEffect(style: .systemMaterial) + ) + } + + super.init() + + glassView.frame = frame + glassView.isOpaque = false + glassView.isUserInteractionEnabled = false + glassView.clipsToBounds = true + glassView.layer.cornerCurve = .continuous + applyBrightness(from: arguments?["brightness"]) + + dateLabel.translatesAutoresizingMaskIntoConstraints = false + dateLabel.text = text + dateLabel.textAlignment = .center + dateLabel.textColor = .secondaryLabel + dateLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont( + for: UIFont.systemFont(ofSize: 14, weight: .medium) + ) + dateLabel.adjustsFontForContentSizeCategory = true + dateLabel.numberOfLines = 1 + dateLabel.lineBreakMode = .byTruncatingTail + dateLabel.isAccessibilityElement = false + + glassView.contentView.addSubview(dateLabel) + NSLayoutConstraint.activate([ + dateLabel.leadingAnchor.constraint( + equalTo: glassView.contentView.leadingAnchor, + constant: 12 + ), + dateLabel.trailingAnchor.constraint( + equalTo: glassView.contentView.trailingAnchor, + constant: -12 + ), + dateLabel.centerYAnchor.constraint( + equalTo: glassView.contentView.centerYAnchor + ), + ]) + + channel.setMethodCallHandler { [weak self] call, result in + switch call.method { + case "setLabel": + guard let text = call.arguments as? String else { + result(FlutterMethodNotImplemented) + return + } + self?.dateLabel.text = text + result(nil) + case "setBrightness": + self?.applyBrightness(from: call.arguments) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + func view() -> UIView { + glassView + } + + private func applyBrightness(from value: Any?) { + let interfaceStyle: UIUserInterfaceStyle = value as? String == "dark" ? .dark : .light + glassView.overrideUserInterfaceStyle = interfaceStyle + } + + deinit { + channel.setMethodCallHandler(nil) + } +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 8cef1a78ff7..6e7449a6333 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:math' show min; +import 'dart:math' show max, min; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; @@ -45,7 +45,7 @@ import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; import 'ime_metrics_settle_observer.dart'; -import 'latest_message_button.dart'; +import 'jump_to_latest_button.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; @@ -58,6 +58,7 @@ import 'reaction_row.dart'; import 'send_message_provider.dart'; import '../profile/user_profile_sheet.dart'; import 'small_avatar.dart'; +import 'sticky_date_header.dart'; import 'thread_detail_page.dart'; import 'timeline_message.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 57a621fbaf6..59e4c746fb8 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -39,6 +39,11 @@ class _MessageList extends HookConsumerWidget { final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); + final stickyDateHeaderState = useValueNotifier( + StickyDateHeaderState.hidden, + ); + final stickyDayTimestamp = useValueNotifier(null); + final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height); final isLoadingOlder = useState(false); final isAtLatest = useState(true); final settledImeBottomInset = useState( @@ -46,7 +51,10 @@ class _MessageList extends HookConsumerWidget { ? appView.viewInsets.bottom / appView.devicePixelRatio : 0.0, ); + final isJumpToLatestVisible = useState(false); final hasUserScrolled = useState(false); + final distanceFromLatest = useRef(0.0); + final hasUnseenLatestEntry = useRef(false); final followsLatest = useState( initialMessageId == null && initialThreadRootId == null, ); @@ -65,6 +73,9 @@ class _MessageList extends HookConsumerWidget { final hasUnreadDeepLink = initialMessageId != null || initialThreadRootId != null; final notifier = ref.read(channelMessagesProvider(channelId).notifier); + final dayTimestampByReversedIndex = {}; + final dayStartByReversedIndex = {}; + final dayHeaderTimestampByReversedIndex = {}; final settledImeLift = usesFixedAndroidImeViewport ? (settledImeBottomInset.value - MediaQuery.viewPaddingOf(context).bottom) @@ -74,6 +85,35 @@ class _MessageList extends HookConsumerWidget { final timelineBottomInset = composerBottomInset + (followsLatest.value ? settledImeLift : 0); final navigationBottomInset = composerBottomInset + settledImeLift; + var currentDayTimestamp = + displayEntries.firstOrNull?.first.message.createdAt; + var currentDayStartIndex = displayEntries.isEmpty + ? -1 + : displayEntries.length - 1; + for ( + var chronologicalIndex = 0; + chronologicalIndex < displayEntries.length; + chronologicalIndex += 1 + ) { + final message = displayEntries[chronologicalIndex].first.message; + final previousMessage = chronologicalIndex > 0 + ? displayEntries[chronologicalIndex - 1].last.message + : null; + final startsDay = + previousMessage == null || + !isSameDay(previousMessage.createdAt, message.createdAt); + final reversedIndex = displayEntries.length - 1 - chronologicalIndex; + if (startsDay) { + currentDayTimestamp = message.createdAt; + currentDayStartIndex = reversedIndex; + dayHeaderTimestampByReversedIndex[reversedIndex] = message.createdAt; + } + final dayTimestamp = currentDayTimestamp; + if (dayTimestamp != null) { + dayTimestampByReversedIndex[reversedIndex] = dayTimestamp; + dayStartByReversedIndex[reversedIndex] = currentDayStartIndex; + } + } useEffect( () { @@ -172,12 +212,120 @@ class _MessageList extends HookConsumerWidget { } double latestAlignment() { - final viewportHeight = context.size?.height ?? 0; + final viewportHeight = timelineViewportHeight.value; return viewportHeight > 0 ? (timelineBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble() : 0.0; } + void updateStickyDateHeader(Iterable rawPositions) { + void setStickyDateHeader( + StickyDateHeaderState state, { + int? activeDayTimestamp, + }) { + stickyDateHeaderState.value = state; + stickyDayTimestamp.value = activeDayTimestamp; + } + + final viewportHeight = timelineViewportHeight.value; + if (viewportHeight <= 0 || displayEntries.isEmpty) { + setStickyDateHeader(StickyDateHeaderState.hidden); + return; + } + + final positions = rawPositions + .where( + (position) => + position.index < displayEntries.length && + position.itemLeadingEdge < 1 && + position.itemTrailingEdge > 0, + ) + .toList(); + if (positions.isEmpty) { + if (!isLoadingOlder.value) { + setStickyDateHeader(StickyDateHeaderState.hidden); + } + return; + } + + final stickyTop = + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.twelve; + double physicalTop(ItemPosition position) => + viewportHeight * (1 - position.itemTrailingEdge); + double physicalBottom(ItemPosition position) => + viewportHeight * (1 - position.itemLeadingEdge); + + final positionAtStickyTop = positions + .where( + (position) => + physicalTop(position) <= stickyTop && + physicalBottom(position) > stickyTop, + ) + .firstOrNull; + if (positionAtStickyTop == null) { + if (!isLoadingOlder.value) { + setStickyDateHeader(StickyDateHeaderState.hidden); + } + return; + } + + final activeDayTimestamp = + dayTimestampByReversedIndex[positionAtStickyTop.index]; + final activeDayStartIndex = + dayStartByReversedIndex[positionAtStickyTop.index]; + if (activeDayTimestamp == null || activeDayStartIndex == null) { + setStickyDateHeader(StickyDateHeaderState.hidden); + return; + } + + final activeHeaderPosition = positions + .where((position) => position.index == activeDayStartIndex) + .firstOrNull; + final oldestVisibleIndex = positions + .map((position) => position.index) + .reduce((a, b) => a > b ? a : b); + final activeHeaderHasCrossed = activeHeaderPosition != null + ? physicalTop(activeHeaderPosition) <= stickyTop + : activeDayStartIndex > oldestVisibleIndex; + if (!activeHeaderHasCrossed) { + setStickyDateHeader(StickyDateHeaderState.hidden); + return; + } + + double? nextHeaderTop; + for (final position in positions) { + if (!dayHeaderTimestampByReversedIndex.containsKey(position.index) || + position.index >= activeDayStartIndex) { + continue; + } + final top = physicalTop(position); + if (top <= stickyTop || + (nextHeaderTop != null && top >= nextHeaderTop)) { + continue; + } + nextHeaderTop = top; + } + + final stickyHeaderHeight = StickyDateHeader.heightOf(context); + final rawTranslateY = nextHeaderTop == null + ? 0.0 + : min(0.0, nextHeaderTop - stickyTop - stickyHeaderHeight - 5); + final translateY = rawTranslateY + .clamp(-(stickyHeaderHeight + 5), 0.0) + .toDouble(); + setStickyDateHeader( + StickyDateHeaderState( + label: formatDayHeading(activeDayTimestamp), + translateY: (translateY * 2).round() / 2, + ), + activeDayTimestamp: activeDayTimestamp, + ); + } + Future performLatestNavigation() async { if (!context.mounted || !itemScrollController.isAttached) { isAutoScrolling.value = false; @@ -192,6 +340,7 @@ class _MessageList extends HookConsumerWidget { ); if (context.mounted && !hasUserScrolled.value) { isAtLatest.value = true; + isJumpToLatestVisible.value = false; } } finally { isAutoScrolling.value = false; @@ -203,6 +352,7 @@ class _MessageList extends HookConsumerWidget { isAutoScrolling.value = true; followsLatest.value = true; hasUserScrolled.value = false; + hasUnseenLatestEntry.value = false; latestNavigationRequest.value += 1; } @@ -253,6 +403,36 @@ class _MessageList extends HookConsumerWidget { ); } + void updateJumpToLatestVisibility( + Iterable positions, { + double? viewportDimension, + }) { + final latestIsVisible = positions.any( + (position) => + position.index == 0 && + position.itemLeadingEdge < 1 && + position.itemTrailingEdge > latestAlignment(), + ); + final viewportHeight = viewportDimension ?? timelineViewportHeight.value; + final visiblePageHeight = max( + 0.0, + viewportHeight - + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) - + composerBottomInset, + ); + final shouldShow = + !latestIsAtBoundary() && + (hasUnseenLatestEntry.value || + !latestIsVisible || + distanceFromLatest.value > visiblePageHeight); + if (isJumpToLatestVisible.value != shouldShow) { + isJumpToLatestVisible.value = shouldShow; + } + } + void realignLatestAfterLayoutChange() { if (latestRealignmentQueued.value || isAutoScrolling.value || @@ -273,48 +453,78 @@ class _MessageList extends HookConsumerWidget { } // A dock or keyboard resize is a layout correction, not a navigation // action. Keeping it instant avoids restarting a smooth scroll for - // every position report while the viewport settles. The rebuilt list - // padding already owns the composer/IME offset; the default alignment - // also keeps short timelines flush with that padding. + // every position report while the viewport settles. itemScrollController.jumpTo(index: 0); }); } - useEffect(() { - void onPositionsChanged() { - final positions = itemPositionsListener.itemPositions.value; - if (positions.isEmpty) return; - final nextIsAtLatest = latestIsAtBoundary(); - if (showUnreadNavigation && - nextIsAtLatest && - detachedWhileUnreadShown.value) { - isUnreadNavigationDismissed.value = true; - } - if (nextIsAtLatest) { - if (!isAtLatest.value) isAtLatest.value = true; - } else if (!followsLatest.value && isAtLatest.value) { - isAtLatest.value = false; - } + useEffect( + () { + void onPositionsChanged() { + final positions = itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + updateStickyDateHeader(positions); + updateJumpToLatestVisibility(positions); + final nextIsAtLatest = latestIsAtBoundary(); + if (showUnreadNavigation && + nextIsAtLatest && + detachedWhileUnreadShown.value) { + isUnreadNavigationDismissed.value = true; + } + if (nextIsAtLatest) { + hasUnseenLatestEntry.value = false; + if (!isAtLatest.value) isAtLatest.value = true; + if (isJumpToLatestVisible.value) { + isJumpToLatestVisible.value = false; + } + } else if (!followsLatest.value && isAtLatest.value) { + isAtLatest.value = false; + } - final oldestVisible = positions - .map((position) => position.index) - .reduce((a, b) => a > b ? a : b); - if (!hasUserScrolled.value || - oldestVisible < displayEntries.length - 3 || - isLoadingOlder.value) { - return; + final oldestVisible = positions + .map((position) => position.index) + .reduce((a, b) => a > b ? a : b); + if (!hasUserScrolled.value || + oldestVisible < displayEntries.length - 3 || + isLoadingOlder.value) { + return; + } + final notifier = ref.read( + channelMessagesProvider(channelId).notifier, + ); + if (notifier.reachedOldest) return; + isLoadingOlder.value = true; + notifier.fetchOlder().whenComplete( + () => isLoadingOlder.value = false, + ); } - final notifier = ref.read(channelMessagesProvider(channelId).notifier); - if (notifier.reachedOldest) return; - isLoadingOlder.value = true; - notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false); - } - itemPositionsListener.itemPositions.addListener(onPositionsChanged); - return () => itemPositionsListener.itemPositions.removeListener( - onPositionsChanged, - ); - }, [channelId, entries.length, itemPositionsListener]); + var disposed = false; + itemPositionsListener.itemPositions.addListener(onPositionsChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!disposed && context.mounted) onPositionsChanged(); + }); + return () { + disposed = true; + itemPositionsListener.itemPositions.removeListener( + onPositionsChanged, + ); + }; + }, + [ + channelId, + entries.length, + itemPositionsListener, + appBarTitleContentHeight, + composerBottomInset, + ], + ); + + useEffect(() { + stickyDateHeaderState.value = StickyDateHeaderState.hidden; + stickyDayTimestamp.value = null; + return null; + }, [channelId]); // Composer size changes and keyboard metrics changes arrive in separate // layout passes. Preserve the latest-message anchor for both, but only @@ -396,12 +606,25 @@ class _MessageList extends HookConsumerWidget { previousLatestEntryId.value = latestEntryId; if (previous == null || latestEntryId == null || - previous == latestEntryId || - !isAtLatest.value) { + previous == latestEntryId) { return null; } + if (!followsLatest.value || hasUserScrolled.value) { + hasUnseenLatestEntry.value = true; + } WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) scrollToLatest(); + if (!context.mounted) return; + if (followsLatest.value && !hasUserScrolled.value) { + scrollToLatest(); + return; + } + final positions = itemPositionsListener.itemPositions.value; + if (positions.isNotEmpty) { + if (latestIsAtBoundary()) { + hasUnseenLatestEntry.value = false; + } + updateJumpToLatestVisibility(positions); + } }); return null; }, [latestEntryId]); @@ -446,8 +669,32 @@ class _MessageList extends HookConsumerWidget { return Stack( children: [ - NotificationListener( + NotificationListener( onNotification: (notification) { + if (notification is ScrollMetricsNotification && + notification.depth != 0) { + return false; + } + if (notification is ScrollNotification && notification.depth != 0) { + return false; + } + if (notification is ScrollMetricsNotification) { + timelineViewportHeight.value = + notification.metrics.viewportDimension; + return false; + } + if (notification is! ScrollNotification) return false; + timelineViewportHeight.value = + notification.metrics.viewportDimension; + distanceFromLatest.value = max( + 0.0, + notification.metrics.pixels - + notification.metrics.minScrollExtent, + ); + updateJumpToLatestVisibility( + itemPositionsListener.itemPositions.value, + viewportDimension: notification.metrics.viewportDimension, + ); if (notification is UserScrollNotification && notification.direction != ScrollDirection.idle) { hasUserScrolled.value = true; @@ -528,7 +775,11 @@ class _MessageList extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showDayDivider) - DayDivider(label: formatDayHeading(message.createdAt)), + DayDivider( + label: formatDayHeading(message.createdAt), + dayTimestamp: message.createdAt, + stickyDayTimestamp: stickyDayTimestamp, + ), if (message.isSystem) _SystemMessageRow( message: message, @@ -572,6 +823,21 @@ class _MessageList extends HookConsumerWidget { ), ), ), + if (!showUnreadNavigation) + Positioned( + left: 0, + right: 0, + top: + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('channel-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), if (showUnreadNavigation) Positioned( left: 0, @@ -595,16 +861,38 @@ class _MessageList extends HookConsumerWidget { ), ), ) - else if (!isAtLatest.value) + else Positioned( left: 0, right: 0, bottom: navigationBottomInset + Grid.xs, child: Center( - child: LatestMessageButton( - key: const ValueKey('channel-jump-to-latest'), - surfaceKey: const ValueKey('channel-jump-to-latest-surface'), - onPressed: scrollToLatest, + child: AnimatedSwitcher( + key: const ValueKey('channel-jump-to-latest-switcher'), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + reverseDuration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: _JumpToLatestScaleAnimation(animation), + alignment: Alignment.bottomCenter, + child: child, + ), + ), + child: !isJumpToLatestVisible.value + ? const SizedBox.shrink( + key: ValueKey('channel-jump-to-latest-hidden'), + ) + : JumpToLatestButton( + key: const ValueKey('channel-jump-to-latest'), + onPressed: scrollToLatest, + ), ), ), ), @@ -612,3 +900,16 @@ class _MessageList extends HookConsumerWidget { ); } } + +class _JumpToLatestScaleAnimation extends Animation + with AnimationWithParentMixin { + @override + final Animation parent; + + _JumpToLatestScaleAnimation(this.parent); + + @override + double get value => parent.status == AnimationStatus.reverse + ? parent.value + : 0.92 + (0.08 * parent.value); +} diff --git a/mobile/lib/features/channels/day_divider.dart b/mobile/lib/features/channels/day_divider.dart index 7d65ad58fe6..24f5bb798e3 100644 --- a/mobile/lib/features/channels/day_divider.dart +++ b/mobile/lib/features/channels/day_divider.dart @@ -1,53 +1,60 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../shared/theme/theme.dart'; -/// Desktop-parity day separator with a centered label over a horizontal rule. +/// In-flow date label. The active date gains a glass capsule when it sticks. class DayDivider extends StatelessWidget { final String label; + final int? dayTimestamp; + final ValueListenable? stickyDayTimestamp; - const DayDivider({super.key, required this.label}); + const DayDivider({ + super.key, + required this.label, + this.dayTimestamp, + this.stickyDayTimestamp, + }); + + Widget _buildOpacity(BuildContext context, {required bool isSticky}) { + return ExcludeSemantics( + excluding: isSticky, + child: AnimatedOpacity( + key: dayTimestamp == null + ? null + : ValueKey('channel-day-divider-opacity-$dayTimestamp'), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: isSticky ? 0 : 1, + child: Text( + label, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.72), + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } @override Widget build(BuildContext context) { + final activeTimestamp = stickyDayTimestamp; + final timestamp = dayTimestamp; return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: SizedBox( - width: double.infinity, - child: Stack( - alignment: Alignment.center, - children: [ - Positioned( - left: 0, - right: 0, - child: Divider( - height: 1, - thickness: 1, - color: context.colors.outlineVariant.withValues(alpha: 0.35), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs + Grid.quarter, - vertical: Grid.half, - ), - decoration: BoxDecoration( - color: context.colors.surface, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.7), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter), + child: Center( + child: activeTimestamp == null || timestamp == null + ? _buildOpacity(context, isSticky: false) + : ValueListenableBuilder( + valueListenable: activeTimestamp, + builder: (context, activeDayTimestamp, _) => _buildOpacity( + context, + isSticky: activeDayTimestamp == timestamp, ), ), - child: Text( - label, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant.withValues(alpha: 0.7), - letterSpacing: 0.22, - ), - ), - ), - ], - ), ), ); } diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart new file mode 100644 index 00000000000..cb6b703a353 --- /dev/null +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -0,0 +1,112 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.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/theme/theme.dart'; + +/// Compact conversation control that returns a detached timeline to its tail. +class JumpToLatestButton extends HookConsumerWidget { + final VoidCallback onPressed; + + const JumpToLatestButton({required this.onPressed, super.key}); + + static const _iosViewType = 'buzz/jump_to_latest_glass'; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nativeChannel = useState(null); + final onPressedRef = useRef(onPressed)..value = onPressed; + final brightness = context.theme.brightness.name; + + useEffect(() { + final channel = nativeChannel.value; + if (channel == null) return null; + channel.setMethodCallHandler((call) async { + if (call.method == 'pressed') onPressedRef.value(); + }); + return () => channel.setMethodCallHandler(null); + }, [nativeChannel.value]); + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited(channel.invokeMethod('setBrightness', brightness)); + } + return null; + }, [nativeChannel.value, brightness]); + + final borderColor = context.colors.onSurface.withValues(alpha: 0.08); + final usesNativeIosGlass = defaultTargetPlatform == TargetPlatform.iOS; + + return Semantics( + button: true, + label: 'Jump to latest message', + child: Tooltip( + excludeFromSemantics: true, + message: 'Jump to latest message', + child: SizedBox.square( + dimension: Grid.xl, + child: usesNativeIosGlass + ? UiKitView( + key: const ValueKey('channel-jump-to-latest-ios-glass'), + viewType: _iosViewType, + hitTestBehavior: PlatformViewHitTestBehavior.opaque, + creationParams: {'brightness': brightness}, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel( + '$_iosViewType/$viewId', + ); + }, + ) + : Material( + color: Colors.transparent, + child: InkResponse( + containedInkWell: true, + customBorder: const CircleBorder(), + onTap: onPressed, + radius: Grid.sm, + child: Align( + key: const ValueKey( + 'channel-jump-to-latest-visual-anchor', + ), + alignment: Alignment.bottomCenter, + child: ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: const ValueKey( + 'channel-jump-to-latest-surface', + ), + width: Grid.lg, + height: Grid.lg, + decoration: BoxDecoration( + color: context.colors.surface.withValues( + alpha: 0.72, + ), + shape: BoxShape.circle, + border: Border.all(color: borderColor), + ), + child: Icon( + LucideIcons.arrowDown, + size: Grid.gutter, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/sticky_date_header.dart b/mobile/lib/features/channels/sticky_date_header.dart new file mode 100644 index 00000000000..f6d17d2b9b2 --- /dev/null +++ b/mobile/lib/features/channels/sticky_date_header.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; + +/// The active date and vertical push-off applied to a sticky date header. +@immutable +class StickyDateHeaderState { + final String? label; + final double translateY; + + const StickyDateHeaderState({this.label, this.translateY = 0}); + + static const hidden = StickyDateHeaderState(); + + bool get isVisible => label != null; + + @override + bool operator ==(Object other) { + return other is StickyDateHeaderState && + other.label == label && + other.translateY == translateY; + } + + @override + int get hashCode => Object.hash(label, translateY); +} + +/// A glass date capsule that remains below the app bar as its day scrolls. +class StickyDateHeader extends StatelessWidget { + final ValueListenable state; + + const StickyDateHeader({required this.state, super.key}); + + static const _iosViewType = 'buzz/sticky_date_glass'; + static const _minimumIosGlassHeight = 28.0; + + /// Height used by the surface and the next-day push-off calculation. + static double heightOf(BuildContext context) { + final labelStyle = context.textTheme.labelMedium; + final unscaledLineHeight = + (labelStyle?.fontSize ?? 14) * (labelStyle?.height ?? 1.25); + final contentHeight = + MediaQuery.textScalerOf(context).scale(unscaledLineHeight) + Grid.xxs; + return defaultTargetPlatform == TargetPlatform.iOS + ? max(_minimumIosGlassHeight, contentHeight) + : contentHeight; + } + + Widget _buildIosGlass(BuildContext context, String label) { + final textStyle = context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ); + final textPainter = TextPainter( + text: TextSpan(text: label, style: textStyle), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + + return _IosStickyDateGlass( + label: label, + width: textPainter.width + Grid.sm, + height: heightOf(context), + ); + } + + Widget _buildFlutterSurface(BuildContext context, String label) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.full), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: const ValueKey('channel-sticky-date-header-surface'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.half, + ), + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(Radii.full), + border: Border.all( + color: context.colors.onSurface.withValues(alpha: 0.08), + ), + ), + child: Semantics( + header: true, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + + return ValueListenableBuilder( + valueListenable: state, + builder: (context, value, _) { + return IgnorePointer( + child: ExcludeSemantics( + excluding: !value.isVisible, + child: AnimatedOpacity( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: value.isVisible ? 1 : 0, + child: Transform.translate( + offset: Offset(0, value.translateY), + child: Center( + child: RepaintBoundary( + child: defaultTargetPlatform == TargetPlatform.iOS + ? _buildIosGlass(context, value.label ?? '') + : _buildFlutterSurface(context, value.label ?? ''), + ), + ), + ), + ), + ), + ); + }, + ); + } +} + +class _IosStickyDateGlass extends HookConsumerWidget { + final String label; + final double width; + final double height; + + const _IosStickyDateGlass({ + required this.label, + required this.width, + required this.height, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nativeChannel = useState(null); + final brightness = context.theme.brightness.name; + + useEffect(() { + final channel = nativeChannel.value; + if (channel != null) { + unawaited(channel.invokeMethod('setLabel', label)); + unawaited(channel.invokeMethod('setBrightness', brightness)); + } + return null; + }, [nativeChannel.value, label, brightness]); + + return Semantics( + header: true, + label: label, + child: ExcludeSemantics( + child: SizedBox( + key: const ValueKey('channel-sticky-date-header-surface'), + width: width, + height: height, + child: UiKitView( + key: const ValueKey('channel-sticky-date-header-ios-glass'), + viewType: StickyDateHeader._iosViewType, + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + creationParams: { + 'label': label, + 'brightness': brightness, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (viewId) { + nativeChannel.value = MethodChannel( + '${StickyDateHeader._iosViewType}/$viewId', + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 79e385452bb..9b9ac596c7f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1717,8 +1717,9 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); - expect(find.text('Latest'), findsOneWidget); + expect(find.text('Latest'), findsNothing); expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget); + expect(find.byTooltip('Jump to latest message'), findsOneWidget); }); testWidgets('loads history through the oldest unread boundary', ( @@ -2515,19 +2516,83 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); - final latestSurface = tester.widget( - find.byKey(const ValueKey('channel-jump-to-latest-surface')), + final latestSurfaceFinder = find.byKey( + const ValueKey('channel-jump-to-latest-surface'), ); + final latestSurface = tester.widget(latestSurfaceFinder); final latestDecoration = latestSurface.decoration! as BoxDecoration; - expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full)); + expect(latestDecoration.shape, BoxShape.circle); expect( latestDecoration.color, - AppTheme.light().colorScheme.surface.withValues(alpha: 0.5), + AppTheme.light().colorScheme.surface.withValues(alpha: 0.72), ); expect( (latestDecoration.border! as Border).top.color, - Colors.black.withValues(alpha: 0.04), + AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.08), + ); + expect( + tester.getSize(find.byKey(const ValueKey('channel-jump-to-latest'))), + const Size.square(Grid.xl), + ); + expect( + tester + .getCenter(find.byKey(const ValueKey('channel-jump-to-latest'))) + .dx, + closeTo(tester.getCenter(messageList).dx, 0.1), + ); + expect( + tester + .getTopLeft(find.byKey(const ValueKey('channel-composer-dock'))) + .dy - + tester + .getBottomRight( + find.byKey(const ValueKey('channel-jump-to-latest')), + ) + .dy, + closeTo(Grid.xs, 0.1), + ); + final latestSwitcher = tester.widget( + find.byKey(const ValueKey('channel-jump-to-latest-switcher')), + ); + expect(latestSwitcher.duration, const Duration(milliseconds: 180)); + expect(latestSwitcher.reverseDuration, const Duration(milliseconds: 160)); + expect(latestSwitcher.switchInCurve, Curves.easeOutCubic); + expect(latestSwitcher.switchOutCurve, Curves.easeInCubic); + final latestScaleTransition = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('channel-jump-to-latest-switcher')), + matching: find.byType(ScaleTransition), + ), + ); + expect(latestScaleTransition.alignment, Alignment.bottomCenter); + final visualAnchor = tester.widget( + find.byKey(const ValueKey('channel-jump-to-latest-visual-anchor')), + ); + expect(visualAnchor.alignment, Alignment.bottomCenter); + expect(tester.getSize(latestSurfaceFinder), const Size.square(Grid.lg)); + expect( + tester.getBottomRight(latestSurfaceFinder).dy, + closeTo( + tester + .getBottomRight( + find.byKey(const ValueKey('channel-jump-to-latest')), + ) + .dy, + 0.1, + ), ); + expect(find.text('Latest'), findsNothing); + expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget); + for (final container in tester.widgetList( + find.descendant( + of: find.byKey(const ValueKey('channel-jump-to-latest')), + matching: find.byType(Container), + ), + )) { + if (container.decoration case final BoxDecoration decoration) { + expect(decoration.boxShadow, anyOf(isNull, isEmpty)); + } + } expect( find.descendant( of: find.byKey(const ValueKey('channel-jump-to-latest')), @@ -2549,6 +2614,30 @@ void main() { expect(findRichText('Newest live update'), findsNothing); await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest'))); + await tester.pump(); + + ScaleTransition exitingScaleTransition() { + return tester.widget( + find.ancestor( + of: find.byKey(const ValueKey('channel-jump-to-latest')), + matching: find.byType(ScaleTransition), + ), + ); + } + + for (var frame = 0; frame < 60; frame += 1) { + await tester.pump(const Duration(milliseconds: 16)); + if (exitingScaleTransition().scale.status == AnimationStatus.reverse) { + break; + } + } + expect(exitingScaleTransition().scale.status, AnimationStatus.reverse); + await tester.pump(const Duration(milliseconds: 120)); + + final collapsedScaleTransition = exitingScaleTransition(); + expect(collapsedScaleTransition.alignment, Alignment.bottomCenter); + expect(collapsedScaleTransition.scale.value, lessThan(0.1)); + await tester.pumpAndSettle(); expect(findRichText('Newest live update'), findsOneWidget); @@ -2742,6 +2831,74 @@ void main() { }, ); + testWidgets( + 'pins the current day below the app bar after its divider scrolls away', + (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final firstDay = + DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000; + final messages = [ + for (var day = 0; day < 3; day += 1) + for (var index = 0; index < 10; index += 1) + _textMsg( + id: 'day-$day-message-$index', + pubkey: 'alice', + content: 'Day $day message $index', + createdAt: firstDay + day * 86400 + index, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final messageList = find.byKey(const ValueKey('channel-message-list')); + final list = tester.widget(messageList); + list.itemScrollController!.jumpTo(index: 14, alignment: 0.8); + await tester.pumpAndSettle(); + + final stickyHeader = find.byKey( + const ValueKey('channel-sticky-date-header'), + ); + final stickySurface = find.byKey( + const ValueKey('channel-sticky-date-header-surface'), + ); + expect(stickyHeader, findsOneWidget); + expect(stickySurface, findsOneWidget); + expect( + find.descendant( + of: stickyHeader, + matching: find.text(formatDayHeading(firstDay + 86400)), + ), + findsOneWidget, + ); + expect( + tester.getTopLeft(stickySurface).dy, + closeTo( + frostedAppBarHeight(tester.element(stickyHeader)) + Grid.twelve, + 1, + ), + ); + expect( + find.descendant( + of: stickyHeader, + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); + }, + ); + testWidgets( 'keeps follow mode off while a tall newest message stays visible', (tester) async { @@ -2790,7 +2947,7 @@ void main() { expect(findRichText('Newest message line 0'), findsOneWidget); expect( find.byKey(const ValueKey('channel-jump-to-latest')), - findsOneWidget, + findsNothing, ); messagesNotifier.setMessages([ diff --git a/mobile/test/features/channels/day_divider_test.dart b/mobile/test/features/channels/day_divider_test.dart new file mode 100644 index 00000000000..23b39052681 --- /dev/null +++ b/mobile/test/features/channels/day_divider_test.dart @@ -0,0 +1,44 @@ +import 'package:buzz/features/channels/day_divider.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('fades the in-flow date while that day is sticky', ( + tester, + ) async { + final stickyDayTimestamp = ValueNotifier(null); + addTearDown(stickyDayTimestamp.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: DayDivider( + label: 'Today', + dayTimestamp: 1000, + stickyDayTimestamp: stickyDayTimestamp, + ), + ), + ), + ); + + AnimatedOpacity opacity() => tester.widget( + find.byKey(const ValueKey('channel-day-divider-opacity-1000')), + ); + + expect(opacity().opacity, 1); + + stickyDayTimestamp.value = 1000; + await tester.pump(); + + expect(opacity().opacity, 0); + expect(opacity().duration, const Duration(milliseconds: 120)); + expect(opacity().curve, Curves.easeOutCubic); + + stickyDayTimestamp.value = null; + await tester.pump(); + + expect(opacity().opacity, 1); + }); +} diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart new file mode 100644 index 00000000000..26b50455859 --- /dev/null +++ b/mobile/test/features/channels/jump_to_latest_button_test.dart @@ -0,0 +1,76 @@ +import 'package:buzz/features/channels/jump_to_latest_button.dart'; +import 'package:buzz/shared/theme/theme.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'; + +void main() { + testWidgets('keeps the native iOS glass in sync with the app theme', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('buzz/jump_to_latest_glass/41'); + final methodCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + methodCalls.add(call); + return null; + }); + try { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ), + ), + ); + + final nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.viewType, 'buzz/jump_to_latest_glass'); + expect(nativeView.creationParams, { + 'brightness': 'light', + }); + expect( + find.byKey(const ValueKey('channel-jump-to-latest-ios-glass')), + findsOneWidget, + ); + + nativeView.onPlatformViewCreated!(41); + await tester.pump(); + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'light', + ); + + methodCalls.clear(); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.dark(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'dark', + ); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/mobile/test/features/channels/sticky_date_header_test.dart b/mobile/test/features/channels/sticky_date_header_test.dart new file mode 100644 index 00000000000..5f89ebb8106 --- /dev/null +++ b/mobile/test/features/channels/sticky_date_header_test.dart @@ -0,0 +1,117 @@ +import 'package:buzz/features/channels/sticky_date_header.dart'; +import 'package:buzz/shared/theme/theme.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'; + +void main() { + testWidgets('updates the native iOS glass date and app theme', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final state = ValueNotifier( + const StickyDateHeaderState(label: 'Yesterday'), + ); + const channel = MethodChannel('buzz/sticky_date_glass/42'); + final methodCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + methodCalls.add(call); + return null; + }); + try { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), + ), + ); + + var nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.viewType, 'buzz/sticky_date_glass'); + expect(nativeView.creationParams, { + 'label': 'Yesterday', + 'brightness': 'light', + }); + expect(find.byType(BackdropFilter), findsNothing); + + nativeView.onPlatformViewCreated!(42); + await tester.pump(); + expect( + methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, + 'Yesterday', + ); + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'light', + ); + + state.value = const StickyDateHeaderState(label: 'Today'); + await tester.pump(); + + nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.creationParams, { + 'label': 'Today', + 'brightness': 'light', + }); + expect( + methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, + 'Today', + ); + + methodCalls.clear(); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.dark(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + methodCalls + .lastWhere((call) => call.method == 'setBrightness') + .arguments, + 'dark', + ); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + state.dispose(); + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('keeps the Flutter date surface on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + final state = ValueNotifier(const StickyDateHeaderState(label: 'Today')); + try { + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), + ), + ), + ); + + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.text('Today'), findsOneWidget); + } finally { + state.dispose(); + debugDefaultTargetPlatformOverride = null; + } + }); +} From c442a90a176845e3989436f2bb24eb6d0ca79d47 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 12:54:40 -0700 Subject: [PATCH 12/27] fix(desktop-chrome): preserve balanced layout when sidebar collapses (#6000) **Category:** fix **User Impact:** The desktop content surface now keeps balanced chrome spacing when the sidebar is collapsed or multiple communities are visible, with a cleaner sidebar transition. **Problem:** Collapsing the left sidebar removed the content surface's left inset, while multi-community layouts also exposed uneven visible gutters and sidebar content during the exit transition. **Solution:** Preserve an 8px visible gutter around the content surface, clip and fade only the sidebar's inner content while it trails off canvas, and leave the opaque shell in place until the existing visibility transition completes.
File changes **desktop/src/app/BuzzThemeSurfaces.tsx** Preserves a balanced visible left gutter after accounting for the content-edge shadow. **desktop/src/shared/ui/sidebar.tsx** Clips the collapsing sidebar, disables interaction off canvas, and fades/translates its inner content without exposing a different background. **desktop/tests/e2e/community-rail.spec.ts** Adds gutter assertions for single-community collapsed-sidebar and multi-community layouts. **desktop/tests/e2e/sidebar.spec.ts** Covers the collapse opacity, translation, clipping, pointer-event behavior, and restoration on reopen.
## Reproduction steps 1. Launch the desktop app with the Buzz theme and at least two communities. 2. Collapse the channel sidebar. 3. Confirm the main content surface keeps equal visible left and right gutters and does not overlap the community rail. 4. Reopen and collapse the sidebar again; confirm its contents fade and trail right while the chrome background stays opaque, then restore fully when reopened. ## Screenshots | Single community | Multiple communities | | --- | --- | | ![Sidebar collapse with a single community](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6000/multiple-communities-collapse.gif) | ![Sidebar collapse with multiple communities](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6000/single-community-collapse.gif) | ## Validation - `pnpm build:e2e` - focused sidebar integration tests: 2 passed - collapsed multi-community smoke regression: 1 passed - Biome check on all four changed files - pre-push desktop check, typecheck, and test hooks - `git diff --check` Related issue/PR: none found. --------- Signed-off-by: Taylor Ho --- desktop/src/app/AppShell.tsx | 1 + desktop/src/app/AppShellChannelSurface.tsx | 17 +++- .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- desktop/src/shared/ui/sidebar.tsx | 26 +++--- desktop/tests/e2e/community-rail.spec.ts | 75 +++++++++++++++- .../tests/e2e/sidebar-offcanvas-rail.spec.ts | 88 ++++++++++++++++++- desktop/tests/e2e/sidebar.spec.ts | 69 +++++++++++++++ 7 files changed, 259 insertions(+), 19 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 6257a75b720..96aa7acbac8 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -894,6 +894,7 @@ export function AppShell() { /> ) : null} ; @@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = { export function AppShellChannelSurface({ children, + hasCommunityRail, isHuddleRoom, isHuddleRoomStarting, mainInsetRef, terminal, }: AppShellChannelSurfaceProps) { + const { isMobile, openMobile, state: sidebarState } = useSidebar(); + const hasCollapsedSidebarGutter = + !isHuddleRoom && + !hasCommunityRail && + (isMobile ? !openMobile : sidebarState === "collapsed"); + return ( + {hasCollapsedSidebarGutter ? ( +
+ ) : null} {isHuddleRoom && !isHuddleRoomStarting ? : null} {isHuddleRoomStarting ? : children} diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 5394065b19c..12cb2ea9d39 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -374,7 +374,7 @@ export function CommunityRail({ return (
@@ -707,8 +709,7 @@ const SidebarGroupAction = React.forwardRef< data-sidebar="group-action" className={cn( "absolute right-3 top-3.5 z-10 flex size-6 items-center justify-center rounded-[4px] p-1 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-colors hover:bg-sidebar-border/35 hover:text-sidebar-foreground focus-visible:bg-sidebar-border/35 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", + MOBILE_ACTION_HIT_AREA, "group-data-[collapsible=icon]:hidden", className, )} @@ -853,8 +854,7 @@ const SidebarMenuAction = React.forwardRef< data-sidebar="menu-action" className={cn( "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", + MOBILE_ACTION_HIT_AREA, "peer-data-[size=sm]/menu-button:top-1", "peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=lg]/menu-button:top-2.5", diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 3f6d38602ab..8d24ef7e1df 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -4,6 +4,7 @@ import { installMockBridge } from "../helpers/bridge"; import { FEATURE_OVERRIDES_STORAGE_KEY } from "../helpers/features"; const RELAY_URL = "ws://localhost:3000"; +const THEME_STORAGE_KEY = "buzz-theme"; const OWNER_PUBKEY = "deadbeef".repeat(8); function snapshotKey(relayUrl: string) { @@ -23,6 +24,25 @@ const COMMUNITY_B = { addedAt: "2026-01-02T00:00:00.000Z", }; +async function expectContentSurfaceHorizontalGutters( + page: import("@playwright/test").Page, + expectedLeftGutter = 1, +) { + const [mainInsetBox, contentBox] = await Promise.all([ + page.locator("[data-buzz-glass-inset]").boundingBox(), + page.locator("[data-buzz-content-surface]").first().boundingBox(), + ]); + expect(mainInsetBox).not.toBeNull(); + expect(contentBox).not.toBeNull(); + const leftGutter = (contentBox?.x ?? 0) - (mainInsetBox?.x ?? 0); + const rightGutter = + (mainInsetBox?.x ?? 0) + + (mainInsetBox?.width ?? 0) - + ((contentBox?.x ?? 0) + (contentBox?.width ?? 0)); + expect(Math.abs(leftGutter - expectedLeftGutter)).toBeLessThan(0.5); + expect(Math.abs(rightGutter - 8)).toBeLessThan(0.5); +} + async function seedCommunities( page: import("@playwright/test").Page, communities: Array>, @@ -64,7 +84,7 @@ test.describe("community rail", () => { "overflow", "visible", ); - await expect(rail).toHaveCSS("z-index", "0"); + await expect(rail).toHaveCSS("z-index", "20"); const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); @@ -137,6 +157,7 @@ test.describe("community rail", () => { // The add-community affordance lives at the bottom of the rail. await expect(page.getByTestId("community-rail-add")).toBeVisible(); + await expectContentSurfaceHorizontalGutters(page); }); test("restores pointer events after dismissing community settings", async ({ @@ -1133,7 +1154,36 @@ test.describe("community rail", () => { ).toBeVisible(); }); + test("keeps the gutter when the mobile sidebar closes without a rail", async ({ + page, + }) => { + await page.setViewportSize({ width: 740, height: 516 }); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await expect( + page.locator('[data-sidebar="sidebar"][data-mobile="true"]'), + ).toBeHidden(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "width", + "8px", + ); + await expectContentSurfaceHorizontalGutters(page, 9); + }); + test("hides the rail with a single community", async ({ page }) => { + await page.addInitScript((themeStorageKey) => { + window.localStorage.setItem(themeStorageKey, "buzz-dark"); + }, THEME_STORAGE_KEY); await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); await page.goto("/"); @@ -1142,6 +1192,25 @@ test.describe("community rail", () => { // adds nothing). await expect(page.getByTestId("app-sidebar")).toBeVisible(); await expect(page.getByTestId("community-rail")).toHaveCount(0); + + await page + .getByRole("button", { name: "Toggle Sidebar", exact: true }) + .click(); + await expect( + page.locator('[data-side="left"][data-state="collapsed"]'), + ).toBeVisible(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "width", + "8px", + ); + const sidebarBackground = await page + .locator("[data-buzz-glass-inset]") + .evaluate((element) => getComputedStyle(element).backgroundColor); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCSS( + "background-color", + sidebarBackground, + ); + await expectContentSurfaceHorizontalGutters(page, 9); }); test("keeps the rail visible when the sidebar is collapsed", async ({ @@ -1174,6 +1243,10 @@ test.describe("community rail", () => { page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), ).toBeVisible(); await expect(page.getByTestId("community-rail-add")).toBeVisible(); + await expect(page.locator("[data-collapsed-content-gutter]")).toHaveCount( + 0, + ); + await expectContentSurfaceHorizontalGutters(page); }); test("clears the macOS traffic lights", async ({ page }) => { diff --git a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts index 8bee8da6592..df94afc6998 100644 --- a/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts +++ b/desktop/tests/e2e/sidebar-offcanvas-rail.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; const SHOTS = "test-results/sidebar-offcanvas-rail"; const THEME_STORAGE_KEY = "buzz-theme"; @@ -20,7 +21,7 @@ const COMMUNITY_B = { }; async function setup(page: Page, theme: string) { - await page.setViewportSize({ width: 1280, height: 800 }); + await page.setViewportSize({ width: 960, height: 540 }); await page.addInitScript( ({ key, value }) => { window.localStorage.setItem(key, value); @@ -52,15 +53,95 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) { page, }) => { await setup(page, theme); + await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/${theme}-expanded.png` }); - await page.locator('[data-sidebar="trigger"]').first().click(); + const communityRail = page.getByTestId("community-rail"); + const communityButton = page.getByTestId( + `community-rail-button-${COMMUNITY_B.id}`, + ); + const railBoxBeforeCollapse = await communityRail.boundingBox(); + expect(railBoxBeforeCollapse).not.toBeNull(); + + // Observe the transition before triggering it, then hold every animated + // sidebar-content property at its midpoint. This keeps the regression + // causal without making its assertions depend on Playwright or rAF + // scheduler latency. + const transition = await communityButton.evaluate(async (button) => { + const rail = button.closest('[data-testid="community-rail"]'); + const trigger = document.querySelector( + '[data-sidebar="trigger"]', + ); + const sidebarContent = document.querySelector( + "[data-sidebar-transition-content]", + ); + if (!(rail instanceof HTMLElement) || !trigger || !sidebarContent) { + return null; + } + + const transitionStarted = new Promise((resolve) => { + sidebarContent.addEventListener("transitionrun", () => resolve(), { + once: true, + }); + }); + trigger.click(); + await transitionStarted; + + const animations = sidebarContent.getAnimations(); + await Promise.all(animations.map((animation) => animation.ready)); + for (const animation of animations) { + animation.pause(); + animation.currentTime = 100; + } + + const buttonBox = button.getBoundingClientRect(); + const railBox = rail.getBoundingClientRect(); + const hit = document.elementFromPoint( + buttonBox.x + buttonBox.width / 2, + buttonBox.y + buttonBox.height / 2, + ); + const railStyle = getComputedStyle(rail); + const sidebarStyle = getComputedStyle(sidebarContent); + const result = { + durations: animations.map( + (animation) => animation.effect?.getTiming().duration, + ), + hitRail: hit === rail || rail.contains(hit), + opacity: railStyle.opacity, + sidebarOpacity: Number.parseFloat(sidebarStyle.opacity), + sidebarScale: sidebarStyle.scale, + sidebarTranslateX: Number.parseFloat(sidebarStyle.translate), + visibility: railStyle.visibility, + x: railBox.x, + y: railBox.y, + }; + + for (const animation of animations) animation.finish(); + return result; + }); + expect(transition).not.toBeNull(); + expect(transition?.durations).toEqual([200, 200, 200]); + expect(transition).toMatchObject({ + hitRail: true, + opacity: "1", + visibility: "visible", + x: railBoxBeforeCollapse?.x, + y: railBoxBeforeCollapse?.y, + }); + expect(transition?.sidebarOpacity).toBeGreaterThan(0); + expect(transition?.sidebarOpacity).toBeLessThan(1); + expect(transition?.sidebarScale).not.toBe("none"); + expect(transition?.sidebarScale).not.toBe("0.95"); + expect(transition?.sidebarTranslateX).toBeGreaterThan(0); + expect(transition?.sidebarTranslateX).toBeLessThan(24); + const shell = page.locator( '[data-state="collapsed"][data-collapsible="offcanvas"]', ); await expect(shell).toHaveCount(1); + // Let the 200ms slide finish; visibility flips at the transition's end. - await page.waitForTimeout(500); + await page.waitForTimeout(250); // Second direct child = the sliding sidebar container (first is the gap). const offscreenSidebar = shell.locator("> div").nth(1); @@ -72,6 +153,7 @@ for (const theme of ["buzz", "buzz-dark", "vesper"]) { await expect( page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), ).toBeVisible(); + await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/${theme}-collapsed.png` }); }); } diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 7419499baf8..01d9d64d67b 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -531,6 +531,75 @@ test("aligns the sidebar search with the channel title outside the Buzz theme", expect(Math.abs(searchCenter - channelTitleCenter)).toBeLessThanOrEqual(2); }); +test("scales the sidebar backward while its chrome closes", async ({ + page, +}) => { + await page.goto("/"); + + const sidebar = page.getByTestId("app-sidebar"); + const sidebarSurface = sidebar.locator("[data-sidebar-transition-content]"); + await expect(sidebarSurface).toHaveCSS("opacity", "1"); + await expect(sidebarSurface).toHaveCSS("scale", "none"); + + await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + + await expect(sidebarSurface).toHaveCSS("opacity", "0"); + await expect(sidebar).toHaveCSS("pointer-events", "none"); + await expect(sidebar).toHaveCSS("overflow", "visible"); + await expect(sidebar.locator(':scope > [data-sidebar="sidebar"]')).toHaveCSS( + "background-color", + await sidebarSurface.evaluate((element) => { + const sidebarElement = element.closest('[data-sidebar="sidebar"]'); + if (!(sidebarElement instanceof HTMLElement)) return ""; + return getComputedStyle(sidebarElement).backgroundColor; + }), + ); + await expect(sidebarSurface).toHaveCSS("scale", "0.95"); + await expect(sidebarSurface).toHaveCSS("translate", "24px"); + const transformOrigin = await sidebarSurface.evaluate( + (element) => getComputedStyle(element).transformOrigin, + ); + const [originX, originY] = transformOrigin.split(" ").map(Number.parseFloat); + const surfaceWidth = await sidebarSurface.evaluate( + (element) => element.clientWidth, + ); + expect(Math.abs(originX - surfaceWidth / 2)).toBeLessThan(0.5); + expect(originY).toBe(0); + await expect(sidebarSurface).toHaveCSS( + "transition-property", + "opacity, scale, translate", + ); + await expect(sidebarSurface).toHaveCSS("transition-duration", "0.2s"); + await expect(sidebarSurface).toHaveCSS( + "transition-timing-function", + "linear", + ); + + await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + await expect(sidebarSurface).toHaveCSS("opacity", "1"); + await expect(sidebar).toHaveCSS("pointer-events", "auto"); + await expect(sidebarSurface).toHaveCSS("scale", "none"); +}); + +test("disables the sidebar collapse transition for reduced motion", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + + const sidebarSurface = page + .getByTestId("app-sidebar") + .locator("[data-sidebar-transition-content]"); + await expect(sidebarSurface).toHaveCSS("transition-duration", "0s"); + + await page.getByRole("button", { name: "Toggle Sidebar" }).click(); + + await expect(sidebarSurface).toHaveCSS("opacity", "0"); + await expect(sidebarSurface).toHaveCSS("scale", "0.95"); + await expect(sidebarSurface).toHaveCSS("translate", "24px"); + await expect(sidebarSurface).toHaveCSS("transition-duration", "0s"); +}); + test("sidebar rail resizes without toggling the sidebar", async ({ page }) => { await page.goto("/"); const rail = page.getByRole("button", { name: "Resize sidebar" }); From d7e8fdb10ca5e055b7af6d22f67d9a8f42cec8ed Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 18 Aug 2026 12:55:00 -0700 Subject: [PATCH 13/27] fix(shared-ui): delay hover disclosures by default (#5821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Hover tooltips and informational popovers now wait for deliberate pointer dwell instead of appearing while users move around the app. **Problem:** Tooltips and hover-controlled popovers appeared after inconsistent, often very short delays, so moving across composer and navigation controls could obstruct the next interaction. **Solution:** Establish a 500 ms shared dwell default with no tooltip skip-delay cascade, apply it to informational hover-controlled Popovers, and preserve immediate click and keyboard behavior. The responsive Community actions navigation submenu retains its documented 80 ms open / 160 ms close timing. ## Before / after | Before | After | |---|---| | ![Before: hover disclosures appear during pointer transit](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5821/before-hover-disclosure.gif) | ![After: hover disclosures wait for deliberate pointer dwell](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/5821/after-hover-disclosure.gif) |
File changes **desktop/src/shared/ui/tooltip.tsx** Wraps the Radix provider with documented 500 ms and zero skip-delay defaults. The provider API still permits a future proven exception, but no current `desktop/src` caller overrides either timing. **desktop/src/shared/ui/popover.tsx** Exports the documented shared hover-open timing for controlled popovers; ordinary click/focus Popovers remain immediate. **desktop/src/main.tsx** Uses the shared Tooltip provider defaults at the application root. **desktop/src/shared/ui/sidebar.tsx** Removes the sidebar's instant Tooltip timing override. **desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx** Removes the local short Tooltip timing override. **desktop/src/features/home/ui/InboxDetailPane.tsx** Removes the local short Tooltip timing override. **desktop/src/features/messages/ui/MessageTimeline.tsx** Removes the timeline's local short Tooltip timing override. **desktop/src/features/messages/ui/MessageTimestamp.tsx** Drops the timestamp-only provider now that its 500 ms, zero-skip behavior is shared globally. **desktop/src/features/channels/ui/BotActivityBar.tsx** Raises composer agent-activity hover dwell from 150 ms to the shared default while preserving immediate click/focus opening. **desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx** Raises channel activity hover dwell from 250 ms to the shared default while preserving immediate focus opening. **desktop/src/features/profile/ui/UserProfilePopover.tsx** Reuses the shared hover timing in place of its equivalent local constant. **desktop/src/shared/ui/PubKey.tsx** Reuses the shared hover timing in place of its equivalent local constant. **desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx** Raises emoji inspection hover dwell from 200 ms to the shared default while preserving immediate focus opening. **desktop/src/features/messages/ui/MessageReactions.tsx** Raises reaction inspection hover dwell from 200 ms to the shared default while preserving immediate focus and reaction clicks. **desktop/src/features/communities/ui/CommunitySwitcher.tsx** Documents the Community actions navigation submenu as an intentional timing exception: 80 ms to open responsively and 160 ms to preserve the pointer bridge into its portalled panel.
## Reproduction steps Move the pointer rapidly across each surface first, then hold it still over a labeled control. Hover-only disclosures should stay closed during transit and open after about **500 ms** of deliberate dwell. Moving directly between adjacent Tooltip triggers should start a fresh 500 ms dwell rather than cascading the next Tooltip open immediately. | Surface | Where to test | What to expect | |---|---|---| | Composer controls | Attachment, emoji, image editor, formatting, and composer toolbar buttons | No Tooltip while sweeping across controls; the hovered control's Tooltip opens after ~500 ms. Clicking remains immediate. | | Message actions | Hover a message, then test reply, react, more-actions, edit, and related action-bar controls | Each Tooltip waits ~500 ms, including when moving between adjacent actions. The action itself still runs immediately on click or keyboard activation. | | Message metadata and content tools | Message timestamps, code-block copy controls, diff controls, system-message controls, and video-player controls | Tooltip appears after ~500 ms. Timestamp behavior should look unchanged; it was already 500 ms with no skip cascade. | | Reaction pills | Hover a reaction with one or more reactors; also click the pill | Reactor Popover waits ~500 ms instead of 200 ms. Clicking still toggles the reaction immediately. | | Inline custom emoji | Hover a rendered custom emoji in message Markdown, then focus it with the keyboard | Emoji inspector waits ~500 ms on hover instead of 200 ms. Keyboard focus opens it immediately. | | Masked links | Hover a masked Markdown link, including one revealed inside a spoiler | Destination Tooltip waits ~500 ms instead of the former app-level 300 ms. Hidden spoilers still reveal no destination; keyboard focus remains immediate. | | Main and collapsed sidebar controls | Collapse the sidebar and hover icon-only navigation/menu buttons; also test community-rail controls | Sidebar Tooltips wait ~500 ms instead of opening instantly. Rapid movement across icons should not produce a tooltip cascade. | | Channel header and management controls | Channel members, huddle, settings, thread-view mode, management rows, and quick-agent controls | Each Tooltip waits ~500 ms; click/keyboard behavior remains immediate. | | Add-channel team chips | Open Add channel where saved teams are available and hover a team chip | Team details Tooltip waits ~500 ms instead of 150 ms; clicking the chip still toggles the team immediately. | | Channel activity preview | Hover a sidebar channel that has activity, then keyboard-focus its trigger | Activity Popover waits ~500 ms instead of 250 ms. Focus opens it immediately, and quickly crossing channel rows should not leave previews in the way. | | Composer agent activity | Run an agent so the composer activity control is present; hover, click, and focus it | Hover Popover waits ~500 ms instead of 150 ms. Click and keyboard focus still open it immediately. | | Inbox and draft controls | Home inbox open-context/more-actions controls, inbox-list controls, draft detail, and drafts panel | Tooltips wait ~500 ms instead of the inbox detail's former 200 ms/local defaults. Clicks remain immediate. | | Profile and public-key previews | Hover avatars, names/mentions, project author identities, and displayed public keys | Profile/pubkey Popovers open after ~500 ms, matching their prior behavior; the change centralizes that timing. Click actions and focus behavior remain immediate. | | Agent, team, memory, and update controls | Managed-agent rows, team identity cards, restart-diff badges, memory actions, setup steps, and update indicator | Tooltips wait ~500 ms and do not cascade when traversing adjacent controls. Actions remain immediate. | | Huddle controls | Huddle bar/indicator, mic controls, and participant-list actions | Tooltips wait ~500 ms; mute, join, participant, and keyboard actions remain immediate. | | Projects and activity surfaces | Project/repository cards, overview rail, contribution graph, activity feed, reviewers, and Pulse note controls | Tooltips wait ~500 ms with a fresh dwell between adjacent targets. Clicking/focusing interactive controls remains immediate. | | Intentional interaction-mode exceptions | Focus a Tooltip trigger; click/focus an ordinary Popover; move the pointer from an open hover Popover into its panel | Tooltip focus and Popover click/focus open immediately because they are explicit user intent, not incidental hover. Hover Popovers retain their 180–200 ms close grace so the pointer can cross into the panel. The Community actions navigation submenu is the intentional exception: it opens after 80 ms and keeps its 160 ms pointer bridge; informational hover Popovers use the shared 500 ms delay. | ## Verification - `pnpm typecheck` - `pnpm check` (passes with three existing diagnostics outside this diff) - `pnpm test` — 4,775 passed - `pnpm build` --------- Signed-off-by: Taylor Ho --- .../channels/ui/AddChannelBotTeamsSection.tsx | 2 +- .../features/channels/ui/BotActivityBar.tsx | 10 ++- .../communities/ui/CommunitySwitcher.tsx | 10 ++- .../src/features/home/ui/InboxDetailPane.tsx | 2 +- .../features/messages/ui/MessageReactions.tsx | 12 ++- .../features/messages/ui/MessageTimeline.tsx | 2 +- .../features/messages/ui/MessageTimestamp.tsx | 46 ++++------- .../profile/ui/UserProfilePopover.tsx | 10 ++- .../sidebar/ui/ChannelActivityPopover.tsx | 10 ++- desktop/src/main.tsx | 2 +- desktop/src/shared/ui/PubKey.tsx | 10 ++- .../shared/ui/markdown/InlineEmojiPopover.tsx | 12 ++- desktop/src/shared/ui/popover.tsx | 4 + desktop/src/shared/ui/sidebar.tsx | 2 +- desktop/src/shared/ui/tooltip.tsx | 18 ++++- desktop/tests/e2e/community-rail.spec.ts | 81 ++++++++++++++++++- .../e2e/composer-tooltip-dismiss.spec.ts | 31 ++++++- 17 files changed, 207 insertions(+), 57 deletions(-) diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 69e511fb40d..512ee6899fb 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({

- +
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a961030..cfa84f02e3c 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -10,7 +10,12 @@ import { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -26,7 +31,6 @@ type BotActivityBarProps = { variant?: "toolbar" | "inline"; }; -const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; @@ -106,7 +110,7 @@ export function BotActivityComposerAction({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer]); const closeWithDelay = React.useCallback(() => { diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index cd530e69081..4d6f2d258ba 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -39,6 +39,12 @@ import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { EditCommunityDialog } from "./EditCommunityDialog"; +// Community actions is a responsive navigation submenu, not an informational +// disclosure. Keep its short hover dwell explicit rather than inheriting the +// shared 500 ms Popover delay intended to prevent incidental inspection UI. +const PROFILE_MENU_HOVER_OPEN_DELAY_MS = 80; +const PROFILE_MENU_HOVER_CLOSE_DELAY_MS = 160; + const CONNECTION_STATE_LABEL: Record = { idle: "Not connected", connecting: "Connecting…", @@ -128,7 +134,9 @@ export function CommunitySwitcher({ clearProfileMenuHoverTimer(); profileMenuHoverTimer.current = window.setTimeout( () => setDropdownOpen(nextOpen), - nextOpen ? 80 : 160, + nextOpen + ? PROFILE_MENU_HOVER_OPEN_DELAY_MS + : PROFILE_MENU_HOVER_CLOSE_DELAY_MS, ); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9bb593087d0..4b64ad10e06 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -622,7 +622,7 @@ function InboxMessageDetailPane({
- +
{canOpenChannel && contextChannelId ? ( diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index d4bec8db6c5..fba878dbb5b 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -12,7 +12,12 @@ import { isPositiveEmojiParticle, useEmojiBurst, } from "@/shared/ui/EmojiBurstProvider"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const REACTION_PILL_BASE_CLASSES = @@ -379,7 +384,10 @@ function ReactionPill({ const handleMouseEnter = React.useCallback(() => { if (reaction.users.length === 0) return; clearTimers(); - openTimeout.current = setTimeout(() => setOpen(true), 200); + openTimeout.current = setTimeout( + () => setOpen(true), + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + ); }, [reaction.users.length, clearTimers]); const scheduleClose = React.useCallback(() => { diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index f8c5395b17a..df9af09e674 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -692,7 +692,7 @@ const MessageTimelineBase = React.forwardRef< ) : null; return ( - +
{showUnreadPill ? (
- - -

- {displayTime} -

-
- - {formatFullDateTime(createdAt)} - -
- + + +

+ {displayTime} +

+
+ + {formatFullDateTime(createdAt)} + +
); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index ca732bb3f1f..257256c5f63 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -28,7 +28,12 @@ import { cn } from "@/shared/lib/cn"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; -import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverAnchor, + PopoverContent, +} from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; @@ -51,7 +56,6 @@ type UserProfilePopoverProps = { botIdenticonValue?: string; }; -const HOVER_OPEN_DELAY_MS = 500; const HOVER_CLOSE_DELAY_MS = 200; const RUNTIME_LABELS: Record = { @@ -244,7 +248,7 @@ export function UserProfilePopover({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer, enableHoverPopover]); const handleMouseLeave = React.useCallback(() => { diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1c86ac4c36b..e6e24ce27b1 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -16,10 +16,14 @@ import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; -import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverAnchor, + PopoverContent, +} from "@/shared/ui/popover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_DELAY_MS = 180; const ACTIVITY_POPOVER_MOTION_STYLE = { "--tw-enter-scale": "1", @@ -310,7 +314,7 @@ export function ChannelActivityPopover({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer, hasContent]); const openImmediately = React.useCallback(() => { if (!hasContent) return; diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index bbb4c5fa425..1520814559f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -86,7 +86,7 @@ function renderApp() { enabled={huddleWindowChannelId() === null} > - + diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx index 153801787ba..fb5ac15a441 100644 --- a/desktop/src/shared/ui/PubKey.tsx +++ b/desktop/src/shared/ui/PubKey.tsx @@ -6,9 +6,13 @@ import { cn } from "@/shared/lib/cn"; import { safeNpub } from "@/shared/lib/nostrUtils"; import { truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; -const HOVER_OPEN_DELAY_MS = 500; const HOVER_CLOSE_DELAY_MS = 200; type PubKeyProps = { @@ -99,7 +103,7 @@ export function PubKey({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer]); const handleMouseLeave = React.useCallback(() => { diff --git a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx index 89b482ae0cf..1082ff20322 100644 --- a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx +++ b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx @@ -1,6 +1,11 @@ import * as React from "react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; export function InlineEmojiPopover({ alt, @@ -27,7 +32,10 @@ export function InlineEmojiPopover({ const handleMouseEnter = React.useCallback(() => { clearTimers(); - openTimeout.current = setTimeout(() => setOpen(true), 200); + openTimeout.current = setTimeout( + () => setOpen(true), + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + ); }, [clearTimers]); const scheduleClose = React.useCallback(() => { diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx index 4c161efe5d8..ede0e15627d 100644 --- a/desktop/src/shared/ui/popover.tsx +++ b/desktop/src/shared/ui/popover.tsx @@ -14,6 +14,10 @@ import { POPOVER_SURFACE_CLASS, } from "@/shared/ui/popoverSurface"; +// Radix Popover has no hover timing API: controlled hover popovers must use this +// shared dwell default themselves. Keep click and keyboard opens immediate. +export const DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS = 500; + const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx index 568bb469182..5dbda329876 100644 --- a/desktop/src/shared/ui/sidebar.tsx +++ b/desktop/src/shared/ui/sidebar.tsx @@ -251,7 +251,7 @@ const SidebarProvider = React.forwardRef< return ( - +
) => ( + +); const Tooltip = TooltipPrimitive.Root; diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 8d24ef7e1df..57b35db386e 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -296,10 +296,87 @@ test.describe("community rail", () => { expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0); expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0); - await page.getByTestId("community-switcher").click(); - const menu = page.getByRole("menu", { name: "Community actions" }); + await communityTrigger.evaluate((trigger) => { + trigger.addEventListener( + "mouseenter", + () => { + trigger.dataset.hoverStartedAt = String(performance.now()); + }, + { once: true }, + ); + trigger.addEventListener("mouseleave", () => { + trigger.dataset.leftAt = String(performance.now()); + }); + const observer = new MutationObserver((records) => { + if ( + trigger.getAttribute("aria-expanded") === "true" && + !trigger.dataset.expandedAt + ) { + trigger.dataset.expandedAt = String(performance.now()); + } + if ( + records.some( + (record) => + record.attributeName === "aria-expanded" && + record.oldValue === "true", + ) + ) { + trigger.dataset.closedAfterOpening = "true"; + } + }); + observer.observe(trigger, { + attributeFilter: ["aria-expanded"], + attributeOldValue: true, + attributes: true, + }); + }); + await communityTrigger.hover(); + await expect(menu).toBeVisible({ timeout: 700 }); + const openDelayMs = await communityTrigger.evaluate((trigger) => { + const hoverStartedAt = Number(trigger.dataset.hoverStartedAt); + const expandedAt = Number(trigger.dataset.expandedAt); + if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) { + throw new Error("Community actions open timing was not recorded"); + } + return expandedAt - hoverStartedAt; + }); + expect(openDelayMs).toBeGreaterThanOrEqual(40); + expect(openDelayMs).toBeLessThan(300); + + const openTriggerBox = await communityTrigger.boundingBox(); + const menuBox = await menu.boundingBox(); + expect(openTriggerBox).not.toBeNull(); + expect(menuBox).not.toBeNull(); + if (!openTriggerBox || !menuBox) { + throw new Error("Community actions geometry unavailable"); + } + const triggerExitX = openTriggerBox.x + openTriggerBox.width - 1; + const triggerExitY = Math.min( + openTriggerBox.y + openTriggerBox.height - 4, + menuBox.y + menuBox.height - 4, + ); + await page.mouse.move(triggerExitX, triggerExitY); + await page.mouse.move(menuBox.x + 8, menuBox.y - 8); + await page.waitForTimeout(80); + await page.mouse.move(menuBox.x + 8, menuBox.y + 8); + const bridgeDurationMs = await communityTrigger.evaluate((trigger) => { + const leftAt = Number(trigger.dataset.leftAt); + if (!Number.isFinite(leftAt)) { + throw new Error( + "Community actions trigger exit timing was not recorded", + ); + } + return performance.now() - leftAt; + }); + expect(bridgeDurationMs).toBeGreaterThanOrEqual(60); + expect(bridgeDurationMs).toBeLessThan(140); + await page.waitForTimeout(180); await expect(menu).toBeVisible(); + await expect(communityTrigger).not.toHaveAttribute( + "data-closed-after-opening", + "true", + ); await expect( menu.getByRole("menuitem", { name: "Copy community URL" }), ).toBeVisible(); diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts index 9b0bb051fc9..d6cd05eb0f3 100644 --- a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts +++ b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts @@ -12,8 +12,8 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); -/** Hover the trigger, then slide the cursor onto the tooltip popup and - * assert the tooltip dismisses instead of persisting. */ +/** Hover the trigger through the shared dwell, then slide the cursor onto the + * tooltip popup and assert the tooltip dismisses instead of persisting. */ async function expectTooltipDismissesOnLeave( page: import("@playwright/test").Page, trigger: import("@playwright/test").Locator, @@ -22,7 +22,9 @@ async function expectTooltipDismissesOnLeave( await trigger.hover(); const tip = page.getByRole("tooltip", { name: tooltipName }); - await expect(tip).toBeVisible(); + await page.waitForTimeout(400); + await expect(tip).toHaveCount(0); + await expect(tip).toBeVisible({ timeout: 1_000 }); // Slide off the trigger onto the tooltip popup. const box = await tip.boundingBox(); @@ -48,6 +50,26 @@ test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ); }); +test("adjacent composer tooltips each require a fresh dwell", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-insert-mention").hover(); + await page.waitForTimeout(400); + const mentionTooltip = page.getByRole("tooltip", { name: "Mention someone" }); + await expect(mentionTooltip).toHaveCount(0); + await expect(mentionTooltip).toBeVisible({ timeout: 1_000 }); + + await page.getByRole("button", { name: "Attach file" }).hover(); + await page.waitForTimeout(400); + const attachTooltip = page.getByRole("tooltip", { name: "Attach file" }); + await expect(attachTooltip).toHaveCount(0); + await expect(attachTooltip).toBeVisible({ timeout: 1_000 }); +}); + test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({ page, }) => { @@ -60,6 +82,9 @@ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", const bold = page.getByRole("button", { name: "Bold" }); await expect(bold).toBeVisible(); + // The formatting strip animates into place; wait for its delayed entrance to + // settle so the pointer remains over the trigger for the full dwell. + await page.waitForTimeout(300); // Tooltip text is "
); } @@ -457,6 +486,7 @@ export function AgentConfigSurfaceRows({ @@ -485,6 +515,8 @@ export function AgentConfigSurfaceRows({ ) : null}
) : null} + + {claudeConfigDirCustom ? : null}
); } diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index f7ee098833b..62d85d385d4 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -25,6 +25,7 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; +import { EffortPickerField } from "./EffortPickerField"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { ADVANCED_FIELDS_MOTION_TRANSITION, @@ -267,10 +268,10 @@ export function AgentInstanceEditDialog({ return runtimeSupportsLlmProviderSelection(matched?.id ?? ""); }, [runtimes, originalAgentCommand]); - // The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime - // (that is what runs once the override is cleared, not the current override). - // Falls back to dual-match (command path, then id) when no persona or its runtime is unset. - // This single prospective id feeds BOTH the block-save gate and submit so they always agree. + // The runtime id active after submit — the single prospective id feeding BOTH + // the block-save gate and submit so they always agree. Inheriting resolves + // from the LINKED PERSONA's runtime (what runs once the override is cleared), + // falling back to dual-match (command path, then id) when no persona. const prospectiveRuntimeId = React.useMemo(() => { if (!inheritHarness) { return selectedRuntime?.id ?? selectedRuntimeId; @@ -425,11 +426,10 @@ export function AgentInstanceEditDialog({ selectedRuntime, }); - // D2: derive advancedRequiredEnvKeys for EnvVarsEditor display. - // The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating. - // D2/D3: the top-level API key owns display, while the readiness gate keeps - // the complete required-key list. The effective snapshot covers persona - // inheritance during an instance inherit transition. + // D2/D3: the top-level API key owns display while the readiness gate keeps the + // complete required-key list; advancedRequiredEnvKeys drives EnvVarsEditor + // display only. The effective snapshot covers persona inheritance during an + // instance inherit transition. const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider); const personaSatisfied = providerApiKeyEnvVar != null && @@ -693,11 +693,9 @@ export function AgentInstanceEditDialog({ : normalizedModel !== (agent.model ?? null) ? normalizedModel : undefined, - // Tri-state provider persistence keyed on providerRuntimeCapability: - // "capable" → persist: value if changed, omit if unchanged. - // "locked" → clear: send null if provider was set, else omit. - // "unknown" → omit always (never send null for a transient state). - // llmProviderFieldVisible is for UX visibility only; not used here. + // Tri-state provider persistence keyed on providerRuntimeCapability + // (see the classification comment above for the capable/locked/unknown + // contract). llmProviderFieldVisible is UX visibility only; not used here. provider: linkedPersona != null ? undefined @@ -1128,6 +1126,8 @@ export function AgentInstanceEditDialog({ ) : null}
+ + setAiDefaultsOpen(true)} triggerRef={aiDefaultsTriggerRef} diff --git a/desktop/src/features/agents/ui/EffortPickerField.tsx b/desktop/src/features/agents/ui/EffortPickerField.tsx new file mode 100644 index 00000000000..a06f17ac11f --- /dev/null +++ b/desktop/src/features/agents/ui/EffortPickerField.tsx @@ -0,0 +1,81 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks"; +import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents"; +import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types"; +import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions"; +import { + effortPickerState, + effortSelectionToPersistedValue, +} from "./effortPicker"; +import { PersonaDropdownField } from "./PersonaDropdownField"; + +/** + * Thinking-effort write control for the edit dialog (B5, v4 direct-write). + * + * Local-only by construction: the write calls `persistAgentEffortLevel`, which + * the Rust command rejects for non-local backends (remote effort is set at + * deploy time via `policy_env`). So the control renders only for a local + * backend AND once the adapter has advertised a `thought_level` configId + * (discovered from the running session — absent pre-first-session and for + * runtimes/models without effort support). The read-only configured-vs-running + * two-facts display lives in `AgentConfigPanel`; this is the write control. + * + * Direct-write: each selection persists immediately and invalidates the config + * surface so the panel's canonical tier reflects the new next-spawn value. + */ +export function EffortPickerField({ + agent, + config, +}: { + agent: ManagedAgent; + config: RuntimeConfigSurface | undefined; +}) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: (level: string | null) => + persistAgentEffortLevel(agent.pubkey, level), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: agentConfigSurfaceQueryKey(agent.pubkey), + }), + }); + const { visible, options, selectValue } = effortPickerState({ + backend: agent.backend, + effortConfigId: config?.effortConfigId, + effortOptions: config?.effortOptions, + currentEffort: config?.normalized.thinkingEffort?.value ?? null, + }); + + if (!visible) { + return null; + } + + return ( +
+ + + mutation.mutate(effortSelectionToPersistedValue(value)) + } + options={options} + placeholder="Adapter default" + value={selectValue} + /> +

+ Applied at the next session start. +

+ {mutation.error instanceof Error ? ( +

{mutation.error.message}

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/McpServersSection.test.mjs b/desktop/src/features/agents/ui/McpServersSection.test.mjs new file mode 100644 index 00000000000..526acbca5e3 --- /dev/null +++ b/desktop/src/features/agents/ui/McpServersSection.test.mjs @@ -0,0 +1,90 @@ +/** + * #3493 provenance: the MCP servers section must attribute its entries to the + * ACTUAL config file the reader read — which, under a custom CLAUDE_CONFIG_DIR, + * is the isolated `/.claude.json`, not the default `~/.claude.json`. + * + * Before this fix `mcpConfigFilePath` was carried on the DTO but no component + * consumed it, so the panel listed the correct servers with no file + * attribution at all. These pin the rendered contract. + */ + +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +import { + McpServersSection, + mcpConfigFileCaption, +} from "./McpServersSection.tsx"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const CLAUDE_MCP = [{ name: "sentinel", kind: "stdio", enabled: true }]; + +test("mcpConfigFileCaption_customPath_returnsFileAttribution", () => { + assert.equal( + mcpConfigFileCaption("/tmp/iso/.claude.json"), + "From config file (/tmp/iso/.claude.json)", + ); +}); + +test("mcpConfigFileCaption_nullPath_returnsNull", () => { + assert.equal(mcpConfigFileCaption(null), null); + assert.equal(mcpConfigFileCaption(undefined), null); +}); + +test("McpServersSection_customConfigDir_rendersIsolatedFilePath", async () => { + const { render } = await import("@testing-library/react"); + const React = await import("react"); + + const { container } = render( + React.createElement(McpServersSection, { + extensions: CLAUDE_MCP, + mcpConfigFilePath: "/tmp/iso/.claude.json", + runtimeId: "claude", + variant: "compact", + }), + ); + + assert.match(container.textContent, /sentinel/); + assert.match( + container.textContent, + /From config file \(\/tmp\/iso\/\.claude\.json\)/, + ); +}); + +test("McpServersSection_noConfigPath_omitsFileAttribution", async () => { + const { render } = await import("@testing-library/react"); + const React = await import("react"); + + const { container } = render( + React.createElement(McpServersSection, { + extensions: CLAUDE_MCP, + mcpConfigFilePath: null, + runtimeId: "claude", + variant: "compact", + }), + ); + + assert.match(container.textContent, /sentinel/); + assert.doesNotMatch(container.textContent, /From config file/); +}); diff --git a/desktop/src/features/agents/ui/McpServersSection.tsx b/desktop/src/features/agents/ui/McpServersSection.tsx index f92f77a3923..db3de3b2001 100644 --- a/desktop/src/features/agents/ui/McpServersSection.tsx +++ b/desktop/src/features/agents/ui/McpServersSection.tsx @@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn"; type McpServersSectionProps = { extensions: ExtensionEntry[]; runtimeId: string | null; + mcpConfigFilePath?: string | null; variant?: "compact" | "profile"; buzzAgentSlot?: React.ReactNode; }; @@ -19,9 +20,19 @@ export function shouldRenderMcpServers( return runtimeId === "buzz-agent" || extensions.length > 0; } +// #3493: the servers are read from the isolated `.claude.json` under a custom +// `CLAUDE_CONFIG_DIR`. Attribute them to that actual file so the panel never +// implies the default `~/.claude.json` when isolation is in effect. +export function mcpConfigFileCaption( + mcpConfigFilePath: string | null | undefined, +): string | null { + return mcpConfigFilePath ? `From config file (${mcpConfigFilePath})` : null; +} + export function McpServersSection({ buzzAgentSlot, extensions, + mcpConfigFilePath, runtimeId, variant = "compact", }: McpServersSectionProps) { @@ -31,6 +42,8 @@ export function McpServersSection({ return null; } + const fileCaption = mcpConfigFileCaption(mcpConfigFilePath); + return (
)} + + {extensions.length > 0 && fileCaption ? ( +

+ {fileCaption} +

+ ) : null}
); } diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 863cd851195..0bc6f9646af 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -109,26 +109,39 @@ export function ModelPicker({ }, [configSurface]); // Send a live `switch_model` frame to each channel the agent is working in - // and wait for the harness to acknowledge. Any single `unsupported_model` - // result rejects the whole pick immediately; all other statuses must arrive - // from every channel before resolving success. + // and wait for the harness to acknowledge. A single `unsupported_model` + // (model unavailable) or `failure` (adapter refused) result rejects the whole + // pick immediately. The busy-path `sent` ack is provisional (the adapter + // isn't consulted until the requeued session); success is confirmed only by a + // real positive terminal frame from every channel, and if none arrives before + // the timeout the pick resolves `"pending"` (accepted, apply deferred). const sendLiveSwitch = React.useCallback( (modelId: string) => { const channelIds = activeTurns.map((turn) => turn.channelId); + // Opaque per-pick correlator. The harness echoes it on the immediate ack + // and the late terminal frame, so a five-minute reconnect replay of an + // earlier pick's result cannot settle this one. + const requestId = crypto.randomUUID(); return awaitLiveSwitchOutcome({ - channelCount: channelIds.length, - modelId, + requestId, + channelIds, subscribe: (listener) => subscribeControlResults(agent.pubkey, listener), sendSwitches: async () => { await Promise.all( channelIds.map((channelId) => - switchManagedAgentModel(agent.pubkey, channelId, modelId), + switchManagedAgentModel( + agent.pubkey, + channelId, + modelId, + requestId, + ), ), ); }, - // No reply in time: treat as sent. The override still rides the - // requeued/next session; we just can't confirm synchronously. + // No positive terminal in time: resolve `"pending"`. The override still + // rides the requeued/next session; we just can't confirm synchronously, + // and must not claim a success that hasn't happened. scheduleTimeout: (onTimeout) => { const timeout = window.setTimeout(onTimeout, 8_000); return () => window.clearTimeout(timeout); @@ -148,6 +161,31 @@ export function ModelPicker({ toast.error("That model isn't available for this agent."); return; } + if (outcome === "failed") { + toast.error( + "Couldn't switch models — the agent kept its current model.", + ); + return; + } + if (outcome === "not_delivered") { + // The switch never reached a session: the turn was already ending, or + // no active turn remained by the time the harness received it. Nothing + // was applied and nothing rides a later session — tell the truth. + toast.error( + "Couldn't switch models — the agent wasn't running a turn to switch.", + ); + return; + } + if (outcome === "pending") { + // The switch was accepted but its apply is deferred to the next + // session (the agent is mid-turn) and didn't confirm before the + // fallback timeout. Tell the truth instead of claiming success. + toast.info( + "Model switch pending — applies when the current turn finishes.", + ); + onModelChanged?.(); + return; + } toast.success("Model switched for this session."); onModelChanged?.(); return; diff --git a/desktop/src/features/agents/ui/effortPicker.test.mjs b/desktop/src/features/agents/ui/effortPicker.test.mjs new file mode 100644 index 00000000000..28c22ec9d95 --- /dev/null +++ b/desktop/src/features/agents/ui/effortPicker.test.mjs @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + EFFORT_DEFAULT_DROPDOWN_VALUE, + effortPickerState, + effortSelectionToPersistedValue, +} from "./effortPicker.ts"; + +const localBackend = { type: "local" }; +const providerBackend = { type: "provider", id: "openai", config: {} }; +const options = [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, +]; + +test("effort picker renders for a local backend with a discovered configId", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.equal(state.visible, true); +}); + +test("effort picker is hidden for a provider backend even when a configId exists", () => { + const state = effortPickerState({ + backend: providerBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "high", + }); + assert.equal(state.visible, false); +}); + +test("effort picker is hidden for a local backend without a discovered configId", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: undefined, + effortOptions: undefined, + currentEffort: null, + }); + assert.equal(state.visible, false); +}); + +test("options lead with the adapter-default sentinel then adapter values", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.deepEqual(state.options, [ + { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE }, + { label: "Low", value: "low" }, + { label: "High", value: "high" }, + ]); +}); + +test("option label falls back to the raw value when displayName is absent", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: [{ value: "medium" }], + currentEffort: null, + }); + assert.deepEqual(state.options[1], { label: "medium", value: "medium" }); +}); + +test("current effort preselects the matching option", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "high", + }); + assert.equal(state.selectValue, "high"); +}); + +test("an unknown current effort falls back to the adapter-default sentinel", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: "extreme", + }); + assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE); +}); + +test("a null current effort selects the adapter-default sentinel", () => { + const state = effortPickerState({ + backend: localBackend, + effortConfigId: "thought_level", + effortOptions: options, + currentEffort: null, + }); + assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE); +}); + +test("the sentinel selection persists as null (clear to adapter default)", () => { + assert.equal( + effortSelectionToPersistedValue(EFFORT_DEFAULT_DROPDOWN_VALUE), + null, + ); +}); + +test("a concrete selection persists as its explicit effort level", () => { + assert.equal(effortSelectionToPersistedValue("high"), "high"); +}); diff --git a/desktop/src/features/agents/ui/effortPicker.ts b/desktop/src/features/agents/ui/effortPicker.ts new file mode 100644 index 00000000000..515355e4ad7 --- /dev/null +++ b/desktop/src/features/agents/ui/effortPicker.ts @@ -0,0 +1,71 @@ +import type { + AcpConfigOptionValue, + ManagedAgentBackend, +} from "@/shared/api/types"; +import type { PersonaDropdownOption } from "./agentConfigOptions"; + +/** + * Sentinel dropdown value for "no explicit effort" — reverts the agent to the + * adapter default at the next spawn. Distinct from any adapter option value. + */ +export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__"; + +/** + * Pure gating + option compute for the effort write control in the edit dialog. + * + * The picker is a LOCAL-only, direct-write control: it calls + * `persistAgentEffortLevel`, which the Rust command rejects for non-local + * backends (remote effort is set at deploy time via `policy_env`). So the UI + * must not offer it for a provider backend, and there's nothing to pick until + * the adapter has advertised a `thought_level` config option (discovered from + * the running session — `effortConfigId` is absent pre-first-session and for + * runtimes/models that don't support effort). + * + * `visible` is the single gate the dialog renders on: local backend AND a + * discovered `effortConfigId`. + */ +export function effortPickerState({ + backend, + effortConfigId, + effortOptions, + currentEffort, +}: { + backend: ManagedAgentBackend; + effortConfigId: string | undefined; + effortOptions: readonly AcpConfigOptionValue[] | undefined; + currentEffort: string | null; +}): { + visible: boolean; + options: PersonaDropdownOption[]; + selectValue: string; +} { + const visible = backend.type === "local" && effortConfigId !== undefined; + + const options: PersonaDropdownOption[] = [ + { label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE }, + ...(effortOptions ?? []).map((option) => ({ + label: option.displayName ?? option.value, + value: option.value, + })), + ]; + + // Preselect the currently-configured effort when it maps to a known option; + // otherwise fall back to the adapter-default sentinel (also the null case). + const trimmed = currentEffort?.trim() ?? ""; + const selectValue = + trimmed.length > 0 && + (effortOptions ?? []).some((option) => option.value === trimmed) + ? trimmed + : EFFORT_DEFAULT_DROPDOWN_VALUE; + + return { visible, options, selectValue }; +} + +/** + * Map a dropdown selection back to the value persisted via + * `persistAgentEffortLevel`: the sentinel clears effort (null → adapter + * default), any other value is the explicit effort level. + */ +export function effortSelectionToPersistedValue(value: string): string | null { + return value === EFFORT_DEFAULT_DROPDOWN_VALUE ? null : value; +} diff --git a/desktop/src/shared/api/agentControl.ts b/desktop/src/shared/api/agentControl.ts index 677f0ffad49..70fb7529396 100644 --- a/desktop/src/shared/api/agentControl.ts +++ b/desktop/src/shared/api/agentControl.ts @@ -17,15 +17,21 @@ export async function cancelManagedAgentTurn( * the harness's cancel-switch-requeue path (busy turn) or invalidate-and-reapply * (idle); the outcome arrives asynchronously as a `control_result` observer * frame, not as the return value here. This is fire-and-forget on the send side. + * + * `requestId` is an opaque per-pick correlator the harness echoes back on both + * the immediate ack and the late terminal frame, so a reconnect replay of an + * earlier pick's result cannot settle this one. */ export async function switchManagedAgentModel( pubkey: string, channelId: string, modelId: string, + requestId: string, ): Promise { await sendAgentObserverControl(pubkey, { type: "switch_model", channelId, modelId, + requestId, }); } diff --git a/desktop/src/shared/api/tauriManagedAgents.ts b/desktop/src/shared/api/tauriManagedAgents.ts index c74b099f885..88bce2ec275 100644 --- a/desktop/src/shared/api/tauriManagedAgents.ts +++ b/desktop/src/shared/api/tauriManagedAgents.ts @@ -50,6 +50,21 @@ export async function setManagedAgentAutoRestart( return fromRawManagedAgent(response); } +/** + * B5: persist the canonical startup effort for a local managed agent. Applied + * as `BUZZ_ACP_EFFORT_LEVEL` at the next spawn. Pass `null` to clear (reverts + * to the adapter default). Rejects non-local agents. + */ +export async function persistAgentEffortLevel( + pubkey: string, + effortLevel: string | null, +): Promise { + return invokeTauri("persist_agent_effort_level", { + pubkey, + effortLevel, + }); +} + export async function listManagedAgentRuntimes(): Promise< ManagedAgentRuntimeStatus[] > { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index dcf6d2e8bc7..41c63f7be97 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -339,18 +339,9 @@ export type ManagedAgent = { modelSource: "definition" | "global" | "instance_legacy" | null; /** LLM inference provider, from the agent's pinned record snapshot. */ provider: string | null; - /** - * `true` when the linked persona has been edited since this agent was - * created — the running agent uses the older pinned snapshot. Surface a - * "out of date" marker and prompt the user to delete + respawn to update. - * Always `false` for non-persona agents and for orphaned agents. - */ + /** True when the linked persona has been edited since this agent was created. */ personaOutOfDate: boolean; - /** - * `true` when the agent's linked persona no longer exists. Distinct from - * out-of-date: there is no current persona to respawn into, so do not prompt - * a respawn — the pinned snapshot is all the config that remains. - */ + /** True when this agent's linked persona no longer exists. */ personaOrphaned: boolean; /** * `true` when the running process was spawned with a config that no longer @@ -461,23 +452,23 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; -/** - * Outcome of a live `switch_model` control frame, surfaced asynchronously via - * the agent's `control_result` observer frame. Busy path: `sent` (cancel + - * requeue on the new model) or `turn_ending` (oneshot already consumed this - * turn). Idle path: `switched`, `unsupported_model`, or `no_active_turn`. - */ +/** Outcome of a live `switch_model` control frame; `failure` lands late. */ export type SwitchManagedAgentModelStatus = | "sent" | "turn_ending" | "switched" | "unsupported_model" - | "no_active_turn"; + | "no_active_turn" + | "failure"; export type ControlResultFrame = { type: "cancel_turn" | "switch_model"; status: string; modelId?: string; + /** Opaque per-pick id echoed from the request; correlates late frames. */ + requestId?: string; + /** Buzz channel UUID from the observer envelope; disambiguates channels. */ + channelId?: string | null; }; export type GitBashPrerequisite = { @@ -657,6 +648,9 @@ export type ConfigSourceReport = { export type ExtensionEntry = { name: string; kind: string; enabled: boolean }; +/** B5/I-7: a single adapter-advertised value for an ACP config option. */ +export type AcpConfigOptionValue = { value: string; displayName?: string }; + export type NormalizedConfig = { model: NormalizedField | null; provider: NormalizedField | null; @@ -675,6 +669,12 @@ export type RuntimeConfigSurface = { advanced: ConfigField[]; extensions: ExtensionEntry[]; sources: ConfigSourceReport; + /** #3493: `true` when the surface was read from a user-set `CLAUDE_CONFIG_DIR` — drives the Keychain caveat note in the panel. */ + claudeConfigDirCustom?: boolean; + /** B5: the adapter-advertised `thought_level` configId, discovered from the running session. Present only for claude after the first session. Drives the effort picker. */ + effortConfigId?: string; + /** B5/I-7: adapter-advertised option values for the `thought_level` option — the picker renders these instead of hardcoded values. */ + effortOptions?: AcpConfigOptionValue[]; }; export type UpdateManagedAgentInput = { From 4f9727a4b3d76389f862faa15241e16e2dd36108 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 18 Aug 2026 16:49:41 -0400 Subject: [PATCH 16/27] chore(scripts): add buzz-adopt-prod-agents.sh (#6250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `scripts/buzz-adopt-prod-agents.sh`, which copies the installed-DMG (production) agent records and owner identity into the dev app-data store so a dev build boots as the **same agents and same owner npub** as the installed app — surviving `just reset`. ## What it restores `just reset` wipes the two things Buzz cannot re-derive: - The agent **records** — `managed-agents.json`, `personas.json`, `teams.json`, and `agents/teams/`. - The **owner identity** nsec, written as `identity.key` (0600, atomic). Agent private keys and identity-key adoption then happen automatically on the next dev boot via the shipped in-app migrations. ## When to run it 1. Quit any running **dev** build. A running installed DMG is fine — the script treats it as a read-only source and only warns against creating/archiving agents or editing teams while it runs. It never kills processes. 2. Run `just reset`. 3. Before the first dev boot, run `bash scripts/buzz-adopt-prod-agents.sh`. - `--dry-run` prints every action with zero writes. - `--force` overwrites an already-populated dev store. - One keychain prompt is expected (reading the prod owner identity). 4. Start the dev build. First boot copies the agent keys prod→dev and adopts `identity.key`, then deletes it. For worktree launches, export `BUZZ_SHARE_IDENTITY=1` (and `BUZZ_PRIVATE_KEY`) or the worktree mints its own duplicate agents. ## Running-process check Only a running **dev** build blocks the run: the script atomically swaps the entire dev `agents/` directory and stages `identity.key` for the next dev boot to consume, so doing that under a live dev process is the corruption the check must prevent. A running installed DMG is allowed — the app writes `managed-agents.json` via tmp+atomic-rename, so it can never hand the script a torn file. Each matching `buzz-desktop` PID is classified by its true post-exec executable path (via `lsof -d txt`, which reports the running vnode with symlinks already resolved); the installed `/Applications/Buzz.app/Contents/MacOS/buzz-desktop` path is the only allow. Anything else still alive blocks fail-closed; a PID that exits before its path can be resolved is ignored. ## Requirements Requires `python3` when a dev `agents/` already exists — it drives the `renamex_np(RENAME_SWAP)` syscall bash cannot call. The script fails in preflight with a clear message if `python3` is absent, never mid-commit. ## Safety Prod is strictly read-only; symlinked or aliased prod/dev roots are refused before any write. The records/identity restore commits in one atomic operation (`renamex_np(RENAME_SWAP)` when a dev store exists, a single `mv` when absent) that fails closed — a crash or I/O error leaves `agents/` either fully old or fully new, and unrelated live dev state (`logs/`, `agent-pids/`, `global-agent-config.json`, retention stores) is preserved verbatim across the swap. This is a temporary measure while we trial it and decide whether to port the logic into the dev-build startup path. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- scripts/buzz-adopt-prod-agents.sh | 479 ++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100755 scripts/buzz-adopt-prod-agents.sh diff --git a/scripts/buzz-adopt-prod-agents.sh b/scripts/buzz-adopt-prod-agents.sh new file mode 100755 index 00000000000..01e02022e03 --- /dev/null +++ b/scripts/buzz-adopt-prod-agents.sh @@ -0,0 +1,479 @@ +#!/usr/bin/env bash +# +# buzz-adopt-prod-agents.sh — copy your installed (production) Buzz agent +# records + owner identity into the dev app-data store so a dev build boots as +# the SAME agents and the SAME owner npub as your installed DMG. +# +# WHY +# `just reset` deletes the entire dev app-data dir (agent records included), +# every buzz-desktop-dev keychain entry, and the `_dev_migration_v1` marker. +# The next dev boot sees an empty store, so key-less records mint fresh +# keypairs against the prod relay → duplicate agent instances, and a fresh +# owner key → auth-tag split-brain. This script restores the two things the +# reset wipes that Buzz cannot re-derive on its own: the agent RECORDS and +# the OWNER identity nsec. Everything else the app repairs itself on next boot +# (see WHAT THIS DOES NOT DO). +# +# WHEN +# Run AFTER `just reset`, BEFORE the first dev boot (`just production` / dev). +# Any running DEV build must be closed; the installed DMG may stay open (it is +# only a read-only source here — see preflight 1a). Idempotent: refuses to +# clobber a non-empty dev store unless --force. +# +# WHAT THIS DOES NOT DO (verified against block/buzz origin/main) +# - It does NOT touch the buzz-desktop-dev keychain. Agent private keys are +# copied prod→dev automatically at next dev boot by +# `migrate_agent_keys_to_dev_service` (desktop/src-tauri/src/managed_agents/ +# storage.rs:460): the reset wiped the `_dev_migration_v1` marker, so it +# re-runs and copies `agent:` keys for every pubkey in the restored +# records. It only ever failed to help before because the reset also wiped +# the RECORDS — step "records" below fixes exactly that. +# - The owner identity is written as a plaintext `identity.key` file, NOT into +# the dev keychain. Next dev boot's legacy-import path adopts it into the +# dev keyring (app_state.rs:583, `ReachableButEmpty` branch = +# the post-reset empty-dev-keyring state) with the crash-safe +# marker-before-delete ordering already shipped. The plaintext file exists +# only until that first boot consumes it. +# +# FLAGS +# --dry-run Print every action; write nothing. +# --force Proceed even if the dev store is non-empty (overwrites records +# and identity.key). Never bypasses the running-dev-build check. +# -h|--help This header. +# +# PATH SAFETY +# The prod store is strictly read-only — nothing under prod is ever created, +# read-modified, or deleted. Prod and dev are hardcoded canonical constants, +# so there is no override path to canonicalize; the only writes go to the +# fixed dev dir. As a guard against odd on-disk state, the script refuses to +# run if the dev store dir/agents subdir is a symlink, or if the prod root or +# any prod source (agents/, the record files, agents/teams/) is a symlink, or +# if prod and an existing dev resolve to the same or overlapping tree. +# +# ENV OVERRIDES (read-only against prod; exist so the identity read can be +# fixture-tested against a scratch keychain without touching real secrets) +# BUZZ_KEYCHAIN_SVC keychain service (default buzz-desktop) +# BUZZ_KEYCHAIN_ACCT keychain account (default secrets) +# +set -euo pipefail + +# --- config ----------------------------------------------------------------- +# Prod/dev app-data dirs are hardcoded canonical constants — the only paths a +# real invocation ever uses. Keychain service/account stay overridable because +# they are read-only against prod (used to fixture-test the identity read). +SUPPORT="$HOME/Library/Application Support" +PROD_DIR="$SUPPORT/xyz.block.buzz.app" +DEV_DIR="$SUPPORT/xyz.block.buzz.app.dev" +KEYCHAIN_SVC="${BUZZ_KEYCHAIN_SVC:-buzz-desktop}" +KEYCHAIN_ACCT="${BUZZ_KEYCHAIN_ACCT:-secrets}" + +DRY_RUN=0 +FORCE=0 + +RECORD_FILES=(managed-agents.json personas.json teams.json) + +# --- helpers ---------------------------------------------------------------- +say() { printf '%s\n' "$*"; } +info() { printf ' %s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +usage() { sed -n '2,/^set -euo/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0; } + +have() { command -v "$1" >/dev/null 2>&1; } + +# Temp paths (files/dirs) to remove on exit. A cleanup FUNCTION consumes this +# array so no path is ever interpolated into trap source — a path containing +# an apostrophe or space cannot break the trap or leave a temp behind. +STAGE_TEMPS=() +# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup_stages EXIT` +cleanup_stages() { + local p + for p in "${STAGE_TEMPS[@]:+${STAGE_TEMPS[@]}}"; do + [[ -e "$p" ]] && rm -rf "$p" + done + return 0 # never let the trap's last test override the script's exit status +} +trap cleanup_stages EXIT + +# Create a mode-0600 staging file next to its final destination so the later +# rename is same-filesystem atomic. mktemp creates 0600 by default; the umask +# guards against a lenient inherited default. Echoes the temp path. +new_stage() { ( umask 077; mktemp "$1.stage.XXXXXX" ); } + +# Atomically move a validated 0600 stage file over its destination. `mv -f` +# replaces the name (never writes through a symlink at $2) so an interrupted +# run can never leave a partial file at the live path. +finalize() { chmod 600 "$1"; mv -f "$1" "$2"; } + +# Atomically exchange two existing directories via renamex_np(RENAME_SWAP) — the +# whole-bundle commit primitive. bash cannot call the syscall, so python3 drives +# it. Fail closed: any unavailability or nonzero syscall result exits nonzero so +# the caller aborts with the live store untouched (never a sequential fallback). +swap_dirs() { + python3 - "$1" "$2" <<'PY' +import ctypes, os, sys +a, b = sys.argv[1], sys.argv[2] +try: + libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + renamex_np = libc.renamex_np +except (OSError, AttributeError) as e: + sys.stderr.write("renamex_np unavailable: %s\n" % e); sys.exit(1) +renamex_np.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] +RENAME_SWAP = 0x2 +if renamex_np(os.fsencode(a), os.fsencode(b), RENAME_SWAP) != 0: + e = ctypes.get_errno() + sys.stderr.write("renamex_np(RENAME_SWAP) failed: errno %d (%s)\n" % (e, os.strerror(e))) + sys.exit(1) +PY +} + +# Strip the volatile runtime_pid field from a managed-agents store (JSON array +# of ManagedAgentRecord; field verified at types.rs:317). Reads $1, writes $2. +# Nonzero exit on malformed input so callers can abort before the rename. +strip_runtime_pid() { + local src="$1" dst="$2" + if have jq; then + jq 'if type=="array" then map(del(.runtime_pid)) else del(.runtime_pid) end' "$src" > "$dst" + elif have python3; then + python3 - "$src" "$dst" <<'PY' +import json, sys +src, dst = sys.argv[1], sys.argv[2] +with open(src) as f: + data = json.load(f) +def strip(r): + if isinstance(r, dict): + r.pop("runtime_pid", None) + return r +data = [strip(r) for r in data] if isinstance(data, list) else strip(data) +with open(dst, "w") as f: + json.dump(data, f, indent=2) +PY + else + die "need jq or python3 to strip runtime_pid" + fi +} + +# --- arg parse -------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=1 ;; + --force) FORCE=1 ;; + -h|--help) usage ;; + *) die "unknown argument: $1 (see --help)" ;; + esac + shift +done + +[[ $DRY_RUN -eq 1 ]] && say "== DRY RUN — no changes will be written ==" + +# --- step 1: preflight ------------------------------------------------------ +say "[1/4] Preflight" + +# 1a. Refuse if a running DEV build is detected; a running installed DMG is +# allowed (read-only detection; never kills). The main app binary is +# `buzz-desktop` for both the installed DMG +# (/Applications/Buzz.app/Contents/MacOS/buzz-desktop) and dev builds +# (target//buzz-desktop via `tauri dev`). Match that path component +# exactly so sidecars/helpers (buzz, buzz-dev-mcp, buzz-agent) don't +# false-positive. +# +# WHY dev blocks but the DMG doesn't: step 2 atomically swaps the entire dev +# agents/ dir and step 3 stages identity.key for the next dev boot to +# consume — doing that under a live dev process is exactly the corruption +# the check must prevent. The DMG is only a read-only source: the app writes +# managed-agents.json via tmp+atomic-rename (managed_agents/storage.rs:603 +# on origin/main), so a running DMG can never hand us a torn file. The only +# residual is cross-file skew if agents/teams are mutated in the DMG during +# the seconds this runs — a warn, self-healing on re-run, not a block. +# +# Classification per PID resolves the true post-exec executable path via +# `lsof -d txt` (the running vnode, symlinks already resolved by the kernel; +# argv[0]/ps comm can lie). Fail closed: the DMG path is the ONLY allow; +# anything else that is still alive blocks. A PID that exits between pgrep +# and resolution (path unresolvable AND process gone) is ignored — it is no +# longer running. A PID still alive but unresolvable (permissions, exotic +# state) blocks. +ALLOWED_PROD_EXE="/Applications/Buzz.app/Contents/MacOS/buzz-desktop" + +# Echo a PID's true executable path (first txt-mapped vnode), or empty if none. +# lsof exits nonzero when the PID is gone; callers use `|| true` so a raced exit +# under `set -e` yields an empty path (handled as raced-exit) instead of aborting. +pid_exe_path() { lsof -a -p "$1" -d txt -Fn 2>/dev/null | awk '/^n/{print substr($0,2); exit}'; } + +# True only for the documented clean no-match — the PID has genuinely exited. +# `ps -p` fails BOTH for a gone PID and for other errors, and `kill -0` fails +# both for ESRCH and EPERM (a live process the caller can't signal), so neither +# status alone distinguishes gone from alive-but-indeterminate. Classify by all +# three signals and fail closed: gone == the ONE documented clean no-match shape +# (macOS `ps` exits EXACTLY 1 with empty stdout and empty stderr). A live PID +# (incl. one we lack permission to signal) exits 0 and prints its pid; any other +# status, any stderr diagnostic, or a setup failure (mktemp/rm) is indeterminate +# and returns non-gone → blocking. `pid_gone` runs as an `elif` condition where +# `errexit` is suppressed, so it must classify failures explicitly rather than +# rely on `set -e` to abort — hence the explicit `|| return 1` on setup steps. +pid_gone() { + local out err rc + err="$(mktemp)" || return 1 + if out="$(ps -p "$1" -o pid= 2>"$err")"; then rc=0; else rc=$?; fi + local had_err=0; [[ -s "$err" ]] && had_err=1 + rm -f "$err" || return 1 + [[ $rc -eq 1 && -z "$out" && $had_err -eq 0 ]] +} + +running_pids="$(pgrep -f '/buzz-desktop( |$)' 2>/dev/null || true)" +dmg_running=0 +dev_blocking=() # "pid:reason" for each PID that blocks the run +if [[ -n "$running_pids" ]]; then + # Canonicalize the allow-path once (resolve any symlink in the DMG bundle + # path) so a symlinked DMG binary still matches the resolved lsof vnode. + allowed_real="$(realpath "$ALLOWED_PROD_EXE" 2>/dev/null || printf '%s' "$ALLOWED_PROD_EXE")" + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + exe="$(pid_exe_path "$pid" || true)" + exe_real="$(realpath "$exe" 2>/dev/null || printf '%s' "$exe")" + if [[ -n "$exe" && "$exe_real" == "$allowed_real" ]]; then + dmg_running=1 + elif pid_gone "$pid"; then + : # process exited between pgrep and resolution — no longer running, ignore + else + # Alive (or indeterminate) and not the allowed DMG path: dev build, + # unresolvable exe, or a liveness probe we can't trust — fail closed. + dev_blocking+=("$pid:${exe:-}") + fi + done <<< "$running_pids" +fi +if [[ ${#dev_blocking[@]} -gt 0 ]]; then + warn "A non-installed buzz-desktop process is running (dev build or unresolvable):" + for entry in "${dev_blocking[@]}"; do warn " PID ${entry%%:*} → ${entry#*:}"; done + warn "Quit any running dev build, then re-run. This script never kills processes." + exit 1 +fi +if [[ $dmg_running -eq 1 ]]; then + info "installed DMG is running — allowed (read-only source)" + warn "Do NOT create/archive agents or edit teams in the DMG while this runs; the 4-file bundle is read as one snapshot." +else + info "no running Buzz process detected" +fi + +# 1b. On-disk symlink refusals. The paths are fixed canonical constants, so +# there is no override spelling to canonicalize — but if something odd +# already exists at those paths (a symlinked dev store, or a prod source +# aliased elsewhere), following it would break the read-only promise or +# write outside the dev dir. Reject symlinks before any mkdir/write. +[[ -d "$PROD_DIR" ]] || die "prod app-data dir not found: $PROD_DIR" +# Prod root must be a real directory, never a symlink: a symlinked prod root +# could alias the dev tree, making source and destination the same path, so the +# read-only prod promise would break and the commit could destroy prod data. +[[ -L "$PROD_DIR" ]] && die "refusing: prod app-data dir is a symlink; must be a real directory." +# Dev write targets: mkdir -p / the directory exchange would otherwise follow a +# symlink here and write outside the dev dir. +for d in "$DEV_DIR" "$DEV_DIR/agents"; do + [[ -L "$d" ]] && die "refusing: $d is a symlink; write target must be a real directory." +done +# Prod source components: a symlinked prod/agents, record file, or teams/ dir +# could alias a dev target and be mutated or destroyed by the commit. Source +# must be real files/dirs. (Symlinked prod sources are also copied by value into +# the stage, silently importing whatever they point at — refuse instead.) +[[ -L "$PROD_DIR/agents" ]] && die "refusing: prod agents/ is a symlink; source must be a real directory." +for f in "${RECORD_FILES[@]}"; do + [[ -L "$PROD_DIR/agents/$f" ]] \ + && die "refusing: prod agents/$f is a symlink; source must be a real file." +done +[[ -L "$PROD_DIR/agents/teams" ]] \ + && die "refusing: prod agents/teams is a symlink; source must be a real directory." +# Physical distinctness: prod and dev must not resolve to the same tree or one +# inside the other. Both roots are now confirmed non-symlinks, but resolve +# physically anyway to catch any alias in an ancestor component. Dev is +# normally absent post-reset (fixed sibling constants can't overlap); only +# compare when it already exists (a forced re-run). +prod_phys="$(cd "$PROD_DIR" && pwd -P)" +if [[ -d "$DEV_DIR" ]]; then + dev_phys="$(cd "$DEV_DIR" && pwd -P)" + [[ "$prod_phys" == "$dev_phys" || "$prod_phys" == "$dev_phys"/* || "$dev_phys" == "$prod_phys"/* ]] \ + && die "refusing: prod and dev resolve to the same or overlapping directory (prod=$prod_phys dev=$dev_phys)." +fi +info "path checks OK (prod=$PROD_DIR dev=$DEV_DIR)" + +# 1c. Prod store must exist and parse. +prod_agents="$PROD_DIR/agents/managed-agents.json" +[[ -f "$prod_agents" ]] || die "prod managed-agents.json not found: $prod_agents" +if have jq; then + jq empty "$prod_agents" 2>/dev/null || die "prod managed-agents.json is not valid JSON: $prod_agents" +elif have python3; then + python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$prod_agents" \ + || die "prod managed-agents.json is not valid JSON: $prod_agents" +fi +info "prod store OK: $prod_agents" + +# 1d. Dev store must be absent/empty unless --force. +dev_agents="$DEV_DIR/agents/managed-agents.json" +if [[ -s "$dev_agents" ]]; then + if [[ $FORCE -eq 1 ]]; then + warn "dev store is non-empty; --force given, will overwrite: $dev_agents" + else + die "dev store already populated ($dev_agents). Re-run with --force to overwrite." + fi +else + info "dev store absent/empty: $dev_agents" +fi + +# 1e. When a live agents/ already exists, the commit is an atomic directory +# exchange (renamex_np RENAME_SWAP), which only python3 can drive. Require +# it here so a missing interpreter fails preflight, never mid-commit. +if [[ $DRY_RUN -eq 0 && -d "$DEV_DIR/agents" ]] && ! have python3; then + die "python3 is required to atomically replace the existing dev agents/ directory." +fi + +# --- step 2: restore records (exact bundle, one atomic commit) -------------- +# The four boot-critical paths (managed-agents.json, personas.json, teams.json, +# teams/) are read as one related state, so the commit must be all-or-nothing. +# Phase 1 builds a COMPLETE replacement agents/ in a staging dir beside the live +# one — the prod bundle plus any unrelated live entries carried across verbatim, +# every transform validated, zero live-path mutation. Phase 2 commits it in ONE +# operation: a single rename when no live agents/ exists, otherwise one atomic +# renamex_np(RENAME_SWAP) exchange (fail closed, never a sequential fallback). +# A crash or I/O error therefore leaves agents/ either fully old or fully new. +say "[2/4] Agent records prod → dev" +if [[ $DRY_RUN -eq 1 ]]; then + info "[dry-run] stage a complete replacement agents/ beside the live dir" + for f in "${RECORD_FILES[@]}"; do + if [[ -f "$PROD_DIR/agents/$f" ]]; then + if [[ "$f" == managed-agents.json || "$f" == personas.json ]]; then + info "[dry-run] stage $f (strip runtime_pid, 0600)" + else + info "[dry-run] stage $f (0600)" + fi + elif [[ -e "$DEV_DIR/agents/$f" ]]; then + info "[dry-run] drop stale dev $f (absent in prod)" + else + info "skip (absent in prod): $f" + fi + done + if [[ -d "$PROD_DIR/agents/teams" ]]; then + info "[dry-run] stage agents/teams/" + elif [[ -e "$DEV_DIR/agents/teams" ]]; then + info "[dry-run] drop stale dev agents/teams/ (absent in prod)" + else + info "skip (absent in prod): agents/teams/" + fi + if [[ -d "$DEV_DIR/agents" ]]; then + info "[dry-run] commit via atomic directory exchange (existing dev agents/)" + else + info "[dry-run] commit via single rename (no existing dev agents/)" + fi +else + # Phase 1 — build the complete replacement in a staging dir on the dev + # filesystem. mktemp -d creates it 0700; record files are chmod 0600. Only + # $DEV_DIR (the stage parent) is created live — never agents/ — so a phase-1 + # failure leaves no live agents/ scaffold behind. + mkdir -p "$DEV_DIR" + stage_agents="$(mktemp -d "$DEV_DIR/agents.stage.XXXXXX")" + STAGE_TEMPS+=("$stage_agents") + + # Seed the stage with the entire live agents/ (a single checked copy — under + # `set -e` a partial copy aborts before any commit), then drop the four bundle + # names FROM THE STAGE so the prod overlay below replaces them cleanly. Any + # other live entry (logs/, agent-pids/, global-agent-config.json, retention + # stores, …) is carried across verbatim and preserved by the directory swap. + if [[ -d "$DEV_DIR/agents" ]]; then + cp -Rp "$DEV_DIR/agents/." "$stage_agents/" + rm -rf -- \ + "$stage_agents/managed-agents.json" \ + "$stage_agents/personas.json" \ + "$stage_agents/teams.json" \ + "$stage_agents/teams" + fi + + # Stage the prod bundle. A record file absent in prod is simply not staged, so + # the swap drops any stale dev copy; the same holds for teams/. + for f in "${RECORD_FILES[@]}"; do + src="$PROD_DIR/agents/$f" + if [[ -f "$src" ]]; then + if [[ "$f" == managed-agents.json || "$f" == personas.json ]]; then + strip_runtime_pid "$src" "$stage_agents/$f" || die "failed to transform $f (dev unchanged)" + info "staged $f (runtime_pid stripped, mode 0600)" + else + cat "$src" > "$stage_agents/$f" + info "staged $f (mode 0600)" + fi + chmod 600 "$stage_agents/$f" + elif [[ -e "$DEV_DIR/agents/$f" ]]; then + info "dropping stale dev $f (absent in prod)" + else + info "skip (absent in prod): $f" + fi + done + if [[ -d "$PROD_DIR/agents/teams" ]]; then + cp -Rp "$PROD_DIR/agents/teams" "$stage_agents/teams" + info "staged agents/teams/" + elif [[ -e "$DEV_DIR/agents/teams" ]]; then + info "dropping stale dev agents/teams/ (absent in prod)" + else + info "skip (absent in prod): agents/teams/" + fi + + # Phase 2 — commit the complete bundle in one atomic operation. + if [[ -d "$DEV_DIR/agents" ]]; then + swap_dirs "$stage_agents" "$DEV_DIR/agents" \ + || die "atomic directory exchange failed (dev agents/ unchanged)." + rm -rf "$stage_agents" # holds the swapped-out old tree after the exchange + info "committed agents/ via atomic exchange" + else + mv "$stage_agents" "$DEV_DIR/agents" # no live dir → single rename + info "committed agents/ via rename (no prior dev store)" + fi +fi + +# --- step 3: owner identity → dev identity.key ------------------------------ +say "[3/4] Owner identity prod keychain → dev identity.key" +info "reading prod keychain (service=$KEYCHAIN_SVC account=$KEYCHAIN_ACCT) — one keychain prompt expected" +blob="$(security find-generic-password -s "$KEYCHAIN_SVC" -a "$KEYCHAIN_ACCT" -w 2>/dev/null || true)" +if [[ -z "$blob" ]]; then + warn "no keychain entry for service=$KEYCHAIN_SVC account=$KEYCHAIN_ACCT — skipping owner identity." + warn "Records were still copied. Your dev build will mint a fresh owner key on boot." +else + # The blob is a JSON map; the owner nsec lives under the "identity" key and + # MUST be a non-empty JSON string (a number/object/null would be a corrupt + # key that boot rejects, falling through to a fresh identity — the exact + # split-brain this helper prevents). + if have jq; then + identity="$(printf '%s' "$blob" | jq -er 'if (.identity | type) == "string" and (.identity | length) > 0 then .identity else empty end' 2>/dev/null || true)" + else + identity="$(printf '%s' "$blob" | python3 -c 'import json,sys; v=json.load(sys.stdin).get("identity"); print(v if isinstance(v,str) and v else "")' 2>/dev/null || true)" + fi + if [[ -z "$identity" ]]; then + warn "keychain blob has no non-empty string \"identity\" entry — skipping owner identity (records still restored)." + else + keyfile="$DEV_DIR/identity.key" + if [[ $DRY_RUN -eq 1 ]]; then + info "[dry-run] write owner nsec → $keyfile (mode 0600, atomic) [secret not shown]" + else + mkdir -p "$DEV_DIR" + # Stage to a 0600 temp, then atomic rename — never truncate the live key + # path, so an interruption cannot leave a partial/corrupt identity.key. + stage="$(new_stage "$keyfile")" + STAGE_TEMPS+=("$stage") + printf '%s' "$identity" > "$stage" + finalize "$stage" "$keyfile" + info "wrote $keyfile (mode 0600) — dev boot adopts it into the dev keyring, then deletes it" + fi + fi +fi + +# --- step 4: closing checklist ---------------------------------------------- +say "[4/4] Done. Next steps" +cat < keys buzz-desktop → buzz-desktop-dev). + - First boot adopts identity.key into the dev keyring, then deletes it. + 2. Worktree launches: worktree-suffixed dev dirs are symlinked to the + canonical dev dir by sync_shared_agent_data ONLY when BUZZ_SHARE_IDENTITY=1. + If you launch from a worktree, export BUZZ_SHARE_IDENTITY=1 (and + BUZZ_PRIVATE_KEY) or the worktree will mint its own duplicate agents. +EOF +if [[ "${BUZZ_SHARE_IDENTITY:-}" != "1" ]]; then + say " NOTE: BUZZ_SHARE_IDENTITY is not set in this shell — set it for worktree launches." +fi +exit 0 From 40f1dac6913d04c87d72610a69ed53bd12377b84 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 18 Aug 2026 22:06:53 +0100 Subject: [PATCH 17/27] Refine mobile pairing confirmation (#6018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - align the mobile security-code confirmation screen with the Add Community visual system while keeping the SAS verification flow unchanged - use “Confirm desktop code” and a 15% primary-color outline on each digit box - support a gitignored, debug-only Android app name and application ID override for side-by-side device testing; release and profile identities remain unchanged ## Validation - `flutter test test/features/pairing/pairing_page_test.dart` (21 tests) - full mobile pre-push suite (1,469 tests) - focused Flutter analysis and formatting - mobile worktree identity contract checks --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- mobile/README.md | 14 + mobile/android/.gitignore | 1 + mobile/android/app/build.gradle.kts | 35 ++- mobile/lib/features/pairing/pairing_page.dart | 276 +++++++++-------- .../features/pairing/pairing_page_test.dart | 282 +++++++++++++++++- scripts/test-mobile-worktree-overrides.sh | 18 +- 6 files changed, 490 insertions(+), 136 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 6a7f38bdf27..1849e1b097d 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -41,6 +41,20 @@ files: signing always win) - `mobile/android/worktree.properties` (read by the debug build type only) +Android developers can keep a stable local test identity that takes precedence +over the generated worktree values by creating the gitignored +`mobile/android/AppOverrides.properties`: + +```properties +appName=Buzz Pairing +applicationIdSuffix=.device_pairing_e2e1 +``` + +These values are consumed by the debug build type only. The standard +`just mobile-build-android` command can still be used; regenerating +`worktree.properties` does not overwrite `AppOverrides.properties`. Release +and profile builds keep the production `Buzz` name and application ID. + For direct Xcode / Android Studio / `flutter run` development, run `./scripts/mobile-worktree-overrides.sh` from the repo root once per branch switch to refresh the display label (the install identity never changes); diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore index 32f598b0fe5..c21f7b4e86b 100644 --- a/mobile/android/.gitignore +++ b/mobile/android/.gitignore @@ -7,6 +7,7 @@ gradle-wrapper.jar GeneratedPluginRegistrant.java .cxx/ /worktree.properties +/AppOverrides.properties # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index ca40ee16c69..091ae65262b 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -30,6 +30,15 @@ val worktreeProps = Properties().apply { if (worktreePropsFile.isFile) worktreePropsFile.inputStream().use { load(it) } } +// Optional gitignored developer overrides are loaded after the generated +// worktree values. They are consumed only by the debug build type below, so a +// long-lived device test build can keep a stable, descriptive local identity +// without changing release/profile or being overwritten by the worktree script. +val appOverridesFile = rootProject.file("AppOverrides.properties") +val appOverrides = + Properties().apply { + if (appOverridesFile.isFile) appOverridesFile.inputStream().use { load(it) } + } val worktreeLabel = worktreeProps.getProperty("label")?.takeIf { it.isNotBlank() } if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+"""))) { throw GradleException( @@ -39,10 +48,22 @@ if (worktreeLabel != null && !worktreeLabel.matches(Regex("""[A-Za-z0-9._-]+""") } val worktreeIdSuffix = worktreeProps.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() } -if (worktreeIdSuffix != null && !worktreeIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) { +val debugIdSuffix = + appOverrides.getProperty("applicationIdSuffix")?.takeIf { it.isNotBlank() } + ?: worktreeIdSuffix +if (debugIdSuffix != null && !debugIdSuffix.matches(Regex("""\.[a-z][a-z0-9_]*"""))) { + throw GradleException( + "debug applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " + + debugIdSuffix, + ) +} +val debugAppName = appOverrides.getProperty("appName")?.takeIf { it.isNotBlank() } +if ( + debugAppName != null && + !debugAppName.matches(Regex("""[A-Za-z0-9][A-Za-z0-9 ._()\-]{0,39}""")) +) { throw GradleException( - "worktree.properties applicationIdSuffix must match \\.[a-z][a-z0-9_]*, got: " + - worktreeIdSuffix, + "debug appName must be 1-40 resource-safe characters, got: " + debugAppName, ) } @@ -109,10 +130,12 @@ android { debug { // Only debug builds take the worktree identity; release/profile // keep the production applicationId and label. - if (worktreeIdSuffix != null) { - applicationIdSuffix = worktreeIdSuffix + if (debugIdSuffix != null) { + applicationIdSuffix = debugIdSuffix } - if (worktreeLabel != null) { + if (debugAppName != null) { + resValue("string", "app_name", debugAppName) + } else if (worktreeLabel != null) { resValue("string", "app_name", "Buzz ($worktreeLabel)") } } diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 7aabe8aed74..97687c4ed6e 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -22,6 +22,7 @@ const _onboardingShellBottom = Color(0xFFD7E7F6); const _onboardingCtaLabel = Color(0xFFD7E6F0); const _onboardingInk = Color(0xFF111111); const _onboardingMutedInk = Color(0xB3111111); +const _onboardingErrorInk = Color(0xFF7A1025); class PairingPage extends HookConsumerWidget { /// When true, the pairing page is being used to add a new community @@ -86,32 +87,22 @@ class PairingPage extends HookConsumerWidget { } final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas; - final themedSystemOverlayStyle = - (context.theme.brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark) - .copyWith(statusBarColor: Colors.transparent); + final onboardingSystemOverlayStyle = SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + ); final pairingAppBar = addingCommunity ? AppBar( - foregroundColor: isVerifyingSas - ? context.colors.onSurface - : _onboardingInk, - systemOverlayStyle: isVerifyingSas - ? themedSystemOverlayStyle - : SystemUiOverlayStyle.dark.copyWith( - statusBarColor: Colors.transparent, - ), + foregroundColor: _onboardingInk, + systemOverlayStyle: onboardingSystemOverlayStyle, leading: IconButton( icon: const Icon(LucideIcons.arrowLeft), onPressed: () => Navigator.of(context).pop(), ), title: Text( identityRecoveryOnly ? 'Send to Desktop' : 'Add Community', - style: isVerifyingSas - ? null - : context.textTheme.titleMedium?.copyWith( - color: _onboardingInk, - ), + style: context.textTheme.titleMedium?.copyWith( + color: _onboardingInk, + ), ), ) : null; @@ -119,30 +110,33 @@ class PairingPage extends HookConsumerWidget { final pairingScaffold = isVerifyingSas ? AnnotatedRegion( key: const Key('pairing-sas-system-overlay'), - value: themedSystemOverlayStyle, - child: Scaffold( - backgroundColor: context.colors.surface, - appBar: pairingAppBar, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.sm), - child: _SasVerificationView( - sasCode: pairingState.sasCode ?? '------', - confirmed: pairingState.userConfirmedSas, - sendsIdentityToDesktop: pairingState.sendsIdentityToDesktop, - protectSensitiveActions: - pairingState.protectSensitiveActions, - biometricLabel: biometricProtectionLabel( - defaultTargetPlatform, - enrolledBiometrics.value ?? const [], + value: onboardingSystemOverlayStyle, + child: _OnboardingBackground( + child: Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.sm), + child: _SasVerificationView( + sasCode: pairingState.sasCode ?? '------', + confirmed: pairingState.userConfirmedSas, + sendsIdentityToDesktop: + pairingState.sendsIdentityToDesktop, + protectSensitiveActions: + pairingState.protectSensitiveActions, + biometricLabel: biometricProtectionLabel( + defaultTargetPlatform, + enrolledBiometrics.value ?? const [], + ), + errorMessage: pairingState.errorMessage, + onProtectionChanged: (value) => ref + .read(pairingProvider.notifier) + .setProtectSensitiveActions(value), + onConfirm: () => + ref.read(pairingProvider.notifier).confirmSas(), + onDeny: () => + ref.read(pairingProvider.notifier).denySas(), ), - errorMessage: pairingState.errorMessage, - onProtectionChanged: (value) => ref - .read(pairingProvider.notifier) - .setProtectSensitiveActions(value), - onConfirm: () => - ref.read(pairingProvider.notifier).confirmSas(), - onDeny: () => ref.read(pairingProvider.notifier).denySas(), ), ), ), @@ -182,6 +176,7 @@ class PairingPage extends HookConsumerWidget { ); final appSurface = PopScope( + key: const Key('pairing-pop-scope'), onPopInvokedWithResult: (didPop, _) { if (didPop) { ref.read(pairingProvider.notifier).reset(); @@ -206,6 +201,10 @@ class PairingPage extends HookConsumerWidget { /// SAS verification screen shown during NIP-AB pairing. class _SasVerificationView extends StatelessWidget { + static const _digitSize = 54.0; + static const _digitGap = 6.0; + static const _digitGroupGap = 14.0; + final String sasCode; final bool confirmed; final bool sendsIdentityToDesktop; @@ -230,65 +229,68 @@ class _SasVerificationView extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, + final verificationContent = Column( + mainAxisSize: MainAxisSize.min, children: [ - const Spacer(flex: 2), - - Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary), - const SizedBox(height: Grid.sm), - - Text('Verify Security Code', style: context.textTheme.headlineSmall), - const SizedBox(height: Grid.xs), - Text( - confirmed - ? 'Waiting for desktop to confirm...' - : 'Does your desktop app show this code?', + 'Confirm desktop code', textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + style: context.textTheme.headlineSmall?.copyWith( + color: _onboardingInk, + fontWeight: FontWeight.w600, + letterSpacing: -0.4, ), ), - - const SizedBox(height: Grid.lg), - - // Large SAS code display - Container( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20), - decoration: BoxDecoration( - color: context.colors.primaryContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: context.colors.primary.withValues(alpha: 0.3), - width: 2, - ), - ), - child: Text( - '${sasCode.substring(0, 3)} ${sasCode.substring(3)}', - style: context.textTheme.displayMedium?.copyWith( - fontFamily: 'GeistMono', - fontWeight: FontWeight.w700, - letterSpacing: 8, - color: context.colors.primary, - ), - ), - ), - - const SizedBox(height: Grid.lg), - + const SizedBox(height: Grid.xxs), Text( sendsIdentityToDesktop - ? 'This sends your full Buzz identity to the desktop\nand grants it permanent access. Only confirm a\ndesktop you trust and a recovery you started.' - : 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.', + ? 'Make sure the six-digit code matches on both devices. Your full Buzz identity will transfer to the desktop and grant it permanent access. Only continue if you started this recovery.' + : 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.', textAlign: TextAlign.center, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + style: context.textTheme.bodyMedium?.copyWith( + color: _onboardingMutedInk, + ), + ), + const SizedBox(height: Grid.md), + Semantics( + label: + 'Confirmation code ${sasCode.substring(0, 3)} ${sasCode.substring(3)}', + child: ExcludeSemantics( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < sasCode.length; index++) ...[ + if (index > 0) + SizedBox(width: index == 3 ? _digitGroupGap : _digitGap), + Container( + key: Key('pairing-sas-code-digit-${index + 1}'), + width: _digitSize, + padding: const EdgeInsets.symmetric(vertical: Grid.xs), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: context.colors.primary.withValues(alpha: 0.15), + ), + ), + child: Text( + sasCode[index], + style: context.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w600, + color: _onboardingInk, + ), + ), + ), + ], + ], + ), + ), ), ), - const SizedBox(height: Grid.sm), - if (!sendsIdentityToDesktop) CheckboxListTile( key: const Key('protect-sensitive-actions-checkbox'), @@ -296,67 +298,103 @@ class _SasVerificationView extends StatelessWidget { onChanged: confirmed ? null : (value) => onProtectionChanged(value ?? false), + activeColor: _onboardingInk, + checkColor: _onboardingCtaLabel, + side: const BorderSide(color: _onboardingInk), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, - title: Text(biometricLabel), - subtitle: const Text('For secure actions'), + title: Text( + biometricLabel, + style: context.textTheme.bodyMedium?.copyWith( + color: _onboardingInk, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'For secure actions', + style: context.textTheme.bodySmall?.copyWith( + color: _onboardingMutedInk, + ), + ), ), - if (errorMessage != null) ...[ const SizedBox(height: Grid.xs), Text( errorMessage!, textAlign: TextAlign.center, style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + color: _onboardingErrorInk, ), ), ], + ], + ); - const SizedBox(height: Grid.lg), - - // Confirm / Deny buttons - if (confirmed) - Row( + final verificationActions = confirmed + ? Row( mainAxisAlignment: MainAxisAlignment.center, children: [ BuzzLoadingIndicator( size: 24, - color: context.colors.primary, + color: _onboardingInk, semanticLabel: 'Connecting', ), const SizedBox(width: Grid.twelve), Text( 'Confirmed — waiting for desktop', style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, + color: _onboardingMutedInk, ), ), ], ) - else - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: onDeny, - icon: const Icon(LucideIcons.x), - label: const Text('Cancel'), - ), - ), - const SizedBox(width: Grid.sm), - Expanded( - child: FilledButton.icon( + : SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FilledButton.icon( + style: _onboardingButtonStyle, onPressed: onConfirm, icon: const Icon(LucideIcons.check), - label: const Text('Codes Match'), + label: const Text('Codes match'), ), - ), - ], - ), + const SizedBox(height: Grid.xxs), + TextButton( + style: _onboardingSecondaryButtonStyle.copyWith( + minimumSize: const WidgetStatePropertyAll( + Size.fromHeight(48), + ), + ), + onPressed: onDeny, + child: const Text('Cancel'), + ), + ], + ), + ); - const Spacer(flex: 3), + return Column( + children: [ + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final verticalPadding = Grid.sm * 2; + final minimumContentHeight = + constraints.maxHeight > verticalPadding + ? constraints.maxHeight - verticalPadding + : 0.0; + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: Grid.sm), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: minimumContentHeight), + child: Center(child: verificationContent), + ), + ); + }, + ), + ), + verificationActions, + const SizedBox(height: Grid.sm), ], ); } diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart index 34b9f4cfb1c..e66e9bb3da4 100644 --- a/mobile/test/features/pairing/pairing_page_test.dart +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_auth/local_auth.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/pairing/pairing_page.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/community.dart'; @@ -65,7 +66,7 @@ void main() { expect(overlay.value.statusBarColor, Colors.transparent); }); - testWidgets('uses light status-bar icons for dark-theme SAS verification', ( + testWidgets('uses the onboarding surface for dark-theme SAS verification', ( tester, ) async { await tester.pumpWidget( @@ -81,9 +82,61 @@ void main() { find.byKey(const Key('pairing-sas-system-overlay')), ); - expect(overlay.value.statusBarIconBrightness, Brightness.light); + expect(overlay.value.statusBarIconBrightness, Brightness.dark); expect(overlay.value.statusBarColor, Colors.transparent); - expect(find.text('Verify Security Code'), findsOneWidget); + final background = tester.widget( + find.byKey(const Key('pairing-onboarding-background')), + ); + final backgroundDecoration = background.decoration as BoxDecoration; + final backgroundGradient = + backgroundDecoration.gradient! as LinearGradient; + expect(backgroundGradient.colors, const [ + Color(0xFFD7D72E), + Color(0xFFD7E7F6), + ]); + expect( + tester.widget(find.byType(Scaffold)).backgroundColor, + Colors.transparent, + ); + expect(find.text('Confirm desktop code'), findsOneWidget); + expect( + find.text( + 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.', + ), + findsOneWidget, + ); + expect(find.text('Does your desktop app show this code?'), findsNothing); + }); + + testWidgets('uses Cancel as the only visible SAS exit', (tester) async { + final notifier = _ConfirmingSasPairingNotifier(); + await tester.pumpWidget( + ProviderScope( + overrides: [pairingProvider.overrideWith(() => notifier)], + child: MaterialApp( + theme: AppTheme.dark(), + home: const PairingPage(addingCommunity: true), + ), + ), + ); + + expect(find.byType(AppBar), findsNothing); + expect(find.text('Add Community'), findsNothing); + expect(find.byIcon(LucideIcons.arrowLeft), findsNothing); + expect(find.byKey(const Key('pairing-pop-scope')), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + expect(notifier.denied, isTrue); + }); + + testWidgets('keeps the add-community header outside SAS', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)), + ); + + expect(find.byType(AppBar), findsOneWidget); + expect(find.text('Add Community'), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowLeft), findsOneWidget); }); testWidgets('reveals pairing code field and connect action', ( @@ -326,7 +379,7 @@ void main() { ); }); - testWidgets('recovery SAS warns about permanent desktop access', ( + testWidgets('recovery SAS puts permanent desktop access in the subtitle', ( tester, ) async { await tester.pumpWidget( @@ -342,11 +395,220 @@ void main() { expect(find.textContaining('full Buzz identity'), findsOneWidget); expect(find.textContaining('permanent access'), findsOneWidget); - expect(find.text('Codes Match'), findsOneWidget); + expect(find.textContaining('started this recovery'), findsOneWidget); + expect(find.text('Codes match'), findsOneWidget); + }); + + testWidgets('matches the onboarding visual system and SAS action layout', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + expect(find.byIcon(LucideIcons.shieldCheck), findsNothing); + expect(find.text('Confirm desktop code'), findsOneWidget); + expect( + find.text( + 'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.', + ), + findsOneWidget, + ); + expect(find.text('Does your desktop app show this code?'), findsNothing); + + final digitFinders = [ + for (var index = 1; index <= 6; index++) + find.byKey(Key('pairing-sas-code-digit-$index')), + ]; + for (final digitFinder in digitFinders) { + expect(tester.getSize(digitFinder).width, 54); + expect( + tester.widget(digitFinder).padding, + const EdgeInsets.symmetric(vertical: Grid.xs), + ); + } + + const onboardingInk = Color(0xFF111111); + const onboardingMutedInk = Color(0xB3111111); + const onboardingCtaLabel = Color(0xFFD7E6F0); + final theme = AppTheme.dark(); + final protectionTile = tester.widget( + find.byKey(const Key('protect-sensitive-actions-checkbox')), + ); + expect(protectionTile.activeColor, onboardingInk); + expect(protectionTile.checkColor, onboardingCtaLabel); + expect(protectionTile.side?.color, onboardingInk); + expect((protectionTile.title as Text).style?.color, onboardingInk); + expect( + (protectionTile.subtitle as Text).style?.color, + onboardingMutedInk, + ); + final firstDigitContainer = tester.widget(digitFinders.first); + final firstDigitDecoration = + firstDigitContainer.decoration! as BoxDecoration; + expect(firstDigitDecoration.color, Colors.white.withValues(alpha: 0.7)); + expect( + (firstDigitDecoration.border! as Border).top.color, + theme.colorScheme.primary.withValues(alpha: 0.15), + ); + final firstDigitText = tester.widget( + find.descendant(of: digitFinders.first, matching: find.text('1')), + ); + expect(firstDigitText.style?.fontFamily, 'Inter'); + expect( + firstDigitText.style?.fontSize, + theme.textTheme.displaySmall?.fontSize, + ); + expect(firstDigitText.style?.fontSize, greaterThanOrEqualTo(36)); + expect(firstDigitText.style?.fontWeight, FontWeight.w600); + expect(firstDigitText.style?.fontFeatures, isNull); + expect(firstDigitText.style?.color, onboardingInk); + + final firstDigit = tester.getTopLeft(digitFinders[0]); + final secondDigit = tester.getTopLeft(digitFinders[1]); + final thirdDigit = tester.getTopLeft(digitFinders[2]); + final fourthDigit = tester.getTopLeft(digitFinders[3]); + expect(secondDigit.dx - firstDigit.dx, 60); + expect(fourthDigit.dx - thirdDigit.dx, 68); + + final confirmFinder = find.widgetWithText(FilledButton, 'Codes match'); + final cancelFinder = find.widgetWithText(TextButton, 'Cancel'); + final confirmButton = tester.widget(confirmFinder); + final cancelButton = tester.widget(cancelFinder); + expect( + confirmButton.style?.backgroundColor?.resolve({}), + onboardingInk, + ); + expect( + confirmButton.style?.foregroundColor?.resolve({}), + onboardingCtaLabel, + ); + expect( + confirmButton.style?.shape?.resolve({}), + isA(), + ); + expect( + cancelButton.style?.backgroundColor?.resolve({}), + onboardingInk.withValues(alpha: 0.1), + ); + expect( + cancelButton.style?.foregroundColor?.resolve({}), + onboardingInk, + ); + expect( + cancelButton.style?.shape?.resolve({}), + isA(), + ); + final confirmTopLeft = tester.getTopLeft(confirmFinder); + final cancelTopLeft = tester.getTopLeft(cancelFinder); + final scaffoldWidth = tester.getSize(find.byType(Scaffold)).width; + expect(confirmTopLeft.dy, lessThan(cancelTopLeft.dy)); + expect(confirmTopLeft.dx, cancelTopLeft.dx); + expect(confirmTopLeft.dx, Grid.sm); + expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2); + expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2); + expect(tester.getSize(confirmFinder).height, 48); + expect(tester.getSize(cancelFinder).height, 48); + expect( + find.textContaining( + 'Only continue if you started this pairing from your desktop.', + ), + findsOneWidget, + ); + expect( + tester.getBottomLeft(find.byType(Scaffold)).dy - + tester.getBottomLeft(cancelFinder).dy, + Grid.sm, + ); + }); + + testWidgets('uses accessible SAS error contrast in both themes', ( + tester, + ) async { + const errorMessage = 'Identity confirmation failed. Nothing transferred.'; + const errorInk = Color(0xFF7A1025); + const gradientColors = [Color(0xFFD7D72E), Color(0xFFD7E7F6)]; + + for (final theme in [AppTheme.light(), AppTheme.dark()]) { + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith( + () => _ConfirmingSasPairingNotifier(errorMessage: errorMessage), + ), + ], + child: MaterialApp(theme: theme, home: const PairingPage()), + ), + ); + + final errorText = tester.widget(find.text(errorMessage)); + expect(errorText.style?.color, errorInk); + for (final background in gradientColors) { + expect( + _contrastRatio(errorInk, background), + greaterThanOrEqualTo(4.5), + ); + } + expect(tester.takeException(), isNull); + } + }); + + testWidgets('keeps SAS actions above the keyboard on small screens', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(360, 560); + tester.view.viewInsets = const FakeViewPadding(bottom: 200); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()), + ], + child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + final cancelFinder = find.widgetWithText(TextButton, 'Cancel'); + expect(tester.getBottomLeft(cancelFinder).dy, 560 - 200 - Grid.sm); + + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -100), + ); + await tester.pump(); + expect(tester.takeException(), isNull); + expect(find.text('Confirm desktop code'), findsOneWidget); + expect(find.textContaining('matches on both devices'), findsOneWidget); + expect( + find.textContaining('Buzz identity will transfer'), + findsOneWidget, + ); + expect(find.text('Codes match'), findsOneWidget); }); }); } +double _contrastRatio(Color foreground, Color background) { + final foregroundLuminance = foreground.computeLuminance(); + final backgroundLuminance = background.computeLuminance(); + final lighter = foregroundLuminance > backgroundLuminance + ? foregroundLuminance + : backgroundLuminance; + final darker = foregroundLuminance > backgroundLuminance + ? backgroundLuminance + : foregroundLuminance; + return (lighter + 0.05) / (darker + 0.05); +} + Future _expandPairingCode(WidgetTester tester) async { await tester.tap(find.text('Use pairing code')); await tester.pumpAndSettle(); @@ -435,15 +697,21 @@ class _RecordingPairingNotifier extends Notifier class _ConfirmingSasPairingNotifier extends Notifier implements PairingNotifier { - _ConfirmingSasPairingNotifier({this.sendsIdentityToDesktop = false}); + _ConfirmingSasPairingNotifier({ + this.sendsIdentityToDesktop = false, + this.errorMessage, + }); final bool sendsIdentityToDesktop; + final String? errorMessage; + bool denied = false; @override PairingState build() => PairingState( status: PairingStatus.confirmingSas, sasCode: '123456', sendsIdentityToDesktop: sendsIdentityToDesktop, + errorMessage: errorMessage, ); @override @@ -463,5 +731,5 @@ class _ConfirmingSasPairingNotifier extends Notifier void setProtectSensitiveActions(bool value) {} @override - void denySas() {} + void denySas() => denied = true; } diff --git a/scripts/test-mobile-worktree-overrides.sh b/scripts/test-mobile-worktree-overrides.sh index 853d1270db1..dccffd2ef2b 100755 --- a/scripts/test-mobile-worktree-overrides.sh +++ b/scripts/test-mobile-worktree-overrides.sh @@ -7,7 +7,8 @@ # display-only label sanitized to [A-Za-z0-9._-]. # - the tracked iOS/Android build files keep production identity, only # consume the overrides in debug configurations, and let a developer's -# AppOverrides.xcconfig take precedence over the worktree defaults. +# AppOverrides.xcconfig / AppOverrides.properties take precedence over the +# worktree defaults. # - scripts/mobile-worktree-clean.sh only ever targets suffixed installs, # never the production app ids. set -euo pipefail @@ -157,6 +158,11 @@ grep -q 'resValue("string", "app_name", "Buzz")' "$gradle" \ grep -q 'worktreeLabel.matches' "$gradle" \ && pass "Gradle validates the worktree label before use" \ || fail "Gradle must validate the worktree label against a safe pattern" +grep -q 'AppOverrides.properties' "$gradle" \ + && grep -q 'debugAppName' "$gradle" \ + && grep -q 'debugIdSuffix' "$gradle" \ + && pass "Android developer overrides can replace the debug name and identity" \ + || fail "Gradle must support debug-only AppOverrides.properties" # Extract a brace-balanced block: everything from the first line matching $2 # to the line where its braces close. Unlike a /start/,/}/ awk range, nested @@ -181,9 +187,10 @@ printf '%s\n' "$sneaky" | extract_block - 'release \{' | grep -q 'worktreeSneaky || fail "release-block extractor must not stop at the first nested close brace" # The worktree suffix/label must only appear inside the debug build type. -extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{' | grep -q 'worktree' \ - && fail "release build type must not reference worktree identity" \ - || pass "release build type does not reference worktree identity" +release_block="$(extract_block "$gradle" 'buildTypes \{' | extract_block - 'release \{')" +printf '%s\n' "$release_block" | grep -Eq 'worktree|debugAppName|debugIdSuffix' \ + && fail "release build type must not reference debug identity overrides" \ + || pass "release build type does not reference debug identity overrides" git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfig \ && pass "iOS override file is gitignored" \ @@ -191,6 +198,9 @@ git -C "$repo_root" check-ignore -q mobile/ios/Flutter/WorktreeOverrides.xcconfi git -C "$repo_root" check-ignore -q mobile/android/worktree.properties \ && pass "Android override file is gitignored" \ || fail "mobile/android/worktree.properties must be gitignored" +git -C "$repo_root" check-ignore -q mobile/android/AppOverrides.properties \ + && pass "Android developer override file is gitignored" \ + || fail "mobile/android/AppOverrides.properties must be gitignored" grep -Eq '^\s+\./scripts/mobile-worktree-overrides\.sh$' "$repo_root/Justfile" \ && pass "just mobile-dev applies the worktree identity" \ || fail "Justfile mobile-dev must run scripts/mobile-worktree-overrides.sh" From 78267b0c3a75840d035ff0cc9ad1984def773886 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 18 Aug 2026 23:00:45 +0100 Subject: [PATCH 18/27] Polish mobile message actions (#5873) ## Summary - Add a lifted-message long-press popover with the reaction tray and grouped actions. - Use a native action surface - Preserve taps and scrolling, exclude attached reactions from the lifted preview, and align the composition to the bottom safe area. Screenshot_20260814-160732 ## Testing - `bin/just mobile-check` - `flutter test test/features/channels/message_actions_test.dart` - `flutter test` (1,364 tests) - Manual interaction review on iPhone and Pixel 10 --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Princess Donut Signed-off-by: Wes Co-authored-by: Princess Donut Co-authored-by: Wes Co-authored-by: Carl --- mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 22 + .../Runner/NativeMessageActionSurface.swift | 453 ++++++++ mobile/ios/RunnerTests/RunnerTests.swift | 151 +++ .../channels/channel_detail_page.dart | 11 + .../channel_detail_page/message_bubble.dart | 290 ++--- .../channel_detail_page/message_list.dart | 6 + .../compose_bar/compose_bar_widget.dart | 41 +- .../channels/compose_bar/helpers.dart | 24 + .../features/channels/message_actions.dart | 168 +-- .../message_action_popover.dart | 999 ++++++++++++++++++ .../message_reaction_tray.dart | 36 + .../message_actions/quick_reaction_row.dart | 139 +++ .../message_actions/reaction_popover.dart | 177 ++-- .../channels/message_long_press_region.dart | 224 ++-- .../features/channels/thread_detail_page.dart | 12 + .../channels/thread_detail_page/avatar.dart | 28 + .../thread_detail_page/thread_message.dart | 316 +++--- .../channels/channel_detail_page_test.dart | 19 +- .../features/channels/compose_bar_test.dart | 184 ++++ .../channels/message_actions_test.dart | 925 ++++++++++++++++ 21 files changed, 3677 insertions(+), 552 deletions(-) create mode 100644 mobile/ios/Runner/NativeMessageActionSurface.swift create mode 100644 mobile/lib/features/channels/message_actions/message_action_popover.dart create mode 100644 mobile/lib/features/channels/message_actions/message_reaction_tray.dart create mode 100644 mobile/lib/features/channels/message_actions/quick_reaction_row.dart create mode 100644 mobile/lib/features/channels/thread_detail_page/avatar.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 45cf085cb79..f127990b390 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; }; 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; }; 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; }; + 4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.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 */; }; @@ -63,6 +64,7 @@ 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; + 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.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; }; @@ -188,6 +190,7 @@ 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */, 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */, + 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -425,6 +428,7 @@ 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */, 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */, 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */, + 4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.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 fa68e539dd0..e8f3d9d1e16 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -10,6 +10,7 @@ import UserNotifications private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? + private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? override func application( _ application: UIApplication, @@ -113,6 +114,27 @@ import UserNotifications messenger: messenger, parentViewController: nativeAttachmentRegistrar?.viewController ) + + if #available(iOS 16.0, *), + let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeMessageActionSurface" + ) { + nativeMessageActionsRegistrar.register( + NativeMessageActionSurfaceFactory(messenger: messenger), + withId: "buzz/native_message_action_surface" + ) + nativeMessageActionSurfaceSupportChannel = FlutterMethodChannel( + name: "buzz/native_message_action_surface", + binaryMessenger: messenger + ) + nativeMessageActionSurfaceSupportChannel?.setMethodCallHandler { call, result in + guard call.method == "isSupported" else { + result(FlutterMethodNotImplemented) + return + } + result(true) + } + } } private static func handleQrScannerMethodCall( diff --git a/mobile/ios/Runner/NativeMessageActionSurface.swift b/mobile/ios/Runner/NativeMessageActionSurface.swift new file mode 100644 index 00000000000..4b0d637cedc --- /dev/null +++ b/mobile/ios/Runner/NativeMessageActionSurface.swift @@ -0,0 +1,453 @@ +import Flutter +import UIKit + +struct NativeMessageActionDefinition { + enum Group: String, CaseIterable { + case primary + case utility + case destructive + } + + let id: String + let title: String + let symbol: String + let group: Group + let isDestructive: Bool + + init?(arguments: [String: Any]) { + guard + let id = arguments["id"] as? String, + let title = arguments["title"] as? String, + let symbol = arguments["symbol"] as? String, + let groupName = arguments["group"] as? String, + let group = Group(rawValue: groupName) + else { + return nil + } + + self.id = id + self.title = title + self.symbol = symbol + self.group = group + isDestructive = arguments["destructive"] as? Bool ?? false + } +} + +enum NativeMessageActionSurfaceLayout { + static let minimumRowHeight: CGFloat = 48 + static let rowVerticalPadding: CGFloat = 4 + static let separatorHeight: CGFloat = 0.5 + static let verticalInset: CGFloat = 4 + static let horizontalInset: CGFloat = 16 + static let iconColumnWidth: CGFloat = 32 + static let iconToTextSpacing: CGFloat = 12 + + static var cornerRadius: CGFloat { + if #available(iOS 26.0, *) { + return 33 + } + return 12 + } + + static func populatedGroups( + actions: [NativeMessageActionDefinition] + ) -> [NativeMessageActionDefinition.Group] { + NativeMessageActionDefinition.Group.allCases.filter { group in + actions.contains { $0.group == group } + } + } + + static func separatorCount( + actions: [NativeMessageActionDefinition] + ) -> Int { + max(0, populatedGroups(actions: actions).count - 1) + } + + static func rowHeight( + minimumHeight: CGFloat = minimumRowHeight, + compatibleWith traitCollection: UITraitCollection? = nil + ) -> CGFloat { + let labelHeight = UIFont.preferredFont( + forTextStyle: .body, + compatibleWith: traitCollection + ).lineHeight + return max( + minimumHeight, + ceil(labelHeight + (rowVerticalPadding * 2)) + ) + } + + static func preferredHeight( + actions: [NativeMessageActionDefinition], + minimumRowHeight: CGFloat = minimumRowHeight, + compatibleWith traitCollection: UITraitCollection? = nil + ) -> CGFloat { + let resolvedRowHeight = rowHeight( + minimumHeight: minimumRowHeight, + compatibleWith: traitCollection + ) + return (verticalInset * 2) + + (CGFloat(actions.count) * resolvedRowHeight) + + (CGFloat(separatorCount(actions: actions)) * separatorHeight) + } +} + +@available(iOS 16.0, *) +enum NativeMessageActionSurfaceAppearance { + // The native list is only one sibling inside the Flutter-owned dialog. + // Keeping it non-modal leaves the reaction tray and dismiss barrier + // reachable to VoiceOver. + static let actionListAccessibilityViewIsModal = false + + static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle { + switch value as? String { + case "dark": + return .dark + case "light": + return .light + default: + return .unspecified + } + } + + static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? { + guard !reduceTransparency else { return nil } + + if #available(iOS 26.0, *) { + let effect = UIGlassEffect(style: .regular) + effect.isInteractive = true + return effect + } + return UIBlurEffect(style: .systemMaterial) + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionRowControl: UIControl { + let actionImageView: UIImageView + let actionTitleLabel = UILabel() + + init( + definition: NativeMessageActionDefinition, + foregroundColor: UIColor, + destructiveColor: UIColor, + minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight, + compatibleWith traitCollection: UITraitCollection? = nil, + onSelected: @escaping () -> Void + ) { + actionImageView = UIImageView(image: UIImage(systemName: definition.symbol)) + super.init(frame: .zero) + + let color = definition.isDestructive ? destructiveColor : foregroundColor + actionImageView.tintColor = color + actionImageView.contentMode = .center + + actionTitleLabel.text = definition.title + actionTitleLabel.textColor = color + actionTitleLabel.font = UIFont.preferredFont( + forTextStyle: .body, + compatibleWith: traitCollection + ) + actionTitleLabel.adjustsFontForContentSizeCategory = true + actionTitleLabel.numberOfLines = 0 + actionTitleLabel.lineBreakMode = .byWordWrapping + actionTitleLabel.setContentCompressionResistancePriority( + .required, + for: .vertical + ) + + let iconColumn = UIView() + iconColumn.translatesAutoresizingMaskIntoConstraints = false + actionImageView.translatesAutoresizingMaskIntoConstraints = false + actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false + iconColumn.addSubview(actionImageView) + addSubview(iconColumn) + addSubview(actionTitleLabel) + + NSLayoutConstraint.activate([ + iconColumn.leadingAnchor.constraint( + equalTo: leadingAnchor, + constant: NativeMessageActionSurfaceLayout.horizontalInset + ), + iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor), + iconColumn.widthAnchor.constraint( + equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth + ), + iconColumn.heightAnchor.constraint( + equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth + ), + actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor), + actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor), + actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor), + actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor), + actionTitleLabel.leadingAnchor.constraint( + equalTo: iconColumn.trailingAnchor, + constant: NativeMessageActionSurfaceLayout.iconToTextSpacing + ), + actionTitleLabel.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -NativeMessageActionSurfaceLayout.horizontalInset + ), + actionTitleLabel.topAnchor.constraint( + greaterThanOrEqualTo: topAnchor, + constant: NativeMessageActionSurfaceLayout.rowVerticalPadding + ), + actionTitleLabel.bottomAnchor.constraint( + lessThanOrEqualTo: bottomAnchor, + constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding + ), + actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + heightAnchor.constraint( + greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight( + minimumHeight: minimumHeight, + compatibleWith: traitCollection + ) + ), + ]) + + accessibilityLabel = definition.title + accessibilityTraits = .button + isAccessibilityElement = true + addAction(UIAction { _ in onSelected() }, for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override var isHighlighted: Bool { + didSet { + backgroundColor = + isHighlighted + ? actionTitleLabel.textColor.withAlphaComponent(0.08) + : .clear + } + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionSeparatorView: UIView { + init(color: UIColor) { + super.init(frame: .zero) + backgroundColor = color + heightAnchor.constraint( + equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight + ).isActive = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is unavailable") + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionSurfaceFactory: 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 { + NativeMessageActionSurfacePlatformView( + frame: frame, + viewIdentifier: viewId, + messenger: messenger, + arguments: args + ) + } +} + +@available(iOS 16.0, *) +final class NativeMessageActionSurfacePlatformView: NSObject, + FlutterPlatformView +{ + private let surfaceView: UIView + private let backdropView: UIVisualEffectView + private let channel: FlutterMethodChannel + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + messenger: FlutterBinaryMessenger, + arguments args: Any? + ) { + let arguments = args as? [String: Any] + let surfaceColor = Self.color( + from: arguments?["surfaceColor"], + fallback: .systemBackground + ) + let foregroundColor = Self.color( + from: arguments?["foregroundColor"], + fallback: .label + ) + let separatorColor = Self.color( + from: arguments?["separatorColor"], + fallback: .separator + ) + let destructiveColor = Self.color( + from: arguments?["errorColor"], + fallback: .systemRed + ) + let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle( + from: arguments?["interfaceStyle"] + ) + var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight + if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber, + requestedRowHeight.doubleValue.isFinite + { + minimumRowHeight = max( + minimumRowHeight, + CGFloat(requestedRowHeight.doubleValue) + ) + } + let actionArguments = arguments?["actions"] as? [[String: Any]] + let actions = + actionArguments?.compactMap( + NativeMessageActionDefinition.init(arguments:) + ) ?? [] + + surfaceView = UIView(frame: frame) + backdropView = UIVisualEffectView( + effect: NativeMessageActionSurfaceAppearance.backdropEffect( + reduceTransparency: UIAccessibility.isReduceTransparencyEnabled + ) + ) + channel = FlutterMethodChannel( + name: "buzz/native_message_action_surface/\(viewId)", + binaryMessenger: messenger + ) + super.init() + + surfaceView.backgroundColor = .clear + surfaceView.clipsToBounds = false + surfaceView.accessibilityViewIsModal = + NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal + surfaceView.overrideUserInterfaceStyle = interfaceStyle + + backdropView.translatesAutoresizingMaskIntoConstraints = false + backdropView.overrideUserInterfaceStyle = interfaceStyle + backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius + backdropView.layer.cornerCurve = .continuous + backdropView.layer.masksToBounds = true + if backdropView.effect == nil { + backdropView.backgroundColor = surfaceColor + } + surfaceView.addSubview(backdropView) + NSLayoutConstraint.activate([ + backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor), + backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor), + backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor), + backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor), + ]) + + if #unavailable(iOS 26.0) { + surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius + surfaceView.layer.shadowRadius = 32 + surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16) + surfaceView.layer.shadowColor = UIColor.black.cgColor + surfaceView.layer.shadowOpacity = 0.2 + } + + install( + actions: actions, + foregroundColor: foregroundColor, + destructiveColor: destructiveColor, + separatorColor: separatorColor, + minimumRowHeight: minimumRowHeight + ) + } + + func view() -> UIView { + surfaceView + } + + private func install( + actions: [NativeMessageActionDefinition], + foregroundColor: UIColor, + destructiveColor: UIColor, + separatorColor: UIColor, + minimumRowHeight: CGFloat + ) { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = false + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + stack.translatesAutoresizingMaskIntoConstraints = false + + backdropView.contentView.addSubview(scrollView) + scrollView.addSubview(stack) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor), + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stack.topAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.topAnchor, + constant: NativeMessageActionSurfaceLayout.verticalInset + ), + stack.bottomAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.bottomAnchor, + constant: -NativeMessageActionSurfaceLayout.verticalInset + ), + stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + ]) + + var installedGroup = false + for group in NativeMessageActionDefinition.Group.allCases { + let groupActions = actions.filter { $0.group == group } + guard !groupActions.isEmpty else { continue } + if installedGroup { + stack.addArrangedSubview( + NativeMessageActionSeparatorView(color: separatorColor) + ) + } + for definition in groupActions { + stack.addArrangedSubview( + NativeMessageActionRowControl( + definition: definition, + foregroundColor: foregroundColor, + destructiveColor: destructiveColor, + minimumHeight: minimumRowHeight, + onSelected: { [weak self] in self?.select(definition) } + ) + ) + } + installedGroup = true + } + } + + private func select(_ definition: NativeMessageActionDefinition) { + channel.invokeMethod("selected", arguments: ["id": definition.id]) + } + + private static func color(from value: Any?, fallback: UIColor) -> UIColor { + guard let number = value as? NSNumber else { return fallback } + let color = number.uint32Value + let alpha = CGFloat((color >> 24) & 0xFF) / 255 + let red = CGFloat((color >> 16) & 0xFF) / 255 + let green = CGFloat((color >> 8) & 0xFF) / 255 + let blue = CGFloat(color & 0xFF) / 255 + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e1c2ce00f62..e26aa11525c 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -403,6 +403,157 @@ class RunnerTests: XCTestCase { } } + func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws { + let actionArguments: [[String: Any]] = [ + [ + "id": "reply", "title": "Reply", + "symbol": "arrowshape.turn.up.left", "group": "primary", + ], + [ + "id": "copyText", "title": "Copy text", + "symbol": "doc.on.doc", "group": "utility", + ], + [ + "id": "delete", "title": "Delete message", + "symbol": "trash", "group": "destructive", "destructive": true, + ], + ] + let definitions = try actionArguments.map { arguments in + try XCTUnwrap(NativeMessageActionDefinition(arguments: arguments)) + } + + XCTAssertEqual( + NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions), + [.primary, .utility, .destructive] + ) + XCTAssertEqual( + NativeMessageActionSurfaceLayout.separatorCount(actions: definitions), + 2 + ) + XCTAssertEqual( + NativeMessageActionSurfaceLayout.preferredHeight( + actions: definitions, + compatibleWith: UITraitCollection( + preferredContentSizeCategory: .large + ) + ), + 153 + ) + } + + @MainActor + func testNativeMessageActionRowUsesUIKitTypographyAndSelection() throws { + let definition = try XCTUnwrap( + NativeMessageActionDefinition( + arguments: [ + "id": "reply", "title": "Reply", + "symbol": "arrowshape.turn.up.left", "group": "primary", + ] + ) + ) + var selected = false + let row = NativeMessageActionRowControl( + definition: definition, + foregroundColor: .label, + destructiveColor: .systemRed, + onSelected: { selected = true } + ) + + XCTAssertEqual( + row.actionTitleLabel.font.fontDescriptor.object(forKey: .textStyle) as? String, + UIFont.TextStyle.body.rawValue + ) + XCTAssertNotNil(row.actionImageView.image) + row.sendActions(for: .touchUpInside) + XCTAssertTrue(selected) + } + + @MainActor + func testNativeMessageActionRowExpandsForAccessibilityTypography() throws { + let traits = UITraitCollection( + preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge + ) + let definition = try XCTUnwrap( + NativeMessageActionDefinition( + arguments: [ + "id": "followThread", "title": "Follow thread", + "symbol": "bell", "group": "utility", + ] + ) + ) + let row = NativeMessageActionRowControl( + definition: definition, + foregroundColor: .label, + destructiveColor: .systemRed, + compatibleWith: traits, + onSelected: {} + ) + let fittingSize = row.systemLayoutSizeFitting( + CGSize(width: 288, height: UIView.layoutFittingCompressedSize.height), + withHorizontalFittingPriority: .required, + verticalFittingPriority: .fittingSizeLevel + ) + row.frame = CGRect(origin: .zero, size: fittingSize) + row.layoutIfNeeded() + let labelFrame = row.convert( + row.actionTitleLabel.bounds, + from: row.actionTitleLabel + ) + + XCTAssertGreaterThan(fittingSize.height, 48) + XCTAssertGreaterThan(labelFrame.height, 0) + XCTAssertGreaterThanOrEqual( + labelFrame.minY, + NativeMessageActionSurfaceLayout.rowVerticalPadding + ) + XCTAssertLessThanOrEqual( + labelFrame.maxY, + fittingSize.height - NativeMessageActionSurfaceLayout.rowVerticalPadding + ) + XCTAssertEqual(row.actionTitleLabel.numberOfLines, 0) + XCTAssertFalse(row.actionTitleLabel.adjustsFontSizeToFitWidth) + } + + @MainActor + func testNativeMessageActionSurfaceUsesSystemMaterial() { + let effect = NativeMessageActionSurfaceAppearance.backdropEffect( + reduceTransparency: false + ) + if #available(iOS 26.0, *) { + XCTAssertTrue(effect is UIGlassEffect) + XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 33) + } else { + XCTAssertTrue(effect is UIBlurEffect) + XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 12) + } + XCTAssertNil( + NativeMessageActionSurfaceAppearance.backdropEffect( + reduceTransparency: true + ) + ) + } + + func testNativeMessageActionListDoesNotHideDialogSiblings() { + XCTAssertFalse( + NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal + ) + } + + func testNativeMessageActionSurfaceMatchesFlutterInterfaceStyle() { + XCTAssertEqual( + NativeMessageActionSurfaceAppearance.interfaceStyle(from: "dark"), + .dark + ) + XCTAssertEqual( + NativeMessageActionSurfaceAppearance.interfaceStyle(from: "light"), + .light + ) + XCTAssertEqual( + NativeMessageActionSurfaceAppearance.interfaceStyle(from: "system"), + .unspecified + ) + } + private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage { let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3)) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 6e7449a6333..0e3251c7523 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -138,6 +138,8 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final composerDockHeight = useState(0.0); + final composerFocusNode = useFocusNode(); + final restoreComposerFocus = useRef(null); final sendMessage = ref.read(sendMessageProvider); final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); @@ -473,6 +475,12 @@ class ChannelDetailPage extends HookConsumerWidget { composerBottomInset: showsComposer ? composerDockHeight.value : 0, + composerFocusNode: showsComposer + ? composerFocusNode + : null, + restoreComposerFocus: showsComposer + ? () => restoreComposerFocus.value?.call() + : null, ); }, ), @@ -521,6 +529,9 @@ class ChannelDetailPage extends HookConsumerWidget { ), ComposeBar( channelId: channel.id, + focusNode: composerFocusNode, + onFocusRestorerChanged: (restoreFocus) => + restoreComposerFocus.value = restoreFocus, channelName: resolvedChannel.isDm ? '' : resolvedChannel.name, diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 87673b3cbe3..8c953b7211b 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -1,6 +1,6 @@ part of '../channel_detail_page.dart'; -class _MessageBubble extends ConsumerWidget { +class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; final Map channelNames; @@ -9,6 +9,8 @@ class _MessageBubble extends ConsumerWidget { final List? allMessages; final bool isMember; final bool isArchived; + final FocusNode? composerFocusNode; + final VoidCallback? restoreComposerFocus; const _MessageBubble({ required this.message, @@ -19,10 +21,13 @@ class _MessageBubble extends ConsumerWidget { this.allMessages, this.isMember = false, this.isArchived = false, + this.composerFocusNode, + this.restoreComposerFocus, }); @override Widget build(BuildContext context, WidgetRef ref) { + final messageSnapshotKey = useMemoized(GlobalKey.new, const []); // Watch only this user's profile to avoid rebuilding on unrelated cache changes. final pk = message.pubkey.toLowerCase(); final profile = @@ -72,7 +77,7 @@ class _MessageBubble extends ConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); - void openMessageActions(Rect anchorRect) { + void openMessageActions(MessageLongPressDetails details) { showMessageActions( context: context, ref: ref, @@ -83,7 +88,12 @@ class _MessageBubble extends ConsumerWidget { currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, - anchorRect: anchorRect, + anchorRect: details.anchorRect, + captureAnchorSnapshot: details.captureSnapshot, + onPopoverPreviewVisibilityChanged: details.setSourceHidden, + onPopoverDismissed: () => details.setSourceHidden(false), + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, ); } @@ -98,9 +108,10 @@ class _MessageBubble extends ConsumerWidget { clipBehavior: Clip.none, child: MessageLongPressInkWell( key: ValueKey('message-row-${message.id}'), - onLongPress: openMessageActions, + onLongPressDetails: openMessageActions, borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), + snapshotKey: messageSnapshotKey, // Tap opens the thread; long-press still opens the action sheet. // MessageContent handles mention, channel-link, and media taps. onTap: allMessages == null @@ -122,143 +133,164 @@ class _MessageBubble extends ConsumerWidget { top: showAuthor ? 0 : Grid.xxs, bottom: showAuthor ? 0 : Grid.xxs, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _UserAvatar( - profile: profile, - pubkey: message.pubkey, - ), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', - ), + RepaintBoundary( + key: messageSnapshotKey, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _UserAvatar( + profile: profile, + pubkey: message.pubkey, + ), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + mentionNames: resolvedMentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, ); }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - messageId: message.id, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, - ), + ], ), - ], - ), + ), + ), + ], ), ), + if (message.reactions.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + ), + child: ReactionRow( + messageId: message.id, + reactions: message.reactions, + onToggle: (emoji) => toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ), ], ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 59e4c746fb8..251e40e2fa4 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -15,6 +15,8 @@ class _MessageList extends HookConsumerWidget { final bool isArchived; final double appBarTitleContentHeight; final double composerBottomInset; + final FocusNode? composerFocusNode; + final VoidCallback? restoreComposerFocus; const _MessageList({ required this.entries, @@ -31,6 +33,8 @@ class _MessageList extends HookConsumerWidget { required this.isArchived, required this.appBarTitleContentHeight, required this.composerBottomInset, + this.composerFocusNode, + this.restoreComposerFocus, }); @override @@ -804,6 +808,8 @@ class _MessageList extends HookConsumerWidget { allMessages: allMessages, isMember: isMember, isArchived: isArchived, + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, ), if (entry.summary != null) _ThreadSummaryRow( diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index c6a26cc59cd..344972f10e0 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -10,6 +10,12 @@ class ComposeBar extends HookConsumerWidget { /// prepare focus-dependent layout (for example, following a thread tail). final VoidCallback? onFocusRequested; + /// Parent-owned if set; otherwise internally created and disposed. + final FocusNode? focusNode; + + /// Receives a restorer which becomes a no-op after replacement/unmount. + final ValueChanged? onFocusRestorerChanged; + /// Optional thread IDs for thread-scoped typing indicators. final String? threadHeadId; final String? rootId; @@ -20,6 +26,8 @@ class ComposeBar extends HookConsumerWidget { this.hintText, this.threadHeadId, this.rootId, + this.focusNode, + this.onFocusRestorerChanged, this.onFocusRequested, required this.onSend, }); @@ -31,15 +39,9 @@ class ComposeBar extends HookConsumerWidget { () => controller.text, ); useEffect(() => controller.dispose, [controller]); - // Restore and persist unsent text as a local draft so the Activity - // inbox Drafts filter reflects real composer state. - // - // The effect is additionally keyed on the active relay + pubkey identity: - // provider-level namespacing alone cannot protect a composer that stays - // mounted through an in-place community/account switch — the controller - // would retain the old identity's text and the next edit would persist it - // into the new identity's store. On identity change we replace the - // controller content with the new identity's own saved draft (or clear). + // Draft identity is part of the effect key because an in-place account or + // community switch can leave this composer mounted. Reload that identity's + // draft so old text cannot be persisted into the new identity's store. final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId); final draftRevision = useRef(0); final draftIdentity = @@ -50,15 +52,13 @@ class ComposeBar extends HookConsumerWidget { defaultTargetPlatform != TargetPlatform.android, ); final androidImeFallbackTimer = useRef(null); - final focusNode = useFocusNode(); - useEffect( - () => - () => androidImeFallbackTimer.value?.cancel(), - [androidImeFallbackTimer], - ); + final ownedFocusNode = useFocusNode(); + final focusNode = this.focusNode ?? ownedFocusNode; useEffect( - () => - () => _dismissComposerKeyboard(focusNode), + () => () { + androidImeFallbackTimer.value?.cancel(); + _dismissComposerKeyboard(focusNode); + }, [focusNode], ); final isEmojiPickerOpen = useState(false); @@ -863,6 +863,13 @@ class ComposeBar extends HookConsumerWidget { androidImeFallbackTimer: androidImeFallbackTimer, ); + _useComposerFocusRestorer( + onChanged: onFocusRestorerChanged, + isExpanded: isComposerExpanded, + focusNode: focusNode, + expand: expandComposer, + ); + final suggestionPanel = _composerSuggestionPanel( channelSuggestions: channelSuggestions, mentionSuggestions: suggestions, diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 09d7a8e8476..78d31e58198 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,5 +1,29 @@ part of '../compose_bar.dart'; +void _useComposerFocusRestorer({ + required ValueChanged? onChanged, + required ValueNotifier isExpanded, + required FocusNode focusNode, + required VoidCallback expand, +}) { + useEffect(() { + if (onChanged == null) return null; + + var isCurrent = true; + void restoreFocus() { + if (!isCurrent) return; + if (isExpanded.value) { + focusNode.requestFocus(); + } else { + expand(); + } + } + + onChanged(restoreFocus); + return () => isCurrent = false; + }, [onChanged, focusNode]); +} + void _useComposerChannelNames( _MarkdownEditingController controller, AsyncValue> channelsAsync, diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index e09f9478a70..da2050802e3 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui'; +import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; @@ -37,11 +39,29 @@ import 'thread_follows/thread_follows_provider.dart'; import 'timeline_message.dart'; part 'message_actions/reaction_popover.dart'; +part 'message_actions/quick_reaction_row.dart'; +part 'message_actions/message_action_popover.dart'; +part 'message_actions/message_reaction_tray.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. const _reminderPreviewLength = 100; +/// Presents the actions for [message] as an anchored popover when both +/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet. +/// +/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid +/// until its future completes, and the returned image becomes this function's +/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports +/// whether the constrained layout actually renders the lifted preview; +/// [onPopoverDismissed] runs after the route completes while [context] is still +/// mounted. Neither callback runs for sheet fallback or a failed capture. +/// +/// [composerFocusNode] remains caller-owned and must outlive the popover. If it +/// has focus when this function is called, the popover unfocuses it and invokes +/// [restoreComposerFocus] only after a dismissal with no selected action. The +/// restorer must remain callable for the same lifetime and no-op if its composer +/// is later disposed or replaced. void showMessageActions({ required BuildContext context, required WidgetRef ref, @@ -51,8 +71,13 @@ void showMessageActions({ List? allMessages, String? currentPubkey, bool isMember = false, - bool isArchived = false, Rect? anchorRect, + Future Function()? captureAnchorSnapshot, + ValueChanged? onPopoverPreviewVisibilityChanged, + VoidCallback? onPopoverDismissed, + FocusNode? composerFocusNode, + VoidCallback? restoreComposerFocus, + bool isArchived = false, EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), }) { final hasReactionOnlyActions = message.isSystem && !canManageMessage; @@ -67,6 +92,26 @@ void showMessageActions({ return; } + if (_tryShowMessageActionsPopover( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, + onPopoverDismissed: onPopoverDismissed, + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, + )) { + return; + } + showBuzzModalBottomSheet( context: context, isScrollControlled: true, @@ -485,9 +530,6 @@ class _FollowThreadTile extends ConsumerWidget { } } -/// Promoted actions for the three dominant mobile jobs: respond now (Reply), -/// hand off context (Copy link — the `buzz://message` link is the workspace's -/// context-transfer primitive), and defer (Remind me). class _FastActionsRow extends ConsumerWidget { final TimelineMessage message; final String channelId; @@ -638,127 +680,9 @@ class _FastActionTile extends StatelessWidget { } } -/// The row of one-tap reactions at the top of the action sheet, plus the "+" -/// tile that opens the full picker. -/// /// The emoji shown are the user's own frequently-used set (desktop's /// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so /// the row is full on a fresh install. -class _QuickReactionRow extends ConsumerWidget { - final TimelineMessage message; - - /// The sheet's context, popped before the reaction fires. - final BuildContext sheetContext; - - /// The long-pressed message's page context — survives the sheet pop, so the - /// picker opened from "+" isn't torn down with the sheet. - final BuildContext pageContext; - - /// The long-pressed message's page ref. The picker callback outlives this - /// bottom sheet, so it must not read through the sheet's disposed ref. - final WidgetRef pageRef; - - /// Drives the staged glyph reveal when this row is shown in the popover. - /// The bottom sheet leaves this null and retains its existing static row. - final Animation? presentationAnimation; - - const _QuickReactionRow({ - required this.message, - required this.sheetContext, - required this.pageContext, - required this.pageRef, - this.presentationAnimation, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final customEmoji = ref.watch(customEmojiListProvider); - final emoji = quickReactionEmoji( - ref.watch(recentEmojiProvider), - customShortcodes: { - for (final entry in customEmoji) entry.shortcode.toLowerCase(), - }, - ); - final customByShortcode = { - for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry, - }; - - void react(String value) { - // The generic picker is also used for composing and statuses. Record - // recency here, at the reaction call site, so only reactions drive the - // quick-reaction row. - pageRef.read(recentEmojiProvider.notifier).record(value); - // The sheet is on its way out, so the burst can't come from this tile — - // hand it to the pill that's about to appear in the timeline. - armReactionBurst(pageRef, message, value); - pageRef.read(channelActionsProvider).addReaction(message.id, value); - } - - return LayoutBuilder( - builder: (context, constraints) { - const desiredCircleSize = 52.0; - const minimumCircleSize = 44.0; - final itemCount = emoji.length + 1; - final gapCount = itemCount - 1; - final circleSize = - ((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount) - .clamp(minimumCircleSize, desiredCircleSize) - .toDouble(); - final gap = - ((constraints.maxWidth - (circleSize * itemCount)) / gapCount) - .clamp(0.0, Grid.twelve) - .toDouble(); - final circles = [ - for (var index = 0; index < emoji.length; index++) - _ReactionItemReveal( - key: ValueKey('quick-reaction-${emoji[index]}'), - animation: presentationAnimation, - index: index, - child: _QuickReactionCircle( - size: circleSize, - onTap: () { - Navigator.of(sheetContext).pop(); - react(emoji[index]); - }, - child: _QuickReactionGlyph( - value: emoji[index], - customByShortcode: customByShortcode, - ), - ), - ), - _ReactionItemReveal( - key: const ValueKey('quick-reaction-more'), - animation: presentationAnimation, - index: emoji.length, - child: _QuickReactionCircle( - size: circleSize, - onTap: () { - Navigator.of(sheetContext).pop(); - showEmojiPicker(context: pageContext, onSelect: react); - }, - child: Icon( - LucideIcons.plus, - size: 24, - color: context.colors.onSurfaceVariant, - ), - ), - ), - ]; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var index = 0; index < circles.length; index++) ...[ - circles[index], - if (index < circles.length - 1) SizedBox(width: gap), - ], - ], - ); - }, - ); - } -} - class _ReactionItemReveal extends StatelessWidget { final Animation? animation; final int index; diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart new file mode 100644 index 00000000000..350afff82b3 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart @@ -0,0 +1,999 @@ +part of '../message_actions.dart'; + +const _messageActionRowHeight = 48.0; +const _messageActionRowVerticalPadding = Grid.xxs; +const _messageActionSeparatorHeight = 0.5; +const _messageActionVerticalInset = Grid.half; +const _messageActionMenuMaxWidth = 288.0; +const _messageActionPreviewMaxWidth = 358.0; +const _messageActionPreviewInset = Grid.xxs; +const _messageActionGap = Grid.twelve; +const _messageActionReactionSelection = '__reaction__'; +const _messageActionTransitionDuration = _reactionPopoverDuration; +const _iosMessageActionTransitionDuration = Duration(milliseconds: 220); +const _iosNativeMessageActionSurfaceChannel = MethodChannel( + 'buzz/native_message_action_surface', +); + +bool _messageActionsPresentationInFlight = false; +bool? _iosNativeMessageActionSurfaceSupported; + +Future _supportsIosNativeMessageActionSurface() async { + if (!Platform.isIOS) return false; + final cached = _iosNativeMessageActionSurfaceSupported; + if (cached != null) return cached; + + try { + final supported = + await _iosNativeMessageActionSurfaceChannel.invokeMethod( + 'isSupported', + ) ?? + false; + _iosNativeMessageActionSurfaceSupported = supported; + return supported; + } on MissingPluginException { + _iosNativeMessageActionSurfaceSupported = false; + return false; + } on PlatformException { + _iosNativeMessageActionSurfaceSupported = false; + return false; + } +} + +bool _tryShowMessageActionsPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, + required Rect? anchorRect, + required Future Function()? captureAnchorSnapshot, + required ValueChanged? onPopoverPreviewVisibilityChanged, + required VoidCallback? onPopoverDismissed, + required FocusNode? composerFocusNode, + required VoidCallback? restoreComposerFocus, +}) { + if (anchorRect == null || captureAnchorSnapshot == null) return false; + final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false; + unawaited( + _showMessageActionsPopover( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + anchorRect: anchorRect, + captureAnchorSnapshot: captureAnchorSnapshot, + onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, + onPopoverDismissed: onPopoverDismissed, + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, + shouldRestoreComposerFocus: shouldRestoreComposerFocus, + ).then((shown) { + if (shown || !context.mounted) return; + showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + }), + ); + return true; +} + +Future _showMessageActionsPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, + required Rect anchorRect, + required Future Function() captureAnchorSnapshot, + required ValueChanged? onPopoverPreviewVisibilityChanged, + required VoidCallback? onPopoverDismissed, + required FocusNode? composerFocusNode, + required VoidCallback? restoreComposerFocus, + required bool shouldRestoreComposerFocus, +}) async { + if (_messageActionsPresentationInFlight) return true; + _messageActionsPresentationInFlight = true; + + try { + final actions = _buildPopoverMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ); + if (actions.isEmpty) return false; + final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface(); + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + + unawaited(HapticFeedback.mediumImpact()); + + final ui.Image snapshot; + try { + snapshot = await captureAnchorSnapshot(); + } catch (_) { + return false; + } + if (!context.mounted) { + snapshot.dispose(); + return false; + } + final useIosNativeActionSurface = await nativeActionSurfaceSupport; + if (!context.mounted) { + snapshot.dispose(); + return false; + } + + final reduceMotion = MediaQuery.disableAnimationsOf(context); + if (shouldRestoreComposerFocus) composerFocusNode!.unfocus(); + + String? selectedActionId; + final dialogRoute = RawDialogRoute( + barrierDismissible: true, + barrierLabel: 'Dismiss message actions', + barrierColor: Colors.transparent, + transitionDuration: reduceMotion + ? Duration.zero + : isIos + ? _iosMessageActionTransitionDuration + : _messageActionTransitionDuration, + transitionBuilder: (context, animation, secondaryAnimation, child) => + child, + pageBuilder: (dialogContext, animation, secondaryAnimation) => + _MessageActionsPopover( + anchorRect: anchorRect, + anchorSnapshot: snapshot, + animation: animation, + message: message, + pageContext: context, + pageRef: ref, + actions: actions, + useIosNativeActionSurface: useIosNativeActionSurface, + onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, + ), + ); + var routePushed = false; + try { + final popResult = Navigator.of( + context, + rootNavigator: true, + ).push(dialogRoute); + routePushed = true; + selectedActionId = await popResult; + } finally { + if (routePushed) await dialogRoute.completed; + snapshot.dispose(); + if (context.mounted) onPopoverDismissed?.call(); + } + + for (final action in actions) { + if (action.id != selectedActionId) continue; + await Future.sync(action.onSelected); + break; + } + if (selectedActionId == null && + shouldRestoreComposerFocus && + context.mounted) { + restoreComposerFocus?.call(); + } + return true; + } finally { + _messageActionsPresentationInFlight = false; + } +} + +List<_PopoverMessageAction> _buildPopoverMessageActions({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required String channelId, + required bool canManageMessage, + required List? allMessages, + required String? currentPubkey, + required bool isMember, + required bool isArchived, +}) { + final actions = <_PopoverMessageAction>[]; + final messages = allMessages; + final canRemind = ref.read(reminderServiceProvider) != null; + + if (!message.isSystem) { + if (messages != null) { + actions.add( + _PopoverMessageAction( + id: 'reply', + title: 'Reply', + icon: LucideIcons.messageSquareReply, + group: _PopoverMessageActionGroup.primary, + onSelected: () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: messages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + ), + ); + } + actions.add( + _PopoverMessageAction( + id: 'copyLink', + title: 'Copy link', + icon: LucideIcons.link2, + group: _PopoverMessageActionGroup.utility, + onSelected: () { + if (!context.mounted) return; + copyToClipboard( + context, + messageLinkFor(message: message, channelId: channelId), + message: 'Message link copied', + ); + }, + ), + ); + if (canRemind) { + actions.add( + _PopoverMessageAction( + id: 'remind', + title: 'Remind me', + icon: LucideIcons.clock, + group: _PopoverMessageActionGroup.utility, + onSelected: () { + if (!context.mounted) return; + showRemindMeLaterSheet( + context: Navigator.of(context, rootNavigator: true).context, + ref: ref, + target: ReminderTarget( + eventId: message.id, + channelId: channelId, + preview: message.content.characters + .take(_reminderPreviewLength) + .toString(), + authorPubkey: message.pubkey, + ), + ); + }, + ), + ); + } + + final readState = ref.read(readStateProvider); + if (readState.isReady) { + final unread = isMessageUnread( + readState, + channelId: channelId, + messageId: message.id, + createdAt: message.createdAt, + threadRootId: message.rootId, + ); + actions.add( + _PopoverMessageAction( + id: unread ? 'markRead' : 'markUnread', + title: unread ? 'Mark read' : 'Mark unread', + icon: unread ? LucideIcons.mailCheck : LucideIcons.mailOpen, + group: _PopoverMessageActionGroup.primary, + onSelected: () { + final notifier = ref.read(readStateProvider.notifier); + if (unread) { + notifier.markContextRead( + msgContextKey(message.id), + message.createdAt, + ); + } else { + notifier.markContextUnread( + msgContextKey(message.id), + channelId: channelId, + ); + } + }, + ), + ); + } + + final rootId = message.rootId ?? message.id; + final following = ref.read(threadFollowsProvider).isFollowing(rootId); + actions.add( + _PopoverMessageAction( + id: following ? 'unfollowThread' : 'followThread', + title: following ? 'Unfollow thread' : 'Follow thread', + icon: following ? LucideIcons.bellOff : LucideIcons.bellRing, + group: _PopoverMessageActionGroup.utility, + onSelected: () { + final notifier = ref.read(threadFollowsProvider.notifier); + if (following) { + notifier.unfollowThread(rootId); + } else { + notifier.followThread(rootId); + } + }, + ), + ); + actions.add( + _PopoverMessageAction( + id: 'copyText', + title: 'Copy text', + icon: LucideIcons.copy, + group: _PopoverMessageActionGroup.utility, + onSelected: () => + Clipboard.setData(ClipboardData(text: message.content)), + ), + ); + } + + if (canManageMessage) { + actions.add( + _PopoverMessageAction( + id: 'edit', + title: 'Edit message', + icon: LucideIcons.pencil, + group: _PopoverMessageActionGroup.primary, + onSelected: () { + if (!context.mounted) return; + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }, + ), + ); + actions.add( + _PopoverMessageAction( + id: 'delete', + title: 'Delete message', + icon: LucideIcons.trash2, + group: _PopoverMessageActionGroup.destructive, + destructive: true, + onSelected: () { + if (!context.mounted) return; + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + ); + }, + ), + ); + } + + const actionOrder = { + 'reply': 0, + 'markRead': 1, + 'markUnread': 1, + 'edit': 2, + 'copyText': 3, + 'copyLink': 4, + 'remind': 5, + 'followThread': 6, + 'unfollowThread': 6, + 'delete': 7, + }; + actions.sort( + (left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!), + ); + return actions; +} + +enum _PopoverMessageActionGroup { primary, utility, destructive } + +class _PopoverMessageAction { + final String id; + final String title; + final IconData icon; + final _PopoverMessageActionGroup group; + final bool destructive; + final FutureOr Function() onSelected; + + const _PopoverMessageAction({ + required this.id, + required this.title, + required this.icon, + required this.group, + required this.onSelected, + this.destructive = false, + }); + + String get iosSymbol => switch (id) { + 'reply' => 'arrowshape.turn.up.left', + 'markRead' => 'envelope.open', + 'markUnread' => 'envelope.badge', + 'edit' => 'pencil', + 'copyText' => 'doc.on.doc', + 'copyLink' => 'link', + 'remind' => 'clock', + 'followThread' => 'bell', + 'unfollowThread' => 'bell.slash', + 'delete' => 'trash', + _ => 'ellipsis', + }; + + Map toPlatformArguments() => { + 'id': id, + 'title': title, + 'symbol': iosSymbol, + 'group': group.name, + 'destructive': destructive, + }; +} + +class _IosNativeMessageActionSurface extends HookWidget { + final List<_PopoverMessageAction> actions; + final double rowHeight; + final ValueChanged onSelected; + + const _IosNativeMessageActionSurface({ + required this.actions, + required this.rowHeight, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final viewId = useState(null); + useEffect(() { + final id = viewId.value; + if (id == null) return null; + final channel = MethodChannel('buzz/native_message_action_surface/$id'); + channel.setMethodCallHandler((call) async { + if (call.method != 'selected' || call.arguments is! Map) return; + final actionId = (call.arguments as Map)['id']; + if (actionId is String) onSelected(actionId); + }); + return () => channel.setMethodCallHandler(null); + }, [viewId.value, onSelected]); + + return UiKitView( + key: const ValueKey('ios-native-message-action-surface'), + viewType: 'buzz/native_message_action_surface', + creationParams: { + 'actions': [for (final action in actions) action.toPlatformArguments()], + 'surfaceColor': context.colors.surface.toARGB32(), + 'foregroundColor': context.colors.onSurface.toARGB32(), + 'separatorColor': context.colors.outlineVariant.toARGB32(), + 'errorColor': context.colors.error.toARGB32(), + 'interfaceStyle': context.colors.brightness.name, + 'rowHeight': rowHeight, + }, + creationParamsCodec: const StandardMessageCodec(), + onPlatformViewCreated: (id) => viewId.value = id, + ); + } +} + +class _MessageActionsPopover extends HookWidget { + final Rect anchorRect; + final ui.Image anchorSnapshot; + final Animation animation; + final TimelineMessage message; + final BuildContext pageContext; + final WidgetRef pageRef; + final List<_PopoverMessageAction> actions; + final bool useIosNativeActionSurface; + final ValueChanged? onPreviewVisibilityChanged; + + const _MessageActionsPopover({ + required this.anchorRect, + required this.anchorSnapshot, + required this.animation, + required this.message, + required this.pageContext, + required this.pageRef, + required this.actions, + required this.useIosNativeActionSurface, + required this.onPreviewVisibilityChanged, + }); + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final selectionStarted = useRef(false); + + void select(Object? result, [VoidCallback? effect]) { + if (selectionStarted.value) return; + selectionStarted.value = true; + Navigator.of(context).pop(result); + effect?.call(); + } + + void selectAction(String actionId) => select(actionId); + return LayoutBuilder( + builder: (context, constraints) { + final safeLeft = mediaQuery.padding.left + Grid.xxs; + final safeRight = + constraints.maxWidth - mediaQuery.padding.right - Grid.xxs; + final safeTop = mediaQuery.padding.top + Grid.xxs; + final safeBottom = + constraints.maxHeight - + mediaQuery.padding.bottom - + mediaQuery.viewInsets.bottom - + Grid.xxs; + final availableWidth = math.max(1.0, safeRight - safeLeft); + final availableHeight = math.max(1.0, safeBottom - safeTop); + final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth); + final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth); + final menuLayout = _MessageActionSurfaceLayout.from(context, actions); + final preferredMenuHeight = menuLayout.preferredHeight; + final minimumMenuHeight = math.min( + menuLayout.rowHeight, + availableHeight, + ); + final showReactionTray = + availableHeight >= + _reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight; + final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0; + final trayGap = showReactionTray ? _messageActionGap : 0.0; + final heightAfterTray = availableHeight - trayHeight - trayGap; + final showPreview = + showReactionTray && + heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0; + final previewMinimumHeight = showPreview ? 48.0 : 0.0; + final previewGap = showPreview ? _messageActionGap : 0.0; + final menuBudget = math.max( + minimumMenuHeight, + availableHeight - + trayHeight - + trayGap - + previewMinimumHeight - + previewGap, + ); + final menuHeight = math.min(preferredMenuHeight, menuBudget); + final previewMaximumHeight = showPreview + ? math.max( + previewMinimumHeight, + availableHeight - + trayHeight - + trayGap - + menuHeight - + previewGap, + ) + : 0.0; + final previewInsetExtent = _messageActionPreviewInset * 2; + final previewSize = showPreview + ? () { + final previewWidthRatio = + (math.min(_messageActionPreviewMaxWidth, availableWidth) - + previewInsetExtent) / + math.max(anchorRect.width, 1); + final previewHeightRatio = + (previewMaximumHeight - previewInsetExtent) / + math.max(anchorRect.height, 1); + final previewScale = math.min( + 1.0, + math.max( + 0.0, + math.min(previewWidthRatio, previewHeightRatio), + ), + ); + return Size( + (anchorRect.width * previewScale) + previewInsetExtent, + (anchorRect.height * previewScale) + previewInsetExtent, + ); + }() + : Size.zero; + final totalHeight = + trayHeight + trayGap + previewSize.height + previewGap + menuHeight; + final contentTop = math.max(safeTop, safeBottom - totalHeight); + final surfaceLeft = + safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2); + final trayRect = Rect.fromLTWH( + surfaceLeft, + contentTop, + trayWidth, + trayHeight, + ); + final trayHostWidth = math.min( + trayWidth + _reactionTraySpringAllowance, + safeRight - surfaceLeft, + ); + final previewRect = Rect.fromLTWH( + surfaceLeft, + trayRect.bottom + trayGap, + previewSize.width, + previewSize.height, + ); + final menuRect = Rect.fromLTWH( + surfaceLeft, + previewRect.bottom + previewGap, + menuWidth, + menuHeight, + ); + return _MessageActionPreviewVisibility( + visible: showPreview, + onChanged: onPreviewVisibilityChanged, + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8, + sigmaY: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8, + ), + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final opacity = Curves.easeOutCubic.transform( + animation.value, + ); + return ColoredBox( + key: const ValueKey('message-actions-background'), + color: context.colors.inverseSurface.withValues( + alpha: 0.14 * opacity, + ), + ); + }, + ), + ), + ), + Positioned.fill( + child: GestureDetector( + key: const ValueKey('message-actions-backdrop'), + behavior: HitTestBehavior.opaque, + onTap: () => select(null), + ), + ), + if (showPreview) + Positioned.fromRect( + rect: previewRect, + child: AnimatedBuilder( + animation: animation, + child: RepaintBoundary( + child: _LiftedMessagePreview( + anchorSnapshot: anchorSnapshot, + ), + ), + builder: (context, child) { + final movement = + (defaultTargetPlatform == TargetPlatform.iOS + ? Curves.easeOutCubic + : Curves.easeInOutCubic) + .transform(animation.value); + final sourceRect = anchorRect.inflate( + _messageActionPreviewInset, + ); + final translation = Offset( + ui.lerpDouble( + sourceRect.left - previewRect.left, + 0, + movement, + )!, + ui.lerpDouble( + sourceRect.top - previewRect.top, + 0, + movement, + )!, + ); + final scaleX = ui.lerpDouble( + sourceRect.width / previewRect.width, + 1, + movement, + )!; + final scaleY = ui.lerpDouble( + sourceRect.height / previewRect.height, + 1, + movement, + )!; + return Transform.translate( + offset: translation, + child: Transform( + alignment: Alignment.topLeft, + transform: Matrix4.diagonal3Values(scaleX, scaleY, 1), + child: child, + ), + ); + }, + ), + ), + if (showReactionTray) + Positioned( + left: trayRect.left, + top: trayRect.top, + width: trayHostWidth, + height: trayRect.height, + child: _MessageReactionTray( + animation: animation, + trayWidth: trayWidth, + message: message, + pageContext: pageContext, + pageRef: pageRef, + popResult: _messageActionReactionSelection, + onSelected: (result, effect) => select(result, effect), + ), + ), + Positioned.fromRect( + rect: menuRect, + child: AnimatedBuilder( + animation: animation, + child: useIosNativeActionSurface + ? _IosNativeMessageActionSurface( + actions: actions, + rowHeight: menuLayout.rowHeight, + onSelected: selectAction, + ) + : _MessageActionSurface( + actions: actions, + onSelected: selectAction, + ), + builder: (context, child) { + final appearance = const Interval( + 0.08, + 0.82, + curve: Curves.easeOutCubic, + ).transform(animation.value); + final fadedChild = Opacity( + opacity: appearance, + child: child, + ); + if (defaultTargetPlatform == TargetPlatform.iOS) { + return fadedChild; + } + return Transform.scale( + alignment: Alignment.topLeft, + scale: ui.lerpDouble(0.96, 1, appearance)!, + child: fadedChild, + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _MessageActionPreviewVisibility extends HookWidget { + final bool visible; + final ValueChanged? onChanged; + final Widget child; + const _MessageActionPreviewVisibility({ + required this.visible, + required this.onChanged, + required this.child, + }); + @override + Widget build(BuildContext context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) onChanged?.call(visible); + }); + return null; + }, [visible, onChanged]); + return child; + } +} + +class _LiftedMessagePreview extends StatelessWidget { + final ui.Image anchorSnapshot; + + const _LiftedMessagePreview({required this.anchorSnapshot}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: const ValueKey('message-action-preview'), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.7), + width: 0.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(_messageActionPreviewInset), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.xs), + child: RawImage( + image: anchorSnapshot, + fit: BoxFit.fill, + filterQuality: FilterQuality.medium, + ), + ), + ), + ); + } +} + +class _MessageActionSurface extends StatelessWidget { + final List<_PopoverMessageAction> actions; + final ValueChanged onSelected; + + const _MessageActionSurface({ + required this.actions, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final menuLayout = _MessageActionSurfaceLayout.from(context, actions); + return Material( + key: const ValueKey('message-action-surface'), + color: context.colors.surface, + surfaceTintColor: Colors.transparent, + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + side: BorderSide( + color: context.colors.outlineVariant.withValues(alpha: 0.55), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: _messageActionVerticalInset, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0 && + actions[index - 1].group != actions[index].group) + Divider( + key: ValueKey( + 'message-action-divider-${actions[index].group.name}', + ), + height: _messageActionSeparatorHeight, + thickness: _messageActionSeparatorHeight, + indent: Grid.xs, + endIndent: Grid.xs, + ), + _MessageActionRow( + action: actions[index], + height: menuLayout.rowHeight, + onSelected: onSelected, + ), + ], + ], + ), + ), + ), + ); + } +} + +class _MessageActionRow extends StatelessWidget { + final _PopoverMessageAction action; + final double height; + final ValueChanged onSelected; + + const _MessageActionRow({ + required this.action, + required this.height, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final foreground = action.destructive + ? context.colors.error + : context.colors.onSurface; + return Semantics( + button: true, + label: action.title, + excludeSemantics: true, + child: InkWell( + key: ValueKey('message-action-${action.id}'), + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onSelected(action.id); + }, + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Row( + children: [ + SizedBox( + width: 32, + child: Center( + child: Icon(action.icon, size: 22, color: foreground), + ), + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: Text( + action.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyLarge?.copyWith( + color: foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MessageActionSurfaceLayout { + final double rowHeight, preferredHeight; + + const _MessageActionSurfaceLayout({ + required this.rowHeight, + required this.preferredHeight, + }); + + factory _MessageActionSurfaceLayout.from( + BuildContext context, + List<_PopoverMessageAction> actions, + ) { + final textPainter = TextPainter( + text: TextSpan( + text: 'Message action', + style: context.textTheme.bodyLarge, + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final rowHeight = math.max( + _messageActionRowHeight, + textPainter.height + (_messageActionRowVerticalPadding * 2), + ); + textPainter.dispose(); + + var separatorCount = 0; + for (var index = 1; index < actions.length; index++) { + if (actions[index - 1].group != actions[index].group) separatorCount += 1; + } + final preferredHeight = + (_messageActionVerticalInset * 2) + + (actions.length * rowHeight) + + (separatorCount * _messageActionSeparatorHeight); + return _MessageActionSurfaceLayout( + rowHeight: rowHeight, + preferredHeight: preferredHeight, + ); + } +} diff --git a/mobile/lib/features/channels/message_actions/message_reaction_tray.dart b/mobile/lib/features/channels/message_actions/message_reaction_tray.dart new file mode 100644 index 00000000000..21281789045 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/message_reaction_tray.dart @@ -0,0 +1,36 @@ +part of '../message_actions.dart'; + +class _MessageReactionTray extends StatelessWidget { + final Animation animation; + final double trayWidth; + final TimelineMessage message; + final BuildContext pageContext; + final WidgetRef pageRef; + final Object popResult; + final void Function(Object? result, VoidCallback effect) onSelected; + + const _MessageReactionTray({ + required this.animation, + required this.trayWidth, + required this.message, + required this.pageContext, + required this.pageRef, + required this.popResult, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return _AnimatedReactionTray( + trayKey: const ValueKey('message-action-reaction-tray'), + animation: animation, + trayWidth: trayWidth, + scaleAlignment: Alignment.bottomLeft, + message: message, + pageContext: pageContext, + pageRef: pageRef, + popResult: popResult, + onSelected: onSelected, + ); + } +} diff --git a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart new file mode 100644 index 00000000000..15a71c30fc9 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart @@ -0,0 +1,139 @@ +part of '../message_actions.dart'; + +class _QuickReactionRow extends ConsumerWidget { + final TimelineMessage message; + + /// The sheet's context, popped before the reaction fires. + final BuildContext sheetContext; + + /// The long-pressed message's page context — survives the sheet pop, so the + /// picker opened from "+" isn't torn down with the sheet. + final BuildContext pageContext; + + /// The long-pressed message's page ref. The picker callback outlives this + /// bottom sheet, so it must not read through the sheet's disposed ref. + final WidgetRef pageRef; + + /// Drives the staged glyph reveal when this row is shown in the popover. + /// The bottom sheet leaves this null and retains its existing static row. + final Animation? presentationAnimation; + + final Object? popResult; + + /// Selects exactly one popover result and side effect. Bottom sheets leave + /// this null and retain their existing local dismissal behavior. + final void Function(Object? result, VoidCallback effect)? onSelected; + + const _QuickReactionRow({ + required this.message, + required this.sheetContext, + required this.pageContext, + required this.pageRef, + this.presentationAnimation, + this.popResult, + this.onSelected, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final customEmoji = ref.watch(customEmojiListProvider); + final emoji = quickReactionEmoji( + ref.watch(recentEmojiProvider), + customShortcodes: { + for (final entry in customEmoji) entry.shortcode.toLowerCase(), + }, + ); + final customByShortcode = { + for (final entry in customEmoji) entry.shortcode.toLowerCase(): entry, + }; + + void react(String value) { + // The generic picker is also used for composing and statuses. Record + // recency here, at the reaction call site, so only reactions drive the + // quick-reaction row. + pageRef.read(recentEmojiProvider.notifier).record(value); + // The sheet is on its way out, so the burst can't come from this tile — + // hand it to the pill that's about to appear in the timeline. + armReactionBurst(pageRef, message, value); + pageRef.read(channelActionsProvider).addReaction(message.id, value); + } + + return LayoutBuilder( + builder: (context, constraints) { + const desiredCircleSize = 52.0; + const minimumCircleSize = 44.0; + final itemCount = emoji.length + 1; + final gapCount = itemCount - 1; + final circleSize = + ((constraints.maxWidth - (Grid.twelve * gapCount)) / itemCount) + .clamp(minimumCircleSize, desiredCircleSize) + .toDouble(); + final gap = + ((constraints.maxWidth - (circleSize * itemCount)) / gapCount) + .clamp(0.0, Grid.twelve) + .toDouble(); + final circles = [ + for (var index = 0; index < emoji.length; index++) + _ReactionItemReveal( + key: ValueKey('quick-reaction-${emoji[index]}'), + animation: presentationAnimation, + index: index, + child: _QuickReactionCircle( + size: circleSize, + onTap: () { + void effect() => react(emoji[index]); + + final select = onSelected; + if (select != null) { + select(popResult, effect); + } else { + Navigator.of(sheetContext).pop(popResult); + effect(); + } + }, + child: _QuickReactionGlyph( + value: emoji[index], + customByShortcode: customByShortcode, + ), + ), + ), + _ReactionItemReveal( + key: const ValueKey('quick-reaction-more'), + animation: presentationAnimation, + index: emoji.length, + child: _QuickReactionCircle( + size: circleSize, + onTap: () { + void effect() => + showEmojiPicker(context: pageContext, onSelect: react); + + final select = onSelected; + if (select != null) { + select(popResult, effect); + } else { + Navigator.of(sheetContext).pop(popResult); + effect(); + } + }, + child: Icon( + LucideIcons.plus, + size: 24, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ]; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var index = 0; index < circles.length; index++) ...[ + circles[index], + if (index < circles.length - 1) SizedBox(width: gap), + ], + ], + ); + }, + ); + } +} diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart index dfbc127861e..11ea608b70a 100644 --- a/mobile/lib/features/channels/message_actions/reaction_popover.dart +++ b/mobile/lib/features/channels/message_actions/reaction_popover.dart @@ -41,7 +41,7 @@ void _showMessageReactionPopover({ ); } -class _MessageReactionPopover extends StatelessWidget { +class _MessageReactionPopover extends HookWidget { final Rect anchorRect; final EdgeInsets spotlightPadding; final Animation animation; @@ -61,6 +61,14 @@ class _MessageReactionPopover extends StatelessWidget { @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); + final selectionStarted = useRef(false); + + void select(Object? result, [VoidCallback? effect]) { + if (selectionStarted.value) return; + selectionStarted.value = true; + Navigator.of(context).pop(result); + effect?.call(); + } return LayoutBuilder( builder: (context, constraints) { @@ -125,7 +133,7 @@ class _MessageReactionPopover extends StatelessWidget { Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => Navigator.of(context).pop(), + onTap: () => select(null), ), ), Positioned( @@ -133,76 +141,111 @@ class _MessageReactionPopover extends StatelessWidget { left: left, width: trayWidth + _reactionTraySpringAllowance, height: _reactionTrayMaxHeight, - child: AnimatedBuilder( + child: _AnimatedReactionTray( + trayKey: const ValueKey('reaction-popover-tray'), animation: animation, - child: SizedBox( - width: trayWidth, - height: _reactionTrayMaxHeight, - child: Padding( - padding: const EdgeInsets.all(Grid.xxs), - child: _QuickReactionRow( - message: message, - sheetContext: context, - pageContext: pageContext, - pageRef: pageRef, - presentationAnimation: animation, - ), + trayWidth: trayWidth, + scaleAlignment: trayScaleAlignment, + message: message, + pageContext: pageContext, + pageRef: pageRef, + onSelected: (result, effect) => select(result, effect), + ), + ), + ], + ); + }, + ); + } +} + +class _AnimatedReactionTray extends StatelessWidget { + final Key trayKey; + final Animation animation; + final double trayWidth; + final AlignmentGeometry scaleAlignment; + final TimelineMessage message; + final BuildContext pageContext; + final WidgetRef pageRef; + final Object? popResult; + final void Function(Object? result, VoidCallback effect)? onSelected; + + const _AnimatedReactionTray({ + required this.trayKey, + required this.animation, + required this.trayWidth, + required this.scaleAlignment, + required this.message, + required this.pageContext, + required this.pageRef, + this.popResult, + this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + child: SizedBox( + width: trayWidth, + height: _reactionTrayMaxHeight, + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: _QuickReactionRow( + message: message, + sheetContext: context, + pageContext: pageContext, + pageRef: pageRef, + presentationAnimation: animation, + popResult: popResult, + onSelected: onSelected, + ), + ), + ), + builder: (context, child) { + final appearance = const Interval( + 0.04, + 0.23, + curve: Curves.easeOutCubic, + ).transform(animation.value); + final expansion = const Interval(0.16, 0.92).transform(animation.value); + final springExpansion = _reactionSpringCurve.transform(expansion); + final width = lerpDouble( + _reactionTrayMaxHeight, + trayWidth, + springExpansion, + )!; + + return Opacity( + opacity: appearance, + child: Transform.scale( + alignment: scaleAlignment, + scale: lerpDouble(0.95, 1, appearance)!, + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: width, + height: _reactionTrayMaxHeight, + child: Material( + key: trayKey, + color: context.colors.surface, + surfaceTintColor: Colors.transparent, + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.2), + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: trayWidth, + maxWidth: trayWidth, + minHeight: _reactionTrayMaxHeight, + maxHeight: _reactionTrayMaxHeight, + child: child, ), ), - builder: (context, child) { - final appearance = const Interval( - 0.04, - 0.23, - curve: Curves.easeOutCubic, - ).transform(animation.value); - final expansion = const Interval( - 0.16, - 0.92, - ).transform(animation.value); - final springExpansion = _reactionSpringCurve.transform( - expansion, - ); - final width = lerpDouble( - _reactionTrayMaxHeight, - trayWidth, - springExpansion, - )!; - - return Opacity( - opacity: appearance, - child: Transform.scale( - alignment: trayScaleAlignment, - scale: lerpDouble(0.95, 1, appearance)!, - child: Align( - alignment: Alignment.centerLeft, - child: SizedBox( - key: const ValueKey('reaction-popover-tray'), - width: width, - height: _reactionTrayMaxHeight, - child: Material( - color: context.colors.surface, - surfaceTintColor: Colors.transparent, - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.2), - shape: const StadiumBorder(), - clipBehavior: Clip.antiAlias, - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: trayWidth, - maxWidth: trayWidth, - minHeight: _reactionTrayMaxHeight, - maxHeight: _reactionTrayMaxHeight, - child: child, - ), - ), - ), - ), - ), - ); - }, ), ), - ], + ), ); }, ); diff --git a/mobile/lib/features/channels/message_long_press_region.dart b/mobile/lib/features/channels/message_long_press_region.dart index d894e4a035b..caf84815a35 100644 --- a/mobile/lib/features/channels/message_long_press_region.dart +++ b/mobile/lib/features/channels/message_long_press_region.dart @@ -1,111 +1,217 @@ -import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +const _iosMessageLongPressDuration = Duration(milliseconds: 200); +const _maxMessageSnapshotDimension = 2048.0; + +double _messageSnapshotPixelRatio(Size size, double devicePixelRatio) { + final longestSide = size.longestSide; + if (!longestSide.isFinite || longestSide <= 0) return devicePixelRatio; + return math.min(devicePixelRatio, _maxMessageSnapshotDimension / longestSide); +} + +/// Geometry and snapshot controls for a completed message long press. +/// +/// Call [captureSnapshot] while the source is still mounted, then use +/// [setSourceHidden] to hide it behind the lifted preview. Restore the source +/// before the preview is disposed or dismissed. +class MessageLongPressDetails { + /// The long-pressed source bounds in global logical coordinates. + final Rect anchorRect; + + /// Captures the current source as an image for the lifted preview. + /// + /// The returned future can fail if the source is no longer mounted. + final Future Function() captureSnapshot; + + /// Hides or reveals the mounted source while its preview is displayed. + final ValueChanged setSourceHidden; + + /// Creates the details passed to a completed long-press callback. + const MessageLongPressDetails({ + required this.anchorRect, + required this.captureSnapshot, + required this.setSourceHidden, + }); +} + /// An [InkWell] whose long press is observed above interactive descendants. class MessageLongPressInkWell extends StatelessWidget { final VoidCallback? onTap; - final ValueChanged onLongPress; + final ValueChanged? onLongPress; + + /// Handles a completed long press with snapshot and source-visibility access. + /// + /// When provided, this callback takes precedence over [onLongPress]. + final ValueChanged? onLongPressDetails; final BorderRadius? borderRadius; final Color? highlightColor; + + /// Identifies the [RepaintBoundary] to capture for the lifted preview. + /// + /// The key's current context must resolve to a boundary covering the message + /// content. When omitted, this widget inserts and owns that boundary. + final GlobalKey? snapshotKey; final Widget child; const MessageLongPressInkWell({ super.key, this.onTap, - required this.onLongPress, + this.onLongPress, + this.onLongPressDetails, this.borderRadius, this.highlightColor, + this.snapshotKey, required this.child, - }); + }) : assert(onLongPress != null || onLongPressDetails != null); @override Widget build(BuildContext context) { return _MessageLongPressRegion( + onTap: onTap, onLongPress: onLongPress, - child: InkWell( - onTap: onTap, - borderRadius: borderRadius, - highlightColor: highlightColor, - child: child, - ), + onLongPressDetails: onLongPressDetails, + borderRadius: borderRadius, + highlightColor: highlightColor, + externalSnapshotKey: snapshotKey, + child: child, ); } } -/// Detects a message long press without competing with interactive descendants. +/// Detects a message long press through Flutter's gesture arena. /// /// Links, media, reactions, and other nested controls keep their normal tap -/// gestures. Moving far enough to scroll cancels the timer; recognizing the -/// hold cancels the pointer so a descendant tap cannot fire on release. +/// gestures, while a completed hold wins over descendant taps. class _MessageLongPressRegion extends HookWidget { - final ValueChanged onLongPress; + final VoidCallback? onTap; + final ValueChanged? onLongPress; + final ValueChanged? onLongPressDetails; + final BorderRadius? borderRadius; + final Color? highlightColor; + final GlobalKey? externalSnapshotKey; final Widget child; const _MessageLongPressRegion({ + required this.onTap, required this.onLongPress, + required this.onLongPressDetails, + required this.borderRadius, + required this.highlightColor, + required this.externalSnapshotKey, required this.child, }); @override Widget build(BuildContext context) { - final activePointer = useRef(null); - final origin = useRef(null); - final timer = useRef(null); - - void cancel() { - timer.value?.cancel(); - timer.value = null; - activePointer.value = null; - origin.value = null; - } - - useEffect(() => cancel, const []); + final fallbackSnapshotKey = useMemoized(GlobalKey.new, const []); + final snapshotKey = externalSnapshotKey ?? fallbackSnapshotKey; + final sourceHidden = useState(false); void recognize() { - final renderObject = context.findRenderObject(); - if (renderObject is! RenderBox || !renderObject.hasSize) return; - onLongPress(renderObject.localToGlobal(Offset.zero) & renderObject.size); - } + final renderObject = snapshotKey.currentContext?.findRenderObject(); + if (renderObject is! RenderRepaintBoundary || !renderObject.hasSize) { + return; + } + final anchorRect = + renderObject.localToGlobal(Offset.zero) & renderObject.size; - void handlePointerDown(PointerDownEvent event) { - if (activePointer.value != null) return; - activePointer.value = event.pointer; - origin.value = event.position; - timer.value = Timer(kLongPressTimeout, () { - final pointer = activePointer.value; - if (pointer == null) return; - timer.value = null; - activePointer.value = null; - origin.value = null; - GestureBinding.instance.cancelPointer(pointer); - recognize(); - }); - } + final detailsCallback = onLongPressDetails; + if (detailsCallback == null) { + onLongPress?.call(anchorRect); + return; + } + final maxSnapshotPixelRatio = math.min( + MediaQuery.devicePixelRatioOf(context), + 2.0, + ); - void handlePointerMove(PointerMoveEvent event) { - if (event.pointer != activePointer.value) return; - final start = origin.value; - if (start == null) return; - final delta = event.position - start; - if (delta.distanceSquared > kTouchSlop * kTouchSlop) cancel(); - } + Future captureSnapshot() async { + RenderRepaintBoundary? boundary; + final renderObject = snapshotKey.currentContext?.findRenderObject(); + if (renderObject is RenderRepaintBoundary && renderObject.hasSize) { + boundary = renderObject; + } + if (boundary == null) { + throw StateError('Message snapshot is unavailable'); + } + final snapshotPixelRatio = _messageSnapshotPixelRatio( + boundary.size, + maxSnapshotPixelRatio, + ); + try { + return await boundary.toImage(pixelRatio: snapshotPixelRatio); + } catch (_) { + await WidgetsBinding.instance.endOfFrame; + final retryBoundary = snapshotKey.currentContext?.findRenderObject(); + if (retryBoundary is! RenderRepaintBoundary || + !retryBoundary.hasSize) { + rethrow; + } + try { + return await retryBoundary.toImage( + pixelRatio: _messageSnapshotPixelRatio( + retryBoundary.size, + maxSnapshotPixelRatio, + ), + ); + } catch (_) { + if (snapshotPixelRatio <= 1) rethrow; + return retryBoundary.toImage( + pixelRatio: _messageSnapshotPixelRatio(retryBoundary.size, 1), + ); + } + } + } + + void setSourceHidden(bool hidden) { + if (!context.mounted) return; + sourceHidden.value = hidden; + } - void handlePointerEnd(PointerEvent event) { - if (event.pointer == activePointer.value) cancel(); + detailsCallback( + MessageLongPressDetails( + anchorRect: anchorRect, + captureSnapshot: captureSnapshot, + setSourceHidden: setSourceHidden, + ), + ); } return Semantics( onLongPress: recognize, - child: Listener( + child: RawGestureDetector( behavior: HitTestBehavior.translucent, - onPointerDown: handlePointerDown, - onPointerMove: handlePointerMove, - onPointerUp: handlePointerEnd, - onPointerCancel: handlePointerEnd, - child: child, + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + duration: defaultTargetPlatform == TargetPlatform.iOS + ? _iosMessageLongPressDuration + : null, + ), + (recognizer) { + recognizer.onLongPressStart = (_) => recognize(); + }, + ), + }, + child: InkWell( + onTap: onTap, + borderRadius: borderRadius, + highlightColor: highlightColor, + child: Opacity( + opacity: sourceHidden.value ? 0 : 1, + child: externalSnapshotKey == null + ? RepaintBoundary(key: snapshotKey, child: child) + : child, + ), + ), ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 6ab3ddd9b63..007702b5a09 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -43,6 +43,7 @@ part 'thread_detail_page/nested_thread_summary_row.dart'; part 'thread_detail_helpers.dart'; part 'thread_detail_page/tail_alignment.dart'; part 'thread_detail_page/thread_message.dart'; +part 'thread_detail_page/avatar.dart'; /// Full-screen thread detail page. /// @@ -72,6 +73,8 @@ class ThreadDetailPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final appView = View.of(context); final composerDockHeight = useState(0.0); + final composerFocusNode = useFocusNode(); + final restoreComposerFocus = useRef(null); final settledImeBottomInset = useState( usesFixedAndroidImeViewport ? appView.viewInsets.bottom / appView.devicePixelRatio @@ -635,6 +638,9 @@ class ThreadDetailPage extends HookConsumerWidget { isMember: isMember, isArchived: isArchived, isThreadHead: true, + composerFocusNode: composerFocusNode, + restoreComposerFocus: () => + restoreComposerFocus.value?.call(), ), Padding( padding: const EdgeInsets.symmetric( @@ -714,6 +720,9 @@ class ThreadDetailPage extends HookConsumerWidget { allMessages: allMsgs, isMember: isMember, isArchived: isArchived, + composerFocusNode: composerFocusNode, + restoreComposerFocus: () => + restoreComposerFocus.value?.call(), ), if (nestedSummary != null) _NestedThreadSummaryRow( @@ -750,6 +759,9 @@ class ThreadDetailPage extends HookConsumerWidget { _ThreadTypingIndicator(entries: threadTyping), ComposeBar( channelId: channelId, + focusNode: composerFocusNode, + onFocusRestorerChanged: (restoreFocus) => + restoreComposerFocus.value = restoreFocus, hintText: 'Reply in thread\u2026', threadHeadId: threadHead.id, rootId: effectiveRootId, diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart new file mode 100644 index 00000000000..502d6cffa8d --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart @@ -0,0 +1,28 @@ +part of '../thread_detail_page.dart'; + +class _Avatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + + const _Avatar({required this.profile, required this.pubkey}); + + @override + Widget build(BuildContext context) { + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final avatarUrl = profile?.avatarUrl; + + return AvatarImage( + imageUrl: avatarUrl, + radius: messageAvatarSize / 2, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + initial, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 248ccc96812..db7d1ba0b42 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -1,6 +1,6 @@ part of '../thread_detail_page.dart'; -class _ThreadMessage extends ConsumerWidget { +class _ThreadMessage extends HookConsumerWidget { final TimelineMessage message; final Map channelNames; final String channelId; @@ -10,6 +10,8 @@ class _ThreadMessage extends ConsumerWidget { final List? allMessages; final bool isMember; final bool isArchived; + final FocusNode? composerFocusNode; + final VoidCallback? restoreComposerFocus; /// Whether this is the message the thread hangs off, which keeps a standing /// "+" where replies only get one once they carry a reaction. @@ -26,10 +28,13 @@ class _ThreadMessage extends ConsumerWidget { this.isMember = false, this.isArchived = false, this.isThreadHead = false, + this.composerFocusNode, + this.restoreComposerFocus, }); @override Widget build(BuildContext context, WidgetRef ref) { + final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final pk = message.pubkey.toLowerCase(); final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? @@ -67,7 +72,7 @@ class _ThreadMessage extends ConsumerWidget { agentMentionPubkeys: agentMentionPubkeys, ); - void openMessageActions(Rect anchorRect) { + void openMessageActions(MessageLongPressDetails details) { showMessageActions( context: context, ref: ref, @@ -78,7 +83,12 @@ class _ThreadMessage extends ConsumerWidget { currentPubkey: currentPubkey, isMember: isMember, isArchived: isArchived, - anchorRect: anchorRect, + anchorRect: details.anchorRect, + captureAnchorSnapshot: details.captureSnapshot, + onPopoverPreviewVisibilityChanged: details.setSourceHidden, + onPopoverDismissed: () => details.setSourceHidden(false), + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, ); } @@ -101,151 +111,174 @@ class _ThreadMessage extends ConsumerWidget { clipBehavior: Clip.none, child: MessageLongPressInkWell( key: ValueKey('thread-message-row-${message.id}'), - onLongPress: openMessageActions, + onLongPressDetails: openMessageActions, borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), + snapshotKey: messageSnapshotKey, child: Padding( padding: EdgeInsets.only( top: showAuthor ? 0 : Grid.xxs, bottom: showAuthor ? 0 : Grid.xxs, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (showAuthor) - GestureDetector( - onTap: () => - showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', - ), + RepaintBoundary( + key: messageSnapshotKey, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _Avatar( + profile: profile, + pubkey: message.pubkey, + ), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + mentionNames: resolvedMentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, ); }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - ReactionRow( - messageId: message.id, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: - isMember && - !isArchived && - (isThreadHead || message.reactions.isNotEmpty), - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ], ), ), - ], - ), + ), + ], ), ), + if (isThreadHead || message.reactions.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: messageAvatarSize + messageAvatarContentGap, + ), + child: ReactionRow( + messageId: message.id, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ), ], ), ), @@ -255,30 +288,3 @@ class _ThreadMessage extends ConsumerWidget { ); } } - -class _Avatar extends StatelessWidget { - final UserProfile? profile; - final String pubkey; - - const _Avatar({required this.profile, required this.pubkey}); - - @override - Widget build(BuildContext context) { - final initial = - profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); - final avatarUrl = profile?.avatarUrl; - - return AvatarImage( - imageUrl: avatarUrl, - radius: messageAvatarSize / 2, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - initial, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9b9ac596c7f..826a419d8da 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1478,8 +1478,19 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('reaction-popover-tray')), findsNothing); - expect(find.byType(BottomSheet), findsOneWidget); + expect( + find.byKey(const ValueKey('message-action-reaction-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); expect(find.text('Copy text'), findsOneWidget); }); @@ -6214,7 +6225,9 @@ void main() { const ValueKey('thread-message-group-reply-29'), ); final composer = find.byKey(const ValueKey('composer-surface')); - await tester.drag(list, const Offset(0, 24)); + // Clear the gesture arena's touch slop so this represents a deliberate + // tail-detaching drag rather than a long-press hold with small motion. + await tester.drag(list, const Offset(0, 48)); await tester.pumpAndSettle(); expect( tester.getBottomLeft(latest).dy, diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 1270bc386c9..775c9f4f719 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -182,6 +182,9 @@ Widget _buildComposeBar({ RelayConfigNotifier Function()? relayConfig, PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(), VoidCallback? onFocusRequested, + FocusNode? focusNode, + ValueChanged? onFocusRestorerChanged, + String composeBarKey = 'compose-bar', }) { return ProviderScope( overrides: [ @@ -229,7 +232,10 @@ Widget _buildComposeBar({ child: Align( alignment: Alignment.bottomCenter, child: ComposeBar( + key: ValueKey(composeBarKey), channelId: 'channel-1', + focusNode: focusNode, + onFocusRestorerChanged: onFocusRestorerChanged, onFocusRequested: onFocusRequested, onSend: onSend, ), @@ -560,6 +566,184 @@ void main() { ); }); + testWidgets('uses a parent-owned focus node when provided', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + focusNode: focusNode, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.text('Message\u2026')); + await tester.pump(); + await tester.pump(); + + expect(focusNode.hasFocus, isTrue); + expect( + tester.widget(find.byType(TextField)).focusNode, + same(focusNode), + ); + }); + + testWidgets('restores the collapsed editor before requesting focus', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + VoidCallback? restoreFocus; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + focusNode: focusNode, + onFocusRestorerChanged: (callback) => restoreFocus = callback, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await tester.tap(find.text('Message\u2026')); + await tester.pump(); + await tester.pump(); + focusNode.unfocus(); + await tester.pump(); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + + restoreFocus!(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('keeps hook order when the parent focus node changes', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + focusNode: focusNode, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('invalidates a registered focus restorer on unmount', ( + tester, + ) async { + final callbacks = []; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onFocusRestorerChanged: callbacks.add, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + final registeredRestorer = callbacks.single; + + await tester.pumpWidget(const SizedBox.shrink()); + + registeredRestorer(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); + + testWidgets('does not let an old restorer mutate a replacement composer', ( + tester, + ) async { + final callbacks = []; + await tester.pumpWidget( + _buildComposeBar( + composeBarKey: 'first-compose-bar', + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onFocusRestorerChanged: callbacks.add, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + final oldRestorer = callbacks.single; + await tester.pumpWidget( + _buildComposeBar( + composeBarKey: 'second-compose-bar', + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onFocusRestorerChanged: callbacks.add, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + expect(callbacks, hasLength(2)); + + oldRestorer(); + await tester.pump(); + await tester.pump(); + expect(find.byType(TextField), findsNothing); + + callbacks.last(); + await tester.pump(); + await tester.pump(); + expect(find.byType(TextField), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('starts Android composer motion with the first IME metrics', ( tester, ) async { diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index 842a97211b5..a8c44e34923 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -1,10 +1,15 @@ +import 'dart:ui' as ui; + +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/message_actions.dart'; +import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart'; import 'package:buzz/features/channels/timeline_message.dart'; import 'package:buzz/shared/reminders/reminder_service.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -180,8 +185,928 @@ Future _pumpImageSheet( await tester.pumpAndSettle(); } +Future _testMessageSnapshot() async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.drawRect( + const Rect.fromLTWH(0, 0, 300, 72), + ui.Paint()..color = const Color(0xffeeeeee), + ); + return recorder.endRecording().toImage(300, 72); +} + +class _MessageActionsPopoverHarness { + final ProviderContainer container; + final ValueNotifier sourceHidden; + + const _MessageActionsPopoverHarness({ + required this.container, + required this.sourceHidden, + }); +} + +Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover( + WidgetTester tester, { + required TimelineMessage message, + required SharedPreferences prefs, + ReadStateNotifier Function()? readStateOverride, + bool canManageMessage = false, + List? allMessages, + ReminderService? reminderService, + bool disableAnimations = false, + EdgeInsets viewInsets = EdgeInsets.zero, + TextScaler textScaler = TextScaler.noScaling, + Future Function()? captureAnchorSnapshot, + FocusNode? composerFocusNode, + bool composerInitiallyFocused = false, + bool launcherOnNestedRoute = false, + ChannelActions Function(Ref ref)? createChannelActions, + Rect anchorRect = const Rect.fromLTWH(32, 260, 300, 72), +}) async { + final sourceHidden = ValueNotifier(false); + + Widget launcherPage() => Scaffold( + key: const ValueKey('message-actions-underlying-page'), + body: Consumer( + builder: (context, ref, _) => Column( + children: [ + if (composerFocusNode != null) + TextField(focusNode: composerFocusNode), + TextButton( + key: const ValueKey('open-message-actions-popover'), + onPressed: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: _channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: 'self', + isMember: true, + anchorRect: anchorRect, + captureAnchorSnapshot: + captureAnchorSnapshot ?? _testMessageSnapshot, + onPopoverPreviewVisibilityChanged: (visible) => + sourceHidden.value = visible, + onPopoverDismissed: () => sourceHidden.value = false, + composerFocusNode: composerFocusNode, + restoreComposerFocus: composerFocusNode?.requestFocus, + ), + child: const Text('open message actions'), + ), + ], + ), + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + readStateProvider.overrideWith( + readStateOverride ?? + () => _FakeReadStateNotifier( + _readState(const {_channelId: 100000}), + ), + ), + reminderServiceProvider.overrideWithValue(reminderService), + if (createChannelActions != null) + channelActionsProvider.overrideWith(createChannelActions), + ], + child: MaterialApp( + theme: AppTheme.light(), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + disableAnimations: disableAnimations, + viewInsets: viewInsets, + textScaler: textScaler, + ), + child: child!, + ), + home: launcherOnNestedRoute + ? Builder( + builder: (context) => Scaffold( + key: const ValueKey('message-actions-root-page'), + body: TextButton( + key: const ValueKey('push-message-actions-launcher'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => launcherPage()), + ), + child: const Text('push launcher'), + ), + ), + ) + : launcherPage(), + ), + ), + ); + if (launcherOnNestedRoute) { + await tester.tap( + find.byKey(const ValueKey('push-message-actions-launcher')), + ); + await tester.pumpAndSettle(); + } + if (composerInitiallyFocused) { + composerFocusNode!.requestFocus(); + await tester.pump(); + } + await tester.tap(find.byKey(const ValueKey('open-message-actions-popover'))); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byKey(const ValueKey('open-message-actions-popover'))), + ); + return _MessageActionsPopoverHarness( + container: container, + sourceHidden: sourceHidden, + ); +} + +Future _dismissMessageActionsPopover(WidgetTester tester) async { + Navigator.of( + tester.element(find.byKey(const ValueKey('message-action-surface'))), + ).pop(); + await tester.pumpAndSettle(); +} + +class _FakeChannelActions extends ChannelActions { + final reactions = <({String eventId, String emoji})>[]; + + _FakeChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: 'self', + ); + + @override + Future addReaction(String eventId, String emoji) async { + reactions.add((eventId: eventId, emoji: emoji)); + } +} + void main() { + testWidgets( + 'message long press keeps taps and scrolling while repeated holds win', + (tester) async { + var parentTaps = 0; + var nestedTaps = 0; + var longPresses = 0; + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + controller: scrollController, + child: Column( + children: [ + Material( + child: MessageLongPressInkWell( + key: const ValueKey('parent-gesture-target'), + onTap: () => parentTaps += 1, + onLongPress: (_) => longPresses += 1, + child: const SizedBox(height: 80, width: 300), + ), + ), + Material( + child: MessageLongPressInkWell( + onLongPress: (_) => longPresses += 1, + child: GestureDetector( + key: const ValueKey('nested-gesture-target'), + behavior: HitTestBehavior.opaque, + onTap: () => nestedTaps += 1, + child: const SizedBox(height: 80, width: 300), + ), + ), + ), + const SizedBox(height: 900), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('parent-gesture-target'))); + await tester.tap(find.byKey(const ValueKey('nested-gesture-target'))); + await tester.pump(); + expect(parentTaps, 1); + expect(nestedTaps, 1); + + for (var index = 0; index < 5; index++) { + await tester.longPress( + find.byKey(const ValueKey('nested-gesture-target')), + ); + await tester.pump(); + expect(longPresses, index + 1); + expect(nestedTaps, 1); + } + + final drag = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('parent-gesture-target'))), + ); + await drag.moveBy(const Offset(0, -30)); + await tester.pump(); + await drag.moveBy(const Offset(0, -70)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + await drag.up(); + await tester.pumpAndSettle(); + + expect(longPresses, 5); + expect(scrollController.offset, greaterThan(0)); + }, + ); + + testWidgets('iOS message long press recognizes at 200 ms', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + var longPresses = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material( + child: MessageLongPressInkWell( + key: const ValueKey('ios-long-press-target'), + onLongPress: (_) => longPresses += 1, + child: const SizedBox(width: 240, height: 80), + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('ios-long-press-target'))), + ); + await tester.pump(const Duration(milliseconds: 199)); + expect(longPresses, 0); + await tester.pump(const Duration(milliseconds: 2)); + expect(longPresses, 1); + await gesture.up(); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets( + 'message long press captures the content inside the ink surface', + (tester) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + MessageLongPressDetails? longPressDetails; + ui.Image? snapshot; + addTearDown(() => snapshot?.dispose()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material( + child: MessageLongPressInkWell( + key: const ValueKey('snapshot-gesture-target'), + onLongPressDetails: (details) => longPressDetails = details, + child: const SizedBox(width: 240, height: 80), + ), + ), + ), + ), + ); + + await tester.longPress( + find.byKey(const ValueKey('snapshot-gesture-target')), + ); + expect(longPressDetails, isNotNull); + + final capture = longPressDetails!.captureSnapshot(); + await tester.pump(); + snapshot = await capture; + + expect(snapshot.width, 240); + expect(snapshot.height, 80); + }, + ); + + testWidgets('message snapshot bounds very tall raster dimensions', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 5000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + MessageLongPressDetails? longPressDetails; + ui.Image? snapshot; + addTearDown(() => snapshot?.dispose()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material( + child: MessageLongPressInkWell( + key: const ValueKey('tall-snapshot-gesture-target'), + onLongPressDetails: (details) => longPressDetails = details, + child: const SizedBox(width: 240, height: 4096), + ), + ), + ), + ), + ); + + await tester.longPress( + find.byKey(const ValueKey('tall-snapshot-gesture-target')), + ); + expect(longPressDetails, isNotNull); + + final capture = longPressDetails!.captureSnapshot(); + await tester.pump(); + snapshot = await capture; + + expect(snapshot.width, 120); + expect(snapshot.height, 2048); + }); + + testWidgets('message snapshot can exclude attached reaction content', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + final snapshotKey = GlobalKey(); + MessageLongPressDetails? longPressDetails; + ui.Image? snapshot; + addTearDown(() => snapshot?.dispose()); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Material( + child: MessageLongPressInkWell( + key: const ValueKey('separate-snapshot-gesture-target'), + snapshotKey: snapshotKey, + onLongPressDetails: (details) => longPressDetails = details, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RepaintBoundary( + key: snapshotKey, + child: const SizedBox(width: 240, height: 80), + ), + Listener( + key: const ValueKey('attached-reactions'), + behavior: HitTestBehavior.opaque, + child: const SizedBox(width: 240, height: 32), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.longPress(find.byKey(const ValueKey('attached-reactions'))); + expect(longPressDetails, isNotNull); + expect(longPressDetails!.anchorRect.height, 80); + + final capture = longPressDetails!.captureSnapshot(); + await tester.pump(); + snapshot = await capture; + + expect(snapshot.width, 240); + expect(snapshot.height, 80); + }); + group('showMessageActions', () { + testWidgets('composes the tray, lifted preview, and compact actions', ( + tester, + ) async { + final prefs = await _mockPrefs(); + final harness = await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), + ); + + expect(harness.sourceHidden.value, isTrue); + expect( + find.byKey(const ValueKey('message-action-reaction-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-surface')), + findsOneWidget, + ); + expect(find.byType(BottomSheet), findsNothing); + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Copy link'), findsOneWidget); + expect(find.text('Remind me'), findsOneWidget); + expect(find.text('Follow thread'), findsOneWidget); + + final trayRect = tester.getRect( + find.byKey(const ValueKey('message-action-reaction-tray')), + ); + final previewRect = tester.getRect( + find.byKey(const ValueKey('message-action-preview')), + ); + final actionRect = tester.getRect( + find.byKey(const ValueKey('message-action-surface')), + ); + final trayMaterial = tester.widget( + find.byKey(const ValueKey('message-action-reaction-tray')), + ); + final actionMaterial = tester.widget( + find.byKey(const ValueKey('message-action-surface')), + ); + expect(previewRect.top, greaterThan(trayRect.bottom)); + expect(actionRect.top, greaterThan(previewRect.bottom)); + expect(previewRect.left, trayRect.left); + expect(actionRect.left, trayRect.left); + expect(actionRect.width, 288); + expect(actionMaterial.color, trayMaterial.color); + + await _dismissMessageActionsPopover(tester); + expect(harness.sourceHidden.value, isFalse); + }); + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets( + '${platform.name} composition keeps the action menu near the safe bottom', + (tester) async { + debugDefaultTargetPlatformOverride = platform; + try { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), + ); + + final actionRect = tester.getRect( + find.byKey(const ValueKey('message-action-surface')), + ); + final logicalHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect(actionRect.bottom, closeTo(logicalHeight - Grid.xxs, 0.1)); + + await _dismissMessageActionsPopover(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + } + + testWidgets('keeps the action menu above the software keyboard', ( + tester, + ) async { + const keyboardInset = 300.0; + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), + viewInsets: const EdgeInsets.only(bottom: keyboardInset), + ); + + final actionRect = tester.getRect( + find.byKey(const ValueKey('message-action-surface')), + ); + final logicalHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + expect( + actionRect.bottom, + closeTo(logicalHeight - keyboardInset - Grid.xxs, 0.1), + ); + + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('collapses fixed sections in a short keyboard viewport', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(800, 220); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + const keyboardInset = 100.0; + final prefs = await _mockPrefs(); + final harness = await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), + viewInsets: const EdgeInsets.only(bottom: keyboardInset), + ); + + expect( + find.byKey(const ValueKey('message-action-reaction-tray')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('message-action-preview')), + findsNothing, + ); + expect(harness.sourceHidden.value, isFalse); + final actionRect = tester.getRect( + find.byKey(const ValueKey('message-action-surface')), + ); + expect(actionRect.top, greaterThanOrEqualTo(Grid.xxs)); + expect( + actionRect.bottom, + lessThanOrEqualTo(220 - keyboardInset - Grid.xxs), + ); + expect(tester.takeException(), isNull); + + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('keeps tall message previews within the visible viewport', ( + tester, + ) async { + const keyboardInset = 300.0; + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(pubkey: 'self'), + prefs: prefs, + canManageMessage: true, + allMessages: [_message(pubkey: 'self')], + reminderService: _stubReminderService(), + viewInsets: const EdgeInsets.only(bottom: keyboardInset), + anchorRect: const Rect.fromLTWH(32, 40, 300, 2000), + ); + + final logicalHeight = + tester.view.physicalSize.height / tester.view.devicePixelRatio; + final visibleBottom = logicalHeight - keyboardInset - Grid.xxs; + expect( + tester + .getRect(find.byKey(const ValueKey('message-action-preview'))) + .top, + greaterThanOrEqualTo(Grid.xxs), + ); + expect( + tester + .getRect(find.byKey(const ValueKey('message-action-surface'))) + .bottom, + lessThanOrEqualTo(visibleBottom), + ); + + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('restores composer focus only after a dismissed popover', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final prefs = await _mockPrefs(); + + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + composerFocusNode: focusNode, + composerInitiallyFocused: true, + ); + + expect(focusNode.hasFocus, isFalse); + await _dismissMessageActionsPopover(tester); + expect(focusNode.hasFocus, isTrue); + }); + + testWidgets('leaves an initially unfocused composer unfocused', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final prefs = await _mockPrefs(); + + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + composerFocusNode: focusNode, + ); + + expect(focusNode.hasFocus, isFalse); + await _dismissMessageActionsPopover(tester); + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('does not restore composer focus after opening reactions', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final prefs = await _mockPrefs(); + + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + composerFocusNode: focusNode, + composerInitiallyFocused: true, + ); + + await tester.tap(find.byKey(const ValueKey('quick-reaction-more'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('does not restore composer focus after selecting an action', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final prefs = await _mockPrefs(); + + await _pumpMessageActionsPopover( + tester, + message: _message(rootId: 'root-9'), + prefs: prefs, + composerFocusNode: focusNode, + composerInitiallyFocused: true, + ); + + await tester.tap( + find.byKey(const ValueKey('message-action-followThread')), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isFalse); + }); + + testWidgets('runs an action after dismissal and can reopen', ( + tester, + ) async { + final prefs = await _mockPrefs(); + final harness = await _pumpMessageActionsPopover( + tester, + message: _message(rootId: 'root-9'), + prefs: prefs, + ); + + await tester.tap( + find.byKey(const ValueKey('message-action-followThread')), + ); + await tester.pumpAndSettle(); + + expect(harness.sourceHidden.value, isFalse); + expect(harness.container.read(threadFollowsProvider).followedRootIds, { + 'root-9', + }); + + await tester.tap( + find.byKey(const ValueKey('open-message-actions-popover')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('message-action-surface')), + findsOneWidget, + ); + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('ignores repeat action taps once dismissal starts', ( + tester, + ) async { + final prefs = await _mockPrefs(); + final harness = await _pumpMessageActionsPopover( + tester, + message: _message(rootId: 'root-9'), + prefs: prefs, + launcherOnNestedRoute: true, + ); + final action = find.byKey(const ValueKey('message-action-followThread')); + final actionWidget = tester.widget(action); + + actionWidget.onTap!.call(); + actionWidget.onTap!.call(); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-actions-underlying-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-actions-root-page')), + findsNothing, + ); + expect(harness.container.read(threadFollowsProvider).followedRootIds, { + 'root-9', + }); + expect(tester.takeException(), isNull); + }); + + testWidgets('ignores repeat backdrop taps once dismissal starts', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + launcherOnNestedRoute: true, + ); + final backdrop = tester.widget( + find.byKey(const ValueKey('message-actions-backdrop')), + ); + + backdrop.onTap!.call(); + backdrop.onTap!.call(); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-actions-underlying-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-actions-root-page')), + findsNothing, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('ignores repeat quick reactions once dismissal starts', ( + tester, + ) async { + final prefs = await _mockPrefs(); + late _FakeChannelActions actions; + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + launcherOnNestedRoute: true, + createChannelActions: (ref) => actions = _FakeChannelActions(ref), + ); + final reaction = find.byKey(const ValueKey('quick-reaction-\u{1F44D}')); + final detector = tester.widget( + find.descendant(of: reaction, matching: find.byType(GestureDetector)), + ); + + detector.onTap!.call(); + detector.onTap!.call(); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('message-actions-underlying-page')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-actions-root-page')), + findsNothing, + ); + expect(actions.reactions, [(eventId: 'msg-1', emoji: '\u{1F44D}')]); + expect(tester.takeException(), isNull); + }); + + testWidgets('fallback action rows grow with accessibility text', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(rootId: 'root-9'), + prefs: prefs, + textScaler: const TextScaler.linear(3), + ); + + final rowFinder = find.byKey( + const ValueKey('message-action-followThread'), + ); + expect(tester.getSize(rowFinder).height, greaterThan(48)); + expect(tester.takeException(), isNull); + + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('orders primary, utility, and destructive action groups', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + canManageMessage: true, + allMessages: [_message()], + reminderService: _stubReminderService(), + ); + + const actionIds = [ + 'reply', + 'markUnread', + 'edit', + 'copyText', + 'copyLink', + 'remind', + 'followThread', + 'delete', + ]; + final actionTops = [ + for (final actionId in actionIds) + tester + .getTopLeft(find.byKey(ValueKey('message-action-$actionId'))) + .dy, + ]; + expect(actionTops, orderedEquals([...actionTops]..sort())); + expect( + find.byKey(const ValueKey('message-action-divider-utility')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-divider-destructive')), + findsOneWidget, + ); + + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('reduced motion presents the complete surface immediately', ( + tester, + ) async { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + disableAnimations: true, + ); + + expect( + find.byKey(const ValueKey('message-action-reaction-tray')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-preview')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-action-surface')), + findsOneWidget, + ); + await _dismissMessageActionsPopover(tester); + }); + + testWidgets('keeps the snapshot alive through the reverse transition', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + ui.Image? snapshot; + try { + final prefs = await _mockPrefs(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: prefs, + captureAnchorSnapshot: () async { + snapshot = await _testMessageSnapshot(); + return snapshot!; + }, + ); + + final image = snapshot!; + expect(image.debugDisposed, isFalse); + Navigator.of( + tester.element(find.byKey(const ValueKey('message-action-surface'))), + ).pop(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('message-action-preview')), + findsOneWidget, + ); + expect(image.debugDisposed, isFalse); + await tester.pump(const Duration(milliseconds: 110)); + expect(image.debugDisposed, isFalse); + + await tester.pumpAndSettle(); + expect(image.debugDisposed, isTrue); + expect(tester.takeException(), isNull); + } finally { + final image = snapshot; + if (image != null && !image.debugDisposed) image.dispose(); + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets('shows parity actions for a regular message', (tester) async { final prefs = await _mockPrefs(); await _pumpSheet(tester, message: _message(), prefs: prefs); From 6ea7a2b2211438359b227a9991cf8ccad2927fe2 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:09:58 -0400 Subject: [PATCH 19/27] fix(desktop): preserve early relay auth challenges (#3320) ## Summary - buffer native WebSocket frames until `plugin:websocket|connect` returns the socket ID and the NIP-42 auth waiter is installed - drain those frames in order before normal inbound handling begins - add a deterministic E2E seam and regression for an AUTH challenge delivered before connect resolves ## Root cause The native WebSocket task starts forwarding relay frames before the connect command returns its socket ID. Buzz relay sends the NIP-42 AUTH challenge immediately on connection, so Desktop could process that challenge while `wsId` and `authRequest` were still unset. The challenge was discarded, the cold connection failed authentication, and the first plain-text send failed. Channel history still appeared because it loads through the Tauri channel-window command rather than this frontend WebSocket. ## Verification - deterministic regression is red before the fix (`connecting` after 5s) and green after it - `pnpm test`: 3,721 passed - `pnpm build:e2e`: passed - focused early-AUTH E2E: passed - existing failed-initial-dial E2E: passed - `pnpm typecheck`: passed - Biome + file-size pre-commit checks: passed The complete 7-test relay-reconnect file passed once before the final line-count-only compaction. After that compaction, two full-file reruns each had the same unrelated startup-seam flake in the existing initial-dial test; both the new test and that existing test pass independently at the committed HEAD. --------- Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- desktop/src/shared/api/relayAuthPolicy.ts | 22 ++++++++ desktop/src/shared/api/relayClientSession.ts | 54 +++++++++---------- .../shared/api/relayInboundBuffer.test.mjs | 46 ++++++++++++++++ desktop/src/shared/api/relayInboundBuffer.ts | 36 +++++++++++++ desktop/src/testing/e2eBridge.ts | 22 +++++++- desktop/tests/e2e/relay-reconnect.spec.ts | 51 ++++++++++++++++-- desktop/tests/helpers/bridge.ts | 4 ++ 7 files changed, 202 insertions(+), 33 deletions(-) create mode 100644 desktop/src/shared/api/relayInboundBuffer.test.mjs create mode 100644 desktop/src/shared/api/relayInboundBuffer.ts diff --git a/desktop/src/shared/api/relayAuthPolicy.ts b/desktop/src/shared/api/relayAuthPolicy.ts index 2d76e0c3f7d..6e100fe7b46 100644 --- a/desktop/src/shared/api/relayAuthPolicy.ts +++ b/desktop/src/shared/api/relayAuthPolicy.ts @@ -26,6 +26,28 @@ export type AuthOkDecision = "authenticated" | "retry" | "terminal"; export const MAX_CONSECUTIVE_AUTH_REJECTIONS = 3; +export type RelayAuthRequest = { + pendingEventId: string; + resolve: () => void; + reject: (error: Error) => void; + timeout: number; +}; + +export function armRelayAuthentication( + timeoutMs: number, + setRequest: (request: RelayAuthRequest) => void, + onTimeout: (error: Error) => void, +): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + const error = new Error("Relay authentication timed out."); + onTimeout(error); + reject(error); + }, timeoutMs); + setRequest({ pendingEventId: "", resolve, reject, timeout }); + }); +} + /** Tracks consecutive AUTH rejections across reconnect attempts. */ export class AuthOkTracker { private consecutiveRejections = 0; diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 29b24f21d1f..5e7f5c06e30 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -67,7 +67,12 @@ import { STALL_IDLE_TIMEOUT_MS, } from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; -import { AuthOkTracker } from "@/shared/api/relayAuthPolicy"; +import { + armRelayAuthentication, + AuthOkTracker, + type RelayAuthRequest, +} from "@/shared/api/relayAuthPolicy"; +import { createRelayInboundBuffer } from "@/shared/api/relayInboundBuffer"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; export class RelayClient { @@ -78,12 +83,7 @@ export class RelayClient { private reconnectWaiters = new RelayReconnectWaiters(); private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; private keepAliveRequested = false; - private authRequest: { - pendingEventId: string; - resolve: () => void; - reject: (error: Error) => void; - timeout: number; - } | null = null; + private authRequest: RelayAuthRequest | null = null; private subscriptions = new Map(); private pendingEvents = new Map(); private eventBuffer: Array<{ subId: string; event: RelayEvent }> = []; @@ -96,7 +96,6 @@ export class RelayClient { private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; private authOkTracker = new AuthOkTracker(); - private terminal = false; private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); @@ -530,17 +529,19 @@ export class RelayClient { this.connectionStateEmitter.set( this.hasConnectedOnce ? "reconnecting" : "connecting", ); - const generation = ++this.connectionGeneration; - this.onMessageChannel = new Channel((message) => { - void this.handleWsMessage(message, generation).catch((error) => { + const inbound = createRelayInboundBuffer( + (message) => this.handleWsMessage(message, generation), + (error) => { if (generation !== this.connectionGeneration) return; this.resetConnection( this.normalizeRelayError(error, "Relay connection errored."), ); - }); - }); - + }, + ); + this.onMessageChannel = new Channel((message) => + inbound.receive(message), + ); try { if (!this.relayUrl) { this.relayUrl = await getRelayWsUrl(); @@ -556,22 +557,21 @@ export class RelayClient { } this.wsId = wsId; - await new Promise((resolve, reject) => { - const timeout = window.setTimeout(() => { - const error = new Error("Relay authentication timed out."); + const authentication = armRelayAuthentication( + AUTH_TIMEOUT_MS, + (request) => { + this.authRequest = request; + }, + (error) => { this.authRequest = null; this.resetConnection(error); - reject(error); - }, AUTH_TIMEOUT_MS); - - this.authRequest = { - pendingEventId: "", - resolve, - reject, - timeout, - }; - }); + }, + ); + const drain = inbound.drain(); + await Promise.race([drain, authentication, inbound.overflow]); + await drain; + await authentication; this.stabilityTimer = window.setTimeout(() => { this.stabilityTimer = null; this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; diff --git a/desktop/src/shared/api/relayInboundBuffer.test.mjs b/desktop/src/shared/api/relayInboundBuffer.test.mjs new file mode 100644 index 00000000000..9add519d730 --- /dev/null +++ b/desktop/src/shared/api/relayInboundBuffer.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createRelayInboundBuffer, + MAX_PENDING_RELAY_FRAMES, +} from "./relayInboundBuffer.ts"; + +test("drains queued frames in order, including frames received during drain", async () => { + const handled = []; + let releaseFirst; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + const inbound = createRelayInboundBuffer(async (message) => { + handled.push(message); + if (message === "first") await firstBlocked; + }, assert.fail); + + inbound.receive("first"); + const drain = inbound.drain(); + inbound.receive("second"); + releaseFirst(); + await drain; + inbound.receive("live"); + await new Promise((resolve) => setTimeout(resolve)); + + assert.deepEqual(handled, ["first", "second", "live"]); +}); + +test("rejects, resets, and stops accepting frames when the cap is exceeded", async () => { + let overflowError; + const inbound = createRelayInboundBuffer( + async () => {}, + (error) => { + overflowError = error; + }, + ); + for (let i = 0; i < MAX_PENDING_RELAY_FRAMES + 1; i++) inbound.receive(i); + + await assert.rejects( + inbound.overflow, + /Relay sent too many frames while connecting/, + ); + assert.match(overflowError.message, /too many frames/); +}); diff --git a/desktop/src/shared/api/relayInboundBuffer.ts b/desktop/src/shared/api/relayInboundBuffer.ts new file mode 100644 index 00000000000..e4291e5ecca --- /dev/null +++ b/desktop/src/shared/api/relayInboundBuffer.ts @@ -0,0 +1,36 @@ +export const MAX_PENDING_RELAY_FRAMES = 256; + +export function createRelayInboundBuffer( + handle: (message: unknown) => Promise, + onError: (error: unknown) => void, +) { + let pending: unknown[] | null | undefined = []; + let rejectOverflow = (_error: Error) => {}; + const overflow = new Promise((_resolve, reject) => { + rejectOverflow = reject; + }); + void overflow.catch(() => {}); + + return { + overflow, + receive(message: unknown) { + if (pending === null) { + void handle(message).catch(onError); + return; + } + if (pending === undefined) return; + if (pending.length >= MAX_PENDING_RELAY_FRAMES) { + pending = undefined; + const error = new Error("Relay sent too many frames while connecting."); + rejectOverflow(error); + onError(error); + return; + } + pending.push(message); + }, + async drain() { + while (pending?.length) await handle(pending.shift()); + if (pending) pending = null; + }, + }; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 1aa98ca4a7f..8da233dfcef 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -411,6 +411,10 @@ type E2eConfig = { nostrBindSignDelayMs?: number; /** Reject successive mock WebSocket connect attempts, then resume. */ websocketConnectErrors?: string[]; + /** Deliver AUTH synchronously, before the mock connect command resolves. */ + websocketAuthBeforeConnectResolves?: boolean; + /** Stall the first AUTH signing command forever; later attempts complete. */ + stallFirstAuthSigning?: boolean; stallWebsocketSends?: boolean; userSearchDelayMs?: number; // NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for @@ -3062,6 +3066,7 @@ const mockAuthResponses: Array<{ success: boolean; message: string }> = []; const mockChannelHistoryCloses: string[] = []; let mockWebsocketUnavailable = false; const relayWebsocketConnectAttemptStarts: number[] = []; +let mockAuthSigningAttempts = 0; let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); @@ -9757,9 +9762,14 @@ async function connectMockSocket(args: { onMessage: unknown }) { subscriptions: new Map(), }); - window.setTimeout(() => { + if (getConfig()?.mock?.websocketAuthBeforeConnectResolves) { sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]); - }, 0); + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } else { + window.setTimeout(() => { + sendWsText(handler, ["AUTH", `mock-challenge-${wsId}`]); + }, 0); + } return wsId; } @@ -10248,6 +10258,7 @@ export function maybeInstallE2eTauriMocks() { mockAuthResponses.length = 0; mockChannelHistoryCloses.length = 0; relayWebsocketConnectAttemptStarts.length = 0; + mockAuthSigningAttempts = 0; deferredSendMessageLiveEchoes.length = 0; deferredLinkPreviewMetadataQueue = []; deferredLinkPreviewUploadQueue = []; @@ -13131,6 +13142,13 @@ export function maybeInstallE2eTauriMocks() { case "nip44_decrypt_from_self": return (payload as { ciphertext: string }).ciphertext; case "create_auth_event": + mockAuthSigningAttempts++; + if ( + getConfig()?.mock?.stallFirstAuthSigning && + mockAuthSigningAttempts === 1 + ) { + return new Promise(() => {}); + } if (identity) { return JSON.stringify( await signWithIdentity(identity, { diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 74f819cc51a..ef5812c9e87 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -80,10 +80,7 @@ async function getMockWebsocketConnectAttempts( ) { return page.evaluate(() => { const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__; - if (!getAttempts) { - throw new Error("E2E websocket attempt seam is not installed."); - } - return getAttempts(); + return getAttempts?.() ?? []; }); } @@ -168,6 +165,52 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); +test("stalled early AUTH signing times out and starts a replacement dial", async ({ + page, +}) => { + test.setTimeout(40_000); + await installMockBridge(page, { + websocketAuthBeforeConnectResolves: true, + stallFirstAuthSigning: true, + }); + await page.goto("/"); + + await expect + .poll(() => getMockWebsocketConnectAttempts(page), { timeout: 32_000 }) + .toHaveLength(2); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 5_000 }, + ) + .toBe("connected"); +}); + +test("AUTH arriving before connect resolves does not lose the first send", async ({ + page, +}) => { + await installMockBridge(page, { + websocketAuthBeforeConnectResolves: true, + }); + await page.goto("/"); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 5_000 }, + ) + .toBe("connected"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const message = `first send after early auth ${Date.now()}`; + await page.getByTestId("message-input").fill(message); + await page.getByTestId("send-message").click(); + + await expect(page.getByTestId("message-timeline")).toContainText(message); +}); + test("failed initial relay dial retries automatically", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["mock relay pod unavailable"], diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 815c362fc10..dbe8c43323b 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -354,6 +354,10 @@ type MockBridgeOptions = { nostrBindSignDelayMs?: number; /** Reject successive mock WebSocket connect attempts, then resume. */ websocketConnectErrors?: string[]; + /** Deliver AUTH synchronously, before the mock connect command resolves. */ + websocketAuthBeforeConnectResolves?: boolean; + /** Stall the first AUTH signing command forever; later attempts complete. */ + stallFirstAuthSigning?: boolean; stallWebsocketSends?: boolean; userSearchDelayMs?: number; // NIP-IA gate inputs — drive the archive-button gate matrix in From e2ade93f02f6d1b4db23e0c442a2c65608e54d36 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 18 Aug 2026 18:18:48 -0400 Subject: [PATCH 20/27] fix(desktop): downscale large avatars for agent-share PNG body (#6260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing an agent encodes a `.agent.png` snapshot with the avatar as the PNG image body. Non-PNG avatars were transcoded at full resolution and already-PNG avatars were carried through unchanged, so a large source avatar could produce a PNG that exceeds the 10 MiB `MAX_SNAPSHOT_PNG_BYTES` cap and fail the send. A 2764×4096 webp avatar encoded to ~26 MB — over 2.5× the ceiling — which is why sharing that agent hung for a few seconds (fetching and transcoding the image) and then failed. The share dialog compounded the problem: on failure it discarded the pipeline's real error (`Snapshot exceeds the 10 MiB size limit…`) and toasted a generic `Couldn't send … Try again.`, hiding the actual cause. ## Changes - **Downscale the avatar for the snapshot PNG body.** The body is only a card thumbnail, so raster avatars are now downscaled to a 512px longest edge (`MAX_PNG_BODY_EDGE`) before PNG re-encoding, mirroring the frontend SVG rasterizer's 512×512 cap in `snapshotAvatarPng.ts`. Already-PNG avatars over the dimension cap route through the same downscaling transcode path instead of a straight tEXt-chunk injection. The manifest's `avatar_url`/`avatar_data_url` source reference is untouched — only the PNG image body is downscaled. - **Surface the real error in the share dialog.** `PersonaShareDialog` (used by `SnapshotShareDialog`/`TeamShareDialog`) and `AgentCardViewerDialog` now toast the send controller's actual error message, falling back to the generic string only when it is empty. A `getCurrentError()` accessor reads the error through a ref because the render-captured `state.error` is stale in the closure immediately after `beginSend` resolves. A unit test pins the invariant: an oversize (2764×4096) avatar produces a snapshot that stays far under the cap with body dimensions clamped to 512px. --------- Signed-off-by: Will Pfleger Signed-off-by: Duncan Co-authored-by: Duncan --- .../src/managed_agents/agent_snapshot.rs | 41 +++++++++++++- .../managed_agents/agent_snapshot_tests.rs | 55 ++++++++++++++++++- .../agents/ui/AgentCardViewerDialog.tsx | 5 +- .../features/agents/ui/PersonaShareDialog.tsx | 5 +- .../agents/ui/useSnapshotSendController.ts | 24 ++++++-- desktop/tests/e2e/agents.spec.ts | 10 +++- 6 files changed, 130 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 5b51c522551..4b734ce1591 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -56,6 +56,13 @@ pub const PNG_CHUNK_KEYWORD: &str = "buzz_agent_snapshot"; /// this are stored as a URL reference instead. const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; // 2 MB +/// Maximum edge (px) for the PNG image body. The body is only a card +/// thumbnail — the manifest keeps the full-resolution source reference — so a +/// large avatar is downscaled here to keep the encoded snapshot well under +/// `MAX_SNAPSHOT_PNG_BYTES`. Mirrors the frontend SVG rasterizer's 512×512 cap +/// in `snapshotAvatarPng.ts`. +const MAX_PNG_BODY_EDGE: u32 = 512; + /// Format discriminator — used for sniffing and validation. pub const FORMAT_DISCRIMINATOR: &str = "buzz-agent-snapshot"; @@ -328,7 +335,7 @@ pub(crate) fn encode_chunk_payload_png( // there is no avatar or it cannot be decoded. let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { Some(bytes) => { - let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") && png_within_body_cap(bytes) { inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) }) @@ -449,6 +456,11 @@ pub(crate) fn make_png_with_text(keyword: &str, text: &str) -> Result, S } /// Transcode a decodable avatar to PNG and add the snapshot manifest chunk. +/// +/// The decoded image is downscaled so its longest edge is at most +/// `MAX_PNG_BODY_EDGE` before PNG re-encoding. The body is only a card +/// thumbnail — this keeps a large source avatar (e.g. a 4K webp) from +/// producing a PNG that blows `MAX_SNAPSHOT_PNG_BYTES`. fn transcode_avatar_to_png_with_text( avatar_bytes: &[u8], keyword: &str, @@ -456,6 +468,7 @@ fn transcode_avatar_to_png_with_text( ) -> Result, String> { let image = image::load_from_memory(avatar_bytes) .map_err(|e| format!("Failed to decode avatar image: {e}"))?; + let image = downscale_to_body_cap(image); let mut png_bytes = Vec::new(); image .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) @@ -463,6 +476,32 @@ fn transcode_avatar_to_png_with_text( inject_text_chunk(&png_bytes, keyword, text) } +/// Downscale so the longest edge is at most `MAX_PNG_BODY_EDGE`, preserving +/// aspect ratio. Images already within the cap are returned untouched. +fn downscale_to_body_cap(image: image::DynamicImage) -> image::DynamicImage { + if image.width() <= MAX_PNG_BODY_EDGE && image.height() <= MAX_PNG_BODY_EDGE { + return image; + } + image.resize( + MAX_PNG_BODY_EDGE, + MAX_PNG_BODY_EDGE, + image::imageops::FilterType::Lanczos3, + ) +} + +/// Whether an already-PNG avatar is within the body dimension cap and can be +/// carried as-is (via a cheap tEXt-chunk injection) instead of being decoded +/// and downscaled. Undecodable headers fall through to the transcode path. +fn png_within_body_cap(png_bytes: &[u8]) -> bool { + Decoder::new(Cursor::new(png_bytes)) + .read_info() + .map(|reader| { + let info = reader.info(); + info.width <= MAX_PNG_BODY_EDGE && info.height <= MAX_PNG_BODY_EDGE + }) + .unwrap_or(false) +} + /// Inject a tEXt chunk into an existing PNG by re-encoding it. /// /// Re-decodes the image data via the `png` crate and writes a fresh PNG with diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index de79df92e84..9f234749bc9 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -234,7 +234,60 @@ fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { assert_eq!((reader.info().width, reader.info().height), (3, 2)); } -// ── PNG memory parity ───────────────────────────────────────────────────── +#[test] +fn png_snapshot_downscales_oversize_avatar_under_cap() { + // A large avatar (mirrors Gurney's 2764×4096 image that encoded to ~26 MB) + // must be downscaled for the PNG body so the snapshot stays under the + // 10 MiB cap — while the manifest keeps the untouched source reference. + // An already-PNG oversize avatar exercises the `png_within_body_cap` guard + // that routes it through the downscaling transcode path. + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(2764, 4096, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8]) + })); + let mut source_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut source_bytes), image::ImageFormat::Png) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&source_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap(); + + assert!( + png_bytes.len() + <= super::MAX_PNG_BODY_EDGE as usize * super::MAX_PNG_BODY_EDGE as usize * 4, + "downscaled snapshot ({} bytes) must be far under the 10 MiB cap", + png_bytes.len() + ); + + let reader = Decoder::new(Cursor::new(png_bytes)).read_info().unwrap(); + let (width, height) = (reader.info().width, reader.info().height); + assert!( + width <= 512 && height <= 512, + "body dimensions {width}×{height} must fit the 512px cap" + ); + // Aspect ratio preserved: the longest edge (height) is clamped to the cap. + assert_eq!(height, 512, "longest edge should hit the 512px cap"); + + // The manifest keeps the untouched full-resolution source reference — only + // the PNG body is downscaled. The oversize source bytes exceed the inline + // cap, so the manifest falls back to the record's `avatar_url`. + let manifest = + decode_snapshot_png(&encode_snapshot_png(&snapshot, Some(&source_bytes)).unwrap()).unwrap(); + assert_eq!( + manifest.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png"), + "manifest must preserve the untouched source avatar reference" + ); + assert!( + manifest.profile.avatar_data_url.is_none(), + "oversize source bytes must not be inlined into the manifest" + ); +} #[test] fn png_round_trip_with_core_memory() { diff --git a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx index c2422466e47..b47cee5e3c5 100644 --- a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx @@ -143,7 +143,10 @@ function AgentCardViewerContent({ toast.success(`Sent ${agentName}'s card.`); closeCardViewer(); } else if (sent === false) { - toast.error("Couldn’t send the card. Try again."); + toast.error( + sendController.getCurrentError() ?? + "Couldn’t send the card. Try again.", + ); } } diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index 5cf4f9ea3ba..13eae7971b2 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -391,7 +391,10 @@ export function SnapshotShareDialog({ toast.success(`Sent a copy of ${displayName}`); onOpenChange(false); } else if (sent === false) { - toast.error(`Couldn’t send ${itemLabel}. Try again.`); + toast.error( + snapshotSendController.getCurrentError() ?? + `Couldn’t send ${itemLabel}. Try again.`, + ); } } diff --git a/desktop/src/features/agents/ui/useSnapshotSendController.ts b/desktop/src/features/agents/ui/useSnapshotSendController.ts index e14481b7502..d7e4daca2c0 100644 --- a/desktop/src/features/agents/ui/useSnapshotSendController.ts +++ b/desktop/src/features/agents/ui/useSnapshotSendController.ts @@ -350,6 +350,12 @@ export type UseSnapshotSendControllerResult = { /** Relay moderation identity to exclude from the people picker. */ relaySelfPubkey: string | null; state: SnapshotSendState; + /** + * Read the latest error synchronously — right after `beginSend` resolves the + * render-captured `state.error` is stale until the next commit, so callers + * that toast on failure must read through here. + */ + getCurrentError: () => string | null; /** * Execute destination creation plus prepare → encode → upload → send behind * one concurrency guard. A second call while the first is in flight returns @@ -390,6 +396,15 @@ export function useSnapshotSendController( error: null, }); + // Mirror `state` into a ref so callers can read the latest error + // synchronously right after `beginSend` resolves — the render-captured + // `state` in their closure is stale until the next render commits. + const stateRef = React.useRef(state); + const commitState = React.useCallback((next: SnapshotSendState) => { + stateRef.current = next; + setState(next); + }, []); + // Single-concurrency guard covering the full encode → upload → send action. // Stored in a ref so it survives re-renders without triggering effects. const guardRef = React.useRef(createSendGuard()); @@ -417,7 +432,7 @@ export function useSnapshotSendController( checkEligibilityFn: () => checkSendEligibility(queryClient, channelId), uploadFn: (bytes, filename) => uploadMediaBytes(bytes, filename), sendFn: (args) => sendMutation.mutateAsync(args), - setStateFn: setState, + setStateFn: commitState, buildMessageFn: (descriptor) => { const message = buildOutgoingMessage("", [descriptor]); return attachmentLabel?.trim() @@ -430,15 +445,15 @@ export function useSnapshotSendController( : message; }, }), - setState, + commitState, ); } const reset = React.useCallback(() => { if (!guardRef.current.inFlight) { - setState({ phase: "idle", error: null }); + commitState({ phase: "idle", error: null }); } - }, []); + }, [commitState]); return { isDmSafetyReady: @@ -447,6 +462,7 @@ export function useSnapshotSendController( relaySelfQuery.status === "success"), relaySelfPubkey: relaySelfQuery.data ?? null, state, + getCurrentError: () => stateRef.current.error, beginSend, reset, }; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index e191d293045..346d1e088ea 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2287,7 +2287,9 @@ test("people sharing blocks a timeout before encoding or upload", async ({ }); await page.getByTestId("persona-share-send").click(); - await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible(); + await expect( + page.getByText("You are currently timed out and cannot send messages."), + ).toBeVisible(); const commands = await readAgentShareCommands(page); expect( @@ -2332,7 +2334,11 @@ test("people sharing rechecks destination eligibility after encoding", async ({ return testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); }); - await expect(page.getByText("Couldn’t send agent. Try again.")).toBeVisible({ + await expect( + page.getByText( + "The selected destination is no longer available. Please pick another.", + ), + ).toBeVisible({ timeout: 5_000, }); const commands = await readAgentShareCommands(page); From 6e8d078ffe1ab27b8dde6bb697551b7d2d1a85b5 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Wed, 19 Aug 2026 03:49:05 +0530 Subject: [PATCH 21/27] fix(desktop): emit camelCase config-write payload fields (#6062) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #6015. `ConfigWriteMechanism` is internally tagged and carried only `rename_all = "camelCase"`. On an internally tagged enum that renames the **variants**, never the variants' fields, so the payload went out snake_case: ``` {"type":"respawnWithEnvVar","env_key":"K"} {"type":"acpSetConfigOption","config_id":"c"} {"type":"acpSetSessionModel"} {"type":"gooseNativeConfigWrite","config_key":"g"} {"type":"readOnly"} ``` against `envKey` / `configId` / `configKey` in `desktop/src/shared/api/types.ts:615-620`. That output is a probe run of the real module before the fix, not a reading of the code. What makes it read as correct is the asymmetry: the variant *names* rename fine, so the `type` discriminant and every `switch (writeVia.type)` behave; and the enclosing `NormalizedField`'s own fields (`writeVia`, `overriddenValue`, `isRequired`) rename fine too, because `rename_all` does apply to struct fields. Only the variant's field is wrong. Adding `rename_all_fields = "camelCase"` fixes it. `rename_all_fields` appeared zero times in `desktop/src-tauri` before this. **Severity, stated plainly: latent, not currently user-visible.** Nothing in `desktop/src/**` reads `.envKey`/`.configId`/`.configKey` off a `writeVia` — `AgentConfigPanel.tsx` is the only `RuntimeConfigSurface` consumer and never touches the field, and no Rust code deserializes the type either. The write-back path these fields exist for is not wired yet. The hazard is for whoever wires it: `invokeTauri` is an unchecked cast, so they get `undefined` with a green `tsc`. I also carried the attribute onto `ConfigFieldType`. Its only payload field is `options`, single-word, so that half is not a fix — it is the attribute the next multi-word field would silently need. One divergence from the issue's suggested step 3: the 20 `e2eBridge.ts` sites already emit camelCase, and camelCase is the contract, so they are correct as written — changing them would have been wrong. What they lacked was provenance, since agreeing with `api/types.ts` while the backend emitted something else is exactly what let this sit. They now name the Rust test that pins the bytes. **Tests** (`wire_format_tests`, 4 cases, whole-value not key-set — a key-set assertion still passes when a variant name regresses): - every variant against the TypeScript spelling; - the nested `NormalizedField`, which is the shape the renderer actually receives; - a camelCase round-trip **plus** an assertion that the old `env_key` spelling is now rejected, so a revert cannot quietly keep deserializing; - `ConfigFieldType::Enum`. Removing `rename_all_fields` again turns three of the four red. Verified locally: full Tauri library suite 2444 passed / 15 ignored / 0 failed; `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets -- -D warnings` clean; `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --all -- --check`; in `desktop/`: `pnpm typecheck`, `pnpm check`, `pnpm test` 4954 passed; `git diff --check`. Not run: the app itself — there is no UI path to this field yet, which is the same reason the bug is latent. --------- Signed-off-by: Taksh --- .../src/managed_agents/config_bridge/types.rs | 117 +++++++++++++++++- desktop/src/testing/e2eBridge.ts | 6 + 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 3842825fe8e..d96736fb69c 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -76,8 +76,21 @@ pub enum ConfigOrigin { } /// How a config field can be written back to the runtime. +/// +/// `rename_all_fields` is load-bearing, not decoration: on an internally +/// tagged enum `rename_all` renames the *variants*, never the variants' +/// fields, so without it `RespawnWithEnvVar` serializes as +/// `{"type":"respawnWithEnvVar","env_key":"…"}` while +/// `desktop/src/shared/api/types.ts` declares `envKey`. `invokeTauri` is an +/// unchecked cast, so `tsc` cannot see the mismatch — the reader just gets +/// `undefined`. `wire_format_matches_typescript_contract` below pins the exact +/// bytes. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] pub enum ConfigWriteMechanism { /// Update record env vars, save, stop + restart agent. RespawnWithEnvVar { env_key: String }, @@ -263,3 +276,105 @@ pub struct AcpModelEntry { pub name: Option, pub description: Option, } + +#[cfg(test)] +mod wire_format_tests { + use super::*; + use serde_json::json; + + /// Every `ConfigWriteMechanism` variant, as `desktop/src/shared/api/types.ts` + /// declares it. Whole-value comparison, not a key-set check: a key-set + /// assertion still passes if the variant *name* regresses, and the `type` + /// discriminant is what every `switch (writeVia.type)` reads. Compared as + /// `serde_json::Value` rather than as text, because JSON object order is + /// not semantic and the contract is the keys and values, not the encoder's + /// field order. + #[test] + fn wire_format_matches_typescript_contract() { + let cases = [ + ( + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + }, + json!({"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}), + ), + ( + ConfigWriteMechanism::AcpSetConfigOption { + config_id: "model".into(), + }, + json!({"type": "acpSetConfigOption", "configId": "model"}), + ), + ( + ConfigWriteMechanism::AcpSetSessionModel, + json!({"type": "acpSetSessionModel"}), + ), + ( + ConfigWriteMechanism::GooseNativeConfigWrite { + config_key: "goose.model".into(), + }, + json!({"type": "gooseNativeConfigWrite", "configKey": "goose.model"}), + ), + (ConfigWriteMechanism::ReadOnly, json!({"type": "readOnly"})), + ]; + for (mechanism, expected) in cases { + assert_eq!( + serde_json::to_value(&mechanism).expect("serialize"), + expected + ); + } + } + + /// The renderer never sees a bare mechanism — it arrives nested inside + /// `NormalizedField`, which is where the mismatch used to hide: the + /// enclosing struct's `writeVia` / `overriddenValue` / `isRequired` all + /// renamed correctly, so only the variant's own field was snake_case. + #[test] + fn nested_field_is_camel_case_all_the_way_down() { + let field = NormalizedField { + value: Some("v".into()), + origin: ConfigOrigin::EnvVar, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + }, + overridden_value: Some("o".into()), + overridden_origin: Some(ConfigOrigin::ConfigFile), + is_required: true, + }; + assert_eq!( + serde_json::to_value(&field).expect("serialize"), + json!({ + "value": "v", + "origin": "envVar", + "writeVia": {"type": "respawnWithEnvVar", "envKey": "GOOSE_MODE"}, + "overriddenValue": "o", + "overriddenOrigin": "configFile", + "isRequired": true, + }) + ); + } + + /// The contract is singular: the shape the renderer sends back round-trips, + /// and the old snake_case spelling is no longer accepted. Without the + /// second half, a future revert would still deserialize and the read path + /// would look healthy. + #[test] + fn camel_case_round_trips_and_snake_case_is_rejected() { + let parsed: ConfigWriteMechanism = + serde_json::from_str(r#"{"type":"respawnWithEnvVar","envKey":"GOOSE_MODE"}"#) + .expect("the TypeScript shape must deserialize"); + assert_eq!( + parsed, + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: "GOOSE_MODE".into(), + } + ); + + assert!( + serde_json::from_str::( + r#"{"type":"respawnWithEnvVar","env_key":"GOOSE_MODE"}"# + ) + .is_err(), + "the pre-fix snake_case spelling must not be accepted" + ); + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8da233dfcef..1f7b1477494 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1817,6 +1817,12 @@ function buildMockConfigSurface(pubkey: string): { sources: Record; } { // Goose running — mixed origins, override on model + // The `writeVia` payloads below are camelCase because that is what the + // backend emits — pinned by `wire_format_matches_typescript_contract` in + // `desktop/src-tauri/src/managed_agents/config_bridge/types.rs`. This mock + // agreed with `api/types.ts` while the real serializer emitted `env_key` / + // `config_id` / `config_key`, so a test against it certified a contract + // nothing produced. Change these only alongside that Rust test. const gooseSurface = { runtimeId: "goose", runtimeLabel: "Goose", From 7e2651791d598a3938ef4560a41801223fb9b2c9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 18 Aug 2026 23:24:09 +0100 Subject: [PATCH 22/27] Add font size and conversation density preferences (#5644) ## Summary - Add separate **Font size** and **Conversation density** controls in Appearance. - Use a 13 / 14 / 15px conversation text ramp for Smaller / Default / Larger while scaling interface typography through a shared virtual-rem system. - Keep layout geometry fixed while typography changes, and compose Cmd +/- text zoom on top of the selected preference. - Let Compact, Comfy, and Spacious control conversation row spacing plus paragraph and list rhythm in Markdown content across Buzz. - Preview font size and density together with click-and-drag comparison; interrupted scrubbing restores the saved preference. - Synchronize preferences across open Buzz windows, including full local-storage clears during sign-out or dev reset. - Promote the segmented control to shared UI and make Settings rows adapt to their card width. ## Validation - `just ci` - 4,965+ desktop unit tests across the final merged branch - 1,465 mobile tests through the full repository gate - Focused Playwright coverage for font-size/density independence, drag preview cancellation, Inbox geometry, thread rendering, keyboard zoom, and cross-surface typography - Fresh E2E build against the final merged branch --------- Signed-off-by: kenny lopez Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Signed-off-by: Princess Donut Co-authored-by: Princess Donut Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> --- AGENTS.md | 17 +- desktop/src/app/useWebviewZoomShortcuts.ts | 17 +- .../src/features/home/ui/InboxListPane.tsx | 4 +- .../src/features/home/ui/InboxMessageRow.tsx | 24 +- .../messages/lib/useRichTextEditor.ts | 2 +- .../src/features/messages/ui/DiffViewer.css | 6 +- .../features/messages/ui/MessageHeader.tsx | 5 +- .../src/features/messages/ui/MessageRow.tsx | 20 +- .../features/messages/ui/MessageTimestamp.tsx | 46 +- .../ui/AppearanceSettingsControls.tsx | 185 ++++++- .../settings/ui/SettingsOptionGroup.tsx | 3 +- .../features/settings/ui/SettingsPanels.tsx | 49 +- desktop/src/main.tsx | 7 +- desktop/src/shared/lib/cn.ts | 16 +- .../conversationDensityPreference.test.mjs | 80 +++ .../lib/conversationDensityPreference.ts | 101 ++++ .../shared/lib/fontSizePreference.test.mjs | 97 ++++ desktop/src/shared/lib/fontSizePreference.ts | 121 +++++ desktop/src/shared/styles/globals.css | 1 + .../shared/styles/globals/avatar-framing.css | 4 +- .../src/shared/styles/globals/components.css | 2 +- .../src/shared/styles/globals/composer.css | 3 +- .../src/shared/styles/globals/terminal.css | 4 +- .../src/shared/styles/globals/typography.css | 56 ++ desktop/src/shared/ui/markdown.tsx | 4 +- desktop/src/shared/ui/segmented-control.tsx | 218 ++++++++ desktop/tailwind.config.js | 54 +- desktop/tests/e2e/agents.spec.ts | 2 + .../tests/e2e/buzz-theme-screenshots.spec.ts | 514 +++++++++++++++++- .../e2e/entity-link-recipient-cards.spec.ts | 4 +- .../e2e/inbox-refactor-screenshots.spec.ts | 225 +++++++- desktop/tests/e2e/mobile-pairing-qr.spec.ts | 10 +- desktop/tests/e2e/profile.spec.ts | 99 +++- .../e2e/top-chrome-zoom-clearance.spec.ts | 21 +- 34 files changed, 1891 insertions(+), 130 deletions(-) create mode 100644 desktop/src/shared/lib/conversationDensityPreference.test.mjs create mode 100644 desktop/src/shared/lib/conversationDensityPreference.ts create mode 100644 desktop/src/shared/lib/fontSizePreference.test.mjs create mode 100644 desktop/src/shared/lib/fontSizePreference.ts create mode 100644 desktop/src/shared/styles/globals/typography.css create mode 100644 desktop/src/shared/ui/segmented-control.tsx diff --git a/AGENTS.md b/AGENTS.md index 3a79c4b38d8..e10e49e664f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -476,11 +476,18 @@ are frozen.** So for any readable text, reach for rem-based Tailwind tokens, never arbitrary px: -- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …). **Chat body/author - text === `text-base` (16px) — chat is the app's base type size**, and the - surrounding timeline elements (timestamps, system rows, code, reactions) are - deliberate steps on that same stock ramp. -- ✅ The `text-2xs` (0.6875rem / 11px) and `text-3xs` (0.5rem / 8px) meta-text +- ✅ Stock rem tokens (`text-base`, `text-sm`, `text-xs`, …) for general + interface text. All of these derive from the virtual typography rem and + therefore follow the user's font-size preference and Cmd +/- zoom. +- ✅ Conversation text uses the named `text-message` token. Its + **Smaller / Default / Larger contract is 13 / 14 / 15px** before keyboard + zoom. Author names use the same conversation-size step; timestamps, system + rows, code, and reactions are deliberate neighboring steps on the shared + virtual-rem ramp. Keep those relationships tokenized rather than restoring a + fixed 16px chat baseline or hardcoding preference-specific values in + components. +- ✅ The `text-2xs` (0.6875rem / 11px at a 16px virtual rem) and `text-3xs` + (0.5rem / 8px at a 16px virtual rem) meta-text tokens (in `desktop/tailwind.config.js` under `theme.extend.fontSize`) for the sub-`text-xs` ramp — timestamps, count badges, tracking labels, tiny glyphs. These replaced the dozens of arbitrary `text-[…rem]` literals that had drifted diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index cda6c0f2ede..e8b93207945 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,13 +1,13 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { applyTextZoomFactor } from "@/shared/lib/fontSizePreference"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.75; const MAX_ZOOM_FACTOR = 1.5; const ZOOM_STEP = 0.1; -const BASE_FONT_SIZE_PX = 16; const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale"; type ZoomAction = "increase" | "decrease" | "reset"; @@ -76,13 +76,12 @@ function readStoredZoomFactor() { } function applyTextScale(zoomFactor: number) { + applyTextZoomFactor(zoomFactor); if (zoomFactor === DEFAULT_ZOOM_FACTOR) { - document.documentElement.style.fontSize = ""; window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY); return; } - document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`; window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor)); } @@ -120,9 +119,21 @@ export function useWebviewZoomShortcuts() { applyTextScale(nextZoomFactor); } + function handleStorage(event: StorageEvent) { + if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) { + return; + } + + const storedZoomFactor = readStoredZoomFactor(); + zoomFactorRef.current = storedZoomFactor; + applyTextZoomFactor(storedZoomFactor); + } + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("storage", handleStorage); return () => { window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("storage", handleStorage); }; }, []); } diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b83a19a1673..f2d4421081d 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -433,14 +433,14 @@ export function InboxListPane({
{timestampLabel} @@ -138,7 +139,7 @@ export function InboxMessageRow({ ) : null}
-

+

{hoverTimestampLabel}

@@ -207,14 +208,20 @@ export function InboxMessageRow({
{isContinuation ? null : ( -
+
- + {message.authorLabel} @@ -240,10 +247,13 @@ export function InboxMessageRow({
)} -
+
{children}
@@ -93,7 +94,7 @@ export function MessageAuthorText({ return ( - ))} - + ({ + value: mode, + label, + Icon, + }))} + testId="appearance-color-mode-control" + value={selectedMode} + /> @@ -808,6 +790,7 @@ function ThemeSettingsCard() { data-testid="appearance-preferences-card" title="Preferences" > + diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 1520814559f..634a9b171a1 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -3,7 +3,8 @@ import ReactDOM from "react-dom/client"; import { App } from "@/app/App"; import { RootErrorBoundary } from "@/app/RootErrorBoundary"; import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDialog"; -import "@fontsource-variable/inter/wght.css"; +import "@fontsource-variable/inter/opsz.css"; +import "@fontsource-variable/inter/opsz-italic.css"; import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/700.css"; import "@/shared/styles/globals.css"; @@ -19,6 +20,8 @@ import { Toaster } from "@/shared/ui/sonner"; import { TooltipProvider } from "@/shared/ui/tooltip"; import { recoverLocalStorageQuotaOnStartup } from "@/shared/lib/localStorageQuota"; import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep"; +import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference"; +import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference"; type E2eWindow = Window & { __BUZZ_E2E__?: unknown; @@ -123,6 +126,8 @@ async function bootstrap() { resetDevWebviewStateFromUrl(); configureDevE2eBridgeFromUrl(); recoverLocalStorageQuotaOnStartup(); + initializeConversationDensityPreference(); + initializeFontSizePreference(); startLocalStorageSweep(); await installE2eBridgeIfConfigured(); await migrateLegacyCommunityStorageBeforeRender(); diff --git a/desktop/src/shared/lib/cn.ts b/desktop/src/shared/lib/cn.ts index a5ef193506d..79fe7d2897e 100644 --- a/desktop/src/shared/lib/cn.ts +++ b/desktop/src/shared/lib/cn.ts @@ -1,6 +1,18 @@ import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { extendTailwindMerge } from "tailwind-merge"; + +const mergeClassNames = extendTailwindMerge({ + extend: { + classGroups: { + "font-size": [ + { + text: ["message", "message-timestamp"], + }, + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return mergeClassNames(clsx(inputs)); } diff --git a/desktop/src/shared/lib/conversationDensityPreference.test.mjs b/desktop/src/shared/lib/conversationDensityPreference.test.mjs new file mode 100644 index 00000000000..2b5391f9277 --- /dev/null +++ b/desktop/src/shared/lib/conversationDensityPreference.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const values = new Map(); +const attributes = new Map(); +const windowListeners = new Map(); + +globalThis.window = { + addEventListener: (type, listener) => windowListeners.set(type, listener), +}; +globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), +}; +globalThis.document = { + documentElement: { + setAttribute: (name, value) => attributes.set(name, value), + }, +}; + +const preference = await import("./conversationDensityPreference.ts"); + +test("defaults invalid and missing conversation densities to comfortable", () => { + assert.equal(preference.parseConversationDensity(null), "comfortable"); + assert.equal(preference.parseConversationDensity("dense"), "comfortable"); + assert.equal(preference.parseConversationDensity("compact"), "compact"); + assert.equal( + preference.parseConversationDensity("comfortable"), + "comfortable", + ); + assert.equal(preference.parseConversationDensity("spacious"), "spacious"); +}); + +test("persists and applies the selected conversation density", () => { + preference.setConversationDensity("compact"); + assert.equal(preference.getConversationDensity(), "compact"); + assert.equal( + values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY), + "compact", + ); + assert.equal(attributes.get("data-conversation-density"), "compact"); +}); + +test("previews a density without changing the saved preference", () => { + preference.setConversationDensity("compact"); + preference.previewConversationDensity("spacious"); + assert.equal(preference.getConversationDensity(), "compact"); + assert.equal( + values.get(preference.CONVERSATION_DENSITY_STORAGE_KEY), + "compact", + ); + assert.equal(attributes.get("data-conversation-density"), "spacious"); + + preference.previewConversationDensity(null); + assert.equal(attributes.get("data-conversation-density"), "compact"); +}); + +test("initializes from the persisted conversation density", () => { + values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "spacious"); + preference.initializeConversationDensityPreference(); + assert.equal(preference.getConversationDensity(), "spacious"); + assert.equal(attributes.get("data-conversation-density"), "spacious"); +}); + +test("applies conversation density changes from another window", () => { + values.set(preference.CONVERSATION_DENSITY_STORAGE_KEY, "compact"); + windowListeners.get("storage")({ + key: preference.CONVERSATION_DENSITY_STORAGE_KEY, + }); + assert.equal(preference.getConversationDensity(), "compact"); + assert.equal(attributes.get("data-conversation-density"), "compact"); +}); + +test("returns to comfortable when another window clears storage", () => { + preference.setConversationDensity("spacious"); + values.clear(); + windowListeners.get("storage")({ key: null }); + assert.equal(preference.getConversationDensity(), "comfortable"); + assert.equal(attributes.get("data-conversation-density"), "comfortable"); +}); diff --git a/desktop/src/shared/lib/conversationDensityPreference.ts b/desktop/src/shared/lib/conversationDensityPreference.ts new file mode 100644 index 00000000000..309a6c8d0e7 --- /dev/null +++ b/desktop/src/shared/lib/conversationDensityPreference.ts @@ -0,0 +1,101 @@ +import * as React from "react"; + +/** Device-level spacing used across conversation surfaces. */ +export type ConversationDensity = "compact" | "comfortable" | "spacious"; + +export const CONVERSATION_DENSITY_STORAGE_KEY = + "buzz.appearance.conversationDensity"; +export const DEFAULT_CONVERSATION_DENSITY: ConversationDensity = "comfortable"; + +const listeners = new Set<() => void>(); +let conversationDensity: ConversationDensity = DEFAULT_CONVERSATION_DENSITY; +let listeningForStorageChanges = false; + +export function parseConversationDensity( + value: string | null | undefined, +): ConversationDensity { + return value === "compact" || value === "comfortable" || value === "spacious" + ? value + : DEFAULT_CONVERSATION_DENSITY; +} + +function readStoredConversationDensity(): ConversationDensity { + try { + return parseConversationDensity( + globalThis.localStorage?.getItem(CONVERSATION_DENSITY_STORAGE_KEY), + ); + } catch { + return DEFAULT_CONVERSATION_DENSITY; + } +} + +function applyConversationDensity(density: ConversationDensity): void { + globalThis.document?.documentElement?.setAttribute( + "data-conversation-density", + density, + ); +} + +function notifyListeners(): void { + for (const listener of listeners) listener(); +} + +function applyStoredConversationDensity(): void { + const nextDensity = readStoredConversationDensity(); + const changed = nextDensity !== conversationDensity; + conversationDensity = nextDensity; + applyConversationDensity(nextDensity); + if (changed) notifyListeners(); +} + +function listenForStorageChanges(): void { + if (listeningForStorageChanges || !globalThis.window?.addEventListener) + return; + globalThis.window.addEventListener("storage", (event) => { + if (event.key === CONVERSATION_DENSITY_STORAGE_KEY || event.key === null) { + applyStoredConversationDensity(); + } + }); + listeningForStorageChanges = true; +} + +/** Apply the persisted preference before React renders to avoid a layout jump. */ +export function initializeConversationDensityPreference(): void { + applyStoredConversationDensity(); + listenForStorageChanges(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getConversationDensity(): ConversationDensity { + return conversationDensity; +} + +export function setConversationDensity(density: ConversationDensity): void { + conversationDensity = density; + applyConversationDensity(density); + try { + globalThis.localStorage?.setItem(CONVERSATION_DENSITY_STORAGE_KEY, density); + } catch { + // Persistence is best-effort; the live preference still applies. + } + notifyListeners(); +} + +/** Temporarily apply a density without changing the saved preference. */ +export function previewConversationDensity( + density: ConversationDensity | null, +): void { + applyConversationDensity(density ?? conversationDensity); +} + +export function useConversationDensity(): ConversationDensity { + return React.useSyncExternalStore( + subscribe, + getConversationDensity, + () => DEFAULT_CONVERSATION_DENSITY, + ); +} diff --git a/desktop/src/shared/lib/fontSizePreference.test.mjs b/desktop/src/shared/lib/fontSizePreference.test.mjs new file mode 100644 index 00000000000..217a3e5a00a --- /dev/null +++ b/desktop/src/shared/lib/fontSizePreference.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import config from "../../../tailwind.config.js"; + +const values = new Map(); +const attributes = new Map(); +const styleValues = new Map(); +const windowListeners = new Map(); +const style = { + setProperty: (name, value) => styleValues.set(name, value), +}; + +globalThis.window = { + addEventListener: (type, listener) => windowListeners.set(type, listener), +}; +globalThis.localStorage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), +}; +globalThis.document = { + documentElement: { + setAttribute: (name, value) => attributes.set(name, value), + style, + }, +}; + +const preference = await import("./fontSizePreference.ts"); + +test("scales fixed line-height utilities with the typography rem", () => { + assert.deepEqual(config.theme.extend.lineHeight, { + 3: "calc(var(--buzz-type-rem) * 0.75)", + 4: "var(--buzz-type-rem)", + 5: "calc(var(--buzz-type-rem) * 1.25)", + 6: "calc(var(--buzz-type-rem) * 1.5)", + 7: "calc(var(--buzz-type-rem) * 1.75)", + 8: "calc(var(--buzz-type-rem) * 2)", + "message-author": "var(--conversation-author-line-height)", + }); +}); + +test("defaults invalid and missing font sizes to default", () => { + assert.equal(preference.parseFontSize(null), "default"); + assert.equal(preference.parseFontSize("medium"), "default"); + assert.equal(preference.parseFontSize("smaller"), "smaller"); + assert.equal(preference.parseFontSize("default"), "default"); + assert.equal(preference.parseFontSize("larger"), "larger"); +}); + +test("persists and applies the selected font size across the app", () => { + preference.applyTextZoomFactor(1); + preference.setFontSize("smaller"); + assert.equal(preference.getFontSize(), "smaller"); + assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller"); + assert.equal(attributes.get("data-font-size"), "smaller"); + assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px"); +}); + +test("previews a font size without changing the saved preference", () => { + preference.applyTextZoomFactor(1.1); + preference.setFontSize("smaller"); + preference.previewFontSize("larger"); + assert.equal(preference.getFontSize(), "smaller"); + assert.equal(values.get(preference.FONT_SIZE_STORAGE_KEY), "smaller"); + assert.equal(attributes.get("data-font-size"), "larger"); + assert.equal(styleValues.get("--buzz-type-rem"), "18.857143px"); + + preference.previewFontSize(null); + assert.equal(attributes.get("data-font-size"), "smaller"); + assert.equal(styleValues.get("--buzz-type-rem"), "16.342857px"); +}); + +test("initializes from the stored font size", () => { + preference.applyTextZoomFactor(1); + values.set(preference.FONT_SIZE_STORAGE_KEY, "larger"); + preference.initializeFontSizePreference(); + assert.equal(preference.getFontSize(), "larger"); + assert.equal(attributes.get("data-font-size"), "larger"); + assert.equal(styleValues.get("--buzz-type-rem"), "17.142857px"); +}); + +test("applies font size changes from another window", () => { + values.set(preference.FONT_SIZE_STORAGE_KEY, "smaller"); + windowListeners.get("storage")({ key: preference.FONT_SIZE_STORAGE_KEY }); + assert.equal(preference.getFontSize(), "smaller"); + assert.equal(attributes.get("data-font-size"), "smaller"); + assert.equal(styleValues.get("--buzz-type-rem"), "14.857143px"); +}); + +test("returns to the default when another window clears storage", () => { + preference.setFontSize("larger"); + values.clear(); + windowListeners.get("storage")({ key: null }); + assert.equal(preference.getFontSize(), "default"); + assert.equal(attributes.get("data-font-size"), "default"); + assert.equal(styleValues.get("--buzz-type-rem"), "16px"); +}); diff --git a/desktop/src/shared/lib/fontSizePreference.ts b/desktop/src/shared/lib/fontSizePreference.ts new file mode 100644 index 00000000000..9604a2c2cbc --- /dev/null +++ b/desktop/src/shared/lib/fontSizePreference.ts @@ -0,0 +1,121 @@ +import * as React from "react"; + +/** Device-level type scale applied throughout the desktop interface. */ +export type FontSize = "smaller" | "default" | "larger"; + +export const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize"; +export const DEFAULT_FONT_SIZE: FontSize = "default"; + +/** + * Virtual rem sizes used by typography tokens. Keeping the real root at 16px + * prevents a text preference from also resizing rem-based layout geometry. + */ +const TYPE_REM_SIZE_PX: Record = { + smaller: 13 / 0.875, + default: 14 / 0.875, + larger: 15 / 0.875, +}; + +const TYPE_REM_PROPERTY = "--buzz-type-rem"; + +const listeners = new Set<() => void>(); +let fontSize: FontSize = DEFAULT_FONT_SIZE; +let textZoomFactor = 1; +let listeningForStorageChanges = false; + +export function parseFontSize(value: string | null | undefined): FontSize { + return value === "smaller" || value === "default" || value === "larger" + ? value + : DEFAULT_FONT_SIZE; +} + +function readStoredFontSize(): FontSize { + try { + return parseFontSize( + globalThis.localStorage?.getItem(FONT_SIZE_STORAGE_KEY), + ); + } catch { + return DEFAULT_FONT_SIZE; + } +} + +function typeRemSizePx(size: FontSize): number { + return ( + Math.round(TYPE_REM_SIZE_PX[size] * textZoomFactor * 1_000_000) / 1_000_000 + ); +} + +function applyFontSize(size: FontSize): void { + const root = globalThis.document?.documentElement; + root?.setAttribute("data-font-size", size); + root?.style.setProperty(TYPE_REM_PROPERTY, `${typeRemSizePx(size)}px`); +} + +function notifyListeners(): void { + for (const listener of listeners) listener(); +} + +function applyStoredFontSize(): void { + const nextSize = readStoredFontSize(); + const changed = nextSize !== fontSize; + fontSize = nextSize; + applyFontSize(nextSize); + if (changed) notifyListeners(); +} + +function listenForStorageChanges(): void { + if (listeningForStorageChanges || !globalThis.window?.addEventListener) + return; + globalThis.window.addEventListener("storage", (event) => { + if (event.key === FONT_SIZE_STORAGE_KEY || event.key === null) { + applyStoredFontSize(); + } + }); + listeningForStorageChanges = true; +} + +/** Apply the persisted preference before React renders to avoid a layout jump. */ +export function initializeFontSizePreference(): void { + applyStoredFontSize(); + listenForStorageChanges(); +} + +/** Combine Cmd +/- zoom with the selected app-wide type scale. */ +export function applyTextZoomFactor(zoomFactor: number): void { + if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return; + textZoomFactor = zoomFactor; + applyFontSize(fontSize); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getFontSize(): FontSize { + return fontSize; +} + +export function setFontSize(size: FontSize): void { + fontSize = size; + applyFontSize(size); + try { + globalThis.localStorage?.setItem(FONT_SIZE_STORAGE_KEY, size); + } catch { + // Persistence is best-effort; the live preference still applies. + } + notifyListeners(); +} + +/** Temporarily apply a size without changing the saved preference. */ +export function previewFontSize(size: FontSize | null): void { + applyFontSize(size ?? fontSize); +} + +export function useFontSize(): FontSize { + return React.useSyncExternalStore( + subscribe, + getFontSize, + () => DEFAULT_FONT_SIZE, + ); +} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 0d5a1032191..53a753fd9e6 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -7,6 +7,7 @@ @import "./globals/composer.css"; @import "./globals/markdown.css"; @import "./globals/theme.css"; +@import "./globals/typography.css"; @import "./globals/skeleton.css"; @import "./globals/spoilers.css"; @import "./globals/components.css"; diff --git a/desktop/src/shared/styles/globals/avatar-framing.css b/desktop/src/shared/styles/globals/avatar-framing.css index 44c11163fd1..79a2e2ad072 100644 --- a/desktop/src/shared/styles/globals/avatar-framing.css +++ b/desktop/src/shared/styles/globals/avatar-framing.css @@ -132,8 +132,8 @@ width: 100%; max-width: none; color: hsl(var(--muted-foreground) / 0.72); - font-size: 0.875rem; - line-height: 1.25rem; + font-size: calc(var(--buzz-type-rem) * 0.875); + line-height: calc(var(--buzz-type-rem) * 1.25); text-align: center; opacity: 0; pointer-events: none; diff --git a/desktop/src/shared/styles/globals/components.css b/desktop/src/shared/styles/globals/components.css index f206539a937..7e0e2fe116d 100644 --- a/desktop/src/shared/styles/globals/components.css +++ b/desktop/src/shared/styles/globals/components.css @@ -558,7 +558,7 @@ .buzz-onboarding-runtime-pill { animation: buzz-onboarding-runtime-pill-in 180ms cubic-bezier(0.22, 1, 0.36, 1) both; - font-size: 0.625rem; + font-size: calc(var(--buzz-type-rem) * 0.625); letter-spacing: 0; line-height: 1; text-transform: uppercase; diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 0af4a59b4f1..2ee690e41de 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -93,10 +93,9 @@ } .rich-text-composer .tiptap { + @apply text-message font-normal tracking-normal; outline: none; min-height: 1lh; - font-size: var(--text-sm); - line-height: var(--text-sm--line-height); } .rich-text-composer .tiptap p { diff --git a/desktop/src/shared/styles/globals/terminal.css b/desktop/src/shared/styles/globals/terminal.css index 544f0ae2efe..c5aa001afb0 100644 --- a/desktop/src/shared/styles/globals/terminal.css +++ b/desktop/src/shared/styles/globals/terminal.css @@ -45,7 +45,7 @@ color: hsl(var(--muted-foreground)); display: flex; font: inherit; - font-size: 0.75rem; + font-size: calc(var(--buzz-type-rem) * 0.75); font-weight: 500; gap: 6px; height: 30px; @@ -130,7 +130,7 @@ .buzz-terminal-designator { align-items: center; display: inline-flex; - font-size: 0.75rem; + font-size: calc(var(--buzz-type-rem) * 0.75); gap: 2px; letter-spacing: 0; } diff --git a/desktop/src/shared/styles/globals/typography.css b/desktop/src/shared/styles/globals/typography.css new file mode 100644 index 00000000000..fa59d372411 --- /dev/null +++ b/desktop/src/shared/styles/globals/typography.css @@ -0,0 +1,56 @@ +@layer base { + :root { + /* + * A virtual typography rem. Font preferences and Cmd +/- change this + * token while the browser root remains 16px, so text scales without also + * resizing rem-based widths, gaps, radii, and controls. + */ + --buzz-type-rem: 1rem; + --text-xs: calc(var(--buzz-type-rem) * 0.75); + --text-sm: calc(var(--buzz-type-rem) * 0.875); + --text-base: var(--buzz-type-rem); + --text-lg: calc(var(--buzz-type-rem) * 1.125); + --text-xl: calc(var(--buzz-type-rem) * 1.25); + --text-2xl: calc(var(--buzz-type-rem) * 1.5); + --text-3xl: calc(var(--buzz-type-rem) * 1.875); + --text-4xl: calc(var(--buzz-type-rem) * 2.25); + --text-5xl: calc(var(--buzz-type-rem) * 3); + --text-6xl: calc(var(--buzz-type-rem) * 3.75); + + /* + * Default conversation type and comfy spacing for channels, DMs, threads, + * Inbox, and the composer. Font size changes the type tokens only; + * Conversation density overrides only spacing. + */ + --conversation-message-font-size: calc(var(--buzz-type-rem) * 0.875); + --conversation-message-line-height: calc(var(--buzz-type-rem) * 1.25); + --conversation-author-line-height: var(--buzz-type-rem); + --conversation-body-gap: 0.125rem; + --conversation-row-padding-block: 0.25rem; + --conversation-paragraph-gap: 0.5rem; + --conversation-list-item-gap: 0.375rem; + --conversation-timestamp-font-size: calc(var(--buzz-type-rem) * 0.75); + --conversation-timestamp-line-height: var(--buzz-type-rem); + } + + :root[data-conversation-density="compact"] { + --conversation-body-gap: 0rem; + --conversation-row-padding-block: 0.25rem; + --conversation-paragraph-gap: 0.375rem; + --conversation-list-item-gap: 0.25rem; + } + + :root[data-conversation-density="spacious"] { + --conversation-body-gap: 0.25rem; + --conversation-row-padding-block: 0.5rem; + --conversation-paragraph-gap: 0.625rem; + --conversation-list-item-gap: 0.5rem; + } + + body { + font-synthesis-weight: none; + font-variant-emoji: unicode; + font-variant-ligatures: no-contextual; + text-rendering: optimizeLegibility; + } +} diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a556278eade..6326709d49f 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1877,10 +1877,10 @@ function MarkdownInner({ className={cn( MESSAGE_MARKDOWN_CLASS, [ - "max-w-none wrap-anywhere text-sm leading-5 text-foreground", + "max-w-none wrap-anywhere text-message font-normal tracking-normal text-foreground", "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "[&>*+*]:mt-3", - "[&>p+p]:mt-1.5", + "[&>p+p]:mt-conversation-paragraph [&>ol]:space-y-conversation-list [&>ul]:space-y-conversation-list", "[&>*+h1]:mt-3.5 [&>*+h2]:mt-3.5 [&>*+h3]:mt-3.5 [&>*+h4]:mt-3.5 [&>*+h5]:mt-3.5 [&>*+h6]:mt-3.5", "[&>h1+*]:mt-0.5 [&>h2+*]:mt-0.5 [&>h3+*]:mt-0.5 [&>h4+*]:mt-0.5 [&>h5+*]:mt-0.5 [&>h6+*]:mt-0.5", "[&>h1+h2]:mt-1.5! [&>h2+h3]:mt-1.5! [&>h3+h4]:mt-1.5! [&>h4+h5]:mt-1.5! [&>h5+h6]:mt-1.5!", diff --git a/desktop/src/shared/ui/segmented-control.tsx b/desktop/src/shared/ui/segmented-control.tsx new file mode 100644 index 00000000000..78cd280bae9 --- /dev/null +++ b/desktop/src/shared/ui/segmented-control.tsx @@ -0,0 +1,218 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +type SegmentOption = { + value: Value; + label: string; + Icon?: React.ComponentType<{ className?: string }>; +}; + +type SegmentedControlSize = "compact" | "default" | "wide"; + +const SIZE_CLASSES: Record = { + compact: "w-48", + default: "w-60", + wide: "w-72", +}; + +/** A mutually exclusive control with equal-width, optionally scrubbable options. */ +export function SegmentedControl({ + className, + indicatorTestId, + legend, + onPreviewChange, + onValueChange, + optionTestIdPrefix, + options, + size = "default", + testId, + value, +}: { + className?: string; + indicatorTestId?: string; + legend: string; + onPreviewChange?: (value: Value | null) => void; + onValueChange: (value: Value) => void; + optionTestIdPrefix: string; + options: readonly SegmentOption[]; + size?: SegmentedControlSize; + testId: string; + value: Value; +}) { + const [previewValue, setPreviewValue] = React.useState(null); + const controlRef = React.useRef(null); + const activePointerIdRef = React.useRef(null); + const pointerStartXRef = React.useRef(null); + const pointerStartValueRef = React.useRef(null); + const scrubValueRef = React.useRef(null); + const skipPointerClickRef = React.useRef(false); + const displayedValue = previewValue ?? value; + const selectedIndex = Math.max( + 0, + options.findIndex((option) => option.value === displayedValue), + ); + + const getValueAtPointer = React.useCallback( + (element: HTMLFieldSetElement, clientX: number): Value => { + const bounds = element.getBoundingClientRect(); + const position = Math.max( + 0, + Math.min(bounds.width - 1, clientX - bounds.left), + ); + const index = Math.min( + options.length - 1, + Math.floor((position / bounds.width) * options.length), + ); + return options[index]?.value ?? value; + }, + [options, value], + ); + + const preview = React.useCallback( + (nextValue: Value | null) => { + scrubValueRef.current = nextValue; + setPreviewValue(nextValue); + onPreviewChange?.(nextValue); + }, + [onPreviewChange], + ); + + const cancelScrub = React.useCallback(() => { + const control = controlRef.current; + const pointerId = activePointerIdRef.current; + activePointerIdRef.current = null; + if (control && pointerId != null && control.hasPointerCapture(pointerId)) { + control.releasePointerCapture(pointerId); + } + pointerStartXRef.current = null; + pointerStartValueRef.current = null; + skipPointerClickRef.current = false; + preview(null); + }, [preview]); + + React.useEffect(() => { + const handleWindowBlur = () => cancelScrub(); + globalThis.addEventListener?.("blur", handleWindowBlur); + return () => { + globalThis.removeEventListener?.("blur", handleWindowBlur); + cancelScrub(); + }; + }, [cancelScrub]); + + const handlePointerDown = ( + event: React.PointerEvent, + ) => { + if (!onPreviewChange || event.button !== 0) return; + event.currentTarget.setPointerCapture(event.pointerId); + activePointerIdRef.current = event.pointerId; + pointerStartXRef.current = event.clientX; + pointerStartValueRef.current = getValueAtPointer( + event.currentTarget, + event.clientX, + ); + scrubValueRef.current = null; + skipPointerClickRef.current = true; + event.preventDefault(); + }; + + const handlePointerMove = ( + event: React.PointerEvent, + ) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + const nextValue = getValueAtPointer(event.currentTarget, event.clientX); + const pointerStartX = pointerStartXRef.current; + const pointerStartValue = pointerStartValueRef.current; + const crossedDragThreshold = + pointerStartX != null && Math.abs(event.clientX - pointerStartX) >= 4; + if ( + scrubValueRef.current == null && + !crossedDragThreshold && + nextValue === pointerStartValue + ) { + return; + } + if (nextValue !== scrubValueRef.current) preview(nextValue); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + const nextValue = getValueAtPointer(event.currentTarget, event.clientX); + activePointerIdRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + pointerStartXRef.current = null; + pointerStartValueRef.current = null; + onValueChange(nextValue); + preview(null); + globalThis.setTimeout(() => { + skipPointerClickRef.current = false; + }, 0); + }; + + const handlePointerCancel = () => cancelScrub(); + + const handleLostPointerCapture = () => { + if (activePointerIdRef.current != null) cancelScrub(); + }; + + return ( +
+ {legend} +
+ ); +} diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 8905fc16cea..07d00b0db92 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -3,18 +3,46 @@ export default { theme: { extend: { // Sub-`text-xs` ramp for meta text (timestamps, count badges, tracking - // labels) and tiny glyphs. Defined in rem so Cmd +/- zoom — which scales - // the root font-size — keeps scaling them. Do NOT reintroduce - // arbitrary `text-[…rem]` / `text-[…px]` literals; the px-text guard - // rejects them. Stock scale picks up from here: xs (12px), sm (14px)… + // labels) and tiny glyphs. These follow the virtual typography rem so + // preferences and Cmd +/- scale text without changing layout geometry. + // Do NOT reintroduce arbitrary `text-[…rem]` / `text-[…px]` literals; + // the px-text guard rejects them. Stock scale picks up from xs. fontSize: { - "2xs": "0.6875rem", // 11px — meta-text workhorse (timestamps, badges) - "3xs": "0.5rem", // 8px — tiny glyphs / micro labels - badge: "0.625rem", // 10px — compact status badges - // 40px — onboarding page titles (tightened tracking for large display type) - title: ["2.5rem", { lineHeight: "1.15", letterSpacing: "-0.02em" }], - // 36px — the backup-step private key, shown large in monospace - "nsec-key": ["2.25rem", { lineHeight: "1.3" }], + "2xs": "calc(var(--buzz-type-rem) * 0.6875)", // 11px at 16px type rem + "3xs": "calc(var(--buzz-type-rem) * 0.5)", // 8px at 16px type rem + badge: "calc(var(--buzz-type-rem) * 0.625)", // 10px at 16px type rem + // Shared channel, DM, thread, and composer type. Variables keep app-wide + // font size and keyboard zoom consistent without branching components. + message: [ + "var(--conversation-message-font-size)", + { lineHeight: "var(--conversation-message-line-height)" }, + ], + "message-timestamp": [ + "var(--conversation-timestamp-font-size)", + { lineHeight: "var(--conversation-timestamp-line-height)" }, + ], + // 40px at the 16px type rem — onboarding page titles. + title: [ + "calc(var(--buzz-type-rem) * 2.5)", + { lineHeight: "1.15", letterSpacing: "-0.02em" }, + ], + // 36px at the 16px type rem — backup-step private key. + "nsec-key": [ + "calc(var(--buzz-type-rem) * 2.25)", + { lineHeight: "1.3" }, + ], + }, + lineHeight: { + // Keep fixed Tailwind line-height utilities in the typography scale so + // Cmd +/- cannot enlarge glyphs inside an unchanged line box. Single- + // line surfaces keep their existing truncate/overflow behavior. + 3: "calc(var(--buzz-type-rem) * 0.75)", + 4: "var(--buzz-type-rem)", + 5: "calc(var(--buzz-type-rem) * 1.25)", + 6: "calc(var(--buzz-type-rem) * 1.5)", + 7: "calc(var(--buzz-type-rem) * 1.75)", + 8: "calc(var(--buzz-type-rem) * 2)", + "message-author": "var(--conversation-author-line-height)", }, boxShadow: { "content-edge": "-1px -1px 0 0 hsl(var(--sidebar-border) / 0.45)", @@ -36,6 +64,10 @@ export default { }, spacing: { 4.5: "1.125rem", + "conversation-body": "var(--conversation-body-gap)", + "conversation-list": "var(--conversation-list-item-gap)", + "conversation-paragraph": "var(--conversation-paragraph-gap)", + "conversation-row": "var(--conversation-row-padding-block)", }, fontFamily: { sans: [ diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 346d1e088ea..b81c5889f3a 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -747,6 +747,8 @@ test("moves agent actions into an overflow menu in a narrow view", async ({ }); await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + // The app-wide default renders text-base at 16px with Tailwind's 1.5 + // line-height ratio, producing a 24px one-line scroll height. await expect( page.getByText("Set up and manage your agents.", { exact: true }), ).toHaveJSProperty("scrollHeight", 24); diff --git a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts index 2bcd36908eb..0c01bfd6602 100644 --- a/desktop/tests/e2e/buzz-theme-screenshots.spec.ts +++ b/desktop/tests/e2e/buzz-theme-screenshots.spec.ts @@ -8,6 +8,8 @@ const THEME_STORAGE_KEY = "buzz-theme"; const GLASS_BACKGROUND_STORAGE_KEY = "buzz-glass-background"; const GLASS_OPACITY_STORAGE_KEY = "buzz-glass-opacity"; const PROMINENT_ACTIVE_TAB_STORAGE_KEY = "buzz-prominent-active-tab"; +const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity"; +const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize"; const MOCK_PUBKEY = "deadbeef".repeat(8); const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; @@ -617,7 +619,7 @@ test("appearance groups theme and preferences into labeled rows", async ({ (lightModeButtonBox.x + lightModeButtonBox.width / 2), ), ).toBeLessThanOrEqual(0.5); - await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.25s"); + await expect(colorModeIndicator).toHaveCSS("transition-duration", "0.2s"); await themeCard.getByTestId("appearance-mode-dark").click(); await waitForAnimations(page); @@ -639,6 +641,512 @@ test("appearance groups theme and preferences into labeled rows", async ({ ).toBeLessThanOrEqual(0.5); }); +test("app font size and conversation density apply independently", async ({ + page, +}) => { + await seedTheme(page, "buzz"); + await installMockBridge(page); + await openAppearance(page, "light"); + + const root = page.locator("html"); + const densityControl = page.getByTestId("conversation-density-control"); + const compact = page.getByTestId("conversation-density-compact"); + const comfortable = page.getByTestId("conversation-density-comfortable"); + const spacious = page.getByTestId("conversation-density-spacious"); + const densityIndicator = page.getByTestId( + "conversation-density-control-indicator", + ); + const fontSizeControl = page.getByTestId("font-size-control"); + const smaller = page.getByTestId("font-size-smaller"); + const defaultSize = page.getByTestId("font-size-default"); + const larger = page.getByTestId("font-size-larger"); + const fontSizeIndicator = page.getByTestId("font-size-control-indicator"); + const preview = page.getByTestId("conversation-preview"); + const previewSurface = page.getByTestId("conversation-preview-surface"); + const previewContent = page.getByTestId("conversation-preview-content"); + const previewChip = preview.getByText("Preview"); + const firstPreviewMessage = previewSurface.locator("article").first(); + const previewMessage = preview.getByText( + "The revised conversation layout is ready to review.", + ); + const previewTimestamp = preview.getByText("9:41"); + const densityDescription = page + .getByTestId("conversation-density-row") + .locator("[data-settings-subcopy]"); + const fontSizeDescription = page + .getByTestId("font-size-row") + .locator("[data-settings-subcopy]"); + const readScale = () => + root.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + authorLineHeight: Number.parseFloat( + style.getPropertyValue("--conversation-author-line-height"), + ), + bodyGap: Number.parseFloat( + style.getPropertyValue("--conversation-body-gap"), + ), + fontSize: style.getPropertyValue("--conversation-message-font-size"), + lineHeight: style.getPropertyValue( + "--conversation-message-line-height", + ), + paragraphGap: Number.parseFloat( + style.getPropertyValue("--conversation-paragraph-gap"), + ), + rowPadding: Number.parseFloat( + style.getPropertyValue("--conversation-row-padding-block"), + ), + timestampFontSize: style.getPropertyValue( + "--conversation-timestamp-font-size", + ), + timestampLineHeight: Number.parseFloat( + style.getPropertyValue("--conversation-timestamp-line-height"), + ), + }; + }); + const readSettingsScale = () => + page.getByTestId("conversation-density-row").evaluate((element) => { + const rowStyle = window.getComputedStyle(element); + const label = element.querySelector("p"); + if (!label) throw new Error("Conversation density label is missing"); + const labelStyle = window.getComputedStyle(label); + return { + fontSize: labelStyle.fontSize, + lineHeight: labelStyle.lineHeight, + minHeight: rowStyle.minHeight, + paddingBlock: rowStyle.paddingTop, + }; + }); + const readPreviewTimestampScale = () => + previewTimestamp.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }); + const readSettingsChromeScale = () => + Promise.all([ + page + .getByRole("heading", { name: "Appearance" }) + .evaluate((element) => window.getComputedStyle(element).fontSize), + page + .getByTestId("settings-nav-appearance") + .evaluate((element) => window.getComputedStyle(element).fontSize), + page + .getByRole("heading", { name: "Preferences" }) + .evaluate((element) => window.getComputedStyle(element).fontSize), + ]); + + await expect( + page.getByRole("group", { name: "Conversation density" }), + ).toBeVisible(); + await expect(page.getByRole("group", { name: "Font size" })).toBeVisible(); + await expect(densityControl).toHaveAccessibleName("Conversation density"); + await expect(fontSizeControl).toHaveAccessibleName("Font size"); + await expect(comfortable).toHaveText("Comfy"); + await expect(defaultSize).toHaveText("Default"); + await expect(preview).toContainText("Preview"); + await expect(preview).not.toContainText("Message #design"); + await expect(comfortable).toHaveAttribute("aria-pressed", "true"); + await expect(defaultSize).toHaveAttribute("aria-pressed", "true"); + await expect(densityDescription).toHaveText( + "Spacing in conversations and Markdown content across Buzz", + ); + await expect(fontSizeDescription).toHaveText( + "Applies across conversations and interface text", + ); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0.125, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.5, + rowPadding: 0.25, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect + .poll(() => + Promise.all([ + densityControl.evaluate( + (element) => element.getBoundingClientRect().width, + ), + fontSizeControl.evaluate( + (element) => element.getBoundingClientRect().width, + ), + page + .getByTestId("appearance-color-mode-control") + .evaluate((element) => element.getBoundingClientRect().width), + ]), + ) + .toEqual([288, 288, 240]); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["14px", "20px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); + await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]); + await expect(densityIndicator).toHaveCSS("transition-duration", "0.2s"); + await expect(densityIndicator).toHaveCSS("transition-property", /transform/); + await expect(fontSizeIndicator).toHaveCSS("transition-duration", "0.2s"); + await expect(fontSizeIndicator).toHaveCSS("transition-property", /transform/); + await expect + .poll(async () => { + const [previewBackground, labelBackground, controlBackground] = + await Promise.all([ + previewSurface.evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ), + previewChip.evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ), + densityControl.evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ), + ]); + return { + labelIsAnnotation: labelBackground !== controlBackground, + previewBackground, + }; + }) + .toEqual({ + labelIsAnnotation: true, + previewBackground: "rgba(0, 0, 0, 0)", + }); + const previewSurfaceBox = await previewSurface.boundingBox(); + const previewChipBox = await previewChip.boundingBox(); + const firstPreviewMessageBox = await firstPreviewMessage.boundingBox(); + expect(previewSurfaceBox).not.toBeNull(); + expect(previewChipBox).not.toBeNull(); + expect(firstPreviewMessageBox).not.toBeNull(); + if (!previewSurfaceBox || !previewChipBox || !firstPreviewMessageBox) { + throw new Error("Conversation preview geometry is missing"); + } + const previewChipRightInset = + previewSurfaceBox.x + + previewSurfaceBox.width - + (previewChipBox.x + previewChipBox.width); + expect(previewChipRightInset).toBeGreaterThanOrEqual(13); + expect(previewChipRightInset).toBeLessThanOrEqual(15); + const previewChipTopInset = previewChipBox.y - previewSurfaceBox.y; + expect(previewChipTopInset).toBeGreaterThanOrEqual(13); + expect(previewChipTopInset).toBeLessThanOrEqual(15); + await expect(previewContent).toHaveCSS("padding-top", "16px"); + await expect(previewContent).toHaveCSS("padding-right", "16px"); + await expect(previewContent).toHaveCSS("padding-bottom", "16px"); + await expect(previewContent).toHaveCSS("padding-left", "16px"); + expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeGreaterThanOrEqual( + 15, + ); + expect(firstPreviewMessageBox.x - previewSurfaceBox.x).toBeLessThanOrEqual( + 17, + ); + expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeGreaterThanOrEqual( + 15, + ); + expect(firstPreviewMessageBox.y - previewSurfaceBox.y).toBeLessThanOrEqual( + 17, + ); + await densityIndicator.evaluate((element) => { + element.addEventListener( + "transitionrun", + () => element.setAttribute("data-transition-ran", "true"), + { once: true }, + ); + }); + + await compact.click(); + await expect(densityIndicator).toHaveAttribute("data-transition-ran", "true"); + await expect(root).toHaveAttribute("data-conversation-density", "compact"); + await expect(root).toHaveAttribute("data-font-size", "default"); + await expect(compact).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + CONVERSATION_DENSITY_STORAGE_KEY, + ), + ) + .toBe("compact"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.375, + rowPadding: 0.25, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect.poll(readPreviewTimestampScale).toEqual(["12px", "16px"]); + await expect.poll(readSettingsChromeScale).toEqual(["24px", "14px", "14px"]); + + await larger.click(); + await expect(root).toHaveAttribute("data-conversation-density", "compact"); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(larger).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("larger"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 17.142857, + bodyGap: 0, + fontSize: "calc(17.142857px * .875)", + lineHeight: "calc(17.142857px * 1.25)", + paragraphGap: 0.375, + rowPadding: 0.25, + timestampFontSize: "calc(17.142857px * .75)", + timestampLineHeight: 17.142857, + }); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["15px", "21.4286px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "15px", + lineHeight: "21.4286px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["12.8571px", "17.1429px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "15px"]); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/15-conversation-compact-larger.png`, + }); + + await spacious.click(); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(spacious).toHaveAttribute("aria-pressed", "true"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 17.142857, + bodyGap: 0.25, + fontSize: "calc(17.142857px * .875)", + lineHeight: "calc(17.142857px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(17.142857px * .75)", + timestampLineHeight: 17.142857, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "15px", + lineHeight: "21.4286px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["12.8571px", "17.1429px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "15px"]); + + await smaller.click(); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "smaller"); + await expect(smaller).toHaveAttribute("aria-pressed", "true"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 14.857143, + bodyGap: 0.25, + fontSize: "calc(14.857143px * .875)", + lineHeight: "calc(14.857143px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(14.857143px * .75)", + timestampLineHeight: 14.857143, + }); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["13px", "18.5714px"]); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "13px", + lineHeight: "18.5714px", + minHeight: "64px", + paddingBlock: "12px", + }); + await expect + .poll(readPreviewTimestampScale) + .toEqual(["11.1429px", "14.8571px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["22.2857px", "13px", "13px"]); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/16-conversation-spacious-smaller.png`, + }); + + await comfortable.click(); + await defaultSize.click(); + await expect(root).toHaveAttribute( + "data-conversation-density", + "comfortable", + ); + await expect(comfortable).toHaveAttribute("aria-pressed", "true"); + + const controlBox = await densityControl.boundingBox(); + const compactBox = await compact.boundingBox(); + const spaciousBox = await spacious.boundingBox(); + expect(controlBox).not.toBeNull(); + expect(compactBox).not.toBeNull(); + expect(spaciousBox).not.toBeNull(); + if (!controlBox || !compactBox || !spaciousBox) { + throw new Error("Conversation density control geometry is missing"); + } + await page.mouse.move( + compactBox.x + compactBox.width / 2, + controlBox.y + controlBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + spaciousBox.x + spaciousBox.width / 2, + controlBox.y + controlBox.height / 2, + ); + await expect(root).toHaveAttribute("data-conversation-density", "spacious"); + await expect(root).toHaveAttribute("data-font-size", "default"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + CONVERSATION_DENSITY_STORAGE_KEY, + ), + ) + .toBe("comfortable"); + await expect.poll(readScale).toEqual({ + authorLineHeight: 16, + bodyGap: 0.25, + fontSize: "calc(16px * .875)", + lineHeight: "calc(16px * 1.25)", + paragraphGap: 0.625, + rowPadding: 0.5, + timestampFontSize: "calc(16px * .75)", + timestampLineHeight: 16, + }); + await expect.poll(readSettingsScale).toEqual({ + fontSize: "14px", + lineHeight: "20px", + minHeight: "64px", + paddingBlock: "12px", + }); + await page.mouse.up(); + await expect(spacious).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + CONVERSATION_DENSITY_STORAGE_KEY, + ), + ) + .toBe("spacious"); + await comfortable.click(); + + const fontSizeControlBox = await fontSizeControl.boundingBox(); + const smallerBox = await smaller.boundingBox(); + const largerBox = await larger.boundingBox(); + expect(fontSizeControlBox).not.toBeNull(); + expect(smallerBox).not.toBeNull(); + expect(largerBox).not.toBeNull(); + if (!fontSizeControlBox || !smallerBox || !largerBox) { + throw new Error("Font size control geometry is missing"); + } + await page.mouse.move( + smallerBox.x + smallerBox.width / 2, + fontSizeControlBox.y + fontSizeControlBox.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + largerBox.x + largerBox.width / 2, + fontSizeControlBox.y + fontSizeControlBox.height / 2, + ); + await expect(root).toHaveAttribute("data-font-size", "larger"); + await expect(root).toHaveAttribute( + "data-conversation-density", + "comfortable", + ); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("default"); + await expect + .poll(() => + previewMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return [style.fontSize, style.lineHeight]; + }), + ) + .toEqual(["15px", "21.4286px"]); + await expect + .poll(readSettingsChromeScale) + .toEqual(["25.7143px", "15px", "15px"]); + + // Losing the window during a scrub cancels the temporary preview rather + // than leaving presentation and persisted selection out of sync. + await page.evaluate(() => window.dispatchEvent(new Event("blur"))); + await expect(root).toHaveAttribute("data-font-size", "default"); + await expect(defaultSize).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("default"); + + // Cancellation resets the gesture completely; the next selection persists. + await larger.click(); + await expect(larger).toHaveAttribute("aria-pressed", "true"); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + FONT_SIZE_STORAGE_KEY, + ), + ) + .toBe("larger"); + await defaultSize.click(); + await waitForAnimations(page); + await page.getByTestId("appearance-preferences-card").screenshot({ + path: `${SHOTS}/14-conversation-preferences.png`, + }); +}); + test("appearance picker — system tab (Buzz follows OS)", async ({ page }) => { await seedTheme(page, "buzz"); await installMockBridge(page); @@ -1051,6 +1559,10 @@ test("glass background keeps the content panel solid", async ({ page }) => { const matchingRadiusControls = [ page.getByTestId("appearance-color-mode-control"), page.getByTestId("appearance-color-mode-indicator"), + page.getByTestId("font-size-control"), + page.getByTestId("font-size-control-indicator"), + page.getByTestId("conversation-density-control"), + page.getByTestId("conversation-density-control-indicator"), page.getByTestId("theme-style-trigger"), page.getByTestId("link-preview-style-trigger"), page.getByTestId("thread-layout-trigger"), diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index 75afe47f3b1..b373092a818 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -116,9 +116,11 @@ test("agent-style message with bare buzz:// links renders entity cards without s await expect( repoCard.locator("[data-link-preview-hostname-favicon]"), ).toHaveCount(0); + // Default typography is 14px; keep the image-less card compact while + // allowing fractional line-height rounding across rendering platforms. expect( await repoCard.evaluate((card) => card.getBoundingClientRect().height), - ).toBeLessThan(84); + ).toBeLessThan(90); await waitForAnimations(page); await page.screenshot({ diff --git a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts index 15a804d71f6..8f1f074e277 100644 --- a/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts +++ b/desktop/tests/e2e/inbox-refactor-screenshots.spec.ts @@ -10,7 +10,7 @@ * tests/e2e/inbox-refactor-screenshots.spec.ts * Output: test-results/inbox-refactor/ */ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; @@ -24,11 +24,66 @@ const DM_CHANNEL_ID = "f48efb06-0c93-5025-aac9-2e646bb6bfa8"; // Mock bridge default pubkey — must match DEFAULT_MOCK_PUBKEY in bridge.ts. const MOCK_PUBKEY = "deadbeef".repeat(8); const DRAFT_STORE_KEY = `buzz-drafts.v1:${MOCK_PUBKEY}`; +const FONT_SIZE_STORAGE_KEY = "buzz.appearance.fontSize"; +const CONVERSATION_DENSITY_STORAGE_KEY = "buzz.appearance.conversationDensity"; // Fixed timestamps so draft ordering renders deterministically. const DRAFT_CREATED_AT_1 = "2026-07-01T10:00:00.000Z"; const DRAFT_CREATED_AT_2 = "2026-07-02T14:30:00.000Z"; +type FontSize = "smaller" | "default" | "larger"; +type ConversationDensity = "compact" | "comfortable" | "spacious"; + +async function seedConversationPreferences( + page: Page, + fontSize: FontSize, + density: ConversationDensity, +) { + await page.addInitScript( + ({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => { + window.localStorage.setItem(densityKey, densityValue); + window.localStorage.setItem(fontSizeKey, fontSizeValue); + }, + { + densityKey: CONVERSATION_DENSITY_STORAGE_KEY, + densityValue: density, + fontSizeKey: FONT_SIZE_STORAGE_KEY, + fontSizeValue: fontSize, + }, + ); +} + +async function applyConversationPreferences( + page: Page, + fontSize: FontSize, + density: ConversationDensity, +) { + await page.evaluate( + ({ densityKey, densityValue, fontSizeKey, fontSizeValue }) => { + const update = (key: string, value: string) => { + const oldValue = window.localStorage.getItem(key); + window.localStorage.setItem(key, value); + window.dispatchEvent( + new StorageEvent("storage", { + key, + newValue: value, + oldValue, + storageArea: window.localStorage, + }), + ); + }; + update(densityKey, densityValue); + update(fontSizeKey, fontSizeValue); + }, + { + densityKey: CONVERSATION_DENSITY_STORAGE_KEY, + densityValue: density, + fontSizeKey: FONT_SIZE_STORAGE_KEY, + fontSizeValue: fontSize, + }, + ); +} + type MockFeedWindow = Window & { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; @@ -273,6 +328,7 @@ test.describe("inbox refactor screenshots", () => { }); test("04 — thread opens at the oldest unread reply", async ({ page }) => { + await seedConversationPreferences(page, "default", "comfortable"); await installMockBridge(page, { mode: "mock" }); await page.goto("/", { waitUntil: "domcontentloaded" }); @@ -341,6 +397,9 @@ test.describe("inbox refactor screenshots", () => { const firstUnreadRow = page.getByTestId(`home-inbox-item-${replyIds[0]}`); await expect(firstUnreadRow).toBeVisible(); + const listPreview = firstUnreadRow.locator(".inbox-preview-markdown"); + await expect(listPreview).toHaveCSS("font-size", "14px"); + await expect(listPreview).toHaveCSS("line-height", "20px"); await firstUnreadRow.click(); const detail = page.getByTestId("home-inbox-detail"); @@ -352,8 +411,172 @@ test.describe("inbox refactor screenshots", () => { await expect(page.getByTestId("home-inbox-selected-message")).toContainText( "Started on the changelog — first pass is up.", ); + + const selectedMessage = page.getByTestId("home-inbox-selected-message"); + const selectedAuthor = selectedMessage.getByTestId("message-author"); + const selectedBody = selectedMessage.locator(".message-markdown").first(); + const selectedTimestamp = selectedMessage.getByTestId( + "inbox-message-timestamp", + ); + const remainingListPreview = page + .locator("[data-testid^='home-inbox-item-']") + .locator(".inbox-preview-markdown") + .first(); + const composerInput = page.getByTestId("message-input"); + const readConversationMetrics = () => + Promise.all([ + remainingListPreview.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }), + selectedMessage.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + paddingBottom: style.paddingBottom, + paddingTop: style.paddingTop, + }; + }), + selectedMessage.evaluate((element) => { + const header = element.querySelector( + "[data-testid='message-header']", + ); + const body = element.querySelector( + "[data-testid='message-body']", + ); + if (!header || !body) { + throw new Error("Inbox message spacing geometry is missing"); + } + return ( + body.getBoundingClientRect().top - + header.getBoundingClientRect().bottom + ); + }), + selectedAuthor.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }), + selectedBody.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }), + selectedTimestamp.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }), + composerInput.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }), + ]); + await expect + .poll(readConversationMetrics) + .toEqual([ + { fontSize: "14px", lineHeight: "20px" }, + { paddingBottom: "4px", paddingTop: "4px" }, + 2, + { fontSize: "14px", lineHeight: "16px" }, + { fontSize: "14px", lineHeight: "20px" }, + { fontSize: "12px", lineHeight: "16px" }, + { fontSize: "14px", lineHeight: "20px" }, + ]); await waitForAnimations(page); await page.screenshot({ path: `${SHOTS}/04-thread-context.png` }); + + await applyConversationPreferences(page, "default", "compact"); + await expect + .poll(readConversationMetrics) + .toEqual([ + { fontSize: "14px", lineHeight: "20px" }, + { paddingBottom: "4px", paddingTop: "4px" }, + 0, + { fontSize: "14px", lineHeight: "16px" }, + { fontSize: "14px", lineHeight: "20px" }, + { fontSize: "12px", lineHeight: "16px" }, + { fontSize: "14px", lineHeight: "20px" }, + ]); + + await applyConversationPreferences(page, "smaller", "compact"); + await expect + .poll(readConversationMetrics) + .toEqual([ + { fontSize: "13px", lineHeight: "18.5714px" }, + { paddingBottom: "4px", paddingTop: "4px" }, + 0, + { fontSize: "13px", lineHeight: "14.8571px" }, + { fontSize: "13px", lineHeight: "18.5714px" }, + { fontSize: "11.1429px", lineHeight: "14.8571px" }, + { fontSize: "13px", lineHeight: "18.5714px" }, + ]); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/05-thread-context-compact.png` }); + + await applyConversationPreferences(page, "larger", "spacious"); + await expect + .poll(readConversationMetrics) + .toEqual([ + { fontSize: "15px", lineHeight: "21.4286px" }, + { paddingBottom: "8px", paddingTop: "8px" }, + 4, + { fontSize: "15px", lineHeight: "17.1429px" }, + { fontSize: "15px", lineHeight: "21.4286px" }, + { fontSize: "12.8571px", lineHeight: "17.1429px" }, + { fontSize: "15px", lineHeight: "21.4286px" }, + ]); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/06-thread-context-spacious.png` }); + + await applyConversationPreferences(page, "default", "comfortable"); + + await page.evaluate(() => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "Equal", + ctrlKey: !isMac, + key: "+", + metaKey: isMac, + shiftKey: true, + }), + ); + }); + + await expect + .poll(async () => [ + await page.evaluate(() => + window + .getComputedStyle(document.documentElement) + .getPropertyValue("--buzz-type-rem") + .trim(), + ), + ...(await readConversationMetrics()), + ]) + .toEqual([ + "17.6px", + { fontSize: "15.4px", lineHeight: "22px" }, + { paddingBottom: "4px", paddingTop: "4px" }, + 2, + { fontSize: "15.4px", lineHeight: "17.6px" }, + { fontSize: "15.4px", lineHeight: "22px" }, + { fontSize: "13.2px", lineHeight: "17.6px" }, + { fontSize: "15.4px", lineHeight: "22px" }, + ]); }); }); diff --git a/desktop/tests/e2e/mobile-pairing-qr.spec.ts b/desktop/tests/e2e/mobile-pairing-qr.spec.ts index 937fd57b473..731cce17e56 100644 --- a/desktop/tests/e2e/mobile-pairing-qr.spec.ts +++ b/desktop/tests/e2e/mobile-pairing-qr.spec.ts @@ -304,9 +304,8 @@ test("pairing completion updates the final step and resets after leaving", async const confirmButton = confirmation.getByTestId("confirm-sas"); const cancelButton = confirmation.getByTestId("deny-sas"); const confirmationBox = await confirmation.boundingBox(); - const confirmationTitleBox = await confirmation - .getByTestId("pairing-sas-title") - .boundingBox(); + const confirmationTitle = confirmation.getByTestId("pairing-sas-title"); + const confirmationTitleBox = await confirmationTitle.boundingBox(); const confirmationCodeBox = await confirmationCode.boundingBox(); const confirmationActionsBox = await confirmation .getByTestId("pairing-sas-actions") @@ -345,10 +344,7 @@ test("pairing completion updates the final step and resets after leaving", async await expect( confirmation.getByText(/Only confirm if you started this pairing/), ).toHaveCount(0); - await expect(confirmation.getByTestId("pairing-sas-title")).toHaveCSS( - "font-size", - "16px", - ); + await expect(confirmationTitle).toHaveCSS("font-size", "16px"); mkdirSync(SCREENSHOT_DIR, { recursive: true }); await waitForAnimations(page); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 6c88b628ce8..a58859ec55f 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -2489,7 +2489,10 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { const getTextScaleState = () => page.evaluate(() => ({ - fontSize: getComputedStyle(document.documentElement).fontSize, + rootFontSize: getComputedStyle(document.documentElement).fontSize, + textRemSize: getComputedStyle(document.documentElement) + .getPropertyValue("--buzz-type-rem") + .trim(), storedScale: localStorage.getItem("buzz:text-scale"), webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number }) .__BUZZ_E2E_WEBVIEW_ZOOM__, @@ -2520,7 +2523,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("+", "Equal", true); await expect.poll(getTextScaleState).toEqual({ - fontSize: "17.6px", + rootFontSize: "16px", + textRemSize: "17.6px", storedScale: "1.1", webviewZoom: 1, }); @@ -2528,7 +2532,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("-", "Minus"); await expect.poll(getTextScaleState).toEqual({ - fontSize: "16px", + rootFontSize: "16px", + textRemSize: "16px", storedScale: null, webviewZoom: 1, }); @@ -2537,7 +2542,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("+", "Equal", true); await expect.poll(getTextScaleState).toEqual({ - fontSize: "19.2px", + rootFontSize: "16px", + textRemSize: "19.2px", storedScale: "1.2", webviewZoom: 1, }); @@ -2545,12 +2551,95 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => { await dispatchPrimaryShortcut("0", "Digit0"); await expect.poll(getTextScaleState).toEqual({ - fontSize: "16px", + rootFontSize: "16px", + textRemSize: "16px", storedScale: null, webviewZoom: 1, }); }); +test("storage clear resets composed font size and keyboard zoom across windows", async ({ + context, + page, +}) => { + await page.goto("/"); + await openSettings(page, "appearance"); + await page.getByTestId("font-size-larger").click(); + + const dispatchZoomIn = () => + page.evaluate(() => { + const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform); + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + code: "Equal", + ctrlKey: !isMac, + key: "+", + metaKey: isMac, + shiftKey: true, + }), + ); + }); + + for (let step = 0; step < 5; step += 1) { + await dispatchZoomIn(); + } + + await expect + .poll(() => + page.evaluate(() => ({ + fontSize: document.documentElement.dataset.fontSize, + textRemSize: getComputedStyle(document.documentElement) + .getPropertyValue("--buzz-type-rem") + .trim(), + textScale: localStorage.getItem("buzz:text-scale"), + })), + ) + .toEqual({ + fontSize: "larger", + textRemSize: "25.714286px", + textScale: "1.5", + }); + + const peerPage = await context.newPage(); + await installMockBridge(peerPage); + await peerPage.goto("/"); + await peerPage.evaluate(() => localStorage.clear()); + + await expect + .poll(() => + page.evaluate(() => ({ + fontSize: document.documentElement.dataset.fontSize, + textRemSize: getComputedStyle(document.documentElement) + .getPropertyValue("--buzz-type-rem") + .trim(), + textScale: localStorage.getItem("buzz:text-scale"), + })), + ) + .toEqual({ + fontSize: "default", + textRemSize: "16px", + textScale: null, + }); + + await page.keyboard.press( + process.platform === "darwin" ? "Meta+-" : "Control+-", + ); + await expect + .poll(() => + page.evaluate(() => ({ + textRemSize: getComputedStyle(document.documentElement) + .getPropertyValue("--buzz-type-rem") + .trim(), + textScale: localStorage.getItem("buzz:text-scale"), + })), + ) + .toEqual({ textRemSize: "14.4px", textScale: "0.9" }); + + await peerPage.close(); +}); + test("shows agent runtimes in agent settings", async ({ page }) => { await page.goto("/"); diff --git a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts index eb69953dbd6..d26001ba6ef 100644 --- a/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts +++ b/desktop/tests/e2e/top-chrome-zoom-clearance.spec.ts @@ -23,7 +23,7 @@ const EXPECTED_NAV_CENTER_Y = 23; // The macOS traffic lights are native chrome: with `trafficLightPosition` // x:16 they occupy roughly x 16–68 regardless of the app's Cmd +/- text // zoom. The top-chrome nav row must clear that band in fixed px, so the -// clearance cannot shrink when the root font size scales down. +// clearance cannot change when text scales. const TRAFFIC_LIGHT_RIGHT_EDGE = 72; async function spoofMacPlatform(page: import("@playwright/test").Page) { @@ -90,15 +90,24 @@ async function seedTextScale( }, scale); } -async function expectRootFontSize( +async function expectTextRemSize( page: import("@playwright/test").Page, fontSize: string, ) { await expect .poll(() => - page.evaluate(() => getComputedStyle(document.documentElement).fontSize), + page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue("--buzz-type-rem") + .trim(), + ), ) .toBe(fontSize); + await expect + .poll(() => + page.evaluate(() => getComputedStyle(document.documentElement).fontSize), + ) + .toBe("16px"); } test.describe("top chrome macOS traffic-light clearance under text zoom", () => { @@ -143,8 +152,8 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => await installMockBridge(page); await page.goto("/"); - // Confirm the zoomed-out scale actually applied to the root font size. - await expectRootFontSize(page, "12px"); + // Confirm the zoomed-out text scale applied without changing the root. + await expectTextRemSize(page, "12px"); expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( TRAFFIC_LIGHT_RIGHT_EDGE, @@ -161,7 +170,7 @@ test.describe("top chrome macOS traffic-light clearance under text zoom", () => await installMockBridge(page); await page.goto("/"); - await expectRootFontSize(page, "24px"); + await expectTextRemSize(page, "24px"); expect(await firstNavButtonX(page)).toBeGreaterThanOrEqual( TRAFFIC_LIGHT_RIGHT_EDGE, From 121e4b3ce7acab6ac310257f444997f58a97cb2e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 18 Aug 2026 18:26:35 -0400 Subject: [PATCH 23/27] fix(desktop): exclude archived agents from nest, order regeneration (#5905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The managed "Active Agents" table in `~/.buzz/AGENTS.md` was rendered from every managed-agent record with no filtering, so archived duplicate instances kept appearing under the active relay's header. This scopes the roster to identity-active agents and makes regeneration safe under concurrency. ## Roster filter: identity-archive only `render_dynamic_section` now drops only records whose pubkey is present in the relay's `kind:13535` archive snapshot. Local records can't tell they're archived — they all carry `is_active: true` (that flag is a *definition*-archive, not an identity-archive), so archive truth lives only relay-side. The read **fails open**: an unreachable relay yields an empty set and hides no one. There is deliberately **no** relay-scope filter. `relay_url` is a legacy creation-era field that `effective_agent_relay_url()` ignores — every agent is eligible on every community, and snapshot-imported records store `relay_url: ""` by design. Filtering on it would hide valid, runnable agents after a workspace switch or import. Foreign-relay relic records leave the table via record deletion, not code. ## Regeneration on archive / unarchive `archive_identity` and `unarchive_identity` submitted the relay event and returned without refreshing `AGENTS.md`, unlike the ~20 other mutation sites that call `try_regenerate_nest`. A just-archived agent therefore lingered on the roster until an unrelated edit or the next launch. Both commands now trigger a regeneration after a successful submit. Regeneration is bound through a `NestRegenTrigger` trait rather than constructing the `try_regenerate_nest` callback at the Tauri-command delegation site. The command cores take `regen: &impl NestRegenTrigger` and own the `|| regen.trigger()` binding; the thin wrappers only pass `&app` (whose `impl` calls `try_regenerate_nest`). This puts the regen wiring inside the unit-tested core — a `CountingRegen` double proves each core fires exactly one regeneration — instead of an untestable seam where a wrapper could silently lose the refresh while the suite stayed green. The regen races the relay's `kind:13535` snapshot update, so it's best-effort and fail-open — a stale render self-heals on the next cycle. ## Ordered regeneration `try_regenerate_nest` previously spawned unconstrained tasks that each snapshotted state, awaited two relay requests, then wrote — so a slow pre-edit generation could overwrite a newer one. Boot made this deterministic: the boot regen races the `apply_workspace` regen, and the fallback-relay render could finish last. `NestRegenGate` fixes this with a single `highest_requested` watermark. A monotonic generation is claimed *synchronously* at request time (encoding call order) and advances the watermark under one lock; the spawned task carries its generation and, at commit, reads the watermark under that same lock — the compare-and-write is atomic with no await held across it. A generation whose number is below the current watermark drops its result instead of rolling the file back. Gating on highest-*requested* rather than highest-*written* is the load-bearing choice: if a newer generation is requested but then fails its relay reads, an older in-flight generation must **not** publish its now-obsolete roster. Behavior delta: once a newer regeneration has been requested, no older generation will ever write; if that newer generation fails, the file is left as-is and self-heals on the next trigger rather than regressing to a stale snapshot. This is an ordered, latest-request-wins gate — not a work coalescer: superseded generations still perform their relay reads and drop the result at commit time. The gate's commit lock is acquired with a poison-to-`io::Error` mapping rather than `expect()`, so a poisoned lock degrades to the same warn-and-continue path as any other commit failure instead of panicking the desktop process (a best-effort housekeeping write must never take down the app). ## One relay target per regeneration A regeneration read the workspace relay override three times — the NIP-11 signer in `fetch_relay_self`, the snapshot query in `query_relay`, and the rendered footer — so a workspace switch mid-flight could pair one relay's advertised signer with another relay's snapshot, fail open, and render archived agents as active. `capture_relay_target` now resolves the effective relay (WebSocket + HTTP API base) once, before any network work, and `fetch_archived_pubkeys_at` threads that single target through both the NIP-11 fetch and the `/query`; the footer renders the same target. Signer, snapshot, and footer always belong to one relay. ## Monotonic archival snapshot publishing `publish_nipia_archival_list` stamped its `kind:13535` snapshot with a whole-second `created_at`. A rapid archive→unarchive within the same second produced two events whose NIP-16 replaceable-event tie-break (higher event id wins) could strand the older, stale archive state as canonical. Publishing now uses a bounded retry that re-reads current archive state and rebuilds the snapshot each attempt, so the published list reflects the latest intended state rather than a fixed same-second race loser. After the attempt budget (8) is exhausted the publisher `bail!`s; the sole caller treats that as a `warn!` side effect and continues, matching the surrounding best-effort submit path. ## Test-file split The renderer, `upsert_managed_section`, and the regeneration-gate tests moved to `nest/render_tests.rs` so each test file stays under the repository's 1000-line ratchet. --------- Signed-off-by: Will Pfleger Signed-off-by: Wes Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Wes Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Co-authored-by: Duncan --- .../src/handlers/identity_archive.rs | 190 +++++ .../buzz-relay/src/handlers/side_effects.rs | 153 +++- .../src/commands/identity_archive.rs | 470 ++++++++++-- desktop/src-tauri/src/egress_guard_tests.rs | 4 + desktop/src-tauri/src/managed_agents/nest.rs | 185 ++++- .../src/managed_agents/nest/render_tests.rs | 713 ++++++++++++++++++ .../src/managed_agents/nest/tests.rs | 410 ---------- desktop/src-tauri/src/relay.rs | 2 +- 8 files changed, 1637 insertions(+), 490 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/nest/render_tests.rs diff --git a/crates/buzz-relay/src/handlers/identity_archive.rs b/crates/buzz-relay/src/handlers/identity_archive.rs index 9da920483fe..40c54647837 100644 --- a/crates/buzz-relay/src/handlers/identity_archive.rs +++ b/crates/buzz-relay/src/handlers/identity_archive.rs @@ -512,6 +512,196 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(id), host) } + #[tokio::test] + async fn archival_snapshot_advances_timestamp_for_rapid_state_replacement() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool.clone()).await else { + return; + }; + let tenant = seed_test_community(&pool).await; + let target_hex = Keys::generate().public_key().to_hex(); + let request_id = "a".repeat(64); + + state + .db + .archive( + tenant.community(), + &target_hex, + "self", + &target_hex, + None, + None, + &request_id, + ) + .await + .expect("archive identity"); + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish archived snapshot"); + let archived_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query archived snapshot") + .into_iter() + .next() + .expect("archived snapshot exists"); + + state + .db + .unarchive(tenant.community(), &target_hex) + .await + .expect("unarchive identity"); + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish unarchived snapshot"); + let final_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query final snapshot") + .into_iter() + .next() + .expect("final snapshot exists"); + + assert!( + final_snapshot.event.created_at > archived_snapshot.event.created_at, + "replacement snapshots must not rely on random same-second event-id ordering" + ); + assert!( + !final_snapshot.event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("p") + && fields.get(1).map(String::as_str) == Some(target_hex.as_str()) + }), + "final snapshot must reflect the canonical empty archive set" + ); + } + + /// Carl review 4954871389 test (b): a stale (pre-unarchive) publisher whose + /// canonical read predates the unarchive must not strand `target` in the + /// authoritative 13535. Deterministic via the `publish_test_hooks` barrier: + /// the stale publisher is held right after it reads `{target}`; the + /// unarchive and the compliant `{}` publish then run; only then is the stale + /// publisher released to attempt its write. Its post-insert + /// `snapshot_is_current` drift check sees canonical `{}` ≠ its `{target}` + /// snapshot, so it rebuilds and converges. RED-on-revert: replace that guard + /// with `let snapshot_is_current = true;` and the released stale publisher + /// commits `{target}` last, stranding the unarchived identity. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_archival_publishers_converge_on_canonical_state() { + let Some(pool) = test_pool().await else { + return; + }; + if sqlx::query("SELECT 1 FROM archived_identities LIMIT 1") + .execute(&pool) + .await + .is_err() + { + return; + } + let Some(state) = test_state(pool.clone()).await else { + return; + }; + let tenant = seed_test_community(&pool).await; + let target_hex = Keys::generate().public_key().to_hex(); + let request_id = "b".repeat(64); + + // canonical -> {target} + state + .db + .archive( + tenant.community(), + &target_hex, + "self", + &target_hex, + None, + None, + &request_id, + ) + .await + .expect("archive identity"); + + // Arm the barrier, then spawn the stale publisher. It reads the + // `{target}` view, reaches the hook, and blocks until released. + let (reached_hook, release) = + crate::handlers::side_effects::publish_test_hooks::arm(tenant.community()); + let stale_tenant = tenant.clone(); + let stale_state = state.clone(); + let stale_publisher = + tokio::spawn( + async move { publish_nipia_archival_list(&stale_tenant, &stale_state).await }, + ); + // Deterministically wait until the stale publisher has read `{target}`. + reached_hook + .await + .expect("stale publisher reached the post-list_archived hook"); + + // canonical -> {} while the stale publisher holds its `{target}` view. + state + .db + .unarchive(tenant.community(), &target_hex) + .await + .expect("unarchive identity"); + // Production publishes after every archive-state mutation; do the same. + publish_nipia_archival_list(&tenant, &state) + .await + .expect("publish after unarchive"); + + // Release the stale publisher: it must detect drift and converge on `{}`. + release.notify_one(); + stale_publisher + .await + .expect("join stale publisher") + .expect("stale publisher converges without error"); + + let final_snapshot = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(state.relay_keypair.public_key().to_bytes().to_vec()), + global_only: true, + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query final snapshot") + .into_iter() + .next() + .expect("final snapshot exists"); + + assert!( + !final_snapshot.event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("p") + && fields.get(1).map(String::as_str) == Some(target_hex.as_str()) + }), + "a stale publisher must converge on the canonical empty set, never \ + strand the unarchived identity in the authoritative 13535" + ); + } + #[tokio::test] async fn owner_archive_rejects_stale_request_after_live_kind0_owner_flip() { let Some(pool) = test_pool().await else { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 282ea776577..f2b58937ab5 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3116,6 +3116,61 @@ pub async fn reconcile_channel_events( Ok(()) } +/// Test-only barrier hooks for [`publish_nipia_archival_list`]. Lets a test +/// hold one publisher after it has read canonical archive state and before it +/// replaces the head, making the stale-read/late-write race deterministic. +/// Compiled only under `cfg(test)`; the production call site is `#[cfg(test)]`. +/// +/// The gate is scoped to a `CommunityId`: only a publisher whose tenant matches +/// the armed community is held. Publishers from other tenants — the rapid +/// archive/unarchive or owner-archive regressions running in parallel under the +/// Rust test runner — pass straight through and never consume the gate armed +/// for the concurrent-publisher test's unique tenant. +#[cfg(test)] +pub(crate) mod publish_test_hooks { + use buzz_core::tenant::CommunityId; + use std::sync::{Arc, Mutex}; + use tokio::sync::{oneshot, Notify}; + + struct Gate { + community: CommunityId, + arrived: oneshot::Sender<()>, + release: Arc, + } + + static GATE: Mutex> = Mutex::new(None); + + /// Arm a one-shot barrier for `community`. Await the returned receiver to + /// learn when the held publisher has reached the hook (i.e. has read + /// canonical state); call `notify_one` on the returned handle to let it + /// proceed. Only the first publisher of the matching community to reach the + /// hook after arming is held; every other publisher passes. + pub(crate) fn arm(community: CommunityId) -> (oneshot::Receiver<()>, Arc) { + let (tx, rx) = oneshot::channel(); + let release = Arc::new(Notify::new()); + *GATE.lock().unwrap() = Some(Gate { + community, + arrived: tx, + release: release.clone(), + }); + (rx, release) + } + + pub(super) async fn after_list_archived(community: CommunityId) { + let gate = { + let mut slot = GATE.lock().unwrap(); + match slot.as_ref() { + Some(gate) if gate.community == community => slot.take(), + _ => None, + } + }; + if let Some(gate) = gate { + let _ = gate.arrived.send(()); + gate.release.notified().await; + } + } +} + /// Publish a kind:13535 archived identities list event (NIP-IA). /// /// Queries all current archived identities and emits a relay-signed, @@ -3124,29 +3179,76 @@ pub async fn publish_nipia_archival_list( tenant: &TenantContext, state: &Arc, ) -> anyhow::Result<()> { - let archived = state.db.list_archived(tenant.community()).await?; - let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + const MAX_REPLACEMENT_ATTEMPTS: usize = 8; + let relay_pubkey = state.relay_keypair.public_key(); + let relay_pubkey_hex = relay_pubkey.to_hex(); + + // A concurrent archive mutation can race between reading the current head and + // replacing it. Rebuild from canonical state on rejection so an older snapshot + // can never strand the final archive set. + for _ in 0..MAX_REPLACEMENT_ATTEMPTS { + let archived = state.db.list_archived(tenant.community()).await?; + // Test-only barrier: lets a test hold a stale publisher here — after it + // has read canonical state, before it replaces the head — so the + // stale-read/late-write ordering the drift check must catch is + // deterministic, not scheduler-dependent. Inert in production. + #[cfg(test)] + publish_test_hooks::after_list_archived(tenant.community()).await; + let mut tags: Vec = Vec::with_capacity(archived.len() + 1); + tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + + for identity in &archived { + tags.push( + Tag::parse(["p", &identity.pubkey]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + ); + } - let mut tags: Vec = Vec::with_capacity(archived.len() + 1); - tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + // NIP-16 resolves same-second replacements by event id. Force this + // canonical snapshot strictly past the current head instead of letting a + // rapid archive→unarchive randomly preserve the stale archive state. + let now = nostr::Timestamp::now().as_secs(); + let previous = state + .db + .query_events(&buzz_db::event::EventQuery { + kinds: Some(vec![KIND_IA_ARCHIVED_LIST as i32]), + pubkey: Some(relay_pubkey.to_bytes().to_vec()), + limit: Some(1), + global_only: true, + ..buzz_db::event::EventQuery::for_community(tenant.community()) + }) + .await?; + let created_at = previous + .first() + .map(|event| (event.event.created_at.as_secs() + 1).max(now)) + .unwrap_or(now); - for identity in &archived { - tags.push( - Tag::parse(["p", &identity.pubkey]) - .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, - ); - } + let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; - let event = EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVED_LIST as u16), "") - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| anyhow::anyhow!("failed to sign kind:{KIND_IA_ARCHIVED_LIST}: {e}"))?; + let (stored, was_inserted) = state + .db + .replace_addressable_event(tenant.community(), &event, None) + .await?; + if !was_inserted { + continue; + } + + let current_archived = state.db.list_archived(tenant.community()).await?; + let snapshot_is_current = + archived + .iter() + .map(|identity| identity.pubkey.as_str()) + .eq(current_archived + .iter() + .map(|identity| identity.pubkey.as_str())); + if !snapshot_is_current { + continue; + } - let (stored, was_inserted) = state - .db - .replace_addressable_event(tenant.community(), &event, None) - .await?; - if was_inserted { dispatch_persistent_event( tenant, state, @@ -3156,13 +3258,16 @@ pub async fn publish_nipia_archival_list( None, ) .await; + info!( + archived_count = archived.len(), + "NIP-IA archived identities list published" + ); + return Ok(()); } - info!( - archived_count = archived.len(), - "NIP-IA archived identities list published" - ); - Ok(()) + anyhow::bail!( + "failed to publish kind:{KIND_IA_ARCHIVED_LIST} after {MAX_REPLACEMENT_ATTEMPTS} concurrent replacements" + ) } /// NIP-DV: publish the relay-signed, per-viewer DM visibility snapshot for diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index d15ee82abc3..0cc5679bf7b 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -12,17 +12,50 @@ //! see §Owner-of-Agent Requests and §Relay Processing Algorithm. use serde::{Deserialize, Serialize}; -use tauri::State; +use tauri::{AppHandle, State}; use crate::{ app_state::AppState, events, + managed_agents::try_regenerate_nest, relay::{ - classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override, - submit_event, SubmitEventResponse, + classify_request_error, query_relay, query_relay_at, relay_api_base_url, + relay_http_base_url, relay_ws_url, relay_ws_url_with_override, submit_event, + workspace_relay_override, SubmitEventResponse, }, }; +/// A relay target resolved from a single workspace-override read, so a caller +/// that performs several relay requests cannot mix two relays if the workspace +/// override changes mid-flight. +/// +/// `relay_ws_url_with_override` and `relay_api_base_url_with_override` each read +/// the override independently; a workspace switch between two such reads can +/// pair one relay's NIP-11 signer with another relay's snapshot query. +/// Capturing both fields from one read — matching those two functions' exact +/// precedence, including the standalone `BUZZ_RELAY_HTTP` path when no override +/// is set — guarantees the pair is internally consistent. +pub(crate) struct RelayTarget { + /// Relay WebSocket URL (drives the NIP-11 fetch and the rendered footer). + pub ws_url: String, + /// Relay HTTP API base URL (drives `/query`). + pub api_base_url: String, +} + +/// Capture the effective relay target once, before any network work. +pub(crate) fn capture_relay_target(state: &AppState) -> RelayTarget { + match workspace_relay_override(state) { + Some(url) => RelayTarget { + api_base_url: relay_http_base_url(&url), + ws_url: url, + }, + None => RelayTarget { + ws_url: relay_ws_url(), + api_base_url: relay_api_base_url(), + }, + } +} + // ── Helpers ───────────────────────────────────────────────────────────────── /// Read `target`'s live `kind:0` event and extract the first valid NIP-OA @@ -139,44 +172,116 @@ pub struct UnarchiveRequest { pub reason: Option, } -/// Submit a `kind:9035` archive request to the relay. Consent path is selected -/// by the relay — we just attach the owner-of-agent `auth` tag when the live -/// `kind:0` proves we own the target, so the relay can choose the `owner` -/// path. Self and admin paths require no auth tag. -#[tauri::command] -pub async fn archive_identity( - req: ArchiveRequest, - state: State<'_, AppState>, +/// Roster refresh a successful archive/unarchive triggers. Binding the action +/// to a *type* rather than a closure selected at each call site is what closes +/// the regression Thufir found: the command wrapper passes a value (`&app`) +/// with no callback to construct, so the "regenerate on success" selection +/// lives entirely inside the cores below — where the tests traverse it. The +/// production binding is the single, irreducible `AppHandle` adapter. +pub(crate) trait NestRegenTrigger { + fn trigger(&self); +} + +impl NestRegenTrigger for AppHandle { + fn trigger(&self) { + try_regenerate_nest(self); + } +} + +/// Submit `builder` to the active workspace relay, then trigger `on_success` +/// exactly once iff the relay accepted the event. +/// +/// This pins the shared half of the archive/unarchive → AGENTS.md-regeneration +/// contract: regeneration is best-effort roster maintenance, so it must fire on +/// a successful submission and must NOT fire when the submit is rejected (a +/// rejected request changed nothing to re-render). +async fn submit_then_regenerate( + builder: nostr::EventBuilder, + state: &AppState, + on_success: impl FnOnce(), ) -> Result { - let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; - let auth_ref = auth_tag.as_ref(); + let response = submit_event(builder, state).await?; + on_success(); + Ok(response) +} +/// `AppHandle`-free core of [`archive_identity`]: resolve the owner-of-agent +/// `auth` tag, build the real `kind:9035` request, submit it, and trigger +/// `regen` so a successful archive refreshes the roster. +/// +/// The command wrapper is untestable (it needs a live Tauri runtime for its +/// `AppHandle`), so this core owns the whole orchestration — including *binding* +/// the regeneration trigger onto the successful-submit path. The wrapper only +/// hands it the `AppHandle` as the trigger; a test drives the exact archive +/// wiring with a counting trigger over a loopback relay. RED-on-revert: change +/// `|| regen.trigger()` to `|| {}` here and +/// `archive_core_fires_regen_only_on_accepted_submit` fails while the unarchive +/// core test stays green. +async fn archive_identity_core( + req: &ArchiveRequest, + state: &AppState, + regen: &impl NestRegenTrigger, +) -> Result { + let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?; let builder = events::build_archive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), req.replaced_by.as_deref(), - auth_ref, + auth_tag.as_ref(), )?; - submit_event(builder, &state).await + submit_then_regenerate(builder, state, || regen.trigger()).await } -/// Submit a `kind:9036` unarchive request to the relay. -#[tauri::command] -pub async fn unarchive_identity( - req: UnarchiveRequest, - state: State<'_, AppState>, +/// `AppHandle`-free core of [`unarchive_identity`]: builds the real `kind:9036` +/// request and triggers `regen` on acceptance. See [`archive_identity_core`] +/// for why this seam is extracted. RED-on-revert: change `|| regen.trigger()` +/// to `|| {}` here and `unarchive_core_fires_regen_only_on_accepted_submit` +/// fails while the archive core test stays green. +async fn unarchive_identity_core( + req: &UnarchiveRequest, + state: &AppState, + regen: &impl NestRegenTrigger, ) -> Result { - let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; - let auth_ref = auth_tag.as_ref(); - + let auth_tag = maybe_owner_auth_tag(state, &req.target_pubkey).await?; let builder = events::build_unarchive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), - auth_ref, + auth_tag.as_ref(), )?; - submit_event(builder, &state).await + submit_then_regenerate(builder, state, || regen.trigger()).await +} + +/// Submit a `kind:9035` archive request to the relay. Consent path is selected +/// by the relay — we just attach the owner-of-agent `auth` tag when the live +/// `kind:0` proves we own the target, so the relay can choose the `owner` +/// path. Self and admin paths require no auth tag. +/// +/// On acceptance, refresh AGENTS.md so a just-archived agent drops from the +/// roster without waiting for the next unrelated edit or app restart. The +/// regen is fire-and-forget and fail-open like every other mutation site; it +/// races the relay's kind:13535 snapshot update, so a stale render self-heals +/// on the next regen. +#[tauri::command] +pub async fn archive_identity( + req: ArchiveRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + archive_identity_core(&req, &state, &app).await +} + +/// Submit a `kind:9036` unarchive request to the relay. See +/// [`archive_identity`]: refresh the roster so an unarchived agent reappears +/// promptly, fail-open against the same snapshot race. +#[tauri::command] +pub async fn unarchive_identity( + req: UnarchiveRequest, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + unarchive_identity_core(&req, &state, &app).await } /// If the current user is the verified NIP-OA owner of `target`, return the @@ -228,8 +333,18 @@ struct RelayInformationDocument { } pub(crate) async fn fetch_relay_self(state: &AppState) -> Result, String> { - let relay_url = relay_ws_url_with_override(state); - let http_url = relay_http_base_url(&relay_url); + fetch_relay_self_at(state, &relay_ws_url_with_override(state)).await +} + +/// Like [`fetch_relay_self`] but reads NIP-11 from an explicit relay WS URL +/// instead of re-resolving the workspace override. Used by +/// [`fetch_archived_pubkeys_at`] so the advertised signer and the snapshot +/// query belong to the same captured relay target. +pub(crate) async fn fetch_relay_self_at( + state: &AppState, + relay_url: &str, +) -> Result, String> { + let http_url = relay_http_base_url(relay_url); let response = state .http_client .get(&http_url) @@ -275,46 +390,71 @@ fn archived_pubkeys_from_snapshot(snapshot: &nostr::Event) -> Vec { .collect() } -/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend -/// caches this and tests membership client-side to drive the "Archived" flair. +/// Read the relay's latest valid `kind:13535` archive snapshot as lowercase +/// hex pubkeys. Shared by the `list_archived_identities` command (frontend +/// flair) and the backend nest regen (excluding archived agents from +/// `AGENTS.md`). /// /// Per NIP-IA §Client Behavior and §Snapshot and Delta Consistency, only a /// snapshot signed by the relay identity advertised in NIP-11 `self` can affect -/// archive state. If the relay has no stable `self`, fail open with an empty -/// snapshot rather than trusting unauthenticated relay-authoritative state. -#[tauri::command] -pub async fn list_archived_identities( - state: State<'_, AppState>, -) -> Result { - let Some(relay_self) = fetch_relay_self(&state).await? else { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); +/// archive state. Every failure path — no stable `self`, no snapshot, a bad +/// signature or wrong author, or a query error — **fails open** with an empty +/// set rather than trusting unauthenticated relay-authoritative state. +pub(crate) async fn fetch_archived_pubkeys(state: &AppState) -> Vec { + fetch_archived_pubkeys_at(state, &capture_relay_target(state)).await +} + +/// Like [`fetch_archived_pubkeys`] but resolves both the NIP-11 signer and the +/// snapshot query against one captured [`RelayTarget`] instead of re-reading +/// the workspace override for each. This keeps a regeneration's advertised +/// signer and its snapshot query on the same relay even if the workspace +/// override changes between the two awaits. +pub(crate) async fn fetch_archived_pubkeys_at( + state: &AppState, + target: &RelayTarget, +) -> Vec { + let Ok(Some(relay_self)) = fetch_relay_self_at(state, &target.ws_url).await else { + return vec![]; }; - let events = query_relay( - &state, + let query = query_relay_at( + state, + &target.api_base_url, &[serde_json::json!({ "authors": [relay_self.clone()], "kinds": [13535], "limit": 1, })], ) - .await?; + .await; + let Ok(events) = query else { + return vec![]; + }; let Some(snapshot) = events.into_iter().next() else { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; }; // Defense-in-depth: the filter should already restrict author, but the // client must still reject malformed or wrongly signed relay state. if !snapshot.verify_id() || !snapshot.verify_signature() { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; } if !snapshot.pubkey.to_hex().eq_ignore_ascii_case(&relay_self) { - return Ok(ArchivedIdentitiesSnapshot { archived: vec![] }); + return vec![]; } + archived_pubkeys_from_snapshot(&snapshot) +} + +/// Read the relay's latest valid `kind:13535` archive snapshot. The frontend +/// caches this and tests membership client-side to drive the "Archived" flair. +#[tauri::command] +pub async fn list_archived_identities( + state: State<'_, AppState>, +) -> Result { Ok(ArchivedIdentitiesSnapshot { - archived: archived_pubkeys_from_snapshot(&snapshot), + archived: fetch_archived_pubkeys(&state).await, }) } @@ -336,6 +476,29 @@ pub async fn get_relay_self(state: State<'_, AppState>) -> Result mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[cfg(not(target_os = "windows"))] + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Counting [`NestRegenTrigger`] double: records how many times the core + /// fires regeneration on the successful-submit path, standing in for the + /// production `AppHandle` binding without a live Tauri runtime. + #[cfg(not(target_os = "windows"))] + #[derive(Default)] + struct CountingRegen(AtomicUsize); + + #[cfg(not(target_os = "windows"))] + impl CountingRegen { + fn count(&self) -> usize { + self.0.load(Ordering::SeqCst) + } + } + + #[cfg(not(target_os = "windows"))] + impl NestRegenTrigger for CountingRegen { + fn trigger(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } /// Build a fake `kind:0` with a valid NIP-OA auth tag for a fresh owner. fn kind0_with_auth(agent: &Keys, owner: &Keys) -> nostr::Event { @@ -478,4 +641,223 @@ mod tests { assert_eq!(minimal.content, ""); assert!(minimal.reason.is_none()); } + + /// Regression for the cross-relay capture defect: `fetch_archived_pubkeys_at` + /// must resolve BOTH the NIP-11 signer and the `/query` snapshot against the + /// single captured [`RelayTarget`], never re-reading the live workspace + /// override. Two loopback relays advertise distinct signers and archive + /// distinct pubkeys; we capture relay A, then mutate the override to relay B + /// before the fetch. Because capture happens once up front, the override's + /// value at any later instant — including between the two archive awaits — + /// is irrelevant by construction, so setting it to B is the strongest form + /// of that perturbation. A must supply both the signer and the snapshot. + /// + /// RED-on-revert: restore `fetch_archived_pubkeys` to read the override for + /// each leg (`fetch_relay_self` + `query_relay`) and this returns B's pubkey. + #[tokio::test] + async fn archived_fetch_never_crosses_relays_mid_flight() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + use axum::{routing::get, routing::post, Json, Router}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + // Build a loopback relay that advertises `relay_keys` as its NIP-11 + // `self` and serves a relay-signed 13535 snapshot archiving `archived`. + async fn spawn_relay(relay_keys: Keys, archived: String) -> String { + let self_hex = relay_keys.public_key().to_hex(); + let snapshot = EventBuilder::new(Kind::Custom(13535), "") + .tags([ + Tag::parse(["-"]).unwrap(), + Tag::parse(["p", &archived]).unwrap(), + ]) + .sign_with_keys(&relay_keys) + .unwrap(); + let snapshot_json = serde_json::to_value(&snapshot).unwrap(); + + let router = Router::new() + .route( + "/", + get(move || { + let self_hex = self_hex.clone(); + async move { Json(serde_json::json!({ "self": self_hex })) } + }), + ) + .route( + "/query", + post(move || { + let snapshot_json = snapshot_json.clone(); + async move { Json(serde_json::json!([snapshot_json])) } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + format!("ws://{addr}") + } + + let relay_a_keys = Keys::generate(); + let relay_b_keys = Keys::generate(); + // Distinct archived pubkeys, unrelated to either relay's signing key — + // nostr 0.37's EventBuilder silently drops a `p` tag that references the + // event's own signer, so the archived key must not equal the relay key. + let archived_on_a = Keys::generate().public_key().to_hex(); + let archived_on_b = Keys::generate().public_key().to_hex(); + let relay_a = spawn_relay(relay_a_keys, archived_on_a.clone()).await; + let relay_b = spawn_relay(relay_b_keys, archived_on_b.clone()).await; + + let state = build_app_state(); + + // Capture relay A, then swap the override to relay B before the fetch. + *state.relay_url_override.lock().unwrap() = Some(relay_a.clone()); + let target = capture_relay_target(&state); + *state.relay_url_override.lock().unwrap() = Some(relay_b.clone()); + + let archived = fetch_archived_pubkeys_at(&state, &target).await; + + assert_eq!( + archived, + vec![archived_on_a], + "signer and snapshot must both come from the captured relay A, \ + never the mutated override (relay B)" + ); + reset_rate_limit_gate(); + } + + /// Spawn a loopback `/events` relay that answers every submit with the + /// given `accepted` verdict, so the archive/unarchive cores see a real + /// success or rejection over the wire. Returns the `ws://` base. + /// + /// The literal `/events` route below is why this file carries an + /// `EVENTS_INVENTORY` row (one occurrence, zero guard calls): a test + /// loopback, never a production egress site. + #[cfg(not(target_os = "windows"))] + async fn spawn_submit_relay(accepted: bool) -> String { + use axum::{routing::post, Json, Router}; + + let router = Router::new() + .route( + "/events", + post(move || async move { + Json(serde_json::json!({ + "event_id": "e".repeat(64), + "accepted": accepted, + "message": if accepted { "" } else { "rejected by relay" }, + })) + }), + ) + // The cores resolve the owner-of-agent auth tag first, which reads + // the target's live kind:0; answer with an empty result set so that + // read resolves to "no owner tag" without a live upstream relay. + .route("/query", post(|| async { Json(serde_json::json!([])) })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.ok(); + }); + format!("ws://{addr}") + } + + /// Regression for the outsider-reported item 1, archive site: a successful + /// `kind:9035` archive MUST trigger nest regeneration, and a rejected + /// submit MUST NOT. This drives the production [`archive_identity_core`] + /// (the exact seam the command wrapper delegates to), forwarding a counting + /// hook against a loopback relay. RED-on-revert: replace the core's + /// forwarded `on_success` with `|| {}` and the "fires once" assertion fails; + /// this pins the archive command's callback independently of unarchive. + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn archive_core_fires_regen_only_on_accepted_submit() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + let state = build_app_state(); + let req = ArchiveRequest { + target_pubkey: Keys::generate().public_key().to_hex(), + content: String::new(), + reason: None, + replaced_by: None, + }; + + // Accepted archive → hook fires exactly once. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await); + let regen = CountingRegen::default(); + let response = archive_identity_core(&req, &state, ®en) + .await + .expect("accepted archive returns Ok"); + assert!(response.accepted); + assert_eq!( + regen.count(), + 1, + "an accepted archive must trigger regeneration exactly once" + ); + + // Rejected submit → error propagates, hook never fires. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await); + let regen = CountingRegen::default(); + let result = archive_identity_core(&req, &state, ®en).await; + assert!(result.is_err(), "a rejected archive must return an error"); + assert_eq!( + regen.count(), + 0, + "a rejected archive changed nothing, so regeneration must not fire" + ); + + reset_rate_limit_gate(); + } + + /// Regression for item 1, unarchive site: mirrors + /// [`archive_core_fires_regen_only_on_accepted_submit`] against the + /// `kind:9036` [`unarchive_identity_core`]. RED-on-revert: replace that + /// core's forwarded `on_success` with `|| {}` and this fails while the + /// archive test stays green — proving each command's callback is pinned + /// independently, not just the shared `submit_then_regenerate`. + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn unarchive_core_fires_regen_only_on_accepted_submit() { + use crate::app_state::build_app_state; + use crate::relay_admission::{reset_rate_limit_gate, TEST_SERIAL}; + + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + + let state = build_app_state(); + let req = UnarchiveRequest { + target_pubkey: Keys::generate().public_key().to_hex(), + content: String::new(), + reason: None, + }; + + // Accepted unarchive → hook fires exactly once. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(true).await); + let regen = CountingRegen::default(); + let response = unarchive_identity_core(&req, &state, ®en) + .await + .expect("accepted unarchive returns Ok"); + assert!(response.accepted); + assert_eq!( + regen.count(), + 1, + "an accepted unarchive must trigger regeneration exactly once" + ); + + // Rejected submit → error propagates, hook never fires. + *state.relay_url_override.lock().unwrap() = Some(spawn_submit_relay(false).await); + let regen = CountingRegen::default(); + let result = unarchive_identity_core(&req, &state, ®en).await; + assert!(result.is_err(), "a rejected unarchive must return an error"); + assert_eq!( + regen.count(), + 0, + "a rejected unarchive changed nothing, so regeneration must not fire" + ); + + reset_rate_limit_gate(); + } } diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 1513742beaf..0c2a9573af6 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -276,6 +276,10 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Mock-relay route in its in-file tests; production publish goes through // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). ("src/commands/personas/sharing.rs", 1, 0), + // Loopback submit relay in `identity_archive.rs`'s in-file regen tests; + // production archive/unarchive publish through the guarded boundary-1 + // funnel via `submit_event`. + ("src/commands/identity_archive.rs", 1, 0), ]; // Needles are assembled at runtime so this scan file itself contains no diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a97..72cf4664272 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -11,10 +11,12 @@ use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRec #[cfg(test)] use super::{BackendKind, RespondTo}; use crate::app_state::AppState; -use crate::relay::relay_ws_url_with_override; +use crate::commands::{capture_relay_target, fetch_archived_pubkeys_at}; +use std::collections::HashSet; use std::fs; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use tauri::{AppHandle, Manager}; use crate::managed_agents::discovery::known_skill_dirs; @@ -523,19 +525,35 @@ fn escape_md_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } +/// True iff the relay has archived this instance's identity. Membership is +/// tested against the relay's `kind:13535` snapshot (lowercased hex); an empty +/// set (relay unreachable) fails open — see [`regenerate_nest_context`]. +fn is_archived(record: &ManagedAgentRecord, archived: &HashSet) -> bool { + archived.contains(&record.pubkey.to_ascii_lowercase()) +} + pub fn render_dynamic_section( personas: &[AgentDefinition], agents: &[ManagedAgentRecord], + archived: &HashSet, relay_url: &str, ) -> String { - let active_agents = if agents.is_empty() { + // Every managed agent is eligible on every community — `relay_url` is a + // legacy creation-era field that `effective_agent_relay_url()` deliberately + // ignores, and snapshot-imported records store it empty by design. The only + // roster filter is identity-archive. + let live: Vec<&ManagedAgentRecord> = agents + .iter() + .filter(|a| !is_archived(a, archived)) + .collect(); + let active_agents = if live.is_empty() { "## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*" .to_string() } else { let mut table = "## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|" .to_string(); - for agent in agents { + for agent in live { let role = agent .persona_id .as_deref() @@ -645,7 +663,124 @@ pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io Ok(()) } -pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { +/// Serializes nest-context writes so a slow, stale regeneration cannot roll the +/// file back over a newer one. This is an ordered, latest-request-wins gate — +/// not a work coalescer: every superseded generation still performs its relay +/// reads, then drops its result at commit time. Adding a true dirty-loop owner +/// would be a larger change and is unwarranted at this user-driven trigger rate. +/// +/// Each regeneration request claims a monotonic generation *synchronously* at +/// request time (see [`NestRegenGate::claim`]), so the generation encodes +/// program order: boot's regen is claimed before `apply_workspace`'s, an edit's +/// regen before the next edit's. The claimed generation travels with the +/// spawned task and gates its write in [`NestRegenGate::commit`]: a task drops +/// its result once a *newer generation has been requested*, even if that newer +/// generation later fails before it writes. Gating on the highest *requested* +/// generation — not the highest *written* one — is what stops a slow, stale +/// pre-edit render from publishing after a newer post-edit render was claimed +/// and then failed during its relay work (which would otherwise leave the +/// obsolete roster authoritative until the next unrelated trigger). Declared +/// semantic: once a newer regeneration is requested, no older one publishes; +/// if that newer one fails, the file simply waits for the next trigger. +/// +/// `claim` and `commit` share one lock, so the "is this still the newest +/// request?" compare is atomic with the synchronous file write. A bare atomic +/// watermark checked separately from the write would let a new claim slip +/// between an older task's eligibility check and its write; holding the lock +/// across both closes that window (no `await` occurs while it is held). +struct NestRegenGate { + /// Highest generation *requested* so far (`0` = none yet). Advanced by + /// [`claim`] and read by [`commit`]; guarding both under this single lock + /// keeps the eligibility compare atomic with the file write. + highest_requested: Mutex, +} + +impl NestRegenGate { + const fn new() -> Self { + Self { + highest_requested: Mutex::new(0), + } + } + + /// Claim the next generation. Call synchronously at request time so the + /// value reflects when the regeneration was requested, not when its task + /// happens to run. Advancing the shared watermark here is what lets a later + /// [`commit`] recognize — and drop — any older generation's stale render. + fn claim(&self) -> u64 { + let mut requested = self + .highest_requested + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *requested += 1; + *requested + } + + /// Non-blocking [`claim`] against the *exact* lock `claim` takes. Returns + /// `Some(generation)` if it acquired the lock — i.e. a claim could proceed + /// with no contention — or `None` if the lock is already held, meaning a + /// concurrent claim would block on it. Because `claim` and `commit` share + /// `highest_requested`, calling this from inside `commit_hooked`'s + /// under-lock hook reports `None`: the eligibility compare and the write + /// are serialized against any new claim. A design that advanced the + /// watermark under a separate lock (or a lock-free atomic) would report + /// `Some` here — the regression this probe proves absent, with no reliance + /// on elapsed time or thread scheduling. + #[cfg(test)] + fn try_claim(&self) -> Option { + match self.highest_requested.try_lock() { + Ok(mut requested) => { + *requested += 1; + Some(*requested) + } + Err(std::sync::TryLockError::WouldBlock) => None, + Err(std::sync::TryLockError::Poisoned(poisoned)) => { + let mut requested = poisoned.into_inner(); + *requested += 1; + Some(*requested) + } + } + } + + /// Commit `content` for `generation`, dropping the write once a newer + /// generation has been *requested* (regardless of whether that newer + /// generation has written or ever will). Returns whether the file was + /// written. The lock spans the compare and the write so the check-and-write + /// is atomic and no await occurs while it is held. + fn commit(&self, agents_md: &Path, content: &str, generation: u64) -> io::Result { + self.commit_hooked(agents_md, content, generation, || {}) + } + + /// [`commit`] with a hook invoked while the lock is held, after the + /// eligibility compare and before the write. Production passes a no-op, so + /// this is exactly [`commit`]; tests pass a hook that calls [`try_claim`] + /// to prove no claim can land inside the compare-then-write window — the + /// probe reports the lock held here, whereas the flawed + /// separate-watermark/separate-write-lock design would report it free. The + /// `impl FnOnce` monomorphizes the no-op away. + fn commit_hooked( + &self, + agents_md: &Path, + content: &str, + generation: u64, + under_lock: impl FnOnce(), + ) -> io::Result { + let requested = self + .highest_requested + .lock() + .map_err(|_| io::Error::other("nest regen gate lock poisoned"))?; + if generation < *requested { + return Ok(false); + } + under_lock(); + upsert_managed_section(agents_md, content)?; + Ok(true) + } +} + +/// Process-wide ordered write gate for nest-context regeneration. +static NEST_REGEN: NestRegenGate = NestRegenGate::new(); + +pub async fn regenerate_nest_context(app: &AppHandle, generation: u64) -> Result<(), String> { let nest = nest_dir().ok_or("cannot resolve home directory for nest")?; let agents_md = nest.join("AGENTS.md"); @@ -656,23 +791,51 @@ pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> { let personas = load_personas(app)?; let agents = load_managed_agents(app)?; let state = app.state::(); - let relay_url = relay_ws_url_with_override(&state); - let content = render_dynamic_section(&personas, &agents, &relay_url); - upsert_managed_section(&agents_md, &content) + // Capture the relay target once, before any network work, so this + // generation's rendered footer, NIP-11 signer, and snapshot query all + // belong to one relay even if a workspace switch changes the override + // between the two archive awaits below. + let target = capture_relay_target(&state); + // Identity-archived agents live only in the relay's `kind:13535` snapshot; + // local records all read `is_active: true`. Fails open (empty set → render + // everyone) so an unreachable relay can't blank the roster. The archive read + // uses the same captured target as the rendered relay; a later generation's + // task always wins the commit, so a fallback-relay boot render cannot bury a + // later apply_workspace render. + let archived: HashSet = fetch_archived_pubkeys_at(&state, &target) + .await + .into_iter() + .collect(); + let content = render_dynamic_section(&personas, &agents, &archived, &target.ws_url); + NEST_REGEN + .commit(&agents_md, &content, generation) .map_err(|e| format!("regenerate nest context: {e}"))?; Ok(()) } -/// Convenience wrapper: regenerates nest context, logging a warning on failure. +/// Convenience wrapper: claims a regeneration generation, then regenerates on a +/// spawned task, logging a warning on failure. /// /// All call sites treat regeneration as fire-and-forget — agents run fine with /// a stale AGENTS.md, so we warn and continue rather than propagating the error. +/// The generation is claimed *here*, synchronously, so it encodes call order; +/// the spawned task carries it into [`NestRegenGate::commit`], which drops +/// a stale render rather than letting a slow task overwrite a newer file. +/// Archive/unarchive trigger this directly, but the regen races the relay's +/// `kind:13535` snapshot update, so a just-archived agent may still linger for +/// one cycle until the next regen (any agent/team edit or the next launch). pub fn try_regenerate_nest(app: &AppHandle) { - if let Err(error) = regenerate_nest_context(app) { - eprintln!("buzz-desktop: nest context regeneration failed: {error}"); - } + let generation = NEST_REGEN.claim(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = regenerate_nest_context(&app, generation).await { + eprintln!("buzz-desktop: nest context regeneration failed: {error}"); + } + }); } +#[cfg(test)] +mod render_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/nest/render_tests.rs b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs new file mode 100644 index 00000000000..ed4ee2c1f9b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest/render_tests.rs @@ -0,0 +1,713 @@ +//! Tests for the dynamic AGENTS.md section renderer, the managed-section +//! upsert, and the regeneration gate. Split from `tests.rs` to keep +//! each test file under the repository's per-file line ratchet. + +use super::*; +use std::collections::HashSet; + +/// Relay URL passed to render calls. Since the roster no longer filters on +/// `relay_url`, this is only echoed into the Workspace footer. +const TEST_RELAY: &str = "ws://example.com:3000"; + +fn make_persona(id: &str, display_name: &str) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: display_name.to_string(), + avatar_url: None, + system_prompt: String::new(), + runtime: None, + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } +} + +fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: name.to_string(), + persona_id: persona_id.map(|s| s.to_string()), + private_key_nsec: String::new(), + auth_tag: None, + relay_url: TEST_RELAY.to_string(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::default(), + backend_agent_id: None, + provider_policy_pending: false, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + env_vars: std::collections::BTreeMap::new(), + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + effort_level: None, + } +} + +#[test] +fn test_render_dynamic_section_with_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Kit", Some("p1"))]; + let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); + assert!(output.contains("| Name | Persona | How to address |")); + assert!(output.contains("## Workspace")); +} + +#[test] +fn test_render_dynamic_section_empty() { + let output = render_dynamic_section(&[], &[], &HashSet::new(), TEST_RELAY); + assert!(output.contains("No agents deployed yet")); +} + +#[test] +fn test_render_dynamic_section_agent_no_persona() { + let personas = vec![make_persona("p1", "Builder")]; + let agents = vec![make_agent("Scout", Some("nonexistent"))]; + let output = render_dynamic_section(&personas, &agents, &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Scout | — | @Scout |")); +} + +#[test] +fn test_render_excludes_archived_agents() { + let personas = vec![make_persona("p1", "Builder")]; + let mut live = make_agent("Live", Some("p1")); + live.pubkey = "aa".repeat(32); + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "bb".repeat(32); + let archived: HashSet = [gone.pubkey.clone()].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[live, gone], &archived, TEST_RELAY); + + assert!(output.contains("| Live | Builder | @Live |")); + assert!( + !output.contains("Archived"), + "archived agent must not render" + ); +} + +#[test] +fn test_render_archived_match_is_case_insensitive() { + let personas = vec![make_persona("p1", "Builder")]; + let mut gone = make_agent("Archived", Some("p1")); + gone.pubkey = "AB".repeat(32); // uppercase hex in the record + // Snapshot pubkeys are lowercased by `archived_pubkeys_from_snapshot`. + let archived: HashSet = ["ab".repeat(32)].into_iter().collect(); + + let output = render_dynamic_section(&personas, &[gone], &archived, TEST_RELAY); + + assert!( + output.contains("No agents deployed yet"), + "all-archived roster renders the empty placeholder" + ); +} + +#[test] +fn test_render_empty_archived_set_renders_all() { + let personas = vec![make_persona("p1", "Builder")]; + let mut a = make_agent("Kit", Some("p1")); + a.pubkey = "cc".repeat(32); + // Fail-open: an empty snapshot (relay unreachable) must render everyone. + let output = render_dynamic_section(&personas, &[a], &HashSet::new(), TEST_RELAY); + assert!(output.contains("| Kit | Builder | @Kit |")); +} + +#[test] +fn test_render_keeps_agent_with_legacy_foreign_relay_pin() { + // `relay_url` is a legacy creation-era field that `effective_agent_relay_url()` + // deliberately ignores — every agent is eligible on every community. A record + // whose stored pin points at a now-defunct relay must still render on the + // active workspace; only identity-archive removes an agent. + let personas = vec![make_persona("p1", "Builder")]; + let here = make_agent("Local", Some("p1")); + let mut elsewhere = make_agent("Foreign", Some("p1")); + elsewhere.relay_url = "wss://defunct.communities.buzz.xyz".to_string(); + + let output = render_dynamic_section(&personas, &[here, elsewhere], &HashSet::new(), TEST_RELAY); + + assert!(output.contains("| Local | Builder | @Local |")); + assert!( + output.contains("| Foreign | Builder | @Foreign |"), + "a legacy foreign relay pin must not hide an agent — the pin is ignored" + ); +} + +#[test] +fn test_render_keeps_snapshot_imported_agent_with_empty_relay_pin() { + // Snapshot-imported records store `relay_url: ""` by design; they resolve + // to the workspace relay at runtime. Such an agent must appear on the active + // workspace, not be hidden by an empty pin. + let personas = vec![make_persona("p1", "Builder")]; + let mut imported = make_agent("Imported", Some("p1")); + imported.relay_url = String::new(); + + let output = render_dynamic_section(&personas, &[imported], &HashSet::new(), TEST_RELAY); + + assert!( + output.contains("| Imported | Builder | @Imported |"), + "an empty relay_url (snapshot-import shape) must still render" + ); +} + +#[test] +fn test_upsert_managed_section_with_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("")); + assert!(result.contains("new section")); + assert!(!result.contains("old section")); + assert!(result.contains("# Header")); + assert!(result.contains("some content")); + assert!(result.contains("after")); +} + +#[test] +fn test_upsert_managed_section_without_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write(&file, "# Header\n\nexisting content\n").unwrap(); + + upsert_managed_section(&file, "injected section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + assert!(result.contains("# Header")); + assert!(result.contains("existing content")); + assert!(result.contains("")); + assert!(result.contains("injected section")); + let begin_pos = result.find("\nsome middle content\n\nold section\n", + ) + .unwrap(); + + upsert_managed_section(&file, "new section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("new section"), + "new content must be present" + ); + assert!( + result.contains("some middle content"), + "content between markers must survive" + ); + + // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); + + // The single BEGIN marker must have a matching END marker after it. + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); + assert!( + end_pos.is_some(), + "an END marker must appear after the appended BEGIN marker" + ); +} + +#[test] +fn test_upsert_begin_only_no_end() { + // A file with BEGIN but no END has an orphan marker. + // find_managed_markers returns None (no END found after BEGIN), + // so strip_orphan_begin_marker removes the BEGIN line. + // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, + // not the body that came after it). + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\nsome content\n\n\norphaned section without end marker\n", + ) + .unwrap(); + + upsert_managed_section(&file, "fresh section").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!(result.contains("# Header"), "original header must survive"); + assert!( + result.contains("some content"), + "original body must survive" + ); + assert!( + result.contains("fresh section"), + "new content must be present" + ); + + let begin_pos = result + .find(BEGIN_MARKER) + .expect("BEGIN marker must be present"); + let end_pos = result.find(END_MARKER).expect("END marker must be present"); + assert!( + begin_pos < end_pos, + "the appended BEGIN marker must precede the appended END marker" + ); + + // Exactly one BEGIN marker after orphan cleanup. + assert_eq!( + result.matches(BEGIN_MARKER).count(), + 1, + "exactly one BEGIN marker after orphan cleanup" + ); +} + +#[test] +fn test_upsert_duplicate_markers() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "replaced").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains("replaced"), + "replacement content must be present" + ); + assert!( + !result.contains("first block"), + "first block must be replaced" + ); + assert!( + result.contains("second block"), + "second pair content must survive" + ); + assert!( + result.contains("between blocks"), + "text between pairs must survive" + ); +} + +#[test] +fn test_upsert_marker_in_code_block() { + let tmp = tempfile::tempdir().unwrap(); + let file = tmp.path().join("AGENTS.md"); + // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. + fs::write( + &file, + "# Header\n\n \n\nReal content here\n", + ) + .unwrap(); + + upsert_managed_section(&file, "appended content").unwrap(); + + let result = fs::read_to_string(&file).unwrap(); + + assert!( + result.contains(" "), + "indented marker inside code block must be preserved verbatim" + ); + assert!( + result.contains("appended content"), + "new content must be appended" + ); + assert!( + result.contains("Real content here"), + "existing body must survive" + ); + + // The real markers appended at the end must be at line-start (column 0). + let begin_pos = result + .find("\nexisting section\n\n", + ) + .unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_first = fs::read_to_string(&file).unwrap(); + + upsert_managed_section(&file, "same content").unwrap(); + let after_second = fs::read_to_string(&file).unwrap(); + + assert_eq!( + after_first, after_second, + "upsert must be idempotent: second call must not alter the file" + ); +} + +/// Write an AGENTS.md skeleton with an empty managed section and return its path. +fn agents_md_with_markers(dir: &Path) -> PathBuf { + let file = dir.join("AGENTS.md"); + fs::write( + &file, + "# Header\n\n\n\n\n", + ) + .unwrap(); + file +} + +#[test] +fn commit_newer_generation_wins_over_a_stale_finisher() { + // Models the CRUD race: generation A snapshots pre-edit state and its relay + // fetch is slow; generation B snapshots post-edit state and commits first. + // When A finally finishes and commits LAST, its lower generation is dropped + // so the file still reflects B. Ordering of *finishing* is the only variable — + // the generation, claimed at request time, decides the winner. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen_a = gate.claim(); // pre-edit request + let gen_b = gate.claim(); // post-edit request + assert!(gen_a < gen_b); + + // B (newer) commits first. + assert!(gate.commit(&file, "post-edit roster", gen_b).unwrap()); + // A (older) finishes last and must be dropped. + assert!( + !gate.commit(&file, "pre-edit roster", gen_a).unwrap(), + "a stale (lower-generation) render must not overwrite a newer one" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("post-edit roster")); + assert!( + !content.contains("pre-edit roster"), + "final file must reflect the newer generation, not the stale finisher" + ); +} + +#[test] +fn commit_boot_fallback_relay_cannot_bury_apply_workspace_relay() { + // Models boot→apply_workspace relay switching: the boot regen (generation 1, + // fallback relay) is claimed first but finishes last; the apply_workspace + // regen (generation 2, workspace relay) commits first. The workspace relay + // render must survive even though the fallback-relay task writes afterward. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let boot_gen = gate.claim(); // boot, fallback relay + let apply_gen = gate.claim(); // apply_workspace, workspace relay + + // apply_workspace's render lands first. + assert!(gate + .commit( + &file, + "## Workspace\n- Relay: wss://workspace.example", + apply_gen, + ) + .unwrap()); + // Boot's slower fallback-relay render finishes last and is dropped. + assert!(!gate + .commit( + &file, + "## Workspace\n- Relay: wss://fallback.example", + boot_gen, + ) + .unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("wss://workspace.example")); + assert!( + !content.contains("wss://fallback.example"), + "the fallback-relay boot render must not overwrite the workspace-relay render" + ); +} + +#[test] +fn commit_failed_newer_request_still_supersedes_older_snapshot() { + // Carl 4954831197, case 1: a newer request that never writes must still + // permanently supersede an older snapshot. gen1 (pre-edit) is claimed and + // its relay work is slow; an edit claims gen2 (post-edit); gen2 then FAILS + // during its relay work, so it never commits. When gen1 finally finishes, + // it must NOT publish its obsolete roster — gating on highest-*requested* + // (advanced by gen2's claim) drops it, whereas gating on highest-*written* + // (0, since gen2 never wrote) would wrongly let gen1 publish. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen1 = gate.claim(); // pre-edit request + let gen2 = gate.claim(); // post-edit request + assert!(gen1 < gen2); + + // gen2 fails during relay work and never reaches commit — nothing written. + + // gen1 finishes last; its stale render must be dropped. + assert!( + !gate.commit(&file, "pre-edit roster", gen1).unwrap(), + "an older snapshot must not publish once a newer generation was requested, \ + even if that newer generation failed before writing" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!( + !content.contains("pre-edit roster"), + "the obsolete pre-edit roster must never become authoritative" + ); +} + +#[test] +fn commit_claim_at_the_older_tasks_cutover_supersedes_it() { + // Carl 4954831197, case 2: a claim arriving at the older task's commit + // cutover must not slip between the eligibility compare and the write. + // gen1 becomes eligible and enters `commit`; while it holds the lock + // (after the compare, before the write) a claim is attempted. The correct + // single-lock gate shares `highest_requested` between `claim` and + // `commit`, so that claim cannot acquire the lock until gen1's write + // releases it — the flawed separate-watermark/separate-write-lock design + // Carl warned about would let the claim proceed immediately. + // + // Determinism: the under-lock hook calls `try_claim`, a non-blocking claim + // against the exact lock `claim` takes, and asserts it reports the lock + // held (`None`). This is a direct statement about the gate's locking with + // no thread, channel, or sleep — the correct design necessarily returns + // `None` and the separate-watermark design necessarily returns `Some`, so + // the discriminator cannot be flipped by scheduler timing. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen1 = gate.claim(); + + let wrote_gen1 = gate + .commit_hooked(&file, "gen1 roster", gen1, || { + // We are past the eligibility compare and hold the lock. A claim + // attempted now must find the shared lock held — proving the + // compare and the write are atomic against any new claim. + assert!( + gate.try_claim().is_none(), + "a claim must not acquire the gate while an older commit holds \ + the shared lock between its eligibility check and its write — \ + the eligibility compare is not atomic with the write \ + (separate-watermark design)" + ); + }) + .unwrap(); + assert!( + wrote_gen1, + "gen1 was still the highest request when it entered commit, so its write \ + is legitimate; the newer request only lands after the lock releases" + ); + + // The lock is free once commit returns, so a newer request now claims and + // may publish over gen1. + let gen2 = gate.claim(); + assert!(gen1 < gen2); + assert!(gate.commit(&file, "gen2 roster", gen2).unwrap()); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("gen2 roster")); + assert!(!content.contains("gen1 roster")); +} + +#[test] +fn commit_equal_generation_is_allowed() { + // The gate rejects only strictly-lower generations. Re-committing the same + // generation (e.g. a retried request) is permitted and refreshes the file. + let gate = NestRegenGate::new(); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + + let gen = gate.claim(); + assert!(gate.commit(&file, "first", gen).unwrap()); + assert!( + gate.commit(&file, "second", gen).unwrap(), + "an equal generation must still be allowed to write" + ); + + let content = fs::read_to_string(&file).unwrap(); + assert!(content.contains("second")); +} + +#[test] +fn commit_poisoned_lock_returns_error_instead_of_panicking() { + // A poisoned gate lock must degrade to an io::Error so the fire-and-forget + // caller warns and continues, never panicking the desktop process (root + // AGENTS.md: no new expect() in production paths). Poison the lock by + // panicking a thread while it holds the guard, then assert commit yields + // Err rather than unwinding. + let gate = std::sync::Arc::new(NestRegenGate::new()); + let tmp = tempfile::tempdir().unwrap(); + let file = agents_md_with_markers(tmp.path()); + let gen = gate.claim(); + + let poisoner = gate.clone(); + let _ = std::thread::spawn(move || { + let _guard = poisoner.highest_requested.lock().unwrap(); + panic!("poison the gate lock"); + }) + .join(); + + let result = gate.commit(&file, "after poison", gen); + assert!( + result.is_err(), + "a poisoned lock must surface as an error, not a panic" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 672094d6c2b..bc67a5b69eb 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -422,416 +422,6 @@ fn ensure_cli_symlink_does_not_clobber_regular_file_dev() { ); } -fn make_persona(id: &str, display_name: &str) -> AgentDefinition { - AgentDefinition { - id: id.to_string(), - display_name: display_name.to_string(), - avatar_url: None, - system_prompt: String::new(), - runtime: None, - model: None, - provider: None, - name_pool: vec![], - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: std::collections::BTreeMap::new(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: String::new(), - updated_at: String::new(), - } -} - -fn make_agent(name: &str, persona_id: Option<&str>) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: String::new(), - name: name.to_string(), - persona_id: persona_id.map(|s| s.to_string()), - private_key_nsec: String::new(), - auth_tag: None, - relay_url: String::new(), - avatar_url: None, - acp_command: String::new(), - agent_command: String::new(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 0, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::default(), - backend_agent_id: None, - provider_policy_pending: false, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: String::new(), - updated_at: String::new(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::default(), - respond_to_allowlist: vec![], - env_vars: std::collections::BTreeMap::new(), - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - effort_level: None, - } -} - -#[test] -fn test_render_dynamic_section_with_agents() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Kit", Some("p1"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Kit | Builder | @Kit |")); - assert!(output.contains("| Name | Persona | How to address |")); - assert!(output.contains("## Workspace")); -} - -#[test] -fn test_render_dynamic_section_empty() { - let output = render_dynamic_section(&[], &[], "ws://example.com:3000"); - assert!(output.contains("No agents deployed yet")); -} - -#[test] -fn test_render_dynamic_section_agent_no_persona() { - let personas = vec![make_persona("p1", "Builder")]; - let agents = vec![make_agent("Scout", Some("nonexistent"))]; - let output = render_dynamic_section(&personas, &agents, "ws://example.com:3000"); - assert!(output.contains("| Scout | — | @Scout |")); -} - -#[test] -fn test_upsert_managed_section_with_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\nold section\n\n\nafter\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("")); - assert!(result.contains("new section")); - assert!(!result.contains("old section")); - assert!(result.contains("# Header")); - assert!(result.contains("some content")); - assert!(result.contains("after")); -} - -#[test] -fn test_upsert_managed_section_without_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write(&file, "# Header\n\nexisting content\n").unwrap(); - - upsert_managed_section(&file, "injected section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - assert!(result.contains("# Header")); - assert!(result.contains("existing content")); - assert!(result.contains("")); - assert!(result.contains("injected section")); - let begin_pos = result.find("\nsome middle content\n\nold section\n", - ) - .unwrap(); - - upsert_managed_section(&file, "new section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("new section"), - "new content must be present" - ); - assert!( - result.contains("some middle content"), - "content between markers must survive" - ); - - // Exactly one BEGIN marker in the output (the orphan was stripped, new one appended). - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); - - // The single BEGIN marker must have a matching END marker after it. - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result[begin_pos..].find(END_MARKER).map(|p| begin_pos + p); - assert!( - end_pos.is_some(), - "an END marker must appear after the appended BEGIN marker" - ); -} - -#[test] -fn test_upsert_begin_only_no_end() { - // A file with BEGIN but no END has an orphan marker. - // find_managed_markers returns None (no END found after BEGIN), - // so strip_orphan_begin_marker removes the BEGIN line. - // Content that followed the orphan BEGIN is preserved (only the marker line is stripped, - // not the body that came after it). - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\nsome content\n\n\norphaned section without end marker\n", - ) - .unwrap(); - - upsert_managed_section(&file, "fresh section").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!(result.contains("# Header"), "original header must survive"); - assert!( - result.contains("some content"), - "original body must survive" - ); - assert!( - result.contains("fresh section"), - "new content must be present" - ); - - let begin_pos = result - .find(BEGIN_MARKER) - .expect("BEGIN marker must be present"); - let end_pos = result.find(END_MARKER).expect("END marker must be present"); - assert!( - begin_pos < end_pos, - "the appended BEGIN marker must precede the appended END marker" - ); - - // Exactly one BEGIN marker after orphan cleanup. - assert_eq!( - result.matches(BEGIN_MARKER).count(), - 1, - "exactly one BEGIN marker after orphan cleanup" - ); -} - -#[test] -fn test_upsert_duplicate_markers() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - fs::write( - &file, - "# Header\n\n\nfirst block\n\n\nbetween blocks\n\n\nsecond block\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "replaced").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains("replaced"), - "replacement content must be present" - ); - assert!( - !result.contains("first block"), - "first block must be replaced" - ); - assert!( - result.contains("second block"), - "second pair content must survive" - ); - assert!( - result.contains("between blocks"), - "text between pairs must survive" - ); -} - -#[test] -fn test_upsert_marker_in_code_block() { - let tmp = tempfile::tempdir().unwrap(); - let file = tmp.path().join("AGENTS.md"); - // Indented by 4 spaces — not at column 0, so should NOT match as a real marker. - fs::write( - &file, - "# Header\n\n \n\nReal content here\n", - ) - .unwrap(); - - upsert_managed_section(&file, "appended content").unwrap(); - - let result = fs::read_to_string(&file).unwrap(); - - assert!( - result.contains(" "), - "indented marker inside code block must be preserved verbatim" - ); - assert!( - result.contains("appended content"), - "new content must be appended" - ); - assert!( - result.contains("Real content here"), - "existing body must survive" - ); - - // The real markers appended at the end must be at line-start (column 0). - let begin_pos = result - .find("\nexisting section\n\n", - ) - .unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_first = fs::read_to_string(&file).unwrap(); - - upsert_managed_section(&file, "same content").unwrap(); - let after_second = fs::read_to_string(&file).unwrap(); - - assert_eq!( - after_first, after_second, - "upsert must be idempotent: second call must not alter the file" - ); -} - #[test] fn refresh_agents_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 685f83b7999..69b7b9ab1f4 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -31,7 +31,7 @@ pub fn relay_ws_url() -> String { /// Read the workspace relay URL override, if set. Returns `None` when no /// override is active or when the mutex is poisoned (best-effort). -fn workspace_relay_override(state: &AppState) -> Option { +pub(crate) fn workspace_relay_override(state: &AppState) -> Option { state .relay_url_override .lock() From 359fe646758d253ee94bf054a87904efd1dce7d1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 18 Aug 2026 23:32:19 +0100 Subject: [PATCH 24/27] Refine the mobile emoji picker (#5853) ## Summary - use a native detented emoji sheet on iOS that opens at two-thirds and keeps scrolling active at every height - align the search, close control, background, and full-width category row while retaining the Flutter tray on Android - add a persisted desktop-style skin-tone selector and show one selected variant per emoji ## Testing - `just ci` gates completed, with the disk-heavy stages resumed individually after generated artifacts filled the worktree volume - full mobile suite: 1,360 tests passed - focused picker/composer/reaction suite: 145 tests passed - signed iOS Release build succeeded - Android debug build installed and launched on Pixel 10 Snapshots are attached in a PR comment. --------- Signed-off-by: kenny lopez Signed-off-by: Watcher Signed-off-by: Kenny Lopez Signed-off-by: Wes Co-authored-by: Watcher Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Wes Co-authored-by: Carl --- mobile/ios/Runner.xcodeproj/project.pbxproj | 20 +- mobile/ios/Runner/AppDelegate.swift | 8 + mobile/ios/Runner/NativeEmojiPicker.swift | 193 +++++ .../ios/Runner/NativeEmojiPickerModel.swift | 437 ++++++++++ mobile/ios/Runner/NativeEmojiPickerView.swift | 596 ++++++++++++++ mobile/ios/RunnerTests/RunnerTests.swift | 225 +++++ .../lib/features/channels/emoji_picker.dart | 212 ++++- .../channels/emoji_picker/category_rail.dart | 117 +++ .../channels/emoji_picker/emoji_grid.dart | 21 +- .../emoji_picker/ios_native_picker.dart | 191 +++++ .../channels/emoji_picker/search_field.dart | 124 +-- .../features/channels/emoji_picker_test.dart | 770 +++++++++++++++++- 12 files changed, 2780 insertions(+), 134 deletions(-) create mode 100644 mobile/ios/Runner/NativeEmojiPicker.swift create mode 100644 mobile/ios/Runner/NativeEmojiPickerModel.swift create mode 100644 mobile/ios/Runner/NativeEmojiPickerView.swift create mode 100644 mobile/lib/features/channels/emoji_picker/ios_native_picker.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index f127990b390..f6f66ee2dc2 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -16,7 +16,10 @@ 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; }; 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; }; 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; }; - 4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */; }; + 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */; }; + 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */; }; + 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */; }; + 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.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 */; }; @@ -64,7 +67,10 @@ 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; - 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; }; + 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = ""; }; + 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = ""; }; + 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = ""; }; + 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.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; }; @@ -190,7 +196,10 @@ 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */, 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */, - 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */, + 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */, + 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */, + 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */, + 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -428,7 +437,10 @@ 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */, 4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */, 4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */, - 4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */, + 4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */, + 4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */, + 4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */, + 4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.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 e8f3d9d1e16..e4ee6dbd916 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -10,6 +10,7 @@ import UserNotifications private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? + private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator? private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel? override func application( @@ -115,6 +116,13 @@ import UserNotifications parentViewController: nativeAttachmentRegistrar?.viewController ) + let nativeEmojiPickerRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzNativeEmojiPicker" + ) + nativeEmojiPickerCoordinator = NativeEmojiPickerCoordinator( + messenger: messenger, + parentViewController: nativeEmojiPickerRegistrar?.viewController + ) if #available(iOS 16.0, *), let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeMessageActionSurface" diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift new file mode 100644 index 00000000000..3109923daba --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -0,0 +1,193 @@ +import Flutter +import SwiftUI +import UIKit + +private struct NativeEmojiMediaHeaderError: Error {} + +final class NativeEmojiPickerCoordinator: NSObject, + UIAdaptivePresentationControllerDelegate +{ + private let channel: FlutterMethodChannel + private static weak var activeCoordinator: NativeEmojiPickerCoordinator? + private weak var parentViewController: UIViewController? + private weak var presentedController: UIViewController? + private var didNotifyDismissal = false + private var isDismissing = false + + init( + messenger: FlutterBinaryMessenger, + parentViewController: UIViewController? + ) { + channel = FlutterMethodChannel( + name: "buzz/native_emoji_picker", + binaryMessenger: messenger + ) + self.parentViewController = parentViewController + super.init() + Self.activeCoordinator = self + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + } + + static func mediaHeaders(for url: URL) async throws -> [String: String] { + guard let channel = activeCoordinator?.channel else { return [:] } + return try await withCheckedThrowingContinuation { continuation in + channel.invokeMethod("mediaHeaders", arguments: url.absoluteString) { result in + if result is FlutterError { + continuation.resume(throwing: NativeEmojiMediaHeaderError()) + return + } + continuation.resume(returning: result as? [String: String] ?? [:]) + } + } + } + + private func handle( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard call.method == "present" else { + result(FlutterMethodNotImplemented) + return + } + guard let arguments = call.arguments as? [String: Any] else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected emoji-picker configuration.", + details: nil + ) + ) + return + } + + DispatchQueue.main.async { [weak self] in + result(self?.present(arguments: arguments) ?? false) + } + } + + @MainActor + private func present(arguments: [String: Any]) -> Bool { + // A sheet is already owned by an earlier caller; report busy rather than a + // successful presentation so the caller does not treat this as its own. + guard presentedController == nil else { return false } + guard + let data = NativeEmojiPickerDataLoader.load(arguments: arguments), + let presenter = topViewController( + from: parentViewController ?? activeWindowRootViewController() + ) + else { + return false + } + + didNotifyDismissal = false + isDismissing = false + let appearance = NativeEmojiPickerAppearance(arguments: arguments) + let content = NativeEmojiPickerView( + data: data, + appearance: appearance, + initialSkinTone: (arguments["skinTone"] as? NSNumber)?.intValue ?? 0, + onSelect: { [weak self] emoji in self?.select(emoji) }, + onSkinToneChanged: { [weak self] value in + self?.channel.invokeMethod("skinToneChanged", arguments: value) + }, + onClose: { [weak self] in self?.dismiss() } + ) + let controller = UIHostingController(rootView: content) + controller.view.backgroundColor = appearance.surface + controller.modalPresentationStyle = .pageSheet + controller.overrideUserInterfaceStyle = appearance.isDark ? .dark : .light + + if let sheet = controller.sheetPresentationController { + let compactID = UISheetPresentationController.Detent.Identifier( + "buzz.emoji.compact" + ) + let mediumID = UISheetPresentationController.Detent.Identifier( + "buzz.emoji.medium" + ) + sheet.detents = [ + .custom(identifier: compactID) { context in + context.maximumDetentValue * 0.34 + }, + .custom(identifier: mediumID) { context in + context.maximumDetentValue * 0.67 + }, + .large(), + ] + sheet.selectedDetentIdentifier = mediumID + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + sheet.prefersEdgeAttachedInCompactHeight = false + sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true + } + + presentedController = controller + presenter.present(controller, animated: true) { [weak self, weak controller] in + controller?.presentationController?.delegate = self + } + return true + } + + @MainActor + private func select(_ emoji: String) { + // A single presentation returns at most one selection. The sheet stays + // live through its dismissal animation, so ignore extra taps that arrive + // before dismissal completes to avoid emitting duplicate selections. + guard !isDismissing else { return } + channel.invokeMethod("selected", arguments: emoji) + dismiss() + } + + @MainActor + private func dismiss() { + isDismissing = true + guard let controller = presentedController else { + notifyDismissalIfNeeded() + return + } + controller.dismiss(animated: true) { [weak self] in + self?.notifyDismissalIfNeeded() + } + } + + func presentationControllerDidDismiss( + _ presentationController: UIPresentationController + ) { + notifyDismissalIfNeeded() + } + + @MainActor + private func notifyDismissalIfNeeded() { + guard !didNotifyDismissal else { return } + didNotifyDismissal = true + presentedController = nil + channel.invokeMethod("dismissed", arguments: nil) + } + + @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 presented = viewController?.presentedViewController { + return topViewController(from: presented) + } + if let navigation = viewController as? UINavigationController { + return topViewController(from: navigation.visibleViewController) + } + if let tab = viewController as? UITabBarController { + return topViewController(from: tab.selectedViewController) + } + return viewController + } +} diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift new file mode 100644 index 00000000000..75bf46446cd --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift @@ -0,0 +1,437 @@ +import Flutter +import SwiftUI +import UIKit + +struct NativeEmojiPickerAppearance { + let surface: UIColor + let control: UIColor + let text: UIColor + let secondaryText: UIColor + let accent: UIColor + let divider: UIColor + let isDark: Bool + + init(arguments: [String: Any]) { + surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground) + control = Self.color( + arguments["controlColor"], + fallback: .secondarySystemBackground + ) + text = Self.color(arguments["textColor"], fallback: .label) + secondaryText = Self.color( + arguments["secondaryTextColor"], + fallback: .secondaryLabel + ) + accent = Self.color(arguments["accentColor"], fallback: .systemBlue) + divider = Self.color(arguments["dividerColor"], fallback: .separator) + isDark = arguments["isDark"] as? Bool ?? false + } + + private static func color(_ raw: Any?, fallback: UIColor) -> UIColor { + guard let value = (raw as? NSNumber)?.uint32Value else { return fallback } + let alpha = CGFloat((value >> 24) & 0xFF) / 255 + let red = CGFloat((value >> 16) & 0xFF) / 255 + let green = CGFloat((value >> 8) & 0xFF) / 255 + let blue = CGFloat(value & 0xFF) / 255 + return UIColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +struct NativeEmojiItem: Identifiable, Hashable { + let id: String + let shortcode: String + let value: String + let name: String + let keywords: [String] + let glyph: String? + let skinVariants: [String] + let imageURL: URL? +} + +struct NativeEmojiSkinTone: Identifiable { + let id: Int + let label: String + let color: UIColor +} + +let nativeEmojiSkinTones = [ + NativeEmojiSkinTone( + id: 0, + label: "Default", + color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1) + ), + NativeEmojiSkinTone( + id: 1, + label: "Light", + color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1) + ), + NativeEmojiSkinTone( + id: 2, + label: "Medium-light", + color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1) + ), + NativeEmojiSkinTone( + id: 3, + label: "Medium", + color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1) + ), + NativeEmojiSkinTone( + id: 4, + label: "Medium-dark", + color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1) + ), + NativeEmojiSkinTone( + id: 5, + label: "Dark", + color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1) + ), +] + +func validNativeEmojiSkinTone(_ value: Int) -> Int { + nativeEmojiSkinTones.indices.contains(value) ? value : 0 +} + +struct NativeEmojiSection: Identifiable { + let id: String + let title: String + let systemImage: String + let items: [NativeEmojiItem] +} + +struct NativeEmojiPickerData { + let sections: [NativeEmojiSection] + let standardItems: [NativeEmojiItem] + let customItems: [NativeEmojiItem] +} + +enum NativeEmojiPickerDataLoader { + static let assetPath = "assets/emoji/emoji-data.json" + + static func load(arguments: [String: Any]) -> NativeEmojiPickerData? { + let key = FlutterDartProject.lookupKey(forAsset: assetPath) + let url = Bundle.main.bundleURL.appendingPathComponent(key) + guard let data = try? Data(contentsOf: url) else { return nil } + return parse(data: data, arguments: arguments) + } + + static func parse( + data: Data, + arguments: [String: Any] + ) -> NativeEmojiPickerData? { + guard + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let rawCategories = root["categories"] as? [[String: Any]], + let rawEmoji = root["emoji"] as? [String: Any] + else { + return nil + } + + var sections: [NativeEmojiSection] = [] + var standardItems: [NativeEmojiItem] = [] + var byValue: [String: NativeEmojiItem] = [:] + + for category in rawCategories { + guard + let categoryID = category["id"] as? String, + let emojiIDs = category["emoji"] as? [String] + else { + continue + } + + var items: [NativeEmojiItem] = [] + for emojiID in emojiIDs { + guard let record = rawEmoji[emojiID] as? [String: Any] else { continue } + let name = record["n"] as? String ?? emojiID + let keywords = record["k"] as? [String] ?? [] + let glyphs: [String] + if let values = record["u"] as? [String] { + glyphs = values + } else if let value = record["u"] as? String { + glyphs = [value] + } else { + glyphs = [] + } + + guard let defaultGlyph = glyphs.first else { continue } + let item = NativeEmojiItem( + id: emojiID, + shortcode: emojiID, + value: defaultGlyph, + name: name, + keywords: keywords, + glyph: defaultGlyph, + skinVariants: glyphs, + imageURL: nil + ) + items.append(item) + standardItems.append(item) + for glyph in glyphs where byValue[glyph] == nil { + byValue[glyph] = item + } + } + + sections.append( + NativeEmojiSection( + id: categoryID, + title: categoryTitle(categoryID), + systemImage: categorySymbol(categoryID), + items: items + ) + ) + } + + let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? [] + let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in + guard + let shortcode = raw["shortcode"] as? String, + let urlString = raw["url"] as? String, + let url = URL(string: urlString) + else { + return nil + } + return NativeEmojiItem( + id: "custom-\(shortcode)", + shortcode: shortcode, + value: ":\(shortcode):", + name: shortcode, + keywords: [], + glyph: nil, + skinVariants: [], + imageURL: url + ) + } + let customByValue = Dictionary( + customItems.map { ($0.value, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + let recentValues = arguments["recent"] as? [String] ?? [] + var seenRecentIDs: Set = [] + let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in + guard let item = byValue[value] ?? customByValue[value] else { return nil } + return seenRecentIDs.insert(item.id).inserted ? item : nil + } + if !recentItems.isEmpty { + sections.insert( + NativeEmojiSection( + id: "frequent", + title: "Frequently used", + systemImage: "clock", + items: recentItems + ), + at: 0 + ) + } + + if !customItems.isEmpty { + sections.append( + NativeEmojiSection( + id: "custom", + title: "Custom", + systemImage: "sparkles", + items: customItems + ) + ) + } + + return NativeEmojiPickerData( + sections: sections, + standardItems: standardItems, + customItems: customItems + ) + } + + private static func categoryTitle(_ id: String) -> String { + switch id { + case "people": return "Smileys & People" + case "nature": return "Animals & Nature" + case "foods": return "Food & Drink" + case "activity": return "Activity" + case "places": return "Travel & Places" + case "objects": return "Objects" + case "symbols": return "Symbols" + case "flags": return "Flags" + default: return id.capitalized + } + } + + private static func categorySymbol(_ id: String) -> String { + switch id { + case "people": return "face.smiling" + case "nature": return "leaf" + case "foods": return "fork.knife" + case "activity": return "figure.run" + case "places": return "airplane" + case "objects": return "lightbulb" + case "symbols": return "heart" + case "flags": return "flag" + default: return "circle.grid.3x3" + } + } +} + +private struct NativeEmojiSearchScore: Comparable { + let tier: Int + let detail: Int + let length: Int + let code: String + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.tier != rhs.tier { return lhs.tier < rhs.tier } + if lhs.detail != rhs.detail { return lhs.detail < rhs.detail } + if lhs.length != rhs.length { return lhs.length < rhs.length } + return lhs.code < rhs.code + } +} + +enum NativeEmojiSearch { + static func results( + query: String, + items: [NativeEmojiItem] + ) -> [NativeEmojiItem] { + items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in + guard let score = score(query: query, item: item) else { return nil } + return (item, score) + } + .sorted { $0.1 < $1.1 } + .map(\.0) + } + + private static func score( + query: String, + item: NativeEmojiItem + ) -> NativeEmojiSearchScore? { + let normalizedQuery = collapse(query) + guard !normalizedQuery.isEmpty else { return nil } + let code = item.shortcode.lowercased() + let normalizedCode = collapse(code) + + if normalizedCode == normalizedQuery { + return makeScore(tier: 0, detail: 0, code: code) + } + if normalizedCode.hasPrefix(normalizedQuery) { + return makeScore(tier: 1, detail: 0, code: code) + } + + let words = ([item.name] + item.keywords) + .flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) } + .map(String.init) + if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) { + return makeScore(tier: 2, detail: index, code: code) + } + if let range = normalizedCode.range(of: normalizedQuery) { + return makeScore( + tier: 3, + detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound), + code: code + ) + } + if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) { + return makeScore(tier: 4, detail: index, code: code) + } + if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) { + return makeScore(tier: 5, detail: span, code: code) + } + return nil + } + + private static func makeScore( + tier: Int, + detail: Int, + code: String + ) -> NativeEmojiSearchScore { + NativeEmojiSearchScore( + tier: tier, + detail: detail, + length: code.count, + code: code + ) + } + + private static func collapse(_ value: String) -> String { + value.lowercased().filter { !":_ -\t\n".contains($0) } + } + + private static func subsequenceSpan(_ query: String, in target: String) -> Int? { + let queryCharacters = Array(query) + guard !queryCharacters.isEmpty else { return nil } + var queryIndex = 0 + var first: Int? + var last = 0 + for (targetIndex, character) in target.enumerated() { + guard character == queryCharacters[queryIndex] else { continue } + if first == nil { first = targetIndex } + last = targetIndex + queryIndex += 1 + if queryIndex == queryCharacters.count { + return last - (first ?? last) + } + } + return nil + } +} + +/// The top offset of each pinned section header, keyed by section id, reported +/// up from the scrolling grid so the rail can follow manual scrolling. +/// +/// The same stream also carries two viewport measurements under the reserved +/// keys below, so the tracker sees the section offsets and the viewport bounds +/// consistently in a single update. Section ids come from the emoji dataset and +/// never collide with these dotted reserved keys. +let nativeEmojiViewportBottomKey = "buzz.emoji.viewportBottom" +let nativeEmojiContentBottomKey = "buzz.emoji.contentBottom" + +struct NativeEmojiSectionOffsetsKey: PreferenceKey { + static let defaultValue: [String: CGFloat] = [:] + + static func reduce( + value: inout [String: CGFloat], + nextValue: () -> [String: CGFloat] + ) { + value.merge(nextValue(), uniquingKeysWith: { _, next in next }) + } +} + +/// Pure selection logic: the highlighted section is the last one whose header +/// has scrolled to or above the top of the viewport. Extracted so the +/// scroll-tracking behaviour can be unit-tested without a live scroll view. +enum NativeEmojiCategoryTracker { + static func selectedSectionID( + order: [String], + offsets: [String: CGFloat], + viewportTop: CGFloat, + viewportBottom: CGFloat? = nil, + contentBottom: CGFloat? = nil + ) -> String? { + // At the clamped bottom of an overflowing list, a final section shorter + // than the viewport can never scroll its header to the top, so the + // header-at-top rule would keep the preceding section highlighted while the + // user is plainly viewing the last one. Detect that case first: the content + // end is on screen (`contentBottom <= viewportBottom`) and the top has + // scrolled away (`firstTop < viewportTop`, so the list really did overflow + // rather than merely fitting). Highlight the last section then. + if let viewportBottom, + let contentBottom, + contentBottom <= viewportBottom + 1, + let firstID = order.first, + let firstTop = offsets[firstID], + firstTop < viewportTop, + let lastID = order.last + { + return lastID + } + + var selected: String? + for id in order { + guard let top = offsets[id] else { continue } + // A small tolerance keeps the header that is flush with the top pinned as + // selected rather than flickering to the next section. + if top <= viewportTop + 1 { + selected = id + } else { + break + } + } + return selected ?? order.first + } +} diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift new file mode 100644 index 00000000000..a71de4b815a --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -0,0 +1,596 @@ +import ImageIO +import SwiftUI +import UIKit + +struct NativeEmojiPickerView: View { + let data: NativeEmojiPickerData + let appearance: NativeEmojiPickerAppearance + let onSelect: (String) -> Void + let onSkinToneChanged: (Int) -> Void + let onClose: () -> Void + + @State private var query = "" + @State private var selectedSectionID: String? + @State private var selectedSkinTone: Int + + private let columns = Array( + repeating: GridItem(.flexible(minimum: 36), spacing: 0), + count: 8 + ) + + private let sectionListSpace = "buzz.emoji.sectionList" + + init( + data: NativeEmojiPickerData, + appearance: NativeEmojiPickerAppearance, + initialSkinTone: Int, + onSelect: @escaping (String) -> Void, + onSkinToneChanged: @escaping (Int) -> Void, + onClose: @escaping () -> Void + ) { + self.data = data + self.appearance = appearance + self.onSelect = onSelect + self.onSkinToneChanged = onSkinToneChanged + self.onClose = onClose + _selectedSkinTone = State( + initialValue: validNativeEmojiSkinTone(initialSkinTone) + ) + } + + var body: some View { + ScrollViewReader { proxy in + VStack(spacing: 0) { + header + if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + categoryRail(proxy) + } + Divider().overlay(Color(uiColor: appearance.divider)) + pickerContent + } + .background(Color(uiColor: appearance.surface)) + .onAppear { + selectedSectionID = data.sections.first?.id + } + } + } + + private var header: some View { + HStack(spacing: 8) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + TextField("Search emoji", text: $query) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .submitLabel(.search) + .foregroundStyle(Color(uiColor: appearance.text)) + if !query.isEmpty { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 14) + .frame(height: 44) + .background(Color(uiColor: appearance.control), in: Capsule()) + .overlay { + Capsule() + .stroke(Color(uiColor: appearance.divider), lineWidth: 1) + } + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color(uiColor: appearance.text)) + .frame(width: 44, height: 44) + .background(Color(uiColor: appearance.control), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close sheet") + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + } + + private func categoryRail(_ proxy: ScrollViewProxy) -> some View { + HStack(spacing: 0) { + ForEach(data.sections) { section in + Button { + selectedSectionID = section.id + withAnimation(.easeOut(duration: 0.24)) { + proxy.scrollTo("section-\(section.id)", anchor: .top) + } + } label: { + Image(systemName: section.systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle( + Color( + uiColor: selectedSectionID == section.id + ? appearance.accent : appearance.secondaryText + ) + ) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background( + selectedSectionID == section.id + ? Color(uiColor: appearance.control) : Color.clear, + in: Circle() + ) + } + .frame(maxWidth: .infinity) + .buttonStyle(.plain) + .accessibilityLabel(section.title) + .accessibilityAddTraits( + selectedSectionID == section.id ? .isSelected : [] + ) + } + Divider() + .frame(height: 24) + .overlay(Color(uiColor: appearance.divider)) + skinToneSelector + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 16) + .frame(height: 44) + } + + private var skinToneSelector: some View { + Menu { + ForEach(nativeEmojiSkinTones) { tone in + Button { + selectedSkinTone = tone.id + onSkinToneChanged(tone.id) + } label: { + Label { + Text(tone.label) + } icon: { + Image(uiImage: skinTonePreviewImage(tone)) + .renderingMode(.original) + } + } + } + } label: { + skinToneDot(nativeEmojiSkinTones[selectedSkinTone]) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.plain) + .accessibilityLabel("Skin tone") + } + + private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View { + Circle() + .fill(Color(uiColor: tone.color)) + .frame(width: 16, height: 16) + .overlay { + Circle() + .fill( + LinearGradient( + colors: [.white.opacity(0.2), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .blendMode(.overlay) + } + .overlay { + Circle().stroke(.black.opacity(0.8), lineWidth: 1) + } + } + + private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage { + let size = CGSize(width: 16, height: 16) + return UIGraphicsImageRenderer(size: size).image { rendererContext in + let context = rendererContext.cgContext + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5) + let circle = UIBezierPath(ovalIn: rect) + + tone.color.setFill() + circle.fill() + + if let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [ + UIColor.white.withAlphaComponent(0.2).cgColor, + UIColor.clear.cgColor, + ] as CFArray, + locations: [0, 1] + ) { + context.saveGState() + circle.addClip() + context.setBlendMode(.overlay) + context.drawLinearGradient( + gradient, + start: CGPoint(x: size.width / 2, y: 0), + end: CGPoint(x: size.width / 2, y: size.height), + options: [] + ) + context.restoreGState() + } + + UIColor.black.withAlphaComponent(0.8).setStroke() + circle.lineWidth = 1 + circle.stroke() + } + } + + @ViewBuilder + private var pickerContent: some View { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.isEmpty { + sectionList(data.sections, tracksSelection: true) + } else { + let custom = NativeEmojiSearch.results( + query: trimmedQuery, + items: data.customItems + ) + let standard = NativeEmojiSearch.results( + query: trimmedQuery, + items: data.standardItems + ) + let sections = [ + NativeEmojiSection( + id: "search-custom", + title: "Custom", + systemImage: "sparkles", + items: custom + ), + NativeEmojiSection( + id: "search-standard", + title: "Emoji", + systemImage: "face.smiling", + items: standard + ), + ].filter { !$0.items.isEmpty } + + if sections.isEmpty { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 28)) + Text("No emoji found").font(.body) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + } else { + sectionList(sections, tracksSelection: false) + } + } + } + + private func sectionList( + _ sections: [NativeEmojiSection], + tracksSelection: Bool + ) -> some View { + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(sections) { section in + Section { + LazyVGrid(columns: columns, spacing: 0) { + ForEach(section.items) { item in + emojiButton(item) + } + } + .padding(.horizontal, 16) + } header: { + HStack { + Text(section.title) + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color(uiColor: appearance.secondaryText)) + Spacer() + } + .padding(.horizontal, 16) + .frame(height: 30) + .background(Color(uiColor: appearance.surface)) + .background(sectionOffsetReporter(id: section.id)) + .id("section-\(section.id)") + } + } + } + .padding(.bottom, 8) + .background(contentBoundaryReporter()) + } + .coordinateSpace(name: sectionListSpace) + .background(viewportBoundaryReporter()) + .scrollDismissesKeyboard(.interactively) + .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in + guard tracksSelection else { return } + selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID( + order: data.sections.map(\.id), + offsets: offsets, + viewportTop: 0, + viewportBottom: offsets[nativeEmojiViewportBottomKey], + contentBottom: offsets[nativeEmojiContentBottomKey] + ) + } + } + + private func sectionOffsetReporter(id: String) -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [id: geometry.frame(in: .named(sectionListSpace)).minY] + ) + } + } + + // The end of the scrolling content, relative to the viewport top. At the + // clamped bottom of an overflowing list this converges on the viewport + // height, which lets the tracker highlight a short final section that can + // never scroll its own header to the top. + private func contentBoundaryReporter() -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [ + nativeEmojiContentBottomKey: + geometry.frame(in: .named(sectionListSpace)).maxY + ] + ) + } + } + + // The fixed viewport height, reported through the same preference stream so + // it stays consistent with the section offsets in each update. + private func viewportBoundaryReporter() -> some View { + GeometryReader { geometry in + Color.clear.preference( + key: NativeEmojiSectionOffsetsKey.self, + value: [nativeEmojiViewportBottomKey: geometry.size.height] + ) + } + } + + private func emojiButton(_ item: NativeEmojiItem) -> some View { + let value = displayValue(for: item) + return Button { + onSelect(value) + } label: { + Group { + if let url = item.imageURL { + NativeEmojiRemoteImage( + url: url, + fallbackColor: appearance.secondaryText + ) + .frame(width: 28, height: 28) + } else { + Text(value).font(.system(size: 28)) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.name) + } + + private func displayValue(for item: NativeEmojiItem) -> String { + guard item.imageURL == nil else { return item.value } + guard item.skinVariants.indices.contains(selectedSkinTone) else { + return item.skinVariants.first ?? item.value + } + return item.skinVariants[selectedSkinTone] + } +} + +struct NativeEmojiRemoteImage: View { + let url: URL + let fallbackColor: UIColor + + @State private var phase: Phase = .loading + + private enum Phase { + case loading + case success(UIImage) + case failure + } + + var body: some View { + Group { + switch phase { + case .loading: + ProgressView().controlSize(.mini) + case .success(let image): + Image(uiImage: image).resizable().scaledToFit() + case .failure: + Image(systemName: "sparkles") + .foregroundStyle(Color(uiColor: fallbackColor)) + } + } + .task(id: requestIdentity) { + do { + let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders( + for: url + ) + var request = URLRequest(url: url) + for (name, value) in requestHeaders { + request.setValue(value, forHTTPHeaderField: name) + } + phase = .success( + try await NativeEmojiRemoteImageLoader.shared.image(for: request) + ) + } catch { + if !Task.isCancelled { phase = .failure } + } + } + } + + private var requestIdentity: String { + url.absoluteString + } +} + +enum NativeEmojiRemoteImageError: Error { + case invalidResponse + case responseTooLarge + case invalidImage +} + +actor NativeEmojiRemoteImageLoader { + typealias Downloader = (URLRequest) async throws -> UIImage + + static let shared = NativeEmojiRemoteImageLoader() + static let defaultMaximumConcurrentDownloads = 4 + + private static let maximumDownloadBytes = 10 * 1024 * 1024 + private static let maximumThumbnailPixels = 84 + private static let defaultCacheByteLimit = 8 * 1024 * 1024 + + private struct Waiter { + let id: UUID + let continuation: CheckedContinuation + } + + private let maximumConcurrentDownloads: Int + private let downloader: Downloader + private let admissionAttemptForTesting: (() -> Void)? + private let cache = NSCache() + private var activeDownloadCount = 0 + private var waiters: [Waiter] = [] + + init( + maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads, + cacheByteLimit: Int = defaultCacheByteLimit, + admissionAttemptForTesting: (() -> Void)? = nil, + downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download + ) { + precondition(maximumConcurrentDownloads > 0) + precondition(cacheByteLimit >= 0) + self.maximumConcurrentDownloads = maximumConcurrentDownloads + self.admissionAttemptForTesting = admissionAttemptForTesting + self.downloader = downloader + cache.totalCostLimit = cacheByteLimit + } + + func image(for request: URLRequest) async throws -> UIImage { + let cacheKey = request as NSURLRequest + if let cached = cache.object(forKey: cacheKey) { + return cached + } + + recordAdmissionAttemptForTesting() + try await acquireDownloadSlot() + defer { releaseDownloadSlot() } + + try Task.checkCancellation() + if let cached = cache.object(forKey: cacheKey) { + return cached + } + + let image = try await downloader(request) + cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image)) + return image + } + + private func recordAdmissionAttemptForTesting() { + admissionAttemptForTesting?() + } + + private func acquireDownloadSlot() async throws { + try Task.checkCancellation() + guard activeDownloadCount >= maximumConcurrentDownloads else { + activeDownloadCount += 1 + return + } + + let waiterID = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + } else { + waiters.append(Waiter(id: waiterID, continuation: continuation)) + } + } + } onCancel: { + Task { await self.cancelWaiter(id: waiterID) } + } + } + + private func cancelWaiter(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(throwing: CancellationError()) + } + + private func releaseDownloadSlot() { + while !waiters.isEmpty { + let waiter = waiters.removeFirst() + waiter.continuation.resume() + return + } + activeDownloadCount -= 1 + } + + private static func download(_ request: URLRequest) async throws -> UIImage { + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard + let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + throw NativeEmojiRemoteImageError.invalidResponse + } + if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), + let byteCount = Int(contentLength), + byteCount > maximumDownloadBytes + { + throw NativeEmojiRemoteImageError.responseTooLarge + } + + var data = Data() + let expected = httpResponse.expectedContentLength + if expected > 0 { + data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes)))) + } + for try await byte in bytes { + guard data.count < maximumDownloadBytes else { + throw NativeEmojiRemoteImageError.responseTooLarge + } + data.append(byte) + } + try Task.checkCancellation() + guard let image = thumbnail(from: data) else { + throw NativeEmojiRemoteImageError.invalidImage + } + return image + } + + private static func thumbnail(from data: Data) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels, + kCGImageSourceShouldCacheImmediately: true, + ] + guard + let image = CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) + else { + return nil + } + return UIImage(cgImage: image) + } + + private static func cacheCost(for image: UIImage) -> Int { + guard let cgImage = image.cgImage else { return 0 } + let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow( + by: cgImage.height + ) + return overflow ? Int.max : cost + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e26aa11525c..e4d56c4ed5a 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -403,6 +403,173 @@ class RunnerTests: XCTestCase { } } + func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() { + let order = ["people", "nature", "flags"] + let offsets: [String: CGFloat] = [ + "people": -320, + "nature": -12, + "flags": 200, + ] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0 + ), + "nature" + ) + } + + func testCategoryTrackerFollowsScrollPastEachHeader() { + let order = ["people", "nature", "flags"] + + // Scrolled to the very top: the first section is highlighted. + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: ["people": 0, "nature": 400, "flags": 800], + viewportTop: 0 + ), + "people" + ) + + // Scrolled far enough that Flags has reached the top. + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: ["people": -800, "nature": -400, "flags": 0], + viewportTop: 0 + ), + "flags" + ) + } + + func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() { + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: ["people", "nature"], + offsets: ["people": 40, "nature": 400], + viewportTop: 0 + ), + "people" + ) + } + + func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() { + // The list has overflowed (People scrolled above the top) and its end is on + // screen, but the short Custom section's header sits below the top because + // the content clamps before it can reach it. The rail must still highlight + // Custom rather than leaving Nature — its predecessor — selected. + let order = ["people", "nature", "custom"] + let offsets: [String: CGFloat] = [ + "people": -900, + "nature": -420, + "custom": 360, + ] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0, + viewportBottom: 500, + contentBottom: 500 + ), + "custom" + ) + } + + func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() { + // The same short-final geometry, but the content end is still below the + // viewport (the user has not reached the bottom), so the ordinary + // header-at-top rule applies and Nature stays selected. + let order = ["people", "nature", "custom"] + let offsets: [String: CGFloat] = [ + "people": -900, + "nature": -420, + "custom": 360, + ] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0, + viewportBottom: 500, + contentBottom: 900 + ), + "nature" + ) + } + + func testCategoryTrackerDoesNotForceLastSectionForAShortList() { + // A list that fits without scrolling has its content end on screen too, but + // its first header is still at the top — so the bottom rule must not fire + // and steal the highlight to the final section. + let order = ["people", "nature"] + let offsets: [String: CGFloat] = ["people": 0, "nature": 120] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: offsets, + viewportTop: 0, + viewportBottom: 500, + contentBottom: 240 + ), + "people" + ) + } + + func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws { + let maximumConcurrentDownloads = 3 + let taskCount = 8 + let probe = NativeEmojiDownloadProbe() + let tasksAttemptedAdmission = XCTestExpectation( + description: "all download tasks attempted admission" + ) + tasksAttemptedAdmission.expectedFulfillmentCount = taskCount + let loader = NativeEmojiRemoteImageLoader( + maximumConcurrentDownloads: maximumConcurrentDownloads, + cacheByteLimit: 0, + admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() }, + downloader: { _ in + await probe.holdDownload() + return UIImage() + } + ) + let tasks = (0.. UInt32 return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16 | UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3]) } + +private actor NativeEmojiDownloadProbe { + private struct MilestoneWaiter { + let count: Int + let continuation: CheckedContinuation + } + + private var active = 0 + private var peakActive = 0 + private var started = 0 + private var releaseContinuations: [CheckedContinuation] = [] + private var milestoneWaiters: [MilestoneWaiter] = [] + + func holdDownload() async { + active += 1 + started += 1 + peakActive = max(peakActive, active) + resumeReachedMilestones() + await withCheckedContinuation { continuation in + releaseContinuations.append(continuation) + } + active -= 1 + } + + func waitUntilStarted(_ count: Int) async { + guard started < count else { return } + await withCheckedContinuation { continuation in + milestoneWaiters.append( + MilestoneWaiter(count: count, continuation: continuation) + ) + } + } + + func releaseOne() { + guard !releaseContinuations.isEmpty else { return } + releaseContinuations.removeFirst().resume() + } + + func releaseAll() { + let continuations = releaseContinuations + releaseContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + + func snapshot() -> (active: Int, peakActive: Int, started: Int) { + (active, peakActive, started) + } + + private func resumeReachedMilestones() { + let reached = milestoneWaiters.filter { $0.count <= started } + milestoneWaiters.removeAll { $0.count <= started } + for waiter in reached { + waiter.continuation.resume() + } + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index e8e62199d70..b66d526e7ee 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -1,4 +1,8 @@ +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'; @@ -10,17 +14,19 @@ 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_sheet_header.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'recent_emoji_provider.dart'; part 'emoji_picker/search_field.dart'; part 'emoji_picker/category_rail.dart'; part 'emoji_picker/emoji_grid.dart'; +part 'emoji_picker/ios_native_picker.dart'; -/// Height of the picker sheet as a fraction of the screen. The full emoji set -/// is ~1.9k glyphs; the old fixed 340px sheet only ever showed a hand-picked -/// subset and had no room to browse. +/// Android keeps the established Flutter tray height. iOS is presented by a +/// native sheet with system detents in [ios_native_picker.dart]. const _sheetHeightFactor = 0.62; /// Opens the full emoji picker as a modal bottom sheet. @@ -33,12 +39,34 @@ void showEmojiPicker({ required BuildContext context, required void Function(String emoji) onSelect, VoidCallback? onDismiss, +}) { + if (defaultTargetPlatform == TargetPlatform.iOS) { + unawaited( + _presentIosEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ), + ); + return; + } + + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); +} + +void _showFlutterEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, + VoidCallback? onDismiss, }) { showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, - backgroundColor: context.colors.surfaceContainerHighest, + showCloseButton: false, builder: (sheetContext) => EmojiPickerSheet( onSelect: (emoji) { Navigator.of(sheetContext).pop(); @@ -53,11 +81,41 @@ class EmojiPickerSheet extends HookConsumerWidget { const EmojiPickerSheet({super.key, required this.onSelect}); + @override + Widget build(BuildContext context, WidgetRef ref) { + return SizedBox( + height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, + child: _EmojiPickerContent(onSelect: onSelect), + ); + } +} + +class _EmojiPickerContent extends HookConsumerWidget { + const _EmojiPickerContent({required this.onSelect}); + + final void Function(String emoji) onSelect; + @override Widget build(BuildContext context, WidgetRef ref) { final dataset = ref.watch(emojiDatasetOrEmptyProvider); final customEmoji = ref.watch(customEmojiListProvider); final recent = ref.watch(recentEmojiProvider); + final prefs = ref.read(savedPrefsProvider); + final skinTone = useState( + _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), + ); + + void selectSkinTone(int value) { + final next = _validSkinTone(value); + if (skinTone.value == next) return; + skinTone.value = next; + unawaited(prefs.setInt(_emojiSkinTonePrefsKey, next)); + } + + final visibleDataset = useMemoized( + () => _datasetForSkinTone(dataset, skinTone.value), + [dataset, skinTone.value], + ); final searchController = useTextEditingController(); final query = useState(''); @@ -74,20 +132,37 @@ class EmojiPickerSheet extends HookConsumerWidget { final sections = useMemoized( () => _buildSections( - dataset: dataset, + dataset: visibleDataset, + sourceDataset: dataset, customEmoji: customEmoji, recent: recent, onSelect: select, ), - [dataset, customEmoji, recent], + [visibleDataset, dataset, customEmoji, recent], ); final offsets = useMemoized(() => _sectionOffsets(sections), [sections]); - final scrollController = useScrollController(); + // A notifier rather than state: the highlight changes on every scroll frame // and only the rail needs to hear about it. Rebuilding the sheet would // rebuild the grid underneath it. - final activeSection = useMemoized(() => ValueNotifier(0), [sections]); + // + // Seed it from the current scroll offset rather than 0: a skin-tone change + // rebuilds [sections] and so replaces this notifier, but the grid keeps its + // scroll position (same controller, same section extents). Resetting to 0 + // here would falsely highlight the first category until the next scroll. + final activeSection = useMemoized( + () => ValueNotifier( + scrollController.hasClients + ? _activeSectionIndex( + offsets, + scrollController.offset, + maxScrollExtent: scrollController.position.maxScrollExtent, + ) + : 0, + ), + [sections], + ); useEffect(() => activeSection.dispose, [activeSection]); useEffect(() { @@ -96,6 +171,7 @@ class EmojiPickerSheet extends HookConsumerWidget { activeSection.value = _activeSectionIndex( offsets, scrollController.offset, + maxScrollExtent: scrollController.position.maxScrollExtent, ); } @@ -119,9 +195,9 @@ class EmojiPickerSheet extends HookConsumerWidget { // while the sheet animates. final results = useMemoized( () => isSearching - ? searchEmoji(trimmedQuery, dataset.all) + ? searchEmoji(trimmedQuery, visibleDataset.all) : const [], - [trimmedQuery, dataset], + [trimmedQuery, visibleDataset], ); final customResults = useMemoized( () => isSearching @@ -134,37 +210,48 @@ class EmojiPickerSheet extends HookConsumerWidget { [trimmedQuery, customEmoji], ); - return SizedBox( - height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, - child: Column( - children: [ - _EmojiSearchField(controller: searchController), - if (!isSearching && sections.isNotEmpty) - ValueListenableBuilder( - valueListenable: activeSection, - builder: (context, active, _) => _CategoryRail( - sections: sections, - activeIndex: active, - onSelect: jumpToSection, + return Column( + children: [ + LayoutBuilder( + builder: (context, constraints) => BuzzSheetHeader( + showDragHandle: true, + leading: SizedBox( + width: constraints.maxWidth - Grid.gutter * 2 - 44 - Grid.xxs, + child: _EmojiSearchField( + controller: searchController, + padding: EdgeInsets.zero, ), ), - Divider(height: 1, color: context.colors.outlineVariant), - Expanded( - child: dataset.isEmpty && customEmoji.isEmpty - ? const Center(child: CircularProgressIndicator()) - : isSearching - ? _EmojiSearchResults( - entries: results, - customEmoji: customResults, - onSelect: select, - ) - : _ContinuousEmojiGrid( - sections: sections, - controller: scrollController, - ), ), - ], - ), + ), + if (!isSearching && sections.isNotEmpty) + ValueListenableBuilder( + valueListenable: activeSection, + builder: (context, active, _) => _CategoryRail( + sections: sections, + activeIndex: active, + onSelect: jumpToSection, + skinTone: skinTone.value, + onSkinToneChanged: selectSkinTone, + ), + ), + Divider(height: 1, color: context.colors.outlineVariant), + Expanded( + child: dataset.isEmpty && customEmoji.isEmpty + ? const Center(child: CircularProgressIndicator()) + : isSearching + ? _EmojiSearchResults( + entries: results, + customEmoji: customResults, + onSelect: select, + controller: scrollController, + ) + : _ContinuousEmojiGrid( + sections: sections, + controller: scrollController, + ), + ), + ], ); } } @@ -177,6 +264,7 @@ class EmojiPickerSheet extends HookConsumerWidget { /// nowhere. List<_EmojiSection> _buildSections({ required EmojiDataset dataset, + required EmojiDataset sourceDataset, required List customEmoji, required List recent, required void Function(String emoji) onSelect, @@ -186,6 +274,7 @@ List<_EmojiSection> _buildSections({ final recentTiles = _resolveRecentTiles( recent: recent, dataset: dataset, + sourceDataset: sourceDataset, customEmoji: customEmoji, onSelect: onSelect, ); @@ -246,15 +335,18 @@ List<_EmojiSection> _buildSections({ List _resolveRecentTiles({ required List recent, required EmojiDataset dataset, + required EmojiDataset sourceDataset, required List customEmoji, required void Function(String emoji) onSelect, }) { final customByShortcode = { for (final emoji in customEmoji) emoji.shortcode.toLowerCase(): emoji, }; - final entriesByNative = { - for (final entry in dataset.all) entry.native: entry, + final sourceEntriesByNative = { + for (final entry in sourceDataset.all) entry.native: entry, }; + final visibleEntriesById = {for (final entry in dataset.all) entry.id: entry}; + final seenStandardIds = {}; final tiles = []; for (final item in recent) { @@ -272,7 +364,9 @@ List _resolveRecentTiles({ ); continue; } - final entry = entriesByNative[value]; + final sourceEntry = sourceEntriesByNative[value]; + if (sourceEntry == null || !seenStandardIds.add(sourceEntry.id)) continue; + final entry = visibleEntriesById[sourceEntry.id]; if (entry == null) continue; tiles.add( _EmojiTile( @@ -284,3 +378,37 @@ List _resolveRecentTiles({ } return tiles; } + +/// Project the dataset to one visible tile per shortcode. Emoji that support +/// skin tones use the selected variant; everything else keeps its default. +EmojiDataset _datasetForSkinTone(EmojiDataset dataset, int skinTone) { + if (dataset.isEmpty) return dataset; + final categories = []; + final all = []; + + for (final category in dataset.categories) { + final variantsById = >{}; + for (final entry in category.emoji) { + variantsById.putIfAbsent(entry.id, () => []).add(entry); + } + final visible = []; + for (final variants in variantsById.values) { + final selected = variants.firstWhere( + (entry) => entry.skinIndex == skinTone, + orElse: () => variants.firstWhere( + (entry) => entry.skinIndex == 0, + orElse: () => variants.first, + ), + ); + visible.add(selected); + all.add(selected); + } + categories.add(EmojiCategory(id: category.id, emoji: visible)); + } + + return EmojiDataset( + categories: categories, + all: all, + nativeToShortcode: dataset.nativeToShortcode, + ); +} diff --git a/mobile/lib/features/channels/emoji_picker/category_rail.dart b/mobile/lib/features/channels/emoji_picker/category_rail.dart index 50df1406886..e654134ec66 100644 --- a/mobile/lib/features/channels/emoji_picker/category_rail.dart +++ b/mobile/lib/features/channels/emoji_picker/category_rail.dart @@ -18,6 +18,20 @@ IconData _categoryIcon(String categoryId) => switch (categoryId) { /// 18px icon it holds. const _railHeight = 36.0; +const _emojiSkinTonePrefsKey = 'buzz.emoji-picker.skin-tone.v1'; + +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)), +]; + +int _validSkinTone(int? value) => + value != null && value >= 0 && value < _skinTones.length ? value : 0; + /// Category selector: one icon per section of the continuous grid, in scroll /// order. Tapping jumps to that section; scrolling moves the highlight. /// @@ -28,11 +42,15 @@ class _CategoryRail extends StatelessWidget { final List<_EmojiSection> sections; final int activeIndex; final ValueChanged onSelect; + final int skinTone; + final ValueChanged onSkinToneChanged; const _CategoryRail({ required this.sections, required this.activeIndex, required this.onSelect, + required this.skinTone, + required this.onSkinToneChanged, }); @override @@ -52,6 +70,12 @@ class _CategoryRail extends StatelessWidget { onTap: () => onSelect(i), ), ), + Expanded( + child: _SkinToneSelector( + value: skinTone, + onChanged: onSkinToneChanged, + ), + ), ], ), ), @@ -59,6 +83,99 @@ class _CategoryRail extends StatelessWidget { } } +class _SkinToneSelector extends StatelessWidget { + const _SkinToneSelector({required this.value, required this.onChanged}); + + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final selected = _skinTones[_validSkinTone(value)]; + return PopupMenuButton( + key: const ValueKey('emoji-skin-tone-selector'), + initialValue: value, + tooltip: 'Skin tone', + position: PopupMenuPosition.under, + onSelected: onChanged, + itemBuilder: (context) => [ + for (final (index, tone) in _skinTones.indexed) + PopupMenuItem( + key: ValueKey('emoji-skin-tone-$index'), + value: index, + child: Row( + children: [ + _SkinToneDot( + key: ValueKey('emoji-skin-tone-dot-$index'), + 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: Semantics( + button: true, + label: 'Skin tone', + child: Center( + child: _SkinToneDot( + key: const ValueKey('emoji-skin-tone-dot-selected'), + color: selected.color, + ), + ), + ), + ); + } +} + +class _SkinToneDot extends StatelessWidget { + const _SkinToneDot({super.key, required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 16, + child: Stack( + fit: StackFit.expand, + children: [ + DecoratedBox( + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + ClipOval( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0.2), + Colors.transparent, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black.withValues(alpha: 0.8)), + ), + ), + ], + ), + ); + } +} + class _CategoryIcon extends StatelessWidget { final IconData icon; final String tooltip; diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart index 354a31e44c6..eb7217be027 100644 --- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart +++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart @@ -63,7 +63,19 @@ List _sectionOffsets(List<_EmojiSection> sections) { } /// Which section owns [offset] — the one whose header is pinned right now. -int _activeSectionIndex(List offsets, double offset) { +/// At the clamped bottom, the final visible section owns the viewport even when +/// it is too short for its header to reach the top. +int _activeSectionIndex( + List offsets, + double offset, { + required double maxScrollExtent, +}) { + if (offsets.isEmpty) return 0; + if (maxScrollExtent > 0 && + offset >= maxScrollExtent - precisionErrorTolerance) { + return offsets.length - 1; + } + var active = 0; for (var i = 0; i < offsets.length; i++) { // Half a header of slack so the highlight flips as a header reaches the @@ -92,7 +104,7 @@ class _EmojiTile extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - key: ValueKey('$keyPrefix-${entry.tileId}'), + key: ValueKey('$keyPrefix-${entry.id}'), onTap: onTap, behavior: HitTestBehavior.opaque, child: Semantics( @@ -196,11 +208,13 @@ class _EmojiSearchResults extends StatelessWidget { final List entries; final List customEmoji; final void Function(String emoji) onSelect; + final ScrollController controller; const _EmojiSearchResults({ required this.entries, required this.customEmoji, required this.onSelect, + required this.controller, }); @override @@ -214,6 +228,7 @@ class _EmojiSearchResults extends StatelessWidget { return CustomScrollView( key: const ValueKey('emoji-picker-search-results'), + controller: controller, slivers: [ if (customEmoji.isNotEmpty) ...[ const _SectionHeader(label: 'Custom'), @@ -279,7 +294,7 @@ class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { return Container( - color: context.colors.surfaceContainerHighest, + color: context.colors.surface, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), child: Text(label, style: _sectionLabelStyle(context)), diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart new file mode 100644 index 00000000000..2986af96360 --- /dev/null +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -0,0 +1,191 @@ +part of '../emoji_picker.dart'; + +const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); + +/// Guards the process-global native method-call handler: one native sheet may +/// own it at a time. A reentrant open would replace the handler and hijack the +/// live sheet's select/dismiss callbacks, so [_presentIosEmojiPicker] coalesces +/// reentry while a presentation is in flight. +bool _iosEmojiPickerPresenting = false; + +@visibleForTesting +void resetIosEmojiPickerPresentationForTest() { + _iosEmojiPickerPresenting = false; +} + +Future _presentIosEmojiPicker({ + required BuildContext context, + required void Function(String emoji) onSelect, + VoidCallback? onDismiss, +}) async { + // Only one native sheet owns the handler at a time. Reject a reentrant open + // without replacing the live sheet's callbacks, and complete the rejected + // caller so its local picker-open lifecycle is not stranded. + if (_iosEmojiPickerPresenting) { + onDismiss?.call(); + return; + } + _iosEmojiPickerPresenting = true; + + final container = ProviderScope.containerOf(context, listen: false); + final paletteState = container.read(customEmojiPaletteProvider); + final List customEmoji; + BuildContext? loadingSheetContext; + var leavingLoadingSheet = false; + var loadingCancelled = false; + Future? loadingSheet; + + if (paletteState case AsyncData(:final value)) { + customEmoji = value; + } else { + // Palette history can take the relay timeout to resolve. Give the tap an + // immediate, cancellable surface instead of holding the global guard while + // the composer appears unresponsive. + final loadingSheetBuilt = Completer(); + loadingSheet = + showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showCloseButton: false, + builder: (sheetContext) { + loadingSheetContext = sheetContext; + if (!loadingSheetBuilt.isCompleted) loadingSheetBuilt.complete(); + return const SizedBox( + key: Key('ios-emoji-picker-palette-loading'), + height: 180, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: Grid.sm), + Text('Loading emoji…'), + ], + ), + ), + ); + }, + ).whenComplete(() { + if (leavingLoadingSheet) return; + loadingCancelled = true; + _iosEmojiPickerPresenting = false; + onDismiss?.call(); + }); + + try { + customEmoji = await container.read(customEmojiPaletteProvider.future); + } catch (_) { + if (loadingCancelled) return; + await loadingSheetBuilt.future; + if (loadingCancelled) return; + leavingLoadingSheet = true; + if (loadingSheetContext case final sheetContext? + when sheetContext.mounted) { + Navigator.of(sheetContext).pop(); + } + await loadingSheet; + // A palette fetch failure must not strand the composer's open state: fall + // back to the Flutter picker, which watches the palette itself. + _iosEmojiPickerPresenting = false; + if (context.mounted) { + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); + } + return; + } + + if (loadingCancelled) return; + await loadingSheetBuilt.future; + if (loadingCancelled) return; + leavingLoadingSheet = true; + if (loadingSheetContext case final sheetContext? + when sheetContext.mounted) { + Navigator.of(sheetContext).pop(); + } + await loadingSheet; + } + if (!context.mounted) { + _iosEmojiPickerPresenting = false; + return; + } + final recent = container.read(recentEmojiProvider); + final mediaAuth = container.read(mediaGetAuthServiceProvider); + final prefs = container.read(savedPrefsProvider); + final colors = context.colors; + var dismissed = false; + + void finish() { + if (dismissed) return; + dismissed = true; + _iosEmojiPickerPresenting = false; + _nativeEmojiPickerChannel.setMethodCallHandler(null); + onDismiss?.call(); + } + + _nativeEmojiPickerChannel.setMethodCallHandler((call) async { + switch (call.method) { + case 'mediaHeaders': + final url = call.arguments; + return url is String + ? mediaAuth.headersFor(url) + : const {}; + case 'selected': + final emoji = call.arguments; + if (emoji is String && emoji.isNotEmpty) onSelect(emoji); + return null; + case 'dismissed': + finish(); + return null; + case 'skinToneChanged': + final value = call.arguments; + if (value is int) { + await prefs.setInt(_emojiSkinTonePrefsKey, _validSkinTone(value)); + } + return null; + } + }); + + try { + final presented = await _nativeEmojiPickerChannel.invokeMethod( + 'present', + { + 'customEmoji': [ + for (final emoji in customEmoji) + {'shortcode': emoji.shortcode, 'url': emoji.url}, + ], + 'recent': [for (final entry in recent) entry.emoji], + 'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)), + 'surfaceColor': colors.surface.toARGB32(), + 'controlColor': colors.surfaceContainerHighest.toARGB32(), + 'textColor': colors.onSurface.toARGB32(), + 'secondaryTextColor': colors.onSurfaceVariant.toARGB32(), + 'accentColor': colors.primary.toARGB32(), + 'dividerColor': colors.outlineVariant.toARGB32(), + 'isDark': Theme.of(context).brightness == Brightness.dark, + }, + ); + if (presented == true) return; + } on MissingPluginException { + // Older builds keep the complete Flutter picker as a safe fallback. + } on PlatformException { + // A native presentation failure should not remove the emoji affordance. + } + + if (dismissed || !context.mounted) { + // `dismissed` means finish() already released the guard; the unmounted + // path releases it here so a future open is not blocked. + _iosEmojiPickerPresenting = false; + return; + } + dismissed = true; + _iosEmojiPickerPresenting = false; + _nativeEmojiPickerChannel.setMethodCallHandler(null); + _showFlutterEmojiPicker( + context: context, + onSelect: onSelect, + onDismiss: onDismiss, + ); +} diff --git a/mobile/lib/features/channels/emoji_picker/search_field.dart b/mobile/lib/features/channels/emoji_picker/search_field.dart index dd4c4ee9233..c61dd9d2cc8 100644 --- a/mobile/lib/features/channels/emoji_picker/search_field.dart +++ b/mobile/lib/features/channels/emoji_picker/search_field.dart @@ -8,68 +8,80 @@ part of '../emoji_picker.dart'; /// words and the OS mangles them. Flutter exposes the same switches directly. class _EmojiSearchField extends StatelessWidget { final TextEditingController controller; + final EdgeInsetsGeometry padding; - const _EmojiSearchField({required this.controller}); + const _EmojiSearchField({ + required this.controller, + this.padding = const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xxs, + ), + }); @override Widget build(BuildContext context) { final colors = context.colors; return Padding( - padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xxs), - child: TextField( - key: const ValueKey('emoji-picker-search'), - controller: controller, - autocorrect: false, - enableSuggestions: false, - textCapitalization: TextCapitalization.none, - textInputAction: TextInputAction.search, - style: searchInputTextStyle.copyWith(color: colors.onSurface), - decoration: InputDecoration( - hintText: 'Search emoji', - hintStyle: searchInputTextStyle.copyWith( - color: colors.onSurfaceVariant, - ), - prefixIcon: Icon( - LucideIcons.search, - size: 18, - color: colors.onSurfaceVariant, - ), - prefixIconConstraints: const BoxConstraints( - minWidth: Grid.md, - minHeight: Grid.md, - ), - suffixIcon: ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) { - if (value.text.isEmpty) return const SizedBox.shrink(); - return IconButton( - key: const ValueKey('emoji-picker-search-clear'), - onPressed: controller.clear, - icon: Icon( - LucideIcons.x, - size: 16, - color: colors.onSurfaceVariant, - ), - visualDensity: VisualDensity.compact, - tooltip: 'Clear search', - ); - }, - ), - filled: true, - fillColor: colors.surface, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: colors.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: colors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(Radii.lg), - borderSide: BorderSide(color: colors.primary), + padding: padding, + child: SizedBox( + height: 44, + child: TextField( + key: const ValueKey('emoji-picker-search'), + controller: controller, + autocorrect: false, + enableSuggestions: false, + textCapitalization: TextCapitalization.none, + textInputAction: TextInputAction.search, + style: searchInputTextStyle.copyWith(color: colors.onSurface), + decoration: InputDecoration( + hintText: 'Search emoji', + hintStyle: searchInputTextStyle.copyWith( + color: colors.onSurfaceVariant, + ), + prefixIcon: Icon( + LucideIcons.search, + size: 18, + color: colors.onSurfaceVariant, + ), + prefixIconConstraints: const BoxConstraints( + minWidth: Grid.md, + minHeight: Grid.md, + ), + suffixIcon: ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + if (value.text.isEmpty) return const SizedBox.shrink(); + return IconButton( + key: const ValueKey('emoji-picker-search-clear'), + onPressed: controller.clear, + icon: Icon( + LucideIcons.x, + size: 16, + color: colors.onSurfaceVariant, + ), + visualDensity: VisualDensity.compact, + tooltip: 'Clear search', + ); + }, + ), + filled: true, + fillColor: colors.surface, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.full), + borderSide: BorderSide(color: colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.full), + borderSide: BorderSide(color: colors.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.full), + borderSide: BorderSide(color: colors.primary), + ), ), ), ), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index bf8086da4c7..9c1cf556169 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/recent_emoji_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; @@ -6,9 +8,13 @@ import 'package:buzz/shared/emoji/emoji_data.dart'; import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.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:shared_preferences/shared_preferences.dart'; +import 'package:nostr/nostr.dart' as nostr; import '../../helpers/widget_helpers.dart'; @@ -41,11 +47,39 @@ final _dataset = () { ), _entry( 'point_up', - native: '\u{261D}\u{1F3FD}', + native: '\u{261D}\u{1F3FB}', categoryId: 'people', name: 'Index Pointing Up', skinIndex: 1, ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FC}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 2, + ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FD}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 3, + ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FE}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 4, + ), + _entry( + 'point_up', + native: '\u{261D}\u{1F3FF}', + categoryId: 'people', + name: 'Index Pointing Up', + skinIndex: 5, + ), ]; final nature = [ _entry( @@ -95,6 +129,49 @@ final _tallDataset = () { const _customEmoji = [ CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'), ]; +const _relayCustomEmoji = [ + CustomEmoji( + shortcode: 'buzzbee', + url: 'https://relay.example/media/buzzbee.png', + ), +]; + +class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier { + _FakeCustomEmojiPaletteNotifier(this.palette); + + final Future> palette; + + @override + Future> build() => palette; +} + +const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); + +void _setMockNativeEmojiPickerHandler( + Future Function(MethodCall call)? handler, +) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeEmojiPickerChannel, handler); +} + +Future _sendNativeEmojiPickerCall( + WidgetTester tester, + String method, [ + Object? arguments, +]) async { + final response = Completer(); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + _nativeEmojiPickerChannel.name, + _nativeEmojiPickerChannel.codec.encodeMethodCall( + MethodCall(method, arguments), + ), + response.complete, + ); + final envelope = await response.future; + return envelope == null + ? null + : _nativeEmojiPickerChannel.codec.decodeEnvelope(envelope); +} Future _prefs() { SharedPreferences.setMockInitialValues({}); @@ -147,26 +224,113 @@ void main() { // shortcut into it, not a page switcher. final grid = find.byKey(const ValueKey('emoji-picker-grid')); expect(grid, findsOneWidget); + final sectionKeys = tester + .widget(grid) + .slivers + .whereType() + .map((sliver) => sliver.key); expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-grinning')), - ), - findsOneWidget, + sectionKeys, + containsAllInOrder(const [ + ValueKey('emoji-section-people'), + ValueKey('emoji-section-nature'), + ValueKey('emoji-section-custom'), + ]), ); + }); + + testWidgets( + 'search shares the sheet header with the shared close control', + (tester) async { + await _pumpPicker(tester, prefs: await _prefs()); + + final search = tester.getRect( + find.byKey(const ValueKey('emoji-picker-search')), + ); + final close = tester.getRect(find.byTooltip('Close sheet')); + + expect(close.size, const Size.square(44)); + expect(search.center.dy, close.center.dy); + expect(close.left - search.right, Grid.xxs); + }, + ); + + testWidgets('the Flutter picker keeps the established tray height', ( + tester, + ) async { + await _pumpPicker(tester, prefs: await _prefs()); + + final picker = find.byType(EmojiPickerSheet); + final context = tester.element(picker); expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-fire')), + tester.getSize(picker).height, + closeTo(MediaQuery.sizeOf(context).height * 0.62, 0.5), + ); + expect(find.byType(DraggableScrollableSheet), findsNothing); + }); + + testWidgets('the Flutter search field is a full pill', (tester) async { + await _pumpPicker(tester, prefs: await _prefs()); + + final field = tester.widget( + find.byKey(const ValueKey('emoji-picker-search')), + ); + final border = field.decoration!.border! as OutlineInputBorder; + expect(border.borderRadius, BorderRadius.circular(Radii.full)); + }); + + testWidgets('uses the shared sheet surface instead of a picker override', ( + tester, + ) async { + final prefs = await _prefs(); + final theme = AppTheme.light().copyWith( + colorScheme: lightColorScheme.copyWith( + surfaceContainerHighest: Colors.grey, + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.green, ), - findsOneWidget, ); - expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + customEmojiListProvider.overrideWithValue(_customEmoji), + ], + child: MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), ), - findsOneWidget, + ); + + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + + final ancestorMaterials = find + .ancestor( + of: find.byType(EmojiPickerSheet), + matching: find.byType(Material), + ) + .evaluate() + .map((element) => element.widget as Material); + expect( + ancestorMaterials.map((material) => material.color), + contains(Colors.green), + ); + expect( + ancestorMaterials.map((material) => material.color), + isNot(contains(Colors.grey)), ); }); @@ -174,21 +338,27 @@ void main() { await _pumpPicker(tester, prefs: await _prefs()); // The rail used to be a short left-aligned strip. Every section now gets - // one evenly-sized slot across the same width the search field spans. - final searchField = tester.getRect( - find.byKey(const ValueKey('emoji-picker-search')), - ); + // one evenly-sized slot across the tray, while search shares its row with + // the close control above. + final picker = tester.getRect(find.byType(EmojiPickerSheet)); final people = tester.getRect(find.byTooltip('Smileys & People')); final nature = tester.getRect(find.byTooltip('Animals & Nature')); final custom = tester.getRect(find.byTooltip('Custom')); + final skinTone = tester.getRect(find.byTooltip('Skin tone')); expect(nature.left, greaterThan(people.left)); expect(custom.left, greaterThan(nature.left)); expect(people.width, closeTo(nature.width, 0.5)); expect(people.width, closeTo(custom.width, 0.5)); - // First slot starts and last slot ends on the search field's edges. - expect(people.left, closeTo(searchField.left, 0.5)); - expect(custom.right, closeTo(searchField.right, 0.5)); + expect(people.width, closeTo(skinTone.width, 0.5)); + expect(people.left, closeTo(picker.left + Grid.gutter, 0.5)); + expect(skinTone.right, closeTo(picker.right - Grid.gutter, 0.5)); + expect( + tester.getSize( + find.byKey(const ValueKey('emoji-skin-tone-dot-selected')), + ), + const Size.square(16), + ); }); testWidgets('tapping the rail scrolls the grid instead of replacing it', ( @@ -211,6 +381,37 @@ void main() { expect(offset(), closeTo(28 + 25 * 40, 0.5)); }); + testWidgets( + 'a bottom-clamped final section stays highlighted after a rail tap', + (tester) async { + await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset); + final colors = Theme.of( + tester.element(find.byType(EmojiPickerSheet)), + ).colorScheme; + Color iconColor(String tooltip) => tester + .widget( + find.descendant( + of: find.byTooltip(tooltip), + matching: find.byType(Icon), + ), + ) + .color!; + + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); + + final grid = tester.widget( + find.byKey(const ValueKey('emoji-picker-grid')), + ); + expect( + grid.controller!.offset, + closeTo(grid.controller!.position.maxScrollExtent, 0.5), + ); + expect(iconColor('Custom'), colors.primary); + expect(iconColor('Animals & Nature'), colors.onSurfaceVariant); + }, + ); + testWidgets('the custom section only exists when the palette has emoji', ( tester, ) async { @@ -223,6 +424,8 @@ void main() { await _pumpPicker(tester, prefs: await _prefs()); expect(find.byTooltip('Custom'), findsOneWidget); + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), findsOneWidget, @@ -236,14 +439,16 @@ void main() { // A community's own emoji used to get their own looser 6-per-row grid, // which read as a different component bolted onto the sheet. - final native = tester.getRect( - find.byKey(const ValueKey('emoji-tile-fire')), - ); + final nativeWidth = tester + .getRect(find.byKey(const ValueKey('emoji-tile-grinning'))) + .width; + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); final custom = tester.getRect( find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), ); - expect(custom.width, closeTo(native.width, 0.5)); - expect(custom.height, closeTo(native.height, 0.5)); + expect(custom.width, closeTo(nativeWidth, 0.5)); + expect(custom.height, closeTo(40, 0.5)); }); testWidgets('typing filters across the standard and custom sets', ( @@ -323,27 +528,116 @@ void main() { expect(find.byTooltip('Smileys & People'), findsOneWidget); }); + testWidgets( + 'changing skin tone keeps the scrolled-to category highlighted', + (tester) async { + // A skin-tone change rebuilds the sections and the active-section + // notifier. Regression: the notifier was recreated at index 0, so a + // user parked on a later category snapped back to the first one in the + // rail while the grid stayed put. The notifier now seeds from the live + // scroll offset, so the highlight survives the rebuild. + await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset); + final colors = Theme.of( + tester.element(find.byType(EmojiPickerSheet)), + ).colorScheme; + Color iconColor(String tooltip) => tester + .widget( + find.descendant( + of: find.byTooltip(tooltip), + matching: find.byType(Icon), + ), + ) + .color!; + + await tester.tap(find.byTooltip('Animals & Nature')); + await tester.pumpAndSettle(); + expect(iconColor('Animals & Nature'), colors.primary); + expect(iconColor('Smileys & People'), colors.onSurfaceVariant); + + await tester.tap(find.byTooltip('Skin tone')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3'))); + await tester.pumpAndSettle(); + + // Still on Nature after the tone rebuild — not reset to People. + expect(iconColor('Animals & Nature'), colors.primary); + expect(iconColor('Smileys & People'), colors.onSurfaceVariant); + }, + ); + testWidgets('a standard emoji emits its glyph', (tester) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); + await tester.tap(find.byTooltip('Animals & Nature')); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('emoji-tile-fire'))); await tester.pumpAndSettle(); expect(selected, ['\u{1F525}']); }); - testWidgets('a skin-tone variant is selectable', (tester) async { + testWidgets('skin tone choice shows and emits one selected variant', ( + tester, + ) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); - await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up-1'))); + expect(find.byKey(const ValueKey('emoji-tile-point_up')), findsOneWidget); + expect(find.byKey(const ValueKey('emoji-tile-point_up-1')), findsNothing); + + await tester.tap(find.byTooltip('Skin tone')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up'))); await tester.pumpAndSettle(); expect(selected, ['\u{261D}\u{1F3FD}']); }); + testWidgets('skin tone choices paint their colors on a solid layer', ( + tester, + ) async { + await _pumpPicker(tester, prefs: await _prefs()); + + await tester.tap(find.byTooltip('Skin tone')); + await tester.pumpAndSettle(); + + const expectedColors = [ + Color(0xFFFFC93A), + Color(0xFFFFDAB7), + Color(0xFFE7B98F), + Color(0xFFC88C61), + Color(0xFFA46134), + Color(0xFF5D4437), + ]; + for (final (index, expectedColor) in expectedColors.indexed) { + final decorations = tester + .widgetList( + find.descendant( + of: find.byKey(ValueKey('emoji-skin-tone-dot-$index')), + matching: find.byType(DecoratedBox), + ), + ) + .map((widget) => widget.decoration) + .whereType(); + + expect( + decorations.any( + (decoration) => + decoration.color == expectedColor && + decoration.gradient == null && + decoration.backgroundBlendMode == null, + ), + isTrue, + ); + } + }); + testWidgets('a custom emoji emits :shortcode:', (tester) async { final selected = await _pumpPicker(tester, prefs: await _prefs()); + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); await tester.tap( find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), ); @@ -358,6 +652,8 @@ void main() { final prefs = await _prefs(); final selected = await _pumpPicker(tester, prefs: prefs); + await tester.tap(find.byTooltip('Animals & Nature')); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('emoji-tile-fire'))); await tester.pumpAndSettle(); @@ -392,6 +688,422 @@ void main() { }); }); + group('iOS native emoji picker', () { + setUp(resetIosEmojiPickerPresentationForTest); + + testWidgets('passes shared theme and custom emoji into the native sheet', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + final mediaAuth = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ); + MethodCall? presentation; + final selected = []; + var dismissals = 0; + _setMockNativeEmojiPickerHandler((call) async { + presentation = call; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier( + Future.value([..._customEmoji, ..._relayCustomEmoji]), + ), + ), + mediaGetAuthServiceProvider.overrideWithValue(mediaAuth), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: selected.add, + onDismiss: () => dismissals += 1, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + + expect(presentation?.method, 'present'); + final arguments = presentation?.arguments as Map; + expect(arguments['surfaceColor'], lightColorScheme.surface.toARGB32()); + expect(arguments['skinTone'], 0); + expect(arguments['customEmoji'], [ + { + 'shortcode': 'partyparrot', + 'url': 'https://example.test/parrot.gif', + }, + { + 'shortcode': 'buzzbee', + 'url': 'https://relay.example/media/buzzbee.png', + }, + ]); + expect(find.byType(EmojiPickerSheet), findsNothing); + + final externalHeaders = await _sendNativeEmojiPickerCall( + tester, + 'mediaHeaders', + 'https://example.test/parrot.gif', + ); + expect(externalHeaders, {}); + final relayHeaders = await _sendNativeEmojiPickerCall( + tester, + 'mediaHeaders', + 'https://relay.example/media/buzzbee.png', + ); + expect( + (relayHeaders as Map)['Authorization'], + startsWith('Nostr '), + ); + + await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4); + await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); + await _sendNativeEmojiPickerCall(tester, 'dismissed'); + expect(selected, ['\u{1F525}']); + expect(dismissals, 1); + expect(prefs.getInt('buzz.emoji-picker.skin-tone.v1'), 4); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('shows cancellable feedback while loading the custom palette', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + final palette = Completer>(); + MethodCall? presentation; + _setMockNativeEmojiPickerHandler((call) async { + presentation = call; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier(palette.future), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + expect(presentation, isNull); + expect( + find.byKey(const Key('ios-emoji-picker-palette-loading')), + findsOneWidget, + ); + expect(find.text('Loading emoji…'), findsOneWidget); + + palette.complete(_customEmoji); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('ios-emoji-picker-palette-loading')), + findsNothing, + ); + expect(presentation?.method, 'present'); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('cancels palette loading without presenting native picker', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + final palette = Completer>(); + var presents = 0; + var dismissals = 0; + _setMockNativeEmojiPickerHandler((call) async { + if (call.method == 'present') presents += 1; + return true; + }); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier(palette.future), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: (_) {}, + onDismiss: () => dismissals += 1, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byKey(const Key('ios-emoji-picker-palette-loading')), + findsOneWidget, + ); + + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + expect(dismissals, 1); + expect(presents, 0); + + palette.complete(_customEmoji); + await tester.pumpAndSettle(); + expect(presents, 0); + expect(dismissals, 1); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets('falls back to the Flutter picker when native cannot present', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + _setMockNativeEmojiPickerHandler((_) async => false); + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + customEmojiPaletteProvider.overrideWith( + () => + _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => + showEmojiPicker(context: context, onSelect: (_) {}), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + expect(find.byType(EmojiPickerSheet), findsOneWidget); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets( + 'a palette load failure falls back to the Flutter picker exactly once', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + var presents = 0; + _setMockNativeEmojiPickerHandler((_) async { + presents += 1; + return true; + }); + var dismissals = 0; + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + emojiDatasetOrEmptyProvider.overrideWithValue(_dataset), + customEmojiPaletteProvider.overrideWith( + () => _FakeCustomEmojiPaletteNotifier( + Future.error(StateError('palette unavailable')), + ), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: (_) {}, + onDismiss: () => dismissals += 1, + ), + child: const Text('Open picker'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open picker')); + await tester.pumpAndSettle(); + + // The native sheet is never presented on a palette error; the + // Flutter picker takes over so the composer's open state is not + // stranded. + expect(presents, 0); + expect(find.byType(EmojiPickerSheet), findsOneWidget); + + // Dismissing the fallback runs onDismiss exactly once. + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + expect(dismissals, 1); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + + testWidgets('a reentrant open cannot steal the live sheet callbacks', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final prefs = await _prefs(); + var presents = 0; + _setMockNativeEmojiPickerHandler((call) async { + if (call.method == 'present') presents += 1; + return true; + }); + + final firstSelected = []; + final secondSelected = []; + var firstDismissals = 0; + var secondDismissals = 0; + + try { + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('self'), + customEmojiPaletteProvider.overrideWith( + () => + _FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)), + ), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => Column( + children: [ + FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: firstSelected.add, + onDismiss: () => firstDismissals += 1, + ), + child: const Text('Open first'), + ), + FilledButton( + onPressed: () => showEmojiPicker( + context: context, + onSelect: secondSelected.add, + onDismiss: () => secondDismissals += 1, + ), + child: const Text('Open second'), + ), + ], + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open first')); + await tester.pumpAndSettle(); + expect(presents, 1); + + // A second open while the first sheet is live is rejected: it neither + // presents again nor replaces the live sheet's method-call handler, + // and its independent lifecycle is completed immediately. + await tester.tap(find.text('Open second')); + await tester.pumpAndSettle(); + expect(presents, 1); + expect(secondDismissals, 1); + + // Native events still reach the original owner, and only it. + await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}'); + await _sendNativeEmojiPickerCall(tester, 'dismissed'); + expect(firstSelected, ['\u{1F525}']); + expect(secondSelected, isEmpty); + expect(firstDismissals, 1); + expect(secondDismissals, 1); + } finally { + _setMockNativeEmojiPickerHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + }); + group('recent emoji ranking', () { test('promotes by use count, breaking ties on recency', () { var entries = []; From a362fecc2389955f942c9581bdfeba379ab115b3 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 18 Aug 2026 18:35:50 -0400 Subject: [PATCH 25/27] perf(desktop): parallelize relay agent directory rebuild (#6258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-agent directory rebuild resolves runtime directories, owner profiles, and managed policies for every candidate agent via dozens of exact-author query batches. Issuing those batches serially made a ~100-agent rebuild take 6.5–8.4s, which dominated @mention autocomplete latency. #6224 already scoped the *send-path* revalidation (`revalidate_relay_agents`) to just the mentioned pubkeys, so the send stall is fixed. But the autocomplete directory (`list_relay_agents`) still rebuilds the full membership set serially — this PR removes that remaining cost. ## Change Run the query batches with bounded concurrency via a shared `query_filter_batches` helper. Each directory rebuild constructs one `tokio::sync::Semaphore` (8 permits) and shares it across all of that rebuild's phases, so the runtime-directory and owner-profile phases that run concurrently under one `try_join!` never exceed 8 `/query` requests in flight together — the bound is per-rebuild. The policy phase reuses the same budget. Same events, keyed by pubkey downstream so ordering is irrelevant. Both `list_relay_agents` (autocomplete) and `revalidate_relay_agents` (scoped send-path check from #6224) funnel through `list_relay_agents_for_selection`, so the helper is a no-op for the tiny 1–3-mention revalidation set and only the full autocomplete rebuild sees the win — the scoped send path is untouched. ## Measurement Live on the production relay, warm connection, full `list_relay_agents`: | | Full-directory rebuild | |---|---| | Serial (before) | 6.5–8.4s | | Bounded-concurrency (after) | 2.0–3.4s | Returned pubkey set is byte-identical pre/post. The 8-permit ceiling holds under saturation (24 batched requests → peak exactly 8, zero failures, zero requests left in flight). Signed-off-by: Will Pfleger Co-authored-by: Duncan --- .../agent_discovery/relay_directory.rs | 84 +++++++++++++------ 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs index d00969bf19c..976519a076b 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs @@ -9,6 +9,41 @@ use crate::{ const RELAY_DIRECTORY_PAGE_SIZE: usize = 500; const RELAY_FILTER_BATCH_SIZE: usize = 10; +/// Per-rebuild ceiling on directory-rebuild `/query` requests in flight at once. +/// The rebuild fans dozens of exact-author batches across the relay; issuing +/// them serially dominated agent-mention send latency (~6 s for ~100 +/// candidates). A bounded window collapses that to a few round trips while +/// keeping the request rate well under the relay's admission gate, which +/// back-pressures any 429 anyway. Each rebuild builds one semaphore and shares +/// it across every phase, so a single rebuild's runtime-directory and +/// owner-profile phases — which run concurrently under one `try_join!` — never +/// exceed it together. (Overlapping rebuilds each hold their own budget.) +const RELAY_DIRECTORY_MAX_CONCURRENCY: usize = 8; + +/// Run one `query_relay` request per `RELAY_FILTER_BATCH_SIZE` chunk of +/// `filters`, each acquiring a permit from `semaphore` so the total in-flight +/// request count stays within the shared ceiling even when several batch sets +/// run concurrently. Returned events are concatenated; order is unspecified — +/// every caller keys the events by pubkey downstream, so ordering is irrelevant. +async fn query_filter_batches( + state: &AppState, + semaphore: &tokio::sync::Semaphore, + filters: &[serde_json::Value], + error_label: &str, +) -> Result, String> { + let pages = futures_util::future::try_join_all(filters.chunks(RELAY_FILTER_BATCH_SIZE).map( + |batch| async move { + let _permit = semaphore.acquire().await.map_err(|error| { + format!("{error_label}: directory concurrency semaphore closed: {error}") + })?; + query_relay(state, batch) + .await + .map_err(|error| format!("{error_label}: {error}")) + }, + )) + .await?; + Ok(pages.into_iter().flatten().collect()) +} fn exact_author_filters(pubkeys: &[String], kind: u16) -> Vec { pubkeys @@ -129,24 +164,26 @@ async fn list_relay_agents_for_selection( return Ok(Vec::new()); } - let mut directory_events = Vec::new(); - let mut profile_events = Vec::new(); let directory_filters = exact_author_filters(&candidate_pubkeys, 10100); let profile_filters = exact_author_filters(&candidate_pubkeys, 0); - for filter_offset in (0..candidate_pubkeys.len()).step_by(RELAY_FILTER_BATCH_SIZE) { - let filter_end = (filter_offset + RELAY_FILTER_BATCH_SIZE).min(candidate_pubkeys.len()); - let (directory, profiles) = tokio::join!( - query_relay(state, &directory_filters[filter_offset..filter_end]), - query_relay(state, &profile_filters[filter_offset..filter_end]), - ); - directory_events.extend( - directory - .map_err(|error| format!("relay agent runtime-directory query failed: {error}"))?, - ); - profile_events.extend( - profiles.map_err(|error| format!("relay agent owner-profile query failed: {error}"))?, - ); - } + // One semaphore per rebuild caps `/query` requests across this rebuild's + // phases, so its runtime-directory and owner-profile phases below stay + // within the ceiling even though `try_join!` runs them concurrently. + let semaphore = tokio::sync::Semaphore::new(RELAY_DIRECTORY_MAX_CONCURRENCY); + let (directory_events, profile_events) = tokio::try_join!( + query_filter_batches( + state, + &semaphore, + &directory_filters, + "relay agent runtime-directory query failed", + ), + query_filter_batches( + state, + &semaphore, + &profile_filters, + "relay agent owner-profile query failed", + ), + )?; // Only the agent's signed NIP-OA profile can name the owner coordinate to // query. Each exact `(owner, d=agent)` filter returns at most one current @@ -161,14 +198,13 @@ async fn list_relay_agents_for_selection( retain_verified_owner(&mut verified_owners, &viewer_pubkey); } let managed_filters = managed_policy_filters(&candidate_pubkeys, &verified_owners); - let mut managed_agent_events = Vec::new(); - for filters in managed_filters.chunks(RELAY_FILTER_BATCH_SIZE) { - managed_agent_events.extend( - query_relay(state, filters) - .await - .map_err(|error| format!("relay agent managed-policy query failed: {error}"))?, - ); - } + let managed_agent_events = query_filter_batches( + state, + &semaphore, + &managed_filters, + "relay agent managed-policy query failed", + ) + .await?; let mut agents = nostr_convert::relay_agents_from_directory_events( &directory_events, From 93114c9c65138397de39729fde0a816eb9f314ab Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 19 Aug 2026 00:01:15 +0100 Subject: [PATCH 26/27] Fix mobile Activity thread navigation (#5850) ## Summary - Open Activity items directly in their target thread while preserving Activity as the Back destination. - Fade in a muted target-message highlight after navigation settles, hold it for three seconds, then fade it away. ## Why Activity deep links hydrated the channel before opening the thread, which left the temporary channel route in the navigation stack. The target highlight also appeared before the route settled and remained indefinitely. ## Validation - `just mobile-check` - Full Flutter test suite (1,357 tests) - Signed Profile build installed and launched on a physical iPhone --------- Signed-off-by: kenny lopez Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Signed-off-by: Princess Donut Signed-off-by: Kenny Lopez Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> --- .../lib/features/activity/activity_page.dart | 4 + .../channels/channel_detail_page.dart | 15 + .../channel_detail_page/message_list.dart | 35 +- .../features/channels/thread_detail_page.dart | 208 +++++-- .../thread_detail_page/thread_message.dart | 29 +- .../shared/widgets/bee_refresh_indicator.dart | 7 +- mobile/lib/shared/widgets/flapping_bee.dart | 4 +- .../features/activity/activity_page_test.dart | 4 + .../channels/channel_detail_page_test.dart | 573 +++++++++++++++++- .../widgets/bee_refresh_indicator_test.dart | 46 +- 10 files changed, 855 insertions(+), 70 deletions(-) diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index dcae998ed0f..62dfb18fc79 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -220,6 +220,8 @@ class ActivityPage extends HookConsumerWidget { channel: channel, initialMessageId: target.id, initialThreadRootId: threadRootId, + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, ), ), ); @@ -240,6 +242,8 @@ class ActivityPage extends HookConsumerWidget { builder: (_) => ChannelDetailPage( channel: channel, initialThreadRootId: draft.threadHeadId, + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, ), ), ); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 0e3251c7523..a36b951e420 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -123,16 +123,29 @@ int? _channelReadTimestamp({ return dateTimeToUnixSeconds(channel.lastMessageAt); } +/// Controls how a hydrated initial thread is added to the navigation stack. +enum InitialThreadRouteBehavior { + /// Keep the channel route beneath the thread. + push, + + /// Replace the temporary channel route so Back returns to its origin. + replaceCurrentRoute, +} + class ChannelDetailPage extends HookConsumerWidget { final Channel channel; final String? initialMessageId; final String? initialThreadRootId; + /// How the automatically opened initial thread affects the route stack. + final InitialThreadRouteBehavior initialThreadRouteBehavior; + const ChannelDetailPage({ super.key, required this.channel, this.initialMessageId, this.initialThreadRootId, + this.initialThreadRouteBehavior = InitialThreadRouteBehavior.push, }); @override @@ -453,6 +466,8 @@ class ChannelDetailPage extends HookConsumerWidget { allMessages: messages, initialMessageId: initialMessageId, initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: + initialThreadRouteBehavior, initialOrdinaryUnreadMessageIds: initialOrdinaryUnreadMessageIds, initialOldestOrdinaryUnreadMessageId: diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 251e40e2fa4..06308063ab0 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -5,6 +5,7 @@ class _MessageList extends HookConsumerWidget { final List allMessages; final String? initialMessageId; final String? initialThreadRootId; + final InitialThreadRouteBehavior initialThreadRouteBehavior; final Set initialOrdinaryUnreadMessageIds; final String? initialOldestOrdinaryUnreadMessageId; final Set initialForcedUnreadMessageIds; @@ -23,6 +24,7 @@ class _MessageList extends HookConsumerWidget { required this.allMessages, required this.initialMessageId, required this.initialThreadRootId, + required this.initialThreadRouteBehavior, required this.initialOrdinaryUnreadMessageIds, required this.initialOldestOrdinaryUnreadMessageId, required this.initialForcedUnreadMessageIds, @@ -569,23 +571,30 @@ class _MessageList extends HookConsumerWidget { if (threadHead == null) return null; didOpenInitialThread.value = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: threadHead, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - initialMessageId: initialMessageId, - ), + if (!context.mounted || ModalRoute.of(context)?.isCurrent != true) { + return; + } + final route = MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + initialMessageId: initialMessageId, ), ); + final navigator = Navigator.of(context); + switch (initialThreadRouteBehavior) { + case InitialThreadRouteBehavior.push: + navigator.push(route); + case InitialThreadRouteBehavior.replaceCurrentRoute: + navigator.pushReplacement(route); + } }); return null; - }, [initialThreadRootId, allMessages]); + }, [initialThreadRootId, allMessages, initialThreadRouteBehavior]); useEffect(() { final targetIndex = reversedIndexOf(initialMessageId); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 007702b5a09..29886331580 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -45,6 +47,11 @@ part 'thread_detail_page/tail_alignment.dart'; part 'thread_detail_page/thread_message.dart'; part 'thread_detail_page/avatar.dart'; +const _landingHighlightDuration = Duration(seconds: 3); +const _landingHighlightDelay = Duration(milliseconds: 50); +const _landingHighlightTransitionDuration = Duration(milliseconds: 300); +const _landingHighlightOpacity = 0.12; + /// Full-screen thread detail page. /// /// Shows the thread head message, direct replies, typing indicators scoped to @@ -80,16 +87,21 @@ class ThreadDetailPage extends HookConsumerWidget { ? appView.viewInsets.bottom / appView.devicePixelRatio : 0.0, ); + useEffect(() { + final session = ref.read(relaySessionProvider.notifier); + return session.registerVisibleChannel(channelId); + }, [channelId]); final sendMessage = ref.read(sendMessageProvider); // Relay thread queries are keyed by the outermost root, even when this // page displays a nested branch. Query that root, then select this head's // direct children from the returned subtree below. final queryRootId = threadHead.rootId ?? threadHead.id; - final repliesState = ref.watch( - threadRepliesWithLocalProvider( - ThreadRepliesArgs(channelId: channelId, rootId: queryRootId), - ), + final repliesArgs = ThreadRepliesArgs( + channelId: channelId, + rootId: queryRootId, ); + final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs)); + final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs)); // The thread query is one-shot and asks only for content kinds, so a // reaction, edit, or deletion that lands while the thread is open never // reaches it — a new pill (and its burst) only showed up after leaving and @@ -106,6 +118,12 @@ class ThreadDetailPage extends HookConsumerWidget { }); final fetchedReplies = replyMessages.value; + // A terminal query error cannot produce a more authoritative list. Keep + // loading states provisional, but let the hydrated route snapshot drive + // the one-shot target jump when the relay query has definitively failed. + final canUseMessagesForInitialTarget = + relayReplyState.value != null || + (relayReplyState.hasError && !relayReplyState.retrying); final liveDeletionHidesHead = _isDeletedBy( liveChannelEvents, threadHead.id, @@ -122,6 +140,68 @@ class ThreadDetailPage extends HookConsumerWidget { threadHead, ...fetchedReplies, ]; + final routeAnimation = ModalRoute.of(context)?.animation; + final reducedLandingHighlightMotion = MediaQuery.disableAnimationsOf( + context, + ); + final highlightedMessageId = useState(null); + final initialTargetReadyForHighlight = useState(false); + useEffect( + () { + final messageId = initialMessageId; + if (messageId == null || !initialTargetReadyForHighlight.value) { + return null; + } + var disposed = false; + Timer? revealTimer; + Timer? dismissTimer; + + void revealHighlight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (disposed) return; + revealTimer = Timer(_landingHighlightDelay, () { + if (disposed) return; + highlightedMessageId.value = messageId; + dismissTimer = Timer( + _landingHighlightDuration + + (reducedLandingHighlightMotion + ? Duration.zero + : _landingHighlightTransitionDuration), + () { + if (!disposed) highlightedMessageId.value = null; + }, + ); + }); + }); + } + + void handleRouteStatus(AnimationStatus status) { + if (status != AnimationStatus.completed) return; + routeAnimation?.removeStatusListener(handleRouteStatus); + revealHighlight(); + } + + if (routeAnimation == null || + routeAnimation.status == AnimationStatus.completed) { + revealHighlight(); + } else { + routeAnimation.addStatusListener(handleRouteStatus); + } + + return () { + disposed = true; + routeAnimation?.removeStatusListener(handleRouteStatus); + revealTimer?.cancel(); + dismissTimer?.cancel(); + }; + }, + [ + initialMessageId, + initialTargetReadyForHighlight.value, + reducedLandingHighlightMotion, + routeAnimation, + ], + ); // Index all messages by parentId so we can find direct children of any // message and compute thread summaries for nested threads. @@ -138,6 +218,7 @@ class ThreadDetailPage extends HookConsumerWidget { final listViewport = useMemoized(LaidOutViewport.new); useEffect(() => listViewport.dispose, [listViewport]); final didJumpToInitialMessage = useRef(false); + final initialHighlightTargetIndex = useState(null); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); final userDragDetachedTailFollow = useRef(false); @@ -271,42 +352,84 @@ class ThreadDetailPage extends HookConsumerWidget { ); } - useEffect(() { - final messageId = initialMessageId; - // Wait for the authoritative thread query before consuming the one-shot - // jump; the fallback main-timeline list can contain only the linked reply. - if (messageId == null || fetchedReplies == null) return null; - final chronologicalIndex = replies.indexWhere( - (reply) => reply.id == messageId, - ); - final targetIndex = messageId == threadHead.id - ? headIndex - : chronologicalIndex < 0 - ? null - : indexForReply(chronologicalIndex); - if (targetIndex == null || didJumpToInitialMessage.value) return null; - didJumpToInitialMessage.value = true; - initialTailSettle.abandon(); - userOptedOutOfTailFollow.value = true; - userDragDetachedTailFollow.value = false; - tailIntent.schedule( - allowed: true, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - !tailIntent.isDragging, - action: () { - // The provisional route snapshot can make the linked reply look like - // the tail. This authoritative deep-link jump intentionally leaves - // the user at an older item, so it must opt out of follow-tail first. - tailIntent.detach(); - followsThreadTail.value = false; - isAtThreadTail.value = false; - itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); - }, - ); - return null; - }, [initialMessageId, fetchedReplies, replies.length]); + useEffect( + () { + final messageId = initialMessageId; + // Wait for either the authoritative thread query or a terminal query + // error before consuming the one-shot jump. During loading, the fallback + // main-timeline list can contain only the linked reply; after an error, + // that hydrated snapshot is the best available target list. + if (messageId == null || !canUseMessagesForInitialTarget) return null; + final chronologicalIndex = replies.indexWhere( + (reply) => reply.id == messageId, + ); + final targetIndex = messageId == threadHead.id + ? headIndex + : chronologicalIndex < 0 + ? null + : indexForReply(chronologicalIndex); + if (targetIndex == null || didJumpToInitialMessage.value) return null; + didJumpToInitialMessage.value = true; + initialTailSettle.abandon(); + userOptedOutOfTailFollow.value = true; + userDragDetachedTailFollow.value = false; + tailIntent.schedule( + allowed: true, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging, + action: () { + // The provisional route snapshot can make the linked reply look like + // the tail. This authoritative deep-link jump intentionally leaves + // the user at an older item, so it must opt out of follow-tail first. + tailIntent.detach(); + followsThreadTail.value = false; + isAtThreadTail.value = false; + itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); + initialHighlightTargetIndex.value = targetIndex; + }, + ); + return null; + }, + [ + initialMessageId, + canUseMessagesForInitialTarget, + fetchedReplies, + replies.length, + ], + ); + + useEffect( + () { + final targetIndex = initialHighlightTargetIndex.value; + if (targetIndex == null || initialTargetReadyForHighlight.value) { + return null; + } + var completionScheduled = false; + void markReadyAfterTargetLayout() { + if (completionScheduled || + !itemPositionsListener.itemPositions.value.any( + (position) => position.index == targetIndex, + )) { + return; + } + completionScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) initialTargetReadyForHighlight.value = true; + }); + } + + itemPositionsListener.itemPositions.addListener( + markReadyAfterTargetLayout, + ); + markReadyAfterTargetLayout(); + return () => itemPositionsListener.itemPositions.removeListener( + markReadyAfterTargetLayout, + ); + }, + [initialHighlightTargetIndex.value, initialTargetReadyForHighlight.value], + ); // A top-anchored list doesn't stick to the newest item the way the old // reversed one did, so follow the tail explicitly: when a reply arrives @@ -633,7 +756,7 @@ class ThreadDetailPage extends HookConsumerWidget { currentPubkey: currentPubkey, showAuthor: true, isHighlighted: - liveHead.id == initialMessageId, + liveHead.id == highlightedMessageId.value, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, @@ -716,7 +839,8 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, currentPubkey: currentPubkey, showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, + isHighlighted: + reply.id == highlightedMessageId.value, allMessages: allMsgs, isMember: isMember, isArchived: isArchived, diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index db7d1ba0b42..89bdd40998f 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -92,14 +92,37 @@ class _ThreadMessage extends HookConsumerWidget { ); } + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final highlightController = useAnimationController( + duration: _landingHighlightTransitionDuration, + ); + final highlightProgress = useAnimation(highlightController); + useEffect(() { + if (reducedMotion) { + highlightController.value = isHighlighted ? 1 : 0; + } else { + unawaited( + highlightController.animateTo( + isHighlighted ? 1 : 0, + duration: _landingHighlightTransitionDuration, + curve: Curves.easeOutCubic, + ), + ); + } + return null; + }, [highlightController, isHighlighted, reducedMotion]); + final highlightColor = highlightProgress == 0 + ? Colors.transparent + : context.colors.primary.withValues( + alpha: _landingHighlightOpacity * highlightProgress, + ); + return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), child: DecoratedBox( key: ValueKey('thread-message-${message.id}'), decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, + color: highlightColor, borderRadius: BorderRadius.circular(Radii.md), ), child: Material( diff --git a/mobile/lib/shared/widgets/bee_refresh_indicator.dart b/mobile/lib/shared/widgets/bee_refresh_indicator.dart index 4a9802f0dae..f5c71adc17e 100644 --- a/mobile/lib/shared/widgets/bee_refresh_indicator.dart +++ b/mobile/lib/shared/widgets/bee_refresh_indicator.dart @@ -385,10 +385,9 @@ class BeeRefreshIndicator extends HookConsumerWidget { width: _beeWidth, color: context.colors.primary, flapAmount: flapAmount, - eyeProgress: - !isLoading && - !showEyeEmoji && - pupilProgress > 0 + eyeProgress: showEyeEmoji + ? 1 + : !isLoading && pupilProgress > 0 ? pupilProgress : null, ), diff --git a/mobile/lib/shared/widgets/flapping_bee.dart b/mobile/lib/shared/widgets/flapping_bee.dart index 9a99bd87966..a9057b60f2a 100644 --- a/mobile/lib/shared/widgets/flapping_bee.dart +++ b/mobile/lib/shared/widgets/flapping_bee.dart @@ -137,7 +137,9 @@ class _FlappingBeePainter extends CustomPainter { canvas.drawPath(finishedMark, Paint()..color = color); if (eyeProgress case final progress?) { - final pupilRadius = 20 * progress.clamp(0.0, 1.0); + // The eye cutouts are 54px wide. A full pupil must reach their 27px + // radius so the emoji-eye overlay never exposes the background beneath. + final pupilRadius = 27 * progress.clamp(0.0, 1.0); final pupilPaint = Paint()..color = color; canvas ..drawCircle(const Offset(193.3, 84.4), pupilRadius, pupilPaint) diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 94d040526d7..ad878b309eb 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -684,6 +684,10 @@ void main() { expect(page.channel.id, 'ch1'); expect(page.initialThreadRootId, 'parent-reply'); expect(page.initialMessageId, 'reply-event'); + expect( + page.initialThreadRouteBehavior, + InitialThreadRouteBehavior.replaceCurrentRoute, + ); }); testWidgets('thread filter matches grouped thread replies', (tester) async { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 826a419d8da..f5d54c257af 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -187,12 +187,20 @@ Widget _buildTestable({ String? canvasContent, String? initialMessageId, String? initialThreadRootId, + InitialThreadRouteBehavior initialThreadRouteBehavior = + InitialThreadRouteBehavior.push, Map> threadReplies = const {}, Map>> pendingThreadReplies = const {}, + Map> Function()> threadReplyLoaders = + const {}, + Map> localThreadReplies = const {}, TextScaler textScaler = TextScaler.noScaling, bool disableAnimations = false, + bool disableRetries = false, + Duration? Function(int retryCount, Object error)? providerRetry, RelaySessionNotifier? relaySessionNotifier, http.Client? mediaClient, + Widget? home, }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -200,6 +208,7 @@ Widget _buildTestable({ final fakeMessagesNotifier = messagesNotifier ?? _FakeMessagesNotifier(messages); return ProviderScope( + retry: providerRetry ?? (disableRetries ? (_, _) => null : null), overrides: [ channelMessagesProvider( _channelId, @@ -240,6 +249,19 @@ Widget _buildTestable({ threadRepliesProvider( ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), ).overrideWith((ref) => entry.value), + for (final entry in threadReplyLoaders.entries) + threadRepliesProvider( + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + ).overrideWith((ref) => entry.value()), + for (final entry in localThreadReplies.entries) + threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + ).overrideWith( + () => _FakeThreadLocalRepliesNotifier( + ThreadRepliesArgs(channelId: _channelId, rootId: entry.key), + entry.value, + ), + ), // Stub the relay client provider so preloadMembers doesn't crash. relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), @@ -265,11 +287,14 @@ Widget _buildTestable({ child: child!, ), navigatorObservers: navigatorObservers, - home: ChannelDetailPage( - channel: resolvedChannel, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - ), + home: + home ?? + ChannelDetailPage( + channel: resolvedChannel, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + initialThreadRouteBehavior: initialThreadRouteBehavior, + ), ), ); } @@ -4434,6 +4459,372 @@ void main() { }); group('Deep-link navigation', () { + testWidgets('fades the target highlight in after the thread route lands', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Target reply', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final timelineMessages = formatTimeline([root, target]); + final threadHead = timelineMessages.firstWhere( + (message) => message.id == root.id, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + threadReplies: { + 'root': [target], + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'target', + ), + ), + ), + child: const Text('Open highlighted thread'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open highlighted thread')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 1)); + + final threadRoute = + ModalRoute.of(tester.element(find.byType(ThreadDetailPage)))! + as MaterialPageRoute; + expect(threadRoute.animation!.status, AnimationStatus.forward); + final transitionDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(transitionDecoration.color, Colors.transparent); + + await tester.pump(threadRoute.transitionDuration); + expect(threadRoute.animation!.status, AnimationStatus.completed); + await tester.pump(); + await tester.pump(); + final landedDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(landedDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final enteringDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(enteringDecoration.color!.a, greaterThan(0)); + expect(enteringDecoration.color!.a, lessThan(0.12)); + + await tester.pump(const Duration(milliseconds: 150)); + final visibleDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(visibleDecoration.color!.a, closeTo(0.12, 0.001)); + }); + + testWidgets('waits for a delayed target jump before highlighting', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final timelineMessages = formatTimeline([root, ...replies]); + final threadHead = timelineMessages.first; + final replyCompleter = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [root], + pendingThreadReplies: {'root': replyCompleter.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'reply-30', + ), + ), + ), + child: const Text('Open delayed thread'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open delayed thread')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-30')), + findsNothing, + ); + + replyCompleter.complete(replies); + await tester.pumpAndSettle(); + + final target = find.byKey(const ValueKey('thread-message-reply-30')); + expect(target, findsOneWidget); + final landedDecoration = + tester.widget(target).decoration as BoxDecoration; + expect(landedDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final enteringDecoration = + tester.widget(target).decoration as BoxDecoration; + expect(enteringDecoration.color!.a, greaterThan(0)); + expect(enteringDecoration.color!.a, lessThan(0.12)); + }); + + testWidgets('waits for a retry before jumping to a hydrated target', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Hydrated target', + createdAt: 1400, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final earlierReplies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final timelineMessages = formatTimeline([root, target]); + final firstAttempt = Completer>(); + var attempts = 0; + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + providerRetry: (retryCount, _) => + retryCount == 0 ? const Duration(seconds: 30) : null, + localThreadReplies: { + 'root': [target], + }, + threadReplyLoaders: { + 'root': () { + attempts++; + if (attempts == 1) return firstAttempt.future; + return Future.value([...earlierReplies, target]); + }, + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: timelineMessages.first, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'target', + ), + ), + ); + await tester.pump(); + firstAttempt.completeError(Exception('transient thread query failure')); + await tester.pump(); + await tester.pump(); + + final targetFinder = find.byKey(const ValueKey('thread-message-target')); + expect(targetFinder, findsOneWidget); + final retryingDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(retryingDecoration.color, Colors.transparent); + expect(attempts, 1); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final stillRetryingDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(stillRetryingDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 2800)); + expect(attempts, 1); + final expiredJumpDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(expiredJumpDecoration.color, Colors.transparent); + + await tester.pump(const Duration(seconds: 30)); + await tester.pumpAndSettle(); + + expect(attempts, 2); + expect( + find.byKey(const ValueKey('thread-message-group-target')), + findsOneWidget, + ); + final landedDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(landedDecoration.color, Colors.transparent); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + final highlightedDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(highlightedDecoration.color!.a, greaterThan(0)); + }); + + testWidgets('highlights a hydrated target after the thread query fails', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Hydrated target', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final timelineMessages = formatTimeline([root, target]); + final replyCompleter = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + pendingThreadReplies: {'root': replyCompleter.future}, + disableRetries: true, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: timelineMessages.first, + allMessages: timelineMessages, + channelId: _testChannel.id, + currentPubkey: null, + isMember: true, + isArchived: false, + initialMessageId: 'target', + ), + ), + ); + await tester.pumpAndSettle(); + + final targetFinder = find.byKey(const ValueKey('thread-message-target')); + expect(targetFinder, findsOneWidget); + final loadingDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(loadingDecoration.color, Colors.transparent); + + replyCompleter.completeError(Exception('thread query failed')); + for (var i = 0; i < 8; i++) { + await tester.pump(); + } + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 150)); + + final highlightedDecoration = + tester.widget(targetFinder).decoration as BoxDecoration; + expect(highlightedDecoration.color!.a, greaterThan(0)); + expect(highlightedDecoration.color!.a, lessThan(0.12)); + }); + testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { @@ -4492,7 +4883,147 @@ void main() { find.byKey(const ValueKey('thread-message-target')), ); final decoration = highlighted.decoration as BoxDecoration; - expect(decoration.color, isNot(Colors.transparent)); + final initialHighlight = decoration.color!; + expect(initialHighlight, isNot(Colors.transparent)); + expect(initialHighlight.a, closeTo(0.12, 0.001)); + + await tester.pump(const Duration(milliseconds: 2999)); + final heldDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(heldDecoration.color, initialHighlight); + + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + + final fadingDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(fadingDecoration.color!.a, greaterThan(0)); + expect(fadingDecoration.color!.a, lessThan(initialHighlight.a)); + + await tester.pump(const Duration(milliseconds: 150)); + final dismissedDecoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(dismissedDecoration.color, Colors.transparent); + }); + + testWidgets('does not replace a newer route after delayed hydration', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + ); + final messagesNotifier = _FakeMessagesNotifier(const []); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: messagesNotifier, + initialThreadRootId: 'root', + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, + ), + ); + await tester.pumpAndSettle(); + + final navigator = Navigator.of( + tester.element(find.byType(ChannelDetailPage)), + ); + messagesNotifier.setMessages([root]); + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('New destination')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('New destination'), findsOneWidget); + expect(find.byType(ThreadDetailPage), findsNothing); + }); + + testWidgets('replaces a temporary channel route for an initial thread', ( + tester, + ) async { + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Thread root', + ); + final target = _textMsg( + id: 'target', + pubkey: 'bob', + content: 'Target reply', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final relaySession = _TrackingRelaySession(); + + await tester.pumpWidget( + _buildTestable( + messages: [root, target], + relaySessionNotifier: relaySession, + threadReplies: { + 'root': [target], + }, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage( + channel: _testChannel, + initialMessageId: 'target', + initialThreadRootId: 'root', + initialThreadRouteBehavior: + InitialThreadRouteBehavior.replaceCurrentRoute, + ), + ), + ), + child: const Text('Open activity thread'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Open activity thread')); + await tester.pumpAndSettle(); + + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect(find.byType(ChannelDetailPage), findsNothing); + expect(relaySession.visibleChannels, [_testChannel.id]); + + await tester.pageBack(); + await tester.pumpAndSettle(); + + expect(find.text('Open activity thread'), findsOneWidget); + expect(find.byType(ChannelDetailPage), findsNothing); + expect(relaySession.visibleChannels, isEmpty); }); }); @@ -7292,6 +7823,15 @@ Channel _channel({required String id, required String name}) => Channel( isMember: true, ); +class _FakeThreadLocalRepliesNotifier extends ThreadLocalRepliesNotifier { + final List _replies; + + _FakeThreadLocalRepliesNotifier(super.args, this._replies); + + @override + List build() => _replies; +} + class _FakeMessagesNotifier extends ChannelMessagesNotifier { List _messages; bool _hasLoadedMessages; @@ -7343,6 +7883,27 @@ class _ErrorMessagesNotifier extends ChannelMessagesNotifier { AsyncError('Connection failed', StackTrace.current); } +class _TrackingRelaySession extends RelaySessionNotifier { + final visibleChannels = []; + + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + + @override + void Function() registerVisibleChannel(String channelId) { + final release = super.registerVisibleChannel(channelId); + visibleChannels.add(channelId); + var released = false; + return () { + if (released) return; + released = true; + visibleChannels.remove(channelId); + release(); + }; + } +} + class _ReconnectingRelaySession extends RelaySessionNotifier { @override SessionState build() => diff --git a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart index de440ebb624..1336e9f9d13 100644 --- a/mobile/test/shared/widgets/bee_refresh_indicator_test.dart +++ b/mobile/test/shared/widgets/bee_refresh_indicator_test.dart @@ -1,14 +1,56 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:buzz/shared/widgets/bee_refresh_indicator.dart'; import 'package:buzz/shared/widgets/flapping_bee.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('full eye progress fills each eye cutout to its edge', ( + tester, + ) async { + const beeKey = ValueKey('full-eye-bee'); + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: FlappingBee( + key: beeKey, + width: 466, + color: Colors.black, + flapAmount: 0, + eyeProgress: 1, + ), + ), + ), + ); + + final boundary = tester.renderObject( + find.descendant( + of: find.byKey(beeKey), + matching: find.byType(RepaintBoundary), + ), + ); + final bytes = await tester.runAsync(() async { + final image = await boundary.toImage(); + final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + image.dispose(); + return data; + }); + expect(bytes, isNotNull); + + int alphaAt(int x, int y) => bytes!.getUint8(((y * 466) + x) * 4 + 3); + + // These points sit inside the 27px eye cutouts but outside the old 20px + // pupil radius, directly covering the light rings seen behind the emoji. + expect(alphaAt(217, 84), 255); + expect(alphaAt(300, 84), 255); + }); + testWidgets('shows the bee while pulling to refresh', (tester) async { const contentKey = ValueKey('loading-content'); var refreshes = 0; @@ -190,7 +232,7 @@ void main() { ); await tester.pump(); - expect(tester.widget(beeFinder).eyeProgress, isNull); + expect(tester.widget(beeFinder).eyeProgress, 1); expect( find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsOneWidget, @@ -239,6 +281,7 @@ void main() { find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsOneWidget, ); + expect(tester.widget(beeFinder).eyeProgress, 1); expect(hapticCalls, hasLength(3)); await secondGesture.moveBy( @@ -250,6 +293,7 @@ void main() { find.byKey(const ValueKey('bee-refresh-eyes-emoji')), findsOneWidget, ); + expect(tester.widget(beeFinder).eyeProgress, 1); expect(hapticCalls, hasLength(3)); await secondGesture.up(); From 537b26f372ef07a81d24e858b1dcda62c07a7e85 Mon Sep 17 00:00:00 2001 From: adrienlacombe <6303520+adrienlacombe@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:13:27 +0200 Subject: [PATCH 27/27] docs(agents): record the file-size gate move and Android app_name precedence Two fork-patch rationales shifted under this sync and would have misled the next merge: - Upstream #6187 made the file-size policy a first-class gate. It is now `just file-size-check`, run repository-wide as the `File size policy` step of the `scripts` job, and no longer hangs off the desktop/web/mobile path filters. The relay.rs row told a future reader to expect an overflow as a red `Desktop Core`, which is now the wrong place to look. - Upstream extended the Android debug label into an `if (debugAppName != null) ... else if (worktreeLabel != null)` chain, so its branch now runs ahead of the fork's brand literal. That is a recurring conflict site, so the row records the resolution: take upstream's new branch, keep the fork's brand in the fallback. Signed-off-by: adrienlacombe <6303520+adrienlacombe@users.noreply.github.com> --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0d290bcbec1..efd0e534634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,7 @@ place. | `.github/workflows/deploy-aws.yml` | new | Continuous deployment of the relay to AWS on every push to `main`. Runs after `docker.yml` via `workflow_run`, authenticates by OIDC (no stored keys), and applies Terraform with the commit's immutable `:sha-<7>` image | | `desktop/src-tauri/src/relay/allowlist.rs` | new | Single-relay host allowlist. Upstream is multi-community by design; this fork ships a client that reaches only `relay.bitcoinmarkets.app`. **Lives under `relay/`, not at the crate root** — see the `relay.rs` row | | `desktop/src-tauri/src/native_websocket.rs` | allowlist call in `open_connection` | The transport is the one path every relay session takes, so a host restriction there cannot be bypassed from the UI | -| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because `lib.rs`'s sorted module list is a permanent conflict site, and because `lib.rs` was itself at the 1000-line desktop ratchet when the move was made in the 2026-08-01 sync. `lib.rs` now carries no fork patch at all. **`relay.rs` has since become the constrained file, and the ratchet is how you find out — as a red `Desktop Core`, not a merge conflict.** The 2026-08-14 sync merged cleanly and pushed it 987 → 1002 against a hard limit of 1000 (`desktop/scripts/check-file-sizes.mjs`; upstream's own `mod get;` was +3, the fork's block +14). Fixed by condensing the fork's two comment blocks to 995, since AGENTS.md is where the reasoning belongs — **do not split or reorganise upstream's `relay.rs` to make room**, that trades 5 lines for a permanent conflict surface. Upstream is extracting submodules from this file on its own (`mod get;`, `mod submit;`), so the pressure should ease; if it does not, the fork's ~11 lines here are the budget to work within | +| `desktop/src-tauri/src/relay.rs` | release builds default to the allowlisted relay; also declares `pub mod allowlist;` | Without the default a release build uses `ws://localhost:3000`, which the allowlist then rejects — a client that cannot connect at all. The module is declared *here* because `lib.rs`'s sorted module list is a permanent conflict site, and because `lib.rs` was itself at the 1000-line desktop ratchet when the move was made in the 2026-08-01 sync. `lib.rs` now carries no fork patch at all. **`relay.rs` has since become the constrained file, and the ratchet is how you find out — as a red check, not a merge conflict.** Upstream #6187 (2026-08-19 sync) made the file-size policy a first-class gate: it is now `just file-size-check`, run repository-wide as the **`File size policy`** step of the `scripts` CI job, and it no longer hangs off the per-surface `desktop`/`web`/`mobile` path filters. So an overflow here fails on every PR regardless of which paths it touched, and it surfaces under `scripts` rather than `Desktop Core` — run `just file-size-check` locally to reproduce. The 2026-08-14 sync merged cleanly and pushed it 987 → 1002 against a hard limit of 1000 (`desktop/scripts/check-file-sizes.mjs`; upstream's own `mod get;` was +3, the fork's block +14). Fixed by condensing the fork's two comment blocks to 995, since AGENTS.md is where the reasoning belongs — **do not split or reorganise upstream's `relay.rs` to make room**, that trades 5 lines for a permanent conflict surface. Upstream is extracting submodules from this file on its own (`mod get;`, `mod submit;`), so the pressure should ease; if it does not, the fork's ~11 lines here are the budget to work within | | `mobile/lib/shared/relay/relay_allowlist.dart` | new | Mobile counterpart. Skips enforcement under `flutter test` (`FLUTTER_TEST`) because upstream tests use `wss://relay.example.com`; editing those 13 files would be a large permanent conflict surface | | `mobile/lib/shared/relay/relay_socket.dart` | allowlist call in `connect()` | Transport choke point, as on desktop | | `mobile/lib/shared/relay/relay_validation.dart` | allowlist call after the shape checks | One hunk covers all four invite/deep-link call sites; placed after the existing checks so malformed input keeps its original error | @@ -215,7 +215,7 @@ place. | `desktop/src-tauri/Info.plist` | `CFBundleDisplayName`, `CFBundleName` and the three `NS*UsageDescription` strings → `BitcoinMarkets` | `productName` only renames the `.app` directory, the DMG and the mounted volume. These keys are what macOS displays: Finder reads `CFBundleDisplayName`, the menu bar reads `CFBundleName`, and the usage descriptions are quoted verbatim in system permission prompts. Verified against a built canary before patching — the bundle was `BitcoinMarkets.app` while `CFBundleName` was still `Buzz`, so the app asked for the microphone as "Buzz". `CFBundleIdentifier` and the `buzz-desktop` executable name stay | | `mobile/ios/Runner/Info.plist` | `CFBundleName` and the three `NS*UsageDescription` strings → `BitcoinMarkets` | The xcconfigs below set `CFBundleDisplayName` (home-screen label); `CFBundleName` is the shorter name iOS falls back to in Settings, and it was still `Buzz`. Usage descriptions appear verbatim in iOS permission prompts | | `mobile/ios/Flutter/Debug.xcconfig`, `Release.xcconfig` | `APP_DISPLAY_NAME = BitcoinMarkets` | iOS home-screen name, debug and release | -| `mobile/android/app/build.gradle.kts` | `app_name` resValue → `BitcoinMarkets`, in `defaultConfig` and the worktree-debug branch | Android launcher label. Two hunks because the worktree label composes onto the same string | +| `mobile/android/app/build.gradle.kts` | `app_name` resValue → `BitcoinMarkets`, in `defaultConfig` and the worktree-debug branch | Android launcher label. Two hunks because the worktree label composes onto the same string. **Upstream now writes the same resource from a third place**: #6049's `debugAppName` (read from the override file's `appName` property) was extended in the 2026-08-19 sync into an `if (debugAppName != null) … else if (worktreeLabel != null)` chain, so upstream's branch runs *before* the fork's. That conflicts every time upstream touches the chain, and the resolution is *take upstream's new branch, keep the fork's brand in the fallback* — never replace the fallback with upstream's `"Buzz ($worktreeLabel)"`. The fork's literal is only reachable when no explicit `appName` override is set, which is the normal worktree case | | `scripts/mobile-worktree-overrides.sh` | branch-labelled debug name | Generates the gitignored per-worktree `APP_DISPLAY_NAME` | | `scripts/test-mobile-worktree-overrides.sh` | assertions derive the production name from `mobile-worktree-overrides.sh` instead of matching the literal `Buzz` | Four assertions hardcoded `Buzz` and **failed CI on `main` for three commits** after the rename (`ff5e83c28`…`684a15f50`), cascading into `Desktop` and `Desktop E2E Integration` through their gate steps. Deriving the name tests the contract the file is for — release unlabelled, debug labelled, iOS and Android agreeing — so a future rename cannot fail it for the wrong reason |