diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 3a090b3eb..3db5c6baa 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -91,7 +91,7 @@ jobs: echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" if [[ "$TAG_PREFIX" == desktop-v ]]; then - echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}" + echo "target_sha=${{ github.event.pull_request.head.sha }}" echo "desktop=true" else echo "target_sha=$GITHUB_SHA" @@ -112,6 +112,7 @@ jobs: PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGED_AT: ${{ github.event.pull_request.merged_at }} run: | VERSION="${VERSION#desktop-v}" export VERSION @@ -146,7 +147,17 @@ jobs: exit 1 fi fi - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ -f sha="$TARGET_SHA" \ - --silent + --silent; then + # Ref creation is atomic. A concurrent retry may have won the race; + # accept that only when it created the exact immutable ref. + EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG was concurrently created at $TARGET_SHA" + exit 0 + fi + echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)" + exit 1 + fi diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index eddebea68..61ccc800a 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read jobs: validate: @@ -20,6 +21,7 @@ jobs: - name: Validate immutable desktop candidate if: startsWith(github.event.pull_request.head.ref, 'version-bump/') env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ github.event.pull_request.head.ref }} run: | VERSION="${VERSION#version-bump/}" diff --git a/RELEASING.md b/RELEASING.md index 23dacea2c..53d580556 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -48,28 +48,30 @@ or mobile GitHub Release. ### Desktop 1. Run `just release-desktop ` from a clean, up-to-date `main` checkout. - The script fetches the current `origin/main`, regenerates - `version-bump/` as one - deterministic candidate commit, records the frozen base and proposed - `desktop-v` tag in `.release/desktop-candidate.json`, updates every - desktop manifest and lockfile, writes a full-SHA changelog, and opens or - updates the PR. -2. Review the recorded base and candidate SHA, the complete changelog, and CI. - The required **Desktop Release Candidate** check validates the exact head. - A trusted repository member, owner, or collaborator must approve that exact - candidate head. Any regeneration or push changes the head, invalidates the - prior approval, and requires both the checks and approval to run again. -3. **Squash merge** the PR. The protected branch must still be exactly the - recorded base; otherwise regenerate the candidate from current `main`. -4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity, - required checks, and trusted approval on the exact candidate head, then tags - the squash commit as `desktop-v`. An admin or ruleset bypass does not - authorize desktop tagging. -5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel - macOS, Windows, and Linux artifacts; publishes the versioned release only - after the complete set succeeds; then updates the rolling updater manifest - last for stable versions. A failed platform leaves no partially published - versioned release. + The script creates one deterministic candidate commit and records both its + frozen base and the verified prior release ledger in candidate metadata. +2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or + pushing the branch creates a new candidate and requires checks to run again. +3. **Squash merge** the PR after all protected-branch checks pass. The merge is + the human authorization event; an authorized owner/admin bypass is treated + the same way. Unrelated changes reaching `main` do not invalidate the + reviewed candidate. +4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR + identity, validates candidate content, and proves every required check came + from its trusted producer and was successful when the PR merged. It creates + `desktop-v` at the exact reviewed PR head—not the squash commit. + Retries accept that tag only at the same SHA and never move it. GitHub does + not expose when an individual check rerun was created, so an ordinary rerun + after merge deliberately makes tag verification fail closed; inspect that + run and create a new candidate version rather than retrying the blocked tag. +5. The tag triggers `release.yml`. It builds and stages all platform artifacts, + publishes the versioned release only after the complete set succeeds, then + updates the rolling updater manifest last for stable versions. + +Because squash merging leaves immutable candidate tags on side history, the next +release uses validated prior candidate metadata as its ledger boundary. It +includes unrelated commits after the prior frozen base and excludes exactly the +prior release's recorded squash commit; tag ancestry is deliberately irrelevant. ### Relay diff --git a/VISION.md b/VISION.md index 900e5a947..66a106bde 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by member | Any member | +| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dc..93109fa94 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -155,7 +155,7 @@ pub struct AcpClient { /// a `cancelled` outcome before the agent returns from `session/prompt`. pending_permission_id: Option, /// Whether we have already sent a response to the pending permission request. - /// Guards against double-response if a timeout fires after the allow_once + /// Guards against double-response if a timeout fires after the rejection /// response was written but before `pending_permission_id` was cleared. permission_responded: bool, /// The JSON-RPC id of the most recently sent `session/prompt` request. @@ -1162,7 +1162,8 @@ impl AcpClient { /// /// While waiting, handles: /// - `session/update` notifications → logged via tracing - /// - `session/request_permission` requests → auto-approved with `allow_once` + /// - `session/request_permission` requests → rejected unless an owner has + /// already selected a non-interactive permission mode at session setup /// - Any other messages → debug-logged and ignored; if they carry an `id` /// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent. /// @@ -1870,12 +1871,12 @@ impl AcpClient { } } - /// Auto-approve a `session/request_permission` request from the agent. + /// Reject a `session/request_permission` request from the agent. /// - /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. - /// If no `allow_once` option exists, falls back to `reject_once`. - /// - /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. + /// Buzz has no human permission prompt in this harness, so selecting + /// `allow_once` would turn any admitted prompt into an implicit approval. + /// Find `reject_once` by kind when the adapter offers it; otherwise use the + /// protocol's cancelled outcome, which is also fail-closed. /// /// The request `id` is stored as `serde_json::Value` to support both numeric /// and string IDs per JSON-RPC 2.0. @@ -1901,40 +1902,7 @@ impl AcpClient { options.len() ); - // Find allow_once by kind — NEVER hardcode optionId. - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - - let response = if let Some(opt) = allow_once { - let option_id = opt["optionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; - tracing::info!( - target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" - ); - permission_response_selected(&id, option_id) - } else { - // No allow_once — fall back to reject_once. - tracing::warn!( - target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" - ); - let reject = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - - if let Some(opt) = reject { - let option_id = opt["optionId"].as_str().unwrap_or("reject"); - permission_response_selected(&id, option_id) - } else { - return Err(AcpError::Protocol( - "no suitable permission option found (neither allow_once nor reject_once)" - .into(), - )); - } - }; + let response = permission_denial_response(&id, options)?; // Write the response first, then mark as responded. // @@ -2046,6 +2014,42 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { }) } +/// Choose the fail-closed response to a `session/request_permission` request. +/// +/// Buzz has no human permission prompt in this harness, so selecting +/// `allow_once` would turn any admitted prompt into an implicit approval. +/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a +/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for +/// adapters that do not offer one. Both answers deny. +/// +/// Kept free of the client so the decision is testable without an agent +/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes. +fn permission_denial_response( + id: &serde_json::Value, + options: &[serde_json::Value], +) -> Result { + let reject_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + + let Some(opt) = reject_once else { + tracing::warn!( + target: "acp::permission", + "no reject_once option found in permission request id={id}, cancelling" + ); + return Ok(permission_response_cancelled(id)); + }; + + let option_id = opt["optionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?; + tracing::info!( + target: "acp::permission", + "rejecting permission id={id} with reject_once optionId={option_id:?}" + ); + Ok(permission_response_selected(id, option_id)) +} + /// Full `session/new` response — session ID plus the raw JSON result. /// /// Callers use the extractor helpers to pull model info from `raw`. @@ -2300,63 +2304,96 @@ mod tests { assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal)); } + fn options(json: &str) -> Vec { + serde_json::from_str(json).expect("option list") + } + + fn outcome(response: &serde_json::Value) -> Option<&str> { + response["result"]["outcome"]["outcome"].as_str() + } + + /// The offered `allow_once` and `allow_always` options must be ignored: + /// there is no human to click them, so choosing either would make every + /// admitted prompt an implicit approval. `optionId`s are deliberately + /// non-obvious to prove they are matched by `kind`, never hardcoded. #[test] - fn find_allow_once_by_kind_not_by_option_id() { - // optionId values are intentionally non-obvious to prove we don't hardcode them. - let options: Vec = serde_json::from_str( + fn permission_requests_select_reject_once_not_allow_once() { + let options = options( r#"[ {"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"}, {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = + permission_denial_response(&serde_json::json!(7), &options).expect("denial response"); - assert!(allow_once.is_some(), "should find allow_once option"); - let opt = allow_once.unwrap(); - // Found by kind, not by hardcoded optionId - assert_eq!(opt["kind"].as_str(), Some("allow_once")); - assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99")); + assert_eq!(outcome(&response), Some("selected")); + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject-42"), + "must select reject_once even when allow options are offered" + ); } + /// Fail-closed backstop: an adapter that offers no `reject_once` must still + /// be denied, via the protocol's cancelled outcome rather than an error or + /// an approval. #[test] - fn find_allow_once_returns_none_when_absent() { - let options: Vec = serde_json::from_str( + fn permission_request_without_reject_once_is_cancelled() { + let options = options( r#"[ - {"optionId": "reject-1", "name": "Reject", "kind": "reject_once"}, - {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"} + {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = permission_denial_response(&serde_json::json!("req-1"), &options) + .expect("cancelled response"); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + assert_eq!( + response["id"].as_str(), + Some("req-1"), + "string ids must round-trip per JSON-RPC 2.0" + ); + } + + /// An empty option list is the degenerate form of the same backstop. + #[test] + fn permission_request_with_no_options_is_cancelled() { + let response = + permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response"); + + assert_eq!(outcome(&response), Some("cancelled")); + } + + /// A `reject_once` option missing its `optionId` is a protocol violation. + /// Erroring propagates to the caller, which tears the turn down — still no + /// approval is ever sent. + #[test] + fn reject_once_without_option_id_is_a_protocol_error() { + let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#); + + let err = permission_denial_response(&serde_json::json!(1), &options) + .expect_err("missing optionId must error"); + + assert!(matches!(err, AcpError::Protocol(_)), "got {err:?}"); } #[test] - fn find_reject_once_fallback_when_no_allow_once() { - let options: Vec = serde_json::from_str( - r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#, - ) - .unwrap(); + fn find_reject_once_by_kind() { + let options = + options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - assert!(allow_once.is_none()); + let response = + permission_denial_response(&serde_json::json!(1), &options).expect("denial response"); - let reject_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - assert!(reject_once.is_some()); - assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("rej-x") + ); } #[test] diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188..d95968584 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -116,7 +116,6 @@ impl std::fmt::Display for RespondTo { /// /// - `default` — agent's built-in behaviour (permission requests per tool call). /// - `acceptEdits` — auto-approve file edits, still ask for other tools. -/// - `bypassPermissions` — skip the permission flow entirely. /// - `dontAsk` — never prompt; reject anything that would require permission. /// - `plan` — planning-only mode (no tool execution). #[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] @@ -127,9 +126,6 @@ pub enum PermissionMode { /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, - /// Skip the permission flow entirely. - #[value(alias = "bypassPermissions")] - BypassPermissions, /// Never prompt; reject anything that would require permission. #[value(alias = "dontAsk")] DontAsk, @@ -145,7 +141,6 @@ impl PermissionMode { match self { Self::Default => "default", Self::AcceptEdits => "acceptEdits", - Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", Self::Plan => "plan", } @@ -432,13 +427,12 @@ pub struct CliArgs { /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// - /// Defaults to `bypassPermissions` which skips the per-tool-call - /// permission flow. Set to `default` to restore the agent's built-in - /// behaviour. + /// Defaults to `dontAsk`, which rejects operations that need interactive + /// approval because Buzz does not expose a human permission prompt. #[arg( long, env = "BUZZ_ACP_PERMISSION_MODE", - default_value = "bypass-permissions", + default_value = "dont-ask", value_enum )] pub permission_mode: PermissionMode, @@ -1469,7 +1463,7 @@ mod tests { memory_enabled: true, model: None, session_title: None, - permission_mode: PermissionMode::BypassPermissions, + permission_mode: PermissionMode::DontAsk, respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: Vec::new(), @@ -2270,10 +2264,6 @@ channels = "ALL" fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); - assert_eq!( - PermissionMode::BypassPermissions.as_wire_str(), - "bypassPermissions" - ); assert_eq!(PermissionMode::DontAsk.as_wire_str(), "dontAsk"); assert_eq!(PermissionMode::Plan.as_wire_str(), "plan"); } @@ -2281,7 +2271,6 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); - assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); @@ -2289,20 +2278,17 @@ channels = "ALL" #[test] fn test_permission_mode_display() { - assert_eq!( - format!("{}", PermissionMode::BypassPermissions), - "bypassPermissions" - ); + assert_eq!(format!("{}", PermissionMode::DontAsk), "dontAsk"); assert_eq!(format!("{}", PermissionMode::Default), "default"); } #[test] fn test_summary_includes_permission_mode() { let mut config = test_config(SubscribeMode::Mentions); - config.permission_mode = PermissionMode::BypassPermissions; + config.permission_mode = PermissionMode::DontAsk; let s = config.summary(); assert!( - s.contains("permission_mode=bypassPermissions"), + s.contains("permission_mode=dontAsk"), "summary should include permission_mode, got: {s}" ); } @@ -2319,9 +2305,9 @@ channels = "ALL" } #[test] - fn test_default_config_uses_bypass_permissions() { + fn test_default_config_rejects_interactive_permissions() { let config = test_config(SubscribeMode::Mentions); - assert_eq!(config.permission_mode, PermissionMode::BypassPermissions); + assert_eq!(config.permission_mode, PermissionMode::DontAsk); } #[test] @@ -2332,7 +2318,6 @@ channels = "ALL" let cases = [ ("default", PermissionMode::Default), ("accept-edits", PermissionMode::AcceptEdits), - ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2347,14 +2332,12 @@ channels = "ALL" #[test] fn test_permission_mode_value_enum_camel_case_aliases() { - // Operators may set env vars using the camelCase wire-format strings - // (e.g. BUZZ_ACP_PERMISSION_MODE=bypassPermissions). The #[value(alias)] - // attributes ensure these parse correctly. + // Operators may set env vars using the camelCase wire-format strings. + // The #[value(alias)] attributes ensure these parse correctly. use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), ("acceptEdits", PermissionMode::AcceptEdits), - ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2367,6 +2350,18 @@ channels = "ALL" } } + #[test] + fn test_permission_mode_rejects_unattended_bypass() { + use clap::ValueEnum; + + for input in ["bypass-permissions", "bypassPermissions"] { + assert!( + PermissionMode::from_str(input, true).is_err(), + "{input:?} must not disable the ACP permission boundary" + ); + } + } + /// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args. /// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`. fn resolve_idle_timeout(idle: Option, turn: Option) -> u64 { diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4a..0c4e5f158 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5125,7 +5125,7 @@ mod build_mcp_servers_tests { memory_enabled: false, model: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_mode: config::PermissionMode::DontAsk, respond_to: config::RespondTo::Anyone, respond_to_allowlist: std::collections::HashSet::new(), allowed_respond_to: vec![], @@ -5347,7 +5347,7 @@ mod error_outcome_emission_tests { memory_enabled: false, model: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_mode: config::PermissionMode::DontAsk, respond_to: config::RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: vec![], diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ddc0330d9..8430307d9 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1017,7 +1017,7 @@ async fn create_session_and_apply_model( // Apply permission mode if not the agent's built-in default AND the agent // advertises the requested mode in session/new. Agents that don't support // the mode (e.g., goose crashes on unrecognized set_config_option values) - // are safely skipped — the harness auto-approves via handle_permission_request. + // are safely skipped — the harness rejects interactive permission requests. if !ctx.permission_mode.is_default() && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) { @@ -1130,11 +1130,7 @@ async fn apply_model_switch( Ok(()) } -/// Set the session permission mode via `session/set_config_option`. -/// -/// Non-fatal for most errors: logs and proceeds. The agent falls back -/// to its default permission mode (`"default"`), which still works via -/// Check if the agent's `session/new` response advertises a given mode ID +/// Check whether the agent's `session/new` response advertises a given mode ID /// in `result.modes.availableModes[].id`. Returns `false` if the modes /// field is absent or the mode isn't listed. fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool { @@ -1150,7 +1146,11 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) .unwrap_or(false) } -/// per-tool auto-approval in `handle_permission_request`. +/// Set the session permission mode via `session/set_config_option`. +/// +/// Non-fatal for most errors: logs and proceeds. The agent falls back to its +/// default mode, and any interactive permission request is rejected by +/// `handle_permission_request`. /// /// **Fatal exception:** if the agent process exits (e.g., goose crashes on /// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn. @@ -1190,7 +1190,7 @@ async fn apply_permission_mode( Ok(Err(e)) => { tracing::warn!( target: "pool::permission", - "failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval" + "failed to set permission mode {wire:?}: {e} — falling back to per-tool rejection" ); } Err(_) => { diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index ff87a33a1..054c33440 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -6,7 +6,7 @@ use tokio::task::JoinSet; use crate::builtin; use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; -use crate::handoff::HandoffOutcome; +use crate::handoff::{ContextRecovery, HandoffOutcome}; use crate::hints::SkillEntry; use crate::llm::Llm; use crate::mcp::McpRegistry; @@ -21,6 +21,34 @@ use crate::wire::{self, WireSender}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support image input. The image was removed from conversation history so this turn can continue. Use a text-based inspection tool or ask the user for a textual description instead."; + +/// Remove image blocks that the provider has explicitly rejected while keeping +/// their surrounding tool result (and therefore the tool-call/result pairing) +/// intact. Returns the number of images removed; zero means the provider error +/// cannot be safely recovered by mutating history. +fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize { + let mut replaced = 0; + for item in history { + let HistoryItem::ToolResult(result) = item else { + continue; + }; + let before = result.content.len(); + result + .content + .retain(|content| !matches!(content, ToolResultContent::Image { .. })); + let removed = before - result.content.len(); + if removed > 0 { + replaced += removed; + result.is_error = true; + result.content.push(ToolResultContent::Text( + UNSUPPORTED_IMAGE_TOOL_MESSAGE.to_string(), + )); + } + } + replaced +} + /// Maximum reply reminders emitted per prompt when `require_reply` is on. /// /// After this many, the turn is allowed to end whether or not anything was @@ -201,6 +229,14 @@ impl RunCtx<'_> { *self.turn_output_tokens = None; *self.turn_cached_input_tokens = None; *self.turn_total_state = TurnTotalState::Unseen; + // Per-turn handoff-attempt counter. Scoped here (not persisted in the + // session) so `BUZZ_AGENT_MAX_HANDOFFS` bounds compactions per + // `session/prompt` turn rather than per session lifetime. A + // long-lived session legitimately needs unbounded handoffs across + // prompts; the cap only exists to stop runaway within a single turn. + // The session-cumulative `handoff_count` (used in log lines) is not + // reset: it reflects total compactions since session start. + let mut handoff_attempts: usize = 0; let mut round = 0u32; // Per-prompt `_Stop` objection count. Bounded per prompt (not per @@ -215,6 +251,10 @@ impl RunCtx<'_> { // successful publish. See `is_buzz_reply_call`. let mut buzz_reply_call_seen = false; let mut reply_nags = 0u32; + // Per-`run()` reactive context-recovery budget. Per-turn, not + // per-session: a fresh prompt deserves a fresh chance to recover, and + // `max_rounds` defaults to 0 (unbounded) so it cannot bound this. + let mut context_recoveries = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -227,7 +267,7 @@ impl RunCtx<'_> { // its next request — the turn continues, it is not restarted. Drain // non-blocking; an empty queue is the common case. self.drain_steers(); - match self.maybe_handoff().await { + match self.maybe_handoff(&mut handoff_attempts).await { HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled), // Context was just reset — the prior request's token count no // longer describes the (now much smaller) history. Clear both @@ -249,10 +289,10 @@ impl RunCtx<'_> { tools.push(builtin::load_skill_def()); } round = round.saturating_add(1); - let response = tokio::select! { + let response_result = tokio::select! { biased; _ = self.cancel.changed() => return Ok(StopReason::Cancelled), - r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r?, + r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r, _ = async { // Keepalive ticker: emit a lightweight session update every 30s // while waiting on the LLM provider. This resets the ACP harness @@ -275,7 +315,78 @@ impl RunCtx<'_> { } } => unreachable!(), }; - + let response = match response_result { + Ok(response) => response, + Err(AgentError::UnsupportedImageInput(detail)) => { + let removed = replace_unsupported_images(self.history); + if removed == 0 { + return Err(AgentError::UnsupportedImageInput(detail)); + } + tracing::warn!( + model = self.effective_model, + removed_images = removed, + "provider rejected image input; removed images from history and continuing turn" + ); + continue; + } + // Reactive context recovery. A context-window 400 is the only + // ground-truth signal that history must shrink, and it arrives + // exactly when the proactive gate cannot act: a failed request + // reports no usage, so `last_request_input_tokens` stays frozen + // at the last SUCCESSFUL (sub-threshold) reading and + // `should_handoff()` returns false forever. Without this arm the + // error propagates out of `run()`, the in-memory session keeps + // the same oversized history, and every later prompt in that + // session fails the same way — a stick that persists across + // turns for the life of the session. (Restarting the agent DOES + // clear it: history lives only in the in-memory session map, so + // a restart is the manual workaround, not an exception to it.) + // + // Retried in-loop rather than returned so the recovered context + // continues the turn the user is waiting on. + Err(AgentError::LlmContextExceeded(e)) => { + match self + .recover_from_context_overflow(&mut context_recoveries) + .await + { + ContextRecovery::Recovered => { + // Refund the round the rejected request consumed. + // `round` is incremented before `complete()`, so + // without this a finite `max_rounds` is spent by a + // request the provider refused to serve: the loop + // would re-enter, hit the cap at the top, and return + // `MaxTurnRequests` having destroyed history and + // never asked the model again — a worse outcome than + // the error it replaced. + // + // This cannot become an unbounded amnesty: refunds + // happen only on a *successful* recovery, and + // recoveries are independently capped by + // `MAX_CONTEXT_RECOVERIES_PER_RUN`, so at most that + // many rounds can ever be refunded in one turn. An + // ordinary round is never refunded. + round = round.saturating_sub(1); + // Same reset as the proactive path (see + // `HandoffOutcome::Performed` above): the frozen + // token reading describes history that no longer + // exists. Clearing it is what lets the gate work + // again on later rounds. + *self.last_request_input_tokens = None; + *self.last_request_history_bytes = None; + continue; + } + ContextRecovery::Cancelled => return Ok(StopReason::Cancelled), + // No rescue left. Surface the provider's own error + // rather than a synthetic one: it names the model and + // the offending sizes, and a visible failure is the + // point — the alternative is retrying forever. + ContextRecovery::Exhausted => { + return Err(AgentError::LlmContextExceeded(e)) + } + } + } + Err(error) => return Err(error), + }; // Record provider-reported input usage so the next loop iteration's // handoff gate can compare it against the token budget. We capture // it together with the history byte size AT THIS MOMENT — which is @@ -945,6 +1056,66 @@ mod tests { use super::*; use serde_json::json; + /// `truncate_history` cannot serve as the context-window fallback: it is + /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body + /// limiter) while the thing the fallback must defend is a TOKEN window + /// (`max_context_tokens`, default 200k). A history large enough to blow a + /// 200k-token window is nowhere near 16 MiB, so at the default budget the + /// fallback evicts nothing at all — which is why the `Skipped -> + /// truncate_history` path left the agent permanently stuck and the reactive + /// ladder had to be built instead. + /// + /// The negative assertion is paired with a positive control (same helper, + /// same fixture, budget set to the window instead) so that "evicted + /// nothing" is a real observation about the unit mismatch rather than a + /// blind probe that could never evict. + #[test] + fn truncate_history_is_a_noop_at_context_window_scale() { + // ~800 KB of history. At any real bytes/token density (densest real + // content is ~1.4 B/tok, typical prose ~3-4) this is >= 200k tokens, + // i.e. already over a 200k window. + let mut history: Vec = Vec::new(); + for i in 0..400 { + history.push(HistoryItem::User(format!("q{i} {}", "x".repeat(1000)))); + history.push(HistoryItem::Assistant { + text: format!("a{i} {}", "y".repeat(1000)), + tool_calls: vec![], + reasoning_details: None, + }); + } + let total: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); + let pressure: usize = history + .iter() + .map(HistoryItem::context_pressure_bytes) + .sum(); + assert!( + total > 800_000, + "fixture must be big enough to exceed a 200k-token window, got {total}" + ); + + // NEGATIVE: the real configured default budget. + let default_budget = 16 * 1024 * 1024; + let mut under_default = history.clone(); + truncate_history(&mut under_default, default_budget); + assert_eq!( + under_default.len(), + history.len(), + "16 MiB byte budget evicted nothing from a {total}-byte history \ + (pressure {pressure}) that already exceeds a 200k-token window" + ); + + // POSITIVE CONTROL: same helper, same fixture, budget set to the + // window instead. If this also evicted nothing the assertion above + // would prove nothing about the unit mismatch -- it would just mean + // the probe is blind. + let mut under_window = history.clone(); + truncate_history(&mut under_window, 200_000); + assert!( + under_window.len() < history.len(), + "positive control must evict: probe is blind otherwise" + ); + } + /// The shapes the guard must recognize as a publish attempt. Callers apply /// the registry checks first; these cover the name suffix and command text. #[test] @@ -1075,6 +1246,47 @@ mod tests { assert!(total_after <= max_bytes); } + #[test] + fn unsupported_images_become_recoverable_tool_errors() { + let mut history = vec![ + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "call-image".into(), + name: "dev__view_image".into(), + arguments: json!({ "source": "spec.png" }), + provider_extra: Default::default(), + }], + reasoning_details: None, + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "call-image".into(), + content: vec![ + ToolResultContent::Text("10x10 image from spec.png".into()), + ToolResultContent::Image { + data: "aW1n".into(), + mime_type: "image/png".into(), + }, + ], + is_error: false, + }), + ]; + + assert_eq!(replace_unsupported_images(&mut history), 1); + let HistoryItem::ToolResult(result) = &history[1] else { + panic!("tool result must stay paired with the assistant tool call"); + }; + assert_eq!(result.provider_id, "call-image"); + assert!(result.is_error); + assert!(result + .content + .iter() + .all(|content| !matches!(content, ToolResultContent::Image { .. }))); + assert!(result.text().contains("does not support image input")); + assert!(result.text().contains("10x10 image from spec.png")); + assert_eq!(replace_unsupported_images(&mut history), 0); + } + #[test] fn truncate_history_noop_when_under_budget() { let mut history = vec![ diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index afbda5379..439e49f4e 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -657,6 +657,21 @@ pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024; pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; +/// Maximum reactive context-recovery attempts per `run()`. A provider +/// context-window 400 is recoverable — shrink history and retry — but the +/// retry must be bounded: `max_rounds` defaults to `0` (unbounded), so without +/// its own budget a request that stays oversized after every rescue would +/// retry forever. On exhaustion the error surfaces to the caller, which is a +/// visible failure rather than a silent infinite rescue. +pub const MAX_CONTEXT_RECOVERIES_PER_RUN: u32 = 3; + +/// Floor for the reactive handoff's history-prompt budget, in bytes. Each +/// recovery attempt halves the budget so the rescue summarize call can escape +/// an overstated `max_context_tokens`, but halving must terminate: below this +/// the prompt can no longer carry a useful summary, so the recovery gives up +/// and surfaces the error instead of issuing ever-smaller doomed requests. +pub const HANDOFF_MIN_PROMPT_BUDGET_BYTES: usize = 4 * 1024; + const DEFAULT_SYSTEM_PROMPT: &str = "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; @@ -714,6 +729,11 @@ pub struct Config { /// operators lower/raise it for other models. Set via /// `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. pub max_context_tokens: u64, + /// Maximum context-handoff attempts permitted within a single + /// `session/prompt` turn. Caps runaway compaction loops inside one turn; + /// does NOT limit handoffs across a session's lifetime — a long-lived + /// session can compact on every successive turn without hitting this bound. + /// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10. pub max_handoffs: usize, pub max_parallel_tools: usize, pub hook_timeout: Duration, diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs index 3b0feefec..5fdbc3079 100644 --- a/crates/buzz-agent/src/handoff.rs +++ b/crates/buzz-agent/src/handoff.rs @@ -1,6 +1,7 @@ use crate::agent::RunCtx; use crate::config::{ - HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES, + HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_MIN_PROMPT_BUDGET_BYTES, + HANDOFF_ORIGINAL_TASK_MAX_BYTES, MAX_CONTEXT_RECOVERIES_PER_RUN, }; use crate::types::HistoryItem; @@ -22,24 +23,147 @@ pub(crate) enum HandoffOutcome { Cancelled, } +/// Result of the reactive context-recovery ladder. +pub(crate) enum ContextRecovery { + /// History was reset; the caller should retry the request. + Recovered, + /// Cancelled mid-recovery. + Cancelled, + /// No rescue remains — the caller must surface the provider error. Either + /// the per-`run()` budget is spent or the prompt budget fell below the + /// floor where a summary can still be useful. + Exhausted, +} + const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \ turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \ you accomplished, key decisions made, what remains, and one concrete next step. Output plain \ text only — no tool calls, no JSON. Stay under 8192 tokens."; impl RunCtx<'_> { - pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome { + pub(crate) async fn maybe_handoff(&mut self, handoff_attempts: &mut usize) -> HandoffOutcome { if !self.should_handoff() { return HandoffOutcome::Skipped; } - if *self.handoff_count >= self.cfg.max_handoffs { - tracing::info!( - "handoff cap reached ({}); using truncation", - self.cfg.max_handoffs + if *handoff_attempts >= self.cfg.max_handoffs { + let projected = self.projected_handoff_input_tokens(); + let threshold = + token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens); + tracing::warn!( + session_id = self.session_id, + reason = "preflight", + handoff_attempts = *handoff_attempts, + max_handoffs = self.cfg.max_handoffs, + projected_tokens = projected, + threshold_tokens = threshold, + "handoff cap reached; using truncation", ); return HandoffOutcome::Skipped; } - let prompt = self.build_handoff_prompt(); + // Consume one attempt slot before calling handoff(). This ensures + // that empty-summary, summarize-error, and cancellation outcomes all + // burn budget — not just successful compactions — so the cap cannot + // be bypassed by a flaky summarizer. + *handoff_attempts += 1; + self.handoff(None).await + } + + /// Handoff forced by a provider context-window rejection, bypassing both + /// gates in [`Self::maybe_handoff`]. + /// + /// The gates exist to *predict* overflow; a 400 naming a context-length + /// overflow is overflow already observed, so neither prediction applies. + /// `should_handoff()` reads a token count frozen at the last SUCCESSFUL + /// request (a failed request reports no usage), so it is under threshold by + /// construction — that frozen reading is the permanent stick. And + /// `max_handoffs` is a cost cap whose only alternative here is a request + /// that cannot succeed. + /// + /// `history_budget_bytes` is explicit rather than derived from + /// `cfg.max_context_tokens`: that window is the quantity the provider just + /// contradicted, so the recovery ladder must not be computed from it. + pub(crate) async fn forced_handoff(&mut self, history_budget_bytes: usize) -> HandoffOutcome { + tracing::warn!( + "provider reported context overflow; forcing handoff (history budget {history_budget_bytes} bytes)" + ); + self.handoff(Some(history_budget_bytes)).await + } + + /// The reactive context-recovery ladder, run after the provider rejected a + /// request with a context-window 400. + /// + /// `attempts` is the caller's per-`run()` recovery counter, advanced here as + /// rungs are consumed. The caller owns it so the budget spans every + /// context-400 in the turn, not just the rungs of one ladder. + /// + /// The shrink schedule is anchored on the history that was just *observed* + /// to be too large, halving from there — not on `cfg.max_context_tokens`, + /// which the provider just contradicted and which may be overstated by an + /// unknown factor. Halving needs no calibration: by the third rung it is at + /// 1/8 of the rejected size. + /// + /// Loops rather than returning after one rung because the summarize call + /// travels the same provider path and can be rejected for the same reason. + /// Treating that as unrecoverable would reproduce the very stick this fixes: + /// the next rung halves the summarizer's own prompt, which is the only way + /// out. + /// + /// Gives up when the next budget would fall below + /// [`HANDOFF_MIN_PROMPT_BUDGET_BYTES`]. That can happen on the FIRST rung + /// when history is already small — correct, not premature: if a few KiB of + /// history still overflows the window, the overflow is dominated by what a + /// handoff cannot shrink (system prompt, tool schemas, the live user + /// prompt), so further halving would only issue smaller doomed requests in + /// place of a clear error. + pub(crate) async fn recover_from_context_overflow( + &mut self, + attempts: &mut u32, + ) -> ContextRecovery { + let rejected_bytes: usize = self + .history + .iter() + .map(HistoryItem::context_pressure_bytes) + .sum(); + loop { + if *attempts >= MAX_CONTEXT_RECOVERIES_PER_RUN { + tracing::error!( + "context recovery budget spent ({MAX_CONTEXT_RECOVERIES_PER_RUN} attempts this turn); surfacing provider error" + ); + return ContextRecovery::Exhausted; + } + // Shift by `attempts + 1`: the first rung already halves, since + // rebuilding the rejected size would just fail again. + let shift = (*attempts + 1).min(usize::BITS - 1); + let budget = rejected_bytes >> shift; + *attempts += 1; + if budget < HANDOFF_MIN_PROMPT_BUDGET_BYTES { + tracing::error!( + "context recovery would shrink the handoff prompt to {budget} bytes, below \ + the {HANDOFF_MIN_PROMPT_BUDGET_BYTES}-byte floor (history {rejected_bytes} \ + bytes); surfacing provider error" + ); + return ContextRecovery::Exhausted; + } + match self.forced_handoff(budget).await { + HandoffOutcome::Performed => return ContextRecovery::Recovered, + HandoffOutcome::Cancelled => return ContextRecovery::Cancelled, + // Summarizer errored or returned nothing — possibly because its + // own prompt overflowed. Truncation is not a usable fallback + // (it sizes against the request-body budget, not context + // pressure), so take the next rung with a smaller prompt. + HandoffOutcome::Skipped => { + tracing::warn!( + "forced handoff at {budget} bytes did not run; shrinking further" + ) + } + } + } + } + + /// The handoff mechanism itself: summarize, reset, re-seat the live prompt. + /// Holds no gate — callers decide whether a handoff is warranted. + async fn handoff(&mut self, history_budget_bytes: Option) -> HandoffOutcome { + let prompt = self.build_handoff_prompt(history_budget_bytes); let tokens_before = self.projected_handoff_input_tokens(); let summary = tokio::select! { biased; @@ -164,7 +288,10 @@ impl RunCtx<'_> { } } - fn build_handoff_prompt(&self) -> String { + /// Build the summarizer prompt. `history_budget_bytes` overrides the + /// budget normally derived from `cfg.max_context_tokens`; `None` keeps the + /// derived value, which is what the proactive path uses. + fn build_handoff_prompt(&self, history_budget_bytes: Option) -> String { let mut head = String::new(); head.push_str(&format!( "[Internal handoff #{} — context reset]\n\n", @@ -192,11 +319,22 @@ impl RunCtx<'_> { (2) what was accomplished, (3) key decisions, (4) what remains, \ (5) one concrete next step. Be concise but thorough. Plain text.\n"; let history_header = "\n# Session History (oldest first)\n"; - let prompt_budget = handoff_prompt_budget_bytes( - self.cfg.max_context_tokens, - HANDOFF_MAX_OUTPUT_TOKENS, - head.len() + history_header.len() + tail.len(), - ); + let fixed_bytes = head.len() + history_header.len() + tail.len(); + // An explicit budget is the allowance for the whole prompt, so subtract + // the fixed frame from it exactly as the derived path does — otherwise + // a caller's ceiling would be silently exceeded by the frame. When the + // frame alone is larger than the budget, history drops to zero and the + // frame is what remains: it is already independently clamped + // (`HANDOFF_ORIGINAL_TASK_MAX_BYTES`, `HANDOFF_MAX_TOOL_NAMES`) and is + // not reducible from here. + let prompt_budget = match history_budget_bytes { + Some(explicit) => explicit.saturating_sub(fixed_bytes), + None => handoff_prompt_budget_bytes( + self.cfg.max_context_tokens, + HANDOFF_MAX_OUTPUT_TOKENS, + fixed_bytes, + ), + }; let mut snippets: Vec = Vec::new(); let mut snippets_bytes = 0usize; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 220d99f9a..267b2d21b 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -130,22 +130,13 @@ impl Llm { ) -> Result { let effort = cfg.thinking_effort; let result = match cfg.provider { - Provider::Anthropic => { - let v = self - .post_anthropic( - cfg, - &anthropic_body( - cfg, - system_prompt, - history, - tools, - effective_model, - effort, - ), - ) - .await?; - parse_anthropic(v) - } + Provider::Anthropic => self + .post_anthropic( + cfg, + &anthropic_body(cfg, system_prompt, history, tools, effective_model, effort), + ) + .await + .and_then(parse_anthropic), Provider::OpenRouter => { let mut body = openai_body(cfg, system_prompt, history, tools, effective_model, None); @@ -155,8 +146,9 @@ impl Llm { effective_model, cfg.prompt_caching, ); - let v = self.post_openrouter(cfg, &body).await?; - parse_openai_with_reasoning_details(v) + self.post_openrouter(cfg, &body) + .await + .and_then(parse_openai_with_reasoning_details) } Provider::OpenAi | Provider::Databricks => { self.openai_request( @@ -230,11 +222,21 @@ impl Llm { // map_err here prepends `(model-name) ` to the inner string only. // This is the single place all provider paths converge, so the mapping // is centralized and never needs to be repeated in each provider arm. + // Every arm above returns its `Result` into this mapper rather than + // using `?` — an early return would silently skip the stamp, which is + // exactly what the Anthropic and OpenRouter arms used to do. result.map_err(|e| match e { AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), AgentError::LlmModelNotFound(s) => { AgentError::LlmModelNotFound(format!("({effective_model}) {s}")) } + // Stamped like the others: this is the error most likely to be read + // during an incident, so it must name the model whose window was + // exceeded. Without an explicit arm it would fall through `other` + // and be the only unstamped provider error. + AgentError::LlmContextExceeded(s) => { + AgentError::LlmContextExceeded(format!("({effective_model}) {s}")) + } other => other, }) } @@ -1084,6 +1086,29 @@ fn responses_body( body } +/// Narrow matcher for "the input exceeded the model's context window" provider +/// errors — the ground-truth signal that history must shrink. Only consulted +/// alongside an HTTP 400 (see the two `!status.is_success()` classification +/// sites), never on its own: the phrases below are specific, but pairing them +/// with the status keeps an unrelated 4xx that happens to quote one of them +/// from triggering a recovery. +/// +/// Deliberately tight. A generic 400 must stay `AgentError::Llm` so it remains +/// terminal — misclassifying one as recoverable would spend the whole recovery +/// budget on an error that shrinking history cannot fix, replacing a clear +/// failure with a slow one. +fn is_context_length_error(body: &str) -> bool { + let b = body.to_ascii_lowercase(); + // OpenAI/Databricks machine-readable code; the most reliable marker. + b.contains("context_length_exceeded") + // Prose forms: OpenAI's classic phrasing and the Databricks gateway's + // "context window of this model" variant seen in both bug reports. + || b.contains("maximum context length") + || b.contains("context window") + // Anthropic: "prompt is too long: N tokens > M maximum". + || b.contains("prompt is too long") +} + /// Narrow matcher for "you should be on the Responses API" provider errors, /// the signal we use to auto-upgrade. Triggers on the literal path /// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose @@ -1706,6 +1731,11 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || e.is_request() } +fn is_unsupported_image_input_error(body: &str) -> bool { + body.to_ascii_lowercase() + .contains("no endpoints found that support image input") +} + /// Build the terminal `AgentError::Llm` for a `post()` exit that has given up /// retrying — persistent retryable status, transport failure, or a body-read /// break. `detail` carries the specific cause (status/body, or the transport @@ -1864,15 +1894,30 @@ where // upstream capacity — no retry was attempted, so cumulative duration // would be misleading. if status == 404 { + let error_body = read_error_body(resp).await; + if is_unsupported_image_input_error(&error_body) { + return Err(PostError::Agent(AgentError::UnsupportedImageInput( + error_body, + ))); + } return Err(PostError::Agent(AgentError::LlmModelNotFound(format!( - "{status}: {}", - read_error_body(resp).await + "{status}: {error_body}" )))); } if !status.is_success() { + let body = read_error_body(resp).await; + // Context-window overflow is a recovery signal, not a terminal + // error: classify it here, where status and body are still separate + // values. Callers must never re-derive this from the formatted + // string — `Llm::complete` stamps the model name onto it before the + // agent loop ever sees it. + if status == 400 && is_context_length_error(&body) { + return Err(PostError::Agent(AgentError::LlmContextExceeded(format!( + "{status}: {body}" + )))); + } return Err(PostError::Agent(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await + "{status}: {body}" )))); } if let Some(len) = resp.content_length() { @@ -2117,6 +2162,9 @@ async fn openrouter_post( // about the model, and reporting a parameter problem as // `LlmModelNotFound` (or vice versa) sends the user to the wrong fix. let error_body = read_error_body(resp).await; + if is_unsupported_image_input_error(&error_body) { + return Err(AgentError::UnsupportedImageInput(error_body)); + } if error_body.contains("No endpoints found that can handle the requested parameters") { return Err(openrouter_parameter_routing_error(&error_body)); } @@ -2181,10 +2229,15 @@ async fn openrouter_post( }; } if !status.is_success() { - return Err(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await - ))); + let body = read_error_body(resp).await; + // Same recovery classification as the shared `post()` terminal: + // `openrouter_post` is a separate implementation with its own retry + // loop and status ladder, so it needs its own arm or OpenRouter + // agents keep the permanent context-400 stuck loop. + if status == 400 && is_context_length_error(&body) { + return Err(AgentError::LlmContextExceeded(format!("{status}: {body}"))); + } + return Err(AgentError::Llm(format!("{status}: {body}"))); } if let Some(len) = resp.content_length() { if len as usize > MAX_LLM_RESPONSE_BYTES { @@ -2487,6 +2540,8 @@ mod tests { }); let status_text = match response.status { 200 => "OK", + 400 => "Bad Request", + 413 => "Payload Too Large", 500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable", @@ -6018,6 +6073,8 @@ mod tests { fn status_line(status: u16) -> &'static str { match status { 200 => "200 OK", + 400 => "400 Bad Request", + 413 => "413 Payload Too Large", 401 => "401 Unauthorized", 402 => "402 Payment Required", 403 => "403 Forbidden", @@ -6131,6 +6188,240 @@ mod tests { (url, captured, attempts) } + /// Wren's rider: assert on the error emerging from `complete()` for the + /// OpenRouter path, not from `openrouter_post`. The bug was the `?` in the + /// provider arm, which is invisible from below — a low-level test can see + /// the classification but not whether the arm returns it into the + /// convergence mapper. The regression test has to cross the layer that had + /// the bug. + /// + /// Two claims here: the variant is `LlmContextExceeded` (so the agent loop + /// can recover), and the message carries the `(model)` stamp (so the arm + /// reaches the mapper at all). Measured before the fix: variant was correct + /// but UNSTAMPED, which is exactly the bypass Wren named. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_context_400_is_typed_and_stamped_through_complete() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 400, + r#"{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "OpenRouter context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + let text = err.to_string(); + assert!( + text.contains("or-model-xyz"), + "OpenRouter arm must return into the convergence mapper so the model stamp is \ + applied; got: {text}" + ); + } + + /// Same two claims on the Anthropic arm — the other `?` Wren named, and the + /// other terminal's provider phrasing ("prompt is too long"). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn anthropic_context_400_is_typed_and_stamped_through_complete() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 300000 tokens > 200000 maximum"}}), + }]) + .await; + let mut c = cfg(Provider::Anthropic); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "claude-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "Anthropic context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + let text = err.to_string(); + assert!( + text.contains("claude-probe-model"), + "Anthropic arm must return into the convergence mapper so the model stamp is \ + applied; got: {text}" + ); + } + + /// Negative arm for the OpenRouter terminal: an ordinary 400 must stay + /// `AgentError::Llm`. Paired with the positive above, this is what proves + /// the matcher — not the status alone — is doing the classification. The + /// body deliberately quotes "tokens" and "model", the words a loose matcher + /// would key on. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_ordinary_400_stays_plain_llm_error() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 400, + r#"{"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}" + ); + } + + /// Negative arm for the shared `post()` terminal (OpenAI/Databricks), the + /// second of the two `!status.is_success()` sites. Same body as the + /// OpenRouter negative so the two terminals are compared on equal input. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_ordinary_400_stays_plain_llm_error() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}" + ); + } + + /// Positive arm for the shared `post()` terminal: OpenAI's machine-readable + /// `context_length_exceeded` code classifies as recoverable. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_context_400_is_typed_through_complete() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "OpenAI context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + assert!( + err.to_string().contains("gpt-probe-model"), + "expected the convergence mapper's model stamp, got: {err}" + ); + } + + /// A context-window 400 must NOT trip the Responses-API auto-upgrade. True + /// by construction — `try_upgrade` matches only `AgentError::Llm` and the + /// typed variant can never reach it — but asserted because the guarantee + /// lives in a pattern match one refactor away from widening, and a silent + /// sticky upgrade would reroute every later OpenAI call for the process. + /// + /// `openai_api = Auto` is load-bearing in BOTH arms: `try_upgrade` is only + /// consulted under `Auto` (`llm.rs:587`), so with the test helper's default + /// `Chat` the upgrade path is disabled outright and the negative below would + /// pass without observing anything. The control caught exactly that. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn context_400_does_not_trip_responses_upgrade() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","code":"context_length_exceeded"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + c.openai_api = OpenAiApi::Auto; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!(matches!(err, AgentError::LlmContextExceeded(_))); + assert!( + !llm.auto_upgraded.load(Ordering::Relaxed), + "a context-window 400 must not latch the Responses-API upgrade" + ); + // Positive control: the same helper DOES latch on a genuine + // "use the Responses API" error, so the negative above is a real + // observation and not a probe that can never fire. + let (base_url2, _c2) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model is only supported in /v1/responses"}}), + }]) + .await; + let mut c2 = cfg(Provider::OpenAi); + c2.base_url = base_url2; + c2.openai_api = OpenAiApi::Auto; + let llm2 = Llm::new(&c2).unwrap(); + let _ = complete_model(&llm2, &c2, "gpt-probe-model").await; + assert!( + llm2.auto_upgraded.load(Ordering::Relaxed), + "control: a genuine Responses-API error must latch the upgrade" + ); + } + + /// The `status == 400` conjunct is load-bearing, not belt-and-braces: the + /// recovery ladder is only a correct response to an INPUT-SIZE rejection. + /// A 403 whose body happens to quote context-window prose (a guardrail + /// echoing the request, say) is a permission failure — shrinking history + /// cannot fix it, so classifying it as recoverable would burn the whole + /// recovery budget on three doomed summarize round-trips and turn a clear + /// immediate error into a slow one. + /// + /// 413 (Payload Too Large) is the right probe status, and picking it took a + /// measurement: my first attempt used 403, which BOTH ladders intercept + /// earlier (shared `post()` maps 401/403 to `LlmAuth`; `openrouter_post()` + /// has its own 403 arm), so those probes never reached the classification + /// site at all and the mutant with the conjunct deleted survived them. 413 + /// is intercepted by neither ladder, so it reaches the same + /// `!status.is_success()` terminal the 400 does — and it is the most + /// plausible real-world carrier of size prose on a non-400. One arm per + /// terminal site. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_413_with_context_prose_is_not_recoverable() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 413, + body: json!({"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \ + {err:?}" + ); + } + + /// Same claim at the OpenRouter terminal, which has its own status ladder. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_413_with_context_prose_is_not_recoverable() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 413, + r#"{"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \ + {err:?}" + ); + } + /// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs) /// must NOT be classified as `LlmAuth`: refreshing a static key returns /// the identical key, so retrying would just waste a duplicate request. @@ -6217,6 +6508,34 @@ mod tests { ); } + /// A provider's explicit image-capability rejection is a recoverable typed + /// error, not a missing model. The agent loop uses this signal to remove the + /// image from history before retrying the next LLM round. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_404_unsupported_image_is_typed_and_not_retried() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 404, + r#"{"error":{"message":"No endpoints found that support image input"}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")), + "image rejection must reach the history-recovery path: got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a deterministic capability rejection must not be retried" + ); + } + /// Every other 404 still maps to `LlmModelNotFound`, including one that /// shares the `No endpoints found` prefix but is about the model rather than /// the parameters — the discriminator is narrow enough that a genuinely diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index e38642198..4a856f7a8 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -388,6 +388,22 @@ pub enum AgentError { Llm(String), LlmAuth(String), LlmModelNotFound(String), + /// The provider rejected the request because the input exceeded the + /// model's context window (an HTTP 400 whose body names a context-length + /// overflow). Typed rather than folded into [`Self::Llm`] because the + /// agent loop treats it as a *recovery* signal, not a terminal error: it + /// is the only ground-truth indication that history must shrink, needing + /// no window estimate that could itself be miscalibrated. + /// + /// Classified where the HTTP status and body are still separate values, so + /// the loop never has to sniff a formatted string — by the time an error + /// leaves `Llm::complete` it has already been decorated with the model + /// name. + LlmContextExceeded(String), + /// The provider explicitly rejected image content for the selected model. + /// Kept distinct so the agent loop can remove the unsupported image from + /// replayed history and give the model a recoverable tool error. + UnsupportedImageInput(String), Mcp(String), Cancelled, } @@ -399,6 +415,8 @@ impl std::fmt::Display for AgentError { Self::Llm(s) => write!(f, "llm: {s}"), Self::LlmAuth(s) => write!(f, "llm auth: {s}"), Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), + Self::LlmContextExceeded(s) => write!(f, "llm context exceeded: {s}"), + Self::UnsupportedImageInput(s) => write!(f, "llm image input unsupported: {s}"), Self::Mcp(s) => write!(f, "mcp: {s}"), Self::Cancelled => write!(f, "cancelled"), } diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 5b660da48..1b7f34616 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -12,6 +12,7 @@ //! (use a large value, e.g. 999, to simulate hang) //! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result //! (default: the literal "ok"); grows history +//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block //! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup //! (for tests that want to verify the child died) //! FAKE_MCP_SPAWN_GRANDCHILD=1 @@ -300,10 +301,18 @@ fn main() { } else { "ok".to_owned() }; + let content = if env_flag("FAKE_MCP_IMAGE_RESULT") { + json!([ + { "type": "text", "text": result_text }, + { "type": "image", "data": "aW1n", "mimeType": "image/png" }, + ]) + } else { + json!([{ "type": "text", "text": result_text }]) + }; write_response( id, json!({ - "content": [{ "type": "text", "text": result_text }], + "content": content, "isError": false, }), ); diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index ef6f9d2d8..4253ef329 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -57,9 +57,26 @@ async fn spawn_fake_llm(responses: Vec) -> String { url } +struct CannedResponse { + status: u16, + body: Value, +} + /// Like `spawn_fake_llm` but also captures the full JSON request body from each /// incoming HTTP request. Returns (url, captured_requests). async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc>>) { + spawn_capturing_fake_llm_with_statuses( + responses + .into_iter() + .map(|body| CannedResponse { status: 200, body }) + .collect(), + ) + .await +} + +async fn spawn_capturing_fake_llm_with_statuses( + responses: Vec, +) -> (String, Arc>>) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let queue = Arc::new(Mutex::new(VecDeque::from(responses))); @@ -122,15 +139,22 @@ async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc) -> CapturingLlm { + spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await +} + +/// Like `spawn_capturing_llm` but each canned response carries its own HTTP +/// status, so a test can serve a real provider rejection (e.g. a context-window +/// 400) instead of only success bodies. +async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let queue = Arc::new(Mutex::new(VecDeque::from(responses))); @@ -66,14 +73,19 @@ async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { captured.lock().await.push(req); } - let body = queue + let (status, body) = queue .lock() .await .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); + .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); let body_s = serde_json::to_string(&body).unwrap(); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + _ => "Error", + }; let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body_s.len(), body_s, ); let _ = sock.write_all(resp.as_bytes()).await; @@ -2281,3 +2293,1293 @@ fn reply_guard_rejects_unparseable_toggle() { "expected the offending key in the error, got: {stderr}" ); } + +/// A prompt large enough that the recovery ladder's halving stays above +/// `HANDOFF_MIN_PROMPT_BUDGET_BYTES` (4 KiB) for all three rungs. +/// +/// This is load-bearing, not decoration: with a tiny history the ladder +/// correctly refuses on the FIRST rung (halving a 49-byte history lands at 24 +/// bytes, far under the floor), so a small fixture cannot exercise recovery at +/// all — it exercises the floor. `marker` is embedded so the prompt is still +/// identifiable in a captured request body. +fn large_prompt(marker: &str) -> String { + let mut s = String::with_capacity(64 * 1024 + marker.len()); + s.push_str(marker); + s.push(' '); + while s.len() < 64 * 1024 { + s.push_str("filler context to make the history realistically large. "); + } + s +} + +/// OpenAI-compatible context-window rejection body, matching the shape the +/// provider actually returns on overflow. +fn openai_context_length_error() -> Value { + json!({ + "error": { + "message": "This model's maximum context length is 8192 tokens. \ + However, your messages resulted in 20000 tokens.", + "type": "invalid_request_error", + "code": "context_length_exceeded", + } + }) +} + +/// A 400 that is NOT a context-window overflow — the negative control for the +/// matcher. Deliberately quotes "tokens" and "model", the words a sloppy +/// matcher would key on. +fn openai_ordinary_400() -> Value { + json!({ + "error": { + "message": "Invalid value for 'max_tokens': must be an integer for this model", + "type": "invalid_request_error", + "code": "invalid_value", + } + }) +} + +/// THE BUG. A provider context-window 400 must be recovered from in-loop, not +/// propagated out of `run()`. +/// +/// Without the reactive path this is a permanent stick, and the mechanism is +/// what makes it permanent rather than transient: a failed request reports no +/// usage, so `last_request_input_tokens` stays frozen at the last SUCCESSFUL +/// (sub-threshold) reading, `should_handoff()` therefore returns false forever, +/// and the in-memory session keeps the same oversized history. Every later +/// prompt in that session fails identically, for the life of the session. +/// (Restarting the agent clears it — history is not written to disk — which is +/// why the only workaround today is a restart.) +/// +/// The sequence here reproduces exactly that state: request 1 succeeds and +/// reports usage well UNDER the threshold (so the proactive gate is provably +/// not what fires), request 2 is rejected with a context-window 400. The agent +/// must force a handoff and retry, so the prompt still ends in a normal +/// `end_turn` rather than a JSON-RPC error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_window_400_recovers_instead_of_sticking() { + let llm = spawn_capturing_llm_with_status(vec![ + // req 1: succeeds, usage 10 tokens — far under any threshold. + (200, openai_text_with_usage("ack", 10)), + // req 2: the overflow rejection. + (400, openai_context_length_error()), + // req 3: the forced handoff's summarize() call. + (200, openai_text("recovered handoff summary")), + // req 4: the retried completion, now on fresh history. + (200, openai_text_with_usage("done after recovery", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + // Large window + large byte budget: neither proactive gate can be + // what produces the handoff, so a handoff here is attributable to + // the reactive path alone. + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + // Cap of 0: proves the forced path bypasses `max_handoffs`. Any + // gated handoff is impossible under this setting. + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"first prompt, succeeds"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0["result"].get("stopReason").is_some(), + "first prompt should succeed: {r0}" + ); + + // Second prompt: its first completion is rejected for context overflow. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("second-prompt-overflows")}]}), + ) + .await; + let r1 = h.recv_until(|v| v["id"] == json!(p1)).await; + assert!( + r1.get("error").is_none(), + "context-window 400 must be recovered in-loop, not returned as an error: {r1} \ + stderr={}", + h.stderr_text() + ); + assert_eq!( + r1["result"]["stopReason"], + "end_turn", + "expected the turn to finish after recovery: {r1} stderr={}", + h.stderr_text() + ); + // 4 requests = the rejected one, the summarize, and the retry. 2 would mean + // no recovery was attempted. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 4, + "expected reject + summarize + retry (4 reqs total), saw {captured} — stderr={}", + h.stderr_text() + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("provider reported context overflow; forcing handoff"), + "expected the forced-handoff log line, got: {stderr}" + ); + h.shutdown().await; +} + +/// A successful recovery must actually send the recovered completion, even when +/// `max_rounds` is finite. `round` is incremented BEFORE the completion that +/// gets rejected, so a naive `continue` after recovery re-enters the loop with +/// the rejected attempt already charged against the cap: with +/// `BUZZ_AGENT_MAX_ROUNDS=1` the turn would return `max_turn_requests` after +/// destructively resetting history, having never sent the retry. That silently +/// converts "recovered" into "history destroyed, question unanswered" — worse +/// than the error it replaced, because the user gets a stop reason rather than a +/// failure. +/// +/// The default `max_rounds` is 0 (unbounded), which is why the rest of the +/// matrix cannot see this: the cap check at the top of the loop never fires. +/// +/// `max_rounds=1` is also the tightest possible setting, so it pins the +/// boundary: exactly one round is authorized, the rejected request must not +/// consume it, and the retry must be the request that spends it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_retry_is_sent_under_a_finite_round_cap() { + let llm = spawn_capturing_llm_with_status(vec![ + // req 1: the overflow rejection (round 1 charged before it is sent). + (400, openai_context_length_error()), + // req 2: the forced handoff's summarize() call. + (200, openai_text("recovered handoff summary")), + // req 3: the retried completion. Under the bug this is never sent. + (200, openai_text_with_usage("done after recovery", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + // The whole point: a finite cap, at its tightest. + ("BUZZ_AGENT_MAX_ROUNDS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("overflows-under-finite-cap")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_none(), + "context-window 400 must be recovered in-loop: {r0} stderr={}", + h.stderr_text() + ); + // The discriminator. `max_turn_requests` here means recovery ran, history + // was reset, and the turn ended without ever asking the model again. + assert_eq!( + r0["result"]["stopReason"], + "end_turn", + "a recovered turn must finish by answering, not by hitting the round cap: {r0} \ + stderr={}", + h.stderr_text() + ); + // 3 requests = reject + summarize + retry. 2 would mean the retry was + // never sent (the bug); the outcome assertion alone cannot tell those apart + // if the stop reason were ever produced some other way. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 3, + "expected reject + summarize + retry (3 reqs), saw {captured} — stderr={}", + h.stderr_text() + ); + h.shutdown().await; +} + +/// The finite round cap must still bind for ORDINARY rounds — the recovery +/// refund must not become a general amnesty. With `max_rounds=1` and no context +/// overflow anywhere, a model that keeps requesting tool calls gets exactly one +/// completion and then `max_turn_requests`. +/// +/// Without this arm, "make the recovered retry possible" is satisfiable by +/// deleting the cap, and the test above would still pass. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn finite_round_cap_still_binds_without_a_context_overflow() { + let llm = spawn_capturing_llm_with_status(vec![ + // Round 1: a tool call, which would normally drive another round. + ( + 200, + openai_tool_call("tc1", "dev__shell", json!({"command": "true"})), + ), + // Never reached: the cap must stop the turn before a second completion. + (200, openai_text_with_usage("should not be sent", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_ROUNDS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"drive a tool call"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert_eq!( + r0["result"]["stopReason"], + "max_turn_requests", + "an ordinary finite cap must still bind: {r0} stderr={}", + h.stderr_text() + ); + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 1, + "exactly one completion is authorized by max_rounds=1, saw {captured} — stderr={}", + h.stderr_text() + ); + h.shutdown().await; +} + +/// Prompt-exactly-once across a forced handoff: the live user prompt must be +/// retained in the fresh history exactly once — not dropped (the model would +/// answer a question it can no longer see) and not duplicated (a doubled prompt +/// re-inflates the context we just shrank, and can produce a doubled action). +/// +/// Asserted on the retry request's own message array, which is the only place +/// the post-reset history is observable from outside. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forced_handoff_retains_live_prompt_exactly_once() { + const MARKER: &str = "unique-live-prompt-marker-7f3a"; + let llm = spawn_capturing_llm_with_status(vec![ + (200, openai_text_with_usage("ack", 10)), + (400, openai_context_length_error()), + (200, openai_text("summary body")), + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"warmup"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p0)).await; + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt(MARKER)}]}), + ) + .await; + let r1 = h.recv_until(|v| v["id"] == json!(p1)).await; + assert!(r1.get("error").is_none(), "expected recovery: {r1}"); + + let captured = llm.captured.lock().await; + let retry = captured + .last() + .expect("at least one captured request") + .clone(); + drop(captured); + let messages = retry["messages"] + .as_array() + .unwrap_or_else(|| panic!("retry request had no messages array: {retry}")); + let occurrences = messages + .iter() + .filter(|m| { + m["content"] + .as_str() + .map(|s| s.contains(MARKER)) + .unwrap_or(false) + }) + .count(); + assert_eq!( + occurrences, 1, + "live prompt must appear exactly once in post-handoff history, saw {occurrences} in \ + {messages:#?}" + ); + h.shutdown().await; +} + +/// Negative control at the loop layer: an ordinary 400 must stay terminal. +/// +/// This is the arm that keeps the recovery narrow. If the matcher were loose, +/// this request would be classified as recoverable, the agent would spend its +/// whole recovery budget summarizing, and a clear immediate failure would +/// become a slow one — with three wasted provider round-trips. Exactly one +/// request, and the prompt returns an error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ordinary_400_stays_terminal_and_triggers_no_recovery() { + let llm = spawn_capturing_llm_with_status(vec![(400, openai_ordinary_400())]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_HANDOFFS", "3"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_some(), + "an ordinary 400 must surface as an error, got: {r0}" + ); + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 1, + "an ordinary 400 must not trigger a recovery attempt; saw {captured} requests — \ + stderr={}", + h.stderr_text() + ); + let stderr = h.stderr_text(); + assert!( + !stderr.contains("provider reported context overflow"), + "ordinary 400 must not be classified as a context overflow, got: {stderr}" + ); + h.shutdown().await; +} + +/// The recovery budget must be finite: a provider that rejects every request +/// for context overflow — including the retries — has to surface the error +/// rather than being rescued forever. `max_rounds` cannot bound this (it +/// defaults to 0/unbounded), so the per-`run()` recovery budget is the only +/// thing standing between this case and an infinite loop. +/// +/// The stub returns a context-400 to EVERY request, so a missing bound shows up +/// as a hang rather than a wrong answer — hence the explicit timeout, which is +/// part of the assertion. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_recovery_budget_exhaustion_surfaces_the_error() { + // Enough canned 400s that the queue is never the thing that stops the loop; + // the fallback response is also a 400-shaped body under this helper only if + // queued, so keep the queue generously long. + let responses: Vec<(u16, Value)> = (0..40) + .map(|_| (400, openai_context_length_error())) + .collect(); + let llm = spawn_capturing_llm_with_status(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("always-overflows")}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(20), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("recovery must be bounded — prompt never returned, so the rescue loop is unbounded"); + assert!( + r0.get("error").is_some(), + "exhausted recovery must surface the provider error, got: {r0}" + ); + let msg = r0["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("context"), + "surfaced error should be the provider's own context-window error, got: {msg}" + ); + // Discriminate WHICH bound stopped the loop. Both the budget and the prompt + // floor produce a surfaced error, so the assertion above passes either way + // — and the floor can fire on the first rung without the budget ever being + // consumed, which would make this test silently exercise a different + // mechanism than its name claims. Pin the budget explicitly. + let stderr = h.stderr_text(); + assert!( + stderr.contains("context recovery budget spent"), + "the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \ + got: {stderr}" + ); + // Corroboration: every rung actually ran a forced handoff. + let rungs = stderr + .matches("provider reported context overflow; forcing handoff") + .count(); + assert_eq!( + rungs, 3, + "expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \ + stderr={stderr}" + ); + h.shutdown().await; +} + +/// The prompt-budget floor, observed on its own. A context-window 400 on a +/// SMALL history must refuse to rescue rather than halve toward zero: the +/// overflow is then dominated by what a handoff cannot shrink (system prompt, +/// tool schemas, the live user prompt), so shrinking history further would only +/// issue smaller doomed requests in place of a clear error. +/// +/// The outcome — a surfaced error — is identical to budget exhaustion, so this +/// asserts the discriminating evidence instead: the floor log line, and that +/// ZERO forced handoffs were attempted. Without the floor the ladder would spend +/// all three rungs summarizing a 40-byte history, which is the behavior this +/// arm exists to forbid. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() { + let responses: Vec<(u16, Value)> = (0..10) + .map(|_| (400, openai_context_length_error())) + .collect(); + let llm = spawn_capturing_llm_with_status(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"tiny"}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(20), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("must not loop — the floor should stop the rescue immediately"); + assert!( + r0.get("error").is_some(), + "a context 400 with no shrinkable history must surface the error, got: {r0}" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("below the") && stderr.contains("floor"), + "the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}" + ); + let rungs = stderr + .matches("provider reported context overflow; forcing handoff") + .count(); + assert_eq!( + rungs, 0, + "no rescue should be attempted below the floor, saw {rungs} — stderr={stderr}" + ); + // Exactly one request: the rejected one. No summarize, no retry. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, 1, + "expected no rescue round-trips below the floor, saw {captured} requests" + ); + h.shutdown().await; +} + +/// The recovery ladder must actually SHRINK, not just re-summarize at the size +/// that was already rejected. +/// +/// Observed on the summarize request's own body — the only externally visible +/// consequence of the prompt budget. The rejected completion carried the full +/// history; the rescue's summarize prompt must be materially smaller. Without +/// this arm, deleting the halving entirely leaves every other test green: they +/// assert that a handoff HAPPENED, and a handoff at the rejected size still +/// happens (it just cannot escape a real overflow, which a stub does not +/// reproduce). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_shrinks_the_summarize_prompt_below_the_rejected_size() { + let llm = spawn_capturing_llm_with_status(vec![ + (400, openai_context_length_error()), + (200, openai_text("summary")), + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("shrink-probe")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!(r0.get("error").is_none(), "expected recovery: {r0}"); + + let captured = llm.captured.lock().await.clone(); + assert!( + captured.len() >= 2, + "expected at least reject + summarize, saw {}", + captured.len() + ); + let content_bytes = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|ms| { + ms.iter() + .filter_map(|m| m["content"].as_str()) + .map(str::len) + .sum() + }) + .unwrap_or(0) + }; + let rejected = content_bytes(&captured[0]); + let summarize = content_bytes(&captured[1]); + assert!( + rejected > 0 && summarize > 0, + "empty measurement is not a result: rejected={rejected} summarize={summarize}" + ); + // Halving from the rejected size lands near 0.5x; 0.75x leaves headroom for + // the summarizer's fixed frame while still failing if no shrink happened. + assert!( + (summarize as f64) < 0.75 * (rejected as f64), + "rescue summarize prompt ({summarize} bytes) must be materially smaller than the \ + rejected request ({rejected} bytes) — the ladder is not shrinking" + ); + h.shutdown().await; +} + +/// The ladder must shrink between RUNGS, not just once on entry. +/// +/// This arm exists because a mutant that pins `shift` to `1` — deleting the +/// `attempts` dependence, so every rung rebuilds the same budget — SURVIVED the +/// whole suite. It had to: `attempts` is 0 on the first rung, so `shift = 1` IS +/// production there, and every other arm stops at rung 1. The single-rung shrink +/// arm above cannot see this; only a fixture that forces a SECOND rung can. +/// +/// The forcing move is the realistic one the ladder was designed for: the +/// summarize call travels the same provider path, so rung 1's summarize is +/// itself rejected for context overflow (`Skipped`), and rung 2 must come back +/// with a materially smaller summarizer prompt. +/// +/// Budgets: history is ~64 KB, so rung 1 asks for ~32 KB and rung 2 for ~16 KB, +/// both comfortably above the 4 KiB floor — the floor must not be what +/// separates them, or this would measure the wrong mechanism. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_shrinks_further_on_each_rung() { + let llm = spawn_capturing_llm_with_status(vec![ + // 1: the completion that overflows. + (400, openai_context_length_error()), + // 2: rung-1 summarize, rejected the same way -> Skipped -> next rung. + (400, openai_context_length_error()), + // 3: rung-2 summarize succeeds. + (200, openai_text("summary")), + // 4: the retried completion. + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("rung-shrink-probe")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_none(), + "expected recovery on the second rung: {r0}" + ); + + // The second rung must actually have been taken — otherwise the byte + // comparison below would compare rung 1 against the retry. + let stderr = h.stderr_text(); + assert!( + stderr.contains("did not run; shrinking further"), + "rung 1 must have been Skipped so rung 2 runs; got: {stderr}" + ); + assert!( + !stderr.contains("below the"), + "the prompt FLOOR must not be involved in this fixture; got: {stderr}" + ); + + let captured = llm.captured.lock().await.clone(); + assert_eq!( + captured.len(), + 4, + "expected reject + rung1 summarize + rung2 summarize + retry, saw {}", + captured.len() + ); + let content_bytes = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|ms| { + ms.iter() + .filter_map(|m| m["content"].as_str()) + .map(str::len) + .sum() + }) + .unwrap_or(0) + }; + let rung1 = content_bytes(&captured[1]); + let rung2 = content_bytes(&captured[2]); + assert!( + rung1 > 0 && rung2 > 0, + "empty measurement is not a result: rung1={rung1} rung2={rung2}" + ); + assert!( + (rung2 as f64) < 0.75 * (rung1 as f64), + "each rung must shrink: rung2 ({rung2} bytes) is not materially smaller than rung1 \ + ({rung1} bytes) — the budget is not tracking `attempts`" + ); + h.shutdown().await; +} + +/// Gate 5, and the DIRECTION the clearing protects: not a spurious handoff, a +/// MISSED one. After a reactive reset the stale `last_request_input_tokens` +/// describes history that no longer exists, and its paired byte baseline +/// describes the pre-reset (larger) history — so `grown` stays near zero and the +/// projection collapses to the stale sub-threshold token count. The gate goes +/// BLIND until history exceeds its pre-reset size. +/// +/// Constructing the divergence takes three turns, and two of the constraints are +/// load-bearing — a first attempt with a simpler fixture produced traces +/// BYTE-IDENTICAL between the fix and its deletion: +/// * Turn 1 must stay UNDER the gate threshold, or the proactive handoff fires +/// first and consumes the queue slot the overflow was meant to land in — no +/// usage is ever recorded, both variants sit at `None`, and the test measures +/// nothing. +/// * The post-recovery retry must report NO usage. A usage-bearing response +/// overwrites both fields with coherent values on the spot, which makes the +/// clear genuinely redundant and the mutant equivalent. The reachable window +/// is exactly when the retry omits usage and the stale pair survives. +/// Turn 3 then carries a large prompt: a cleared baseline falls through to the +/// byte signal and hands off, while the stale pair projects +/// `10 + (190KB - 100KB)` = ~90k tokens, under the 180k threshold, and does not. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reactive_reset_clears_usage_baseline_so_the_gate_is_not_blind() { + // ~100 KB: under the 180 KB byte-fallback threshold, so turn 1 does NOT + // trip the proactive gate, but large enough to be the stale `measured_bytes` + // that suppresses `grown` later. + let mut medium = String::with_capacity(100 * 1024); + medium.push_str("turn-one-medium "); + while medium.len() < 100 * 1024 { + medium.push_str("padding under the byte fallback threshold. "); + } + // ~190 KB: over the threshold, so a CLEARED baseline must hand off. + let mut big = String::with_capacity(190 * 1024); + big.push_str("turn-three-large "); + while big.len() < 190 * 1024 { + big.push_str("padding to exceed the byte fallback threshold. "); + } + + let llm = spawn_capturing_llm_with_status(vec![ + // Turn 1: succeeds, reporting a SMALL usage reading against a ~100 KB + // history. This is the pair that goes stale. + (200, openai_text_with_usage("ack-medium", 10)), + // Turn 2: the overflow. + (400, openai_context_length_error()), + // Turn 2: the forced handoff's summarize. + (200, openai_text("forced summary")), + // Turn 2: the retry — NO usage block, so the baseline is not refreshed. + (200, openai_text("recovered, no usage reported")), + // Turn 3: with a cleared baseline a gated summarize comes first; with a + // stale one this slot is the completion instead. Spares so an exhausted + // queue is never what ends a turn. + (200, openai_text("gated summary")), + (200, openai_text_with_usage("done", 10)), + (200, openai_text_with_usage("spare-1", 10)), + (200, openai_text_with_usage("spare-2", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + // Must permit a GATED handoff — turn 3 observes the proactive gate, + // which a cap of 0 would forbid. + ("BUZZ_AGENT_MAX_HANDOFFS", "5"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + // Turn 1: under threshold, records the usage pair. + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": medium}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("turn 1 must return"); + assert!(r0.get("error").is_none(), "turn 1 should succeed: {r0}"); + assert!( + !h.stderr_text().contains("handoff #"), + "precondition: turn 1 must NOT hand off, or no usage pair is recorded and this test \ + measures nothing. stderr={}", + h.stderr_text() + ); + + // Turn 2: small prompt, overflow, reactive recovery. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"small, overflows"}]}), + ) + .await; + let r1 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p1)), + ) + .await + .expect("turn 2 must return"); + assert!(r1.get("error").is_none(), "turn 2 should recover: {r1}"); + assert!( + h.stderr_text() + .contains("provider reported context overflow; forcing handoff"), + "precondition: the reactive path must have run in turn 2. stderr={}", + h.stderr_text() + ); + let handoffs_after_turn2 = h.stderr_text().matches("handoff #").count(); + + // Turn 3: large prompt. A cleared baseline sees it via the byte signal and + // hands off; a stale pair under-projects and stays blind. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": big}]}), + ) + .await; + let r2 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p2)), + ) + .await + .expect("turn 3 must return"); + assert!(r2.get("error").is_none(), "turn 3 should succeed: {r2}"); + let stderr = h.stderr_text(); + let handoffs_after_turn3 = stderr.matches("handoff #").count(); + assert!( + handoffs_after_turn3 > handoffs_after_turn2, + "turn 3 must produce a GATED handoff ({handoffs_after_turn2} before, \ + {handoffs_after_turn3} after): the reactive reset must clear the usage baseline, or the \ + proactive gate under-projects and stays blind to an oversized history. stderr={stderr}" + ); + h.shutdown().await; +} + +// ─── Tests: per-turn handoff cap semantics ─────────────────────────────────── + +/// A session that has already performed N handoffs in previous turns must still +/// compact on subsequent turns — the per-session lifetime kill switch is gone. +/// +/// Mechanism: the gate fires at the start of each round, comparing +/// `last_request_input_tokens` (stored by the previous response) against the +/// token threshold. So: +/// - Turn 1 complete() returns usage=950 (> threshold=900). Turn ends; usage stored. +/// - Turn 2 round 0: 950 >= 900 → handoff. post-handoff complete() returns usage=950. +/// Session `handoff_count` is now 1; `turn_handoff_count` was just reset to 0 at +/// turn start and is now 1. +/// - Turn 3 round 0: `turn_handoff_count` resets to 0; session count is 1 but +/// the gate uses `turn_handoff_count` → cap not reached → handoff fires again. +/// +/// Without the fix (`handoff_count` compared against cap, never reset): +/// session count after turn 2 = 1 >= max_handoffs=1 → gate permanently blocked +/// for all subsequent turns → history grows until provider wall. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handoff_cap_resets_per_turn_not_per_session() { + // LLM call sequence: + // req 1: turn 1 complete() → usage=950 (over threshold) + // req 2: turn 2 pre-flight summarize → summary text + // req 3: turn 2 complete() → usage=950 (re-arms gate for turn 3) + // req 4: turn 3 pre-flight summarize → summary text ← cap reset proves this fires + // req 5: turn 3 complete() → done + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("ack-t1", 950), // turn 1: stores high usage + openai_text("summary-t2"), // turn 2: pre-flight summarize + openai_text_with_usage("done-t2", 950), // turn 2: post-handoff, re-arms gate + openai_text("summary-t3"), // turn 3: pre-flight summarize (cap reset) + openai_text_with_usage("done-t3", 10), // turn 3: post-handoff complete + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + // Cap of 1 per turn. Before the fix this permanently disables the + // gate once session handoff_count reaches 1. + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + // Turn 1: no prior usage; preflight skips (byte-fallback not triggered by + // tiny prompt). complete() stores usage=950. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + assert_eq!( + llm.captured.lock().await.len(), + 1, + "turn 1 must produce exactly 1 LLM request" + ); + + // Turn 2: 950 >= threshold=900 → handoff fires. Session handoff_count: 1. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p2)).await; + assert_eq!( + llm.captured.lock().await.len(), + 3, + "turn 2 must produce 2 LLM requests (summarize + complete), 3 total" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff #1"), + "expected first handoff log after turn 2; got: {stderr}" + ); + + // Turn 3: turn_handoff_count resets to 0 → gate fires again despite + // session handoff_count=1 == cap=1. + let p3 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 3"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p3)).await; + assert_eq!( + llm.captured.lock().await.len(), + 5, + "turn 3 must also produce 2 LLM requests (per-turn cap reset → handoff fires again), \ + 5 total" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff #2"), + "expected second handoff log after turn 3 (cap reset); got: {stderr}" + ); + + h.shutdown().await; +} + +/// Within a single turn, the per-turn cap still bounds the number of handoffs. +/// A turn that exceeds `max_handoffs` compaction attempts must emit a WARN and +/// fall back to truncation — it must NOT compact indefinitely. +/// +/// Mechanism: with cap=1 and a multi-round turn (tool call in round 1 → round 2), +/// the pre-flight handoff fires at the start of round 1 (usage from a *previous* +/// turn is high). After the compaction, the post-handoff complete() in round 1 +/// returns a tool call, causing a second round. Round 2's preflight sees that +/// turn_handoff_count=1 == max_handoffs=1, so it refuses and emits WARN. +/// +/// A steer is injected while the run is active to prove that the steer path +/// does NOT reset `handoff_attempts` — the cap must still fire on round 1 with +/// no second summarize call. +/// +/// This test requires a fake MCP server to produce a tool-call round. +/// It drives via `fake-mcp` — the same binary used in other multi-round tests. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handoff_cap_binds_within_a_single_turn() { + // LLM call sequence in turn 2 (turn 1 seeds the usage): + // req 1: turn 1 complete() → usage=950 (over threshold=900) + // req 2: turn 2 round 0 summarize() → summary (handoff_attempts: 0→1) + // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate) + // [fake-mcp tool executes; steer queued while run is active] + // req 4: turn 2 round 1 preflight → 950 >= 900 AND attempts=1 >= max=1 + // → WARN, skip (cap exhausted for this turn) + // req 5: turn 2 round 1 complete() → end_turn (steer text folded into messages) + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + // Build a tool-call response that also carries usage so the gate re-arms + // on round 1's preflight (without usage, last_request_input_tokens is None + // after the handoff clears it, and the byte-fallback won't fire on tiny history). + let tool_call_with_usage = { + let mut v = openai_tool_call("tc-1", "test_tool", json!({})); + v["usage"] = json!({ + "prompt_tokens": 950u64, + "completion_tokens": 5, + "total_tokens": 955, + }); + v + }; + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("seed", 950), // turn 1: seed high usage + openai_text("handoff-summary"), // turn 2 round 0: summarize + tool_call_with_usage, // turn 2 round 0: tool call + usage (re-arms) + openai_text_with_usage("end_turn_text", 10), // turn 2 round 1: final answer + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + + // Init with the fake MCP server so test_tool is available. + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "cap_test", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + // Turn 1: seed high usage. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Turn 2: triggers a handoff at round 0, then a tool call, then round 1 + // where the cap is already exhausted. A steer is injected while the run + // is active to prove mid-turn steers cannot reset `handoff_attempts`. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"do work"}]}), + ) + .await; + + // Drain until the final response, approving tool-permission requests, + // capturing the activeRunId once it is broadcast, sending one steer, + // and verifying that it is accepted in the live run. + let mut run_id: Option = None; + let mut steer_id: i64 = -1; + let mut steer_accepted = false; + loop { + let v = h.recv().await; + + // Capture the run id from the first session/update that carries it, + // then immediately queue a steer. This must happen before round 1 so + // the steer text is present but the cap check still fires — proving + // the counter is not reset by the steer path. + if run_id.is_none() { + if let Some(rid) = v["params"]["update"]["_meta"]["goose"]["activeRunId"].as_str() { + run_id = Some(rid.to_owned()); + steer_id = h + .send( + "_goose/unstable/session/steer", + json!({ + "sessionId": sid, + "expectedRunId": rid, + "prompt": [{"type":"text","text":"STEER-CANARY: also consider the edge case"}], + }), + ) + .await; + } + } + + // Steer response: assert it was accepted in the live run. + if steer_id >= 0 && v["id"] == json!(steer_id) { + assert!( + v.get("result").is_some(), + "steer must be accepted while the run is active; got: {v}" + ); + assert_eq!( + v["result"]["runId"].as_str(), + run_id.as_deref(), + "steer must reference the live run id" + ); + steer_accepted = true; + continue; + } + + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p2) { + assert!( + v.get("result").is_some(), + "turn 2 must succeed even when cap blocks round-1 handoff; got: {v}" + ); + break; + } + } + + assert!( + steer_accepted, + "steer was never accepted during turn 2; the steer arm is missing coverage" + ); + + // 4 LLM requests: seed + summarize + tool-call-with-usage + final-complete. + let count = llm.captured.lock().await.len(); + assert_eq!( + count, 4, + "expected 4 LLM requests (seed + summarize + tool-call + final); got {count}" + ); + + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff cap reached"), + "expected cap-reached WARN in stderr; got: {stderr}" + ); + assert!( + stderr.contains("reason=\"preflight\""), + "expected reason=\"preflight\" field in cap WARN; got: {stderr}" + ); + assert!( + stderr.contains("handoff_attempts="), + "expected handoff_attempts field in cap WARN; got: {stderr}" + ); + assert!( + stderr.contains("max_handoffs="), + "expected max_handoffs field in cap WARN; got: {stderr}" + ); + + h.shutdown().await; +} + +/// A failing `summarize()` call must still consume one slot from the per-turn +/// handoff-attempt budget. Before the fix, `handoff_count` was incremented only +/// on a successful compaction; a flaky summarizer could be retried indefinitely +/// within a turn. The fix moves the increment to before `summarize()`. +/// +/// Proof: with `max_handoffs=1` and a multi-round turn: +/// - Round 0 preflight: threshold met, attempts: 0→1, summarize() fails → Skipped. +/// - Round 1 preflight: attempts=1 >= cap=1 → WARN (cap hit despite no successful +/// compaction). Without the pre-summarize increment, attempts would still be 0 +/// here and a second summarize() would be attempted — the bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_summarize_burns_handoff_attempt_budget() { + // We need the summarize() call to fail. The summarize path uses the same + // fake LLM server; we queue an HTTP error body for the summarize request. + // But our spawn_capturing_llm always returns 200, so we use a non-OpenAI- + // shaped response that the agent will treat as an error (missing `choices`). + // + // LLM call sequence: + // req 1: turn 1 complete() → usage=950 (seeds the gate) + // req 2: turn 2 round 0 summarize() → malformed response (treated as error) + // handoff_attempts incremented to 1 BEFORE this + // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate) + // req 4: turn 2 round 1 preflight → cap reached: WARN (attempts=1 >= max=1) + // req 5: turn 2 round 1 complete() → end_turn + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let bad_summary_response = json!({ "error": "upstream unavailable" }); // no `choices` + let tool_call_with_usage = { + let mut v = openai_tool_call("tc-2", "test_tool", json!({})); + v["usage"] = json!({ + "prompt_tokens": 950u64, + "completion_tokens": 5, + "total_tokens": 955, + }); + v + }; + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("seed", 950), // turn 1: seed usage + bad_summary_response, // turn 2 round 0: summarize fails + tool_call_with_usage, // turn 2 round 0: complete → tool call + openai_text_with_usage("done", 10), // turn 2 round 1: final answer + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "budget_test", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + // Turn 1: seed high usage. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Turn 2: round 0 summarize fails, but attempts was already incremented. + // Round 1 preflight must see cap hit and emit WARN. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"work"}]}), + ) + .await; + + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p2) { + assert!(v.get("result").is_some(), "turn 2 must succeed; got: {v}"); + break; + } + } + + let stderr = h.stderr_text(); + // Round 0: the failed summarize should warn about the failure. + assert!( + stderr.contains("handoff failed") || stderr.contains("handoff returned empty"), + "expected summarize-failure WARN; got: {stderr}" + ); + // Round 1: cap must be hit (attempts=1 from the failed attempt). + assert!( + stderr.contains("handoff cap reached"), + "expected cap-reached WARN after failed summarize burned the attempt; got: {stderr}" + ); + + h.shutdown().await; +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b1be7c503..3c6f1d591 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -108,6 +108,15 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// dedicated push lease tables. pub const KIND_PUSH_LEASE: u32 = 30350; +/// NIP-PMA: owner-encrypted private managed-agent aggregate. +/// +/// Addressed by `(owner pubkey, kind, agent pubkey)`. The signed outer tags +/// expose only the agent coordinate, CAS generation/predecessor, and active/deleted +/// state required for relay enforcement. Content is NIP-44 v2 encrypted from +/// the owner's key to itself and contains the runnable identity/configuration +/// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. +pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -117,7 +126,11 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// /// Currently a tiny linear set. If this grows past ~4 kinds, convert to a /// compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_PUSH_LEASE]; +pub const AUTHOR_ONLY_KINDS: &[u32] = &[ + KIND_EVENT_REMINDER, + KIND_PUSH_LEASE, + KIND_PRIVATE_MANAGED_AGENT, +]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's @@ -643,6 +656,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, + KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -843,6 +857,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PRIVATE_MANAGED_AGENT)); // 30179 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1..7424915c8 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -32,6 +32,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// NIP-PMA owner-encrypted private managed-agent wire codec. +pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; /// Tenant identity — the server-resolved community key carried on scoped paths. diff --git a/crates/buzz-core/src/private_managed_agent.rs b/crates/buzz-core/src/private_managed_agent.rs new file mode 100644 index 000000000..180dd6fa0 --- /dev/null +++ b/crates/buzz-core/src/private_managed_agent.rs @@ -0,0 +1,1134 @@ +//! NIP-PMA private managed-agent wire codec. +//! +//! This module defines and validates the inert wire format only. Relays must +//! not accept [`KIND_PRIVATE_MANAGED_AGENT`](crate::kind::KIND_PRIVATE_MANAGED_AGENT) +//! until the dedicated privacy and aggregate-CAS transactions are deployed. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::str::FromStr; + +use nostr::nips::nip44::{self, Version}; +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::Message; +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, SECP256K1}; +use serde::de::{DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT}; + +/// Wire-format discriminator for decrypted private managed-agent payloads. +pub const FORMAT: &str = "buzz-private-managed-agent"; +/// Current decrypted payload schema version. +pub const VERSION: u32 = 1; +/// NIP-44 v2 plaintext limit. +pub const MAX_PLAINTEXT_BYTES: usize = 65_535; +/// Maximum plausible NIP-44 v2 ciphertext length. +pub const MAX_CIPHERTEXT_BYTES: usize = 87_472; +/// Largest integer represented exactly by interoperable JSON implementations. +pub const MAX_SAFE_GENERATION: u64 = (1_u64 << 53) - 1; +/// Maximum number of environment variables in one private payload. +pub const MAX_ENV_VARS: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable key. +pub const MAX_ENV_KEY_BYTES: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable value. +pub const MAX_ENV_VALUE_BYTES: usize = 16_384; +/// Maximum number of explicit agent arguments. +pub const MAX_AGENT_ARGS: usize = 256; +/// Maximum UTF-8 bytes in one argument. +pub const MAX_AGENT_ARG_BYTES: usize = 8_192; +/// Maximum serialized bytes accepted for an extension/recovery/config value. +pub const MAX_VALUE_BYTES: usize = 32_768; + +/// Errors returned by the private managed-agent codec. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + /// The signed outer event is malformed or does not match the expected owner. + #[error("invalid private managed-agent envelope: {0}")] + InvalidEnvelope(String), + /// The ciphertext could not be authenticated/decrypted. Deliberately redacted. + #[error("private managed-agent payload could not be decrypted")] + Decrypt, + /// The decrypted JSON is malformed, ambiguous, or semantically invalid. + #[error("invalid private managed-agent payload: {0}")] + InvalidPayload(String), + /// Encryption failed. + #[error("private managed-agent encryption failed")] + Encrypt, + /// Event signing failed. + #[error("private managed-agent signing failed")] + Sign, +} + +/// Authoritative lifecycle state repeated in the outer tags and ciphertext. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum State { + /// Runnable aggregate. + Active, + /// Anti-resurrection tombstone. + Deleted, +} + +impl State { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } +} + +/// Versioned signed-event recovery material for a bound public projection. +/// +/// Retaining the complete signed event makes reconstruction unambiguous: its +/// signature, ID, author, kind, coordinate, and exact content bytes can all be +/// checked without trusting replaceable-event history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionRecoveryV1 { + /// Recovery schema version. Version 1 stores one complete signed event. + pub version: u32, + /// Exact signed public projection event. + pub signed_event: Event, +} + +/// Complete definition projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefinitionBinding { + /// CAS-managed definition revision pinned by this aggregate. + pub revision: u64, + /// Exact signed kind:30175 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Complete kind:30177 projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstanceBinding { + /// Exact signed kind:30177 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Secret agent identity material. It never appears in public projections. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateIdentity { + /// Agent private key in nsec form. + pub private_key_nsec: String, + /// Optional NIP-OA owner attestation JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_tag: Option, +} + +impl fmt::Debug for PrivateIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateIdentity") + .field("private_key_nsec", &"") + .field("auth_tag", &self.auth_tag.as_ref().map(|_| "")) + .finish() + } +} + +/// Portable private runnable configuration. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateConfig { + /// Explicit kind:30175 coordinate, when definition-backed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_coordinate: Option, + /// Intended relay endpoint; validated again on each device before use. + pub relay_url: String, + /// Explicit harness override; never launched without local validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_command_override: Option, + /// Explicit harness arguments; validated again on each device. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_args: Vec, + /// Idle timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout_seconds: Option, + /// Absolute turn timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turn_duration_seconds: Option, + /// Secret environment overrides. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, + /// Versioned backend configuration. Device/provider validation is required. + pub backend: Value, + /// Durable remote backend identity; ownership/existence is device-validated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_agent_id: Option, + /// Portable team linkage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_id: Option, + /// Portable identity within a team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_name_in_team: Option, + /// Versioned provider/definition relay-mesh marker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay_mesh: Option, +} + +impl fmt::Debug for PrivateConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateConfig") + .field("contents", &"") + .finish() + } +} + +/// Fields present only when [`Payload::state`] is [`State::Active`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivePayload { + /// Exact definition projection binding. + pub definition: DefinitionBinding, + /// Exact public instance projection binding. + pub instance_projection: InstanceBinding, + /// Secret identity material. + pub identity: PrivateIdentity, + /// Private portable/device-validated configuration. + pub config: PrivateConfig, +} + +/// Decrypted private managed-agent payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + /// Always [`FORMAT`]. + pub format: String, + /// Always [`VERSION`]. + pub version: u32, + /// Agent pubkey and event `d` coordinate. + pub agent_pubkey: String, + /// Owner pubkey and signed event author. + pub owner_pubkey: String, + /// Monotonic CAS generation. + pub generation: u64, + /// Exact predecessor event ID; absent only for generation one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_event_id: Option, + /// Lifecycle state, repeated in the outer `state` tag. + pub state: State, + /// RFC3339 bookkeeping timestamp; never used for conflict resolution. + pub updated_at: String, + /// Required for active records and forbidden for tombstones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, + /// Required for tombstones and forbidden for active records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + /// Forward-compatible namespaced data. Core semantics must never depend on it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +/// Validated public metadata from a private managed-agent event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + /// Agent pubkey from `d`. + pub agent_pubkey: PublicKey, + /// Owner pubkey from the signed event author. + pub owner_pubkey: PublicKey, + /// CAS generation from `g`. + pub generation: u64, + /// CAS predecessor from `prev`. + pub previous_event_id: Option, + /// Lifecycle state from `state`. + pub state: State, +} + +/// Compute the lowercase SHA-256 binding for exact projection content bytes. +pub fn content_sha256(content: &[u8]) -> String { + hex::encode(Sha256::digest(content)) +} + +/// Validate a signed outer envelope before any decryption. +pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result { + if event.kind.as_u16() as u32 != KIND_PRIVATE_MANAGED_AGENT { + return Err(Error::InvalidEnvelope("wrong kind".into())); + } + if &event.pubkey != expected_owner { + return Err(Error::InvalidEnvelope( + "author is not expected owner".into(), + )); + } + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidEnvelope( + "invalid event id or signature".into(), + )); + } + if event.content.is_empty() || event.content.len() > MAX_CIPHERTEXT_BYTES { + return Err(Error::InvalidEnvelope("invalid ciphertext length".into())); + } + + let mut d = None; + let mut g = None; + let mut prev = None; + let mut state = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() != 2 { + return Err(Error::InvalidEnvelope( + "every tag must have exactly one value".into(), + )); + } + let slot = match parts[0].as_str() { + "d" => &mut d, + "g" => &mut g, + "prev" => &mut prev, + "state" => &mut state, + name => return Err(Error::InvalidEnvelope(format!("unexpected tag: {name}"))), + }; + if slot.replace(parts[1].clone()).is_some() { + return Err(Error::InvalidEnvelope(format!( + "duplicate {} tag", + parts[0] + ))); + } + } + + let agent_pubkey = parse_canonical_pubkey( + "d", + d.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing d tag".into()))?, + )?; + let owner_pubkey = *expected_owner; + let generation = parse_generation( + g.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing g tag".into()))?, + )?; + let previous_event_id = match prev { + Some(value) => Some(parse_event_id("prev", &value)?), + None => None, + }; + if (generation == 1) != previous_event_id.is_none() { + return Err(Error::InvalidEnvelope( + "prev must be absent exactly at generation 1".into(), + )); + } + let state = match state.as_deref() { + Some("active") => State::Active, + Some("deleted") => State::Deleted, + Some(_) => return Err(Error::InvalidEnvelope("invalid state tag".into())), + None => return Err(Error::InvalidEnvelope("missing state tag".into())), + }; + Ok(Envelope { + agent_pubkey, + owner_pubkey, + generation, + previous_event_id, + state, + }) +} + +/// Encrypt and sign an inert private managed-agent event candidate. +pub fn build_event(owner_keys: &Keys, payload: &Payload, created_at: u64) -> Result { + validate_payload(payload)?; + if payload.owner_pubkey != owner_keys.public_key().to_hex() { + return Err(Error::InvalidPayload( + "owner_pubkey does not match signing key".into(), + )); + } + let plaintext = serde_json::to_vec(payload).map_err(|_| Error::Encrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::InvalidPayload( + "plaintext exceeds NIP-44 limit".into(), + )); + } + let plaintext = std::str::from_utf8(&plaintext).map_err(|_| Error::Encrypt)?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + plaintext, + Version::V2, + ) + .map_err(|_| Error::Encrypt)?; + let mut tags = vec![ + parse_tag(["d", payload.agent_pubkey.as_str()])?, + parse_tag(["g", payload.generation.to_string().as_str()])?, + parse_tag(["state", payload.state.as_str()])?, + ]; + if let Some(previous) = payload.previous_event_id.as_deref() { + tags.push(parse_tag(["prev", previous])?); + } + EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(owner_keys) + .map_err(|_| Error::Sign) +} + +/// Validate, owner-self decrypt, strictly parse, and cross-check a payload. +pub fn validate_and_decrypt( + event: &Event, + owner_keys: &Keys, +) -> Result<(Envelope, Payload), Error> { + let envelope = validate_envelope(event, &owner_keys.public_key())?; + let plaintext = nip44::decrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + &event.content, + ) + .map_err(|_| Error::Decrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::Decrypt); + } + let value = parse_strict_json(plaintext.as_bytes())?; + let payload: Payload = + serde_json::from_value(value).map_err(|e| Error::InvalidPayload(format!("schema: {e}")))?; + validate_payload(&payload)?; + if payload.agent_pubkey != envelope.agent_pubkey.to_hex() + || payload.owner_pubkey != envelope.owner_pubkey.to_hex() + || payload.generation != envelope.generation + || payload.state != envelope.state + || payload.previous_event_id.as_deref() + != envelope + .previous_event_id + .as_ref() + .map(EventId::to_hex) + .as_deref() + { + return Err(Error::InvalidPayload( + "outer/inner metadata mismatch".into(), + )); + } + Ok((envelope, payload)) +} + +/// Validate decrypted payload semantics independently of encryption. +pub fn validate_payload(payload: &Payload) -> Result<(), Error> { + if payload.format != FORMAT || payload.version != VERSION { + return Err(Error::InvalidPayload( + "unsupported format or version".into(), + )); + } + let agent = parse_canonical_pubkey("agent_pubkey", &payload.agent_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + validate_generation_and_prev(payload.generation, payload.previous_event_id.as_deref())?; + parse_rfc3339("updated_at", &payload.updated_at)?; + for (key, value) in &payload.extensions { + if key.is_empty() || key.len() > 128 || !key.contains(':') { + return Err(Error::InvalidPayload( + "extension keys must be non-empty namespaced strings <= 128 bytes".into(), + )); + } + validate_value_size("extension", value)?; + } + match payload.state { + State::Active => { + if payload.deleted_at.is_some() { + return Err(Error::InvalidPayload( + "active payload must not contain deleted_at".into(), + )); + } + let active = payload.active.as_ref().ok_or_else(|| { + Error::InvalidPayload("active payload missing active body".into()) + })?; + validate_active(active, &agent, &payload.owner_pubkey)?; + } + State::Deleted => { + if payload.active.is_some() { + return Err(Error::InvalidPayload( + "deleted payload must not contain active body".into(), + )); + } + parse_rfc3339( + "deleted_at", + payload.deleted_at.as_deref().ok_or_else(|| { + Error::InvalidPayload("deleted payload missing deleted_at".into()) + })?, + )?; + } + } + Ok(()) +} + +fn validate_active( + active: &ActivePayload, + agent: &PublicKey, + owner_pubkey: &str, +) -> Result<(), Error> { + if active.definition.revision == 0 || active.definition.revision > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload("invalid definition revision".into())); + } + let definition_d = + parse_definition_coordinate(active.config.definition_coordinate.as_deref(), owner_pubkey)?; + validate_binding( + "definition", + KIND_PERSONA, + owner_pubkey, + Some(&definition_d), + &active.definition.event_id, + &active.definition.content_sha256, + &active.definition.recovery, + )?; + validate_binding( + "instance_projection", + KIND_MANAGED_AGENT, + owner_pubkey, + Some(&agent.to_hex()), + &active.instance_projection.event_id, + &active.instance_projection.content_sha256, + &active.instance_projection.recovery, + )?; + let agent_keys = Keys::parse(active.identity.private_key_nsec.trim()) + .map_err(|_| Error::InvalidPayload("invalid agent nsec".into()))?; + if agent_keys.public_key() != *agent { + return Err(Error::InvalidPayload( + "agent nsec does not derive agent_pubkey".into(), + )); + } + if let Some(auth_tag) = &active.identity.auth_tag { + validate_auth_tag(auth_tag, owner_pubkey, agent)?; + } + let config = &active.config; + if config.relay_url.is_empty() || config.relay_url.len() > 4096 { + return Err(Error::InvalidPayload("invalid relay_url length".into())); + } + if config.agent_args.len() > MAX_AGENT_ARGS + || config + .agent_args + .iter() + .any(|arg| arg.len() > MAX_AGENT_ARG_BYTES) + { + return Err(Error::InvalidPayload("agent_args exceed limits".into())); + } + if config.env_vars.len() > MAX_ENV_VARS + || config.env_vars.iter().any(|(k, v)| { + k.is_empty() || k.len() > MAX_ENV_KEY_BYTES || v.len() > MAX_ENV_VALUE_BYTES + }) + { + return Err(Error::InvalidPayload("env_vars exceed limits".into())); + } + validate_value_size("backend", &config.backend)?; + if let Some(mesh) = &config.relay_mesh { + validate_value_size("relay_mesh", mesh)?; + } + Ok(()) +} + +fn validate_auth_tag(auth_tag: &str, expected_owner: &str, agent: &PublicKey) -> Result<(), Error> { + if auth_tag.is_empty() || auth_tag.len() > 4096 { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let parts: Vec = serde_json::from_str(auth_tag) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if parts.len() != 4 || parts[0] != "auth" || parts[1] != expected_owner || !parts[2].is_empty() + { + return Err(Error::InvalidPayload( + "auth_tag must be an unconditional attestation for this owner".into(), + )); + } + parse_canonical_pubkey("auth_tag owner", &parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if agent.to_hex() == expected_owner { + return Err(Error::InvalidPayload( + "auth_tag must attest a distinct agent key".into(), + )); + } + if parts[3].len() != 128 + || !parts[3] + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let signature = Signature::from_str(&parts[3]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let preimage = format!("nostr:agent-auth:{}:", agent.to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let message = Message::from_digest(digest.into()); + let owner = PublicKey::from_hex(&parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let owner = owner + .xonly() + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + SECP256K1 + .verify_schnorr(&signature, &message, &owner) + .map_err(|_| Error::InvalidPayload("invalid auth_tag signature".into())) +} + +fn parse_definition_coordinate( + coordinate: Option<&str>, + owner_pubkey: &str, +) -> Result { + let coordinate = coordinate.ok_or_else(|| { + Error::InvalidPayload("active payload missing definition_coordinate".into()) + })?; + let mut parts = coordinate.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let d = parts.next(); + if kind != Some("30175") || owner != Some(owner_pubkey) || d.is_none_or(str::is_empty) { + return Err(Error::InvalidPayload( + "definition_coordinate must be 30175::".into(), + )); + } + Ok(d.unwrap().to_owned()) +} + +fn validate_binding( + label: &str, + expected_kind: u32, + owner_pubkey: &str, + expected_d: Option<&str>, + event_id: &str, + hash: &str, + recovery: &ProjectionRecoveryV1, +) -> Result<(), Error> { + parse_event_id(label, event_id).map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_lower_hex_32(&format!("{label}.content_sha256"), hash) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + if recovery.version != 1 { + return Err(Error::InvalidPayload(format!( + "unsupported {label} recovery version" + ))); + } + let event = &recovery.signed_event; + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidPayload(format!( + "invalid {label} recovery event" + ))); + } + if event.id.to_hex() != event_id + || event.kind.as_u16() as u32 != expected_kind + || event.pubkey.to_hex() != owner_pubkey + || content_sha256(event.content.as_bytes()) != hash + { + return Err(Error::InvalidPayload(format!( + "{label} recovery does not match binding" + ))); + } + let d_tags: Vec<_> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")).then_some(parts) + }) + .collect(); + if d_tags.len() != 1 || d_tags[0].len() != 2 || d_tags[0][1].is_empty() { + return Err(Error::InvalidPayload(format!( + "{label} recovery must have exactly one non-empty d tag" + ))); + } + if expected_d.is_some_and(|expected| d_tags[0][1] != expected) { + return Err(Error::InvalidPayload(format!( + "{label} recovery has wrong coordinate" + ))); + } + validate_value_size( + label, + &serde_json::to_value(recovery) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))?, + ) +} + +fn validate_generation_and_prev(generation: u64, previous: Option<&str>) -> Result<(), Error> { + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload( + "generation must be a positive safe integer".into(), + )); + } + if (generation == 1) != previous.is_none() { + return Err(Error::InvalidPayload( + "previous_event_id must be absent exactly at generation 1".into(), + )); + } + if let Some(value) = previous { + parse_event_id("previous_event_id", value) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + } + Ok(()) +} + +fn validate_value_size(label: &str, value: &Value) -> Result<(), Error> { + let len = serde_json::to_vec(value) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))? + .len(); + if len > MAX_VALUE_BYTES { + return Err(Error::InvalidPayload(format!("{label} exceeds size limit"))); + } + Ok(()) +} + +fn parse_rfc3339(label: &str, value: &str) -> Result<(), Error> { + chrono::DateTime::parse_from_rfc3339(value) + .map(|_| ()) + .map_err(|_| Error::InvalidPayload(format!("{label} must be RFC3339"))) +} + +fn parse_generation(value: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|b| b.is_ascii_digit()) + { + return Err(Error::InvalidEnvelope("g must be canonical decimal".into())); + } + let generation = value + .parse::() + .map_err(|_| Error::InvalidEnvelope("invalid g tag".into()))?; + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidEnvelope( + "g must be a positive safe integer".into(), + )); + } + Ok(generation) +} + +fn parse_canonical_pubkey(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + let key = PublicKey::from_hex(value) + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label}")))?; + key.xonly() + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label} curve point")))?; + Ok(key) +} + +fn parse_event_id(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + EventId::from_hex(value).map_err(|_| Error::InvalidEnvelope(format!("invalid {label}"))) +} + +fn parse_lower_hex_32(label: &str, value: &str) -> Result<(), Error> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidEnvelope(format!( + "{label} must be 64 lowercase hex chars" + ))); + } + Ok(()) +} + +fn parse_tag(parts: [&str; N]) -> Result { + Tag::parse(parts).map_err(|_| Error::InvalidEnvelope("failed to build tag".into())) +} + +fn parse_strict_json(bytes: &[u8]) -> Result { + struct StrictValue; + impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + fn deserialize>(self, d: D) -> Result { + d.deserialize_any(self) + } + } + impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("valid JSON with unique object keys") + } + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite float")) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(self) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(value) = seq.next_element_seed(StrictValue)? { + out.push(value); + } + Ok(Value::Array(out)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut seen = HashSet::new(); + let mut out = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(serde::de::Error::custom(format!("duplicate key: {key}"))); + } + out.insert(key, map.next_value_seed(StrictValue)?); + } + Ok(Value::Object(out)) + } + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue + .deserialize(&mut deserializer) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + deserializer + .end() + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::ToBech32; + + fn auth_tag(owner: &Keys, agent: &Keys) -> String { + let preimage = format!("nostr:agent-auth:{}:", agent.public_key().to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let signature = owner.sign_schnorr(&Message::from_digest(digest.into())); + serde_json::json!([ + "auth", + owner.public_key().to_hex(), + "", + signature.to_string() + ]) + .to_string() + } + + fn payload(owner: &Keys, agent: &Keys) -> Payload { + let definition_event = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + let instance_event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "instance") + .tags(vec![Tag::parse([ + "d", + agent.public_key().to_hex().as_str(), + ]) + .unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: agent.public_key().to_hex(), + owner_pubkey: owner.public_key().to_hex(), + generation: 1, + previous_event_id: None, + state: State::Active, + updated_at: "2026-08-03T18:00:00Z".into(), + active: Some(ActivePayload { + definition: DefinitionBinding { + revision: 1, + event_id: definition_event.id.to_hex(), + content_sha256: content_sha256(definition_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: definition_event, + }, + }, + instance_projection: InstanceBinding { + event_id: instance_event.id.to_hex(), + content_sha256: content_sha256(instance_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: instance_event, + }, + }, + identity: PrivateIdentity { + private_key_nsec: agent.secret_key().to_bech32().unwrap(), + auth_tag: None, + }, + config: PrivateConfig { + definition_coordinate: Some(format!( + "30175:{}:test-agent", + owner.public_key().to_hex() + )), + relay_url: "wss://relay.example".into(), + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: Some(300), + max_turn_duration_seconds: None, + env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]), + backend: serde_json::json!({"type": "local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + }, + }), + deleted_at: None, + extensions: BTreeMap::new(), + } + } + + #[test] + fn owner_self_round_trip_binds_outer_and_inner() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let expected = payload(&owner, &agent); + let event = build_event(&owner, &expected, 1_785_780_000).unwrap(); + let (envelope, actual) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(actual, expected); + assert_eq!(envelope.agent_pubkey, agent.public_key()); + assert_eq!(envelope.owner_pubkey, owner.public_key()); + assert_eq!(envelope.generation, 1); + assert_eq!(envelope.state, State::Active); + } + + #[test] + fn debug_output_redacts_private_material() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let private_key_nsec = candidate + .active + .as_ref() + .unwrap() + .identity + .private_key_nsec + .clone(); + let active = candidate.active.as_mut().unwrap(); + active.identity.auth_tag = Some("secret-auth-tag".into()); + active.config.backend = serde_json::json!({"token": "secret-backend-token"}); + + let debug = format!("{candidate:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(&private_key_nsec)); + assert!(!debug.contains("secret-auth-tag")); + assert!(!debug.contains("not-public")); + assert!(!debug.contains("secret-backend-token")); + } + + #[test] + fn wrong_owner_and_tampering_fail_closed() { + let owner = Keys::generate(); + let event = + build_event(&owner, &payload(&owner, &Keys::generate()), 1_785_780_000).unwrap(); + let stranger = Keys::generate(); + assert!(matches!( + validate_and_decrypt(&event, &stranger), + Err(Error::InvalidEnvelope(_)) + )); + + let mut tampered = event; + tampered.content.push('A'); + assert!(matches!( + validate_and_decrypt(&tampered, &owner), + Err(Error::InvalidEnvelope(_)) + )); + } + + #[test] + fn duplicate_and_unknown_json_fields_are_rejected() { + let duplicate = br#"{"format":"a","format":"b"}"#; + assert!(matches!( + parse_strict_json(duplicate), + Err(Error::InvalidPayload(message)) if message.contains("duplicate key") + )); + + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("surprise".into(), Value::Bool(true)); + let err = serde_json::from_value::(value).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + candidate.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &agent)); + validate_payload(&candidate).unwrap(); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&Keys::generate(), &agent)); + assert!(validate_payload(&candidate).is_err()); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&owner, &Keys::generate())); + assert!(validate_payload(&candidate).is_err()); + + let mut self_attested = payload(&owner, &owner); + self_attested.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &owner)); + assert!(matches!( + validate_payload(&self_attested), + Err(Error::InvalidPayload(message)) if message.contains("distinct agent key") + )); + + let valid = auth_tag(&owner, &agent); + let mut parts: Vec = serde_json::from_str(&valid).unwrap(); + parts[2] = "kind=9".into(); + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(serde_json::to_string(&parts).unwrap()); + assert!(validate_payload(&candidate).is_err()); + } + + #[test] + fn active_identity_must_derive_coordinate() { + let owner = Keys::generate(); + let mut candidate = payload(&owner, &Keys::generate()); + candidate.active.as_mut().unwrap().identity.private_key_nsec = + Keys::generate().secret_key().to_bech32().unwrap(); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not derive") + )); + } + + #[test] + fn tombstone_requires_successor_shape() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut deleted = payload(&owner, &agent); + deleted.generation = 2; + deleted.previous_event_id = Some("33".repeat(32)); + deleted.state = State::Deleted; + deleted.active = None; + deleted.deleted_at = Some("2026-08-03T18:01:00Z".into()); + validate_payload(&deleted).unwrap(); + + deleted.previous_event_id = None; + assert!(validate_payload(&deleted).is_err()); + } + + #[test] + fn outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let body = payload(&owner, &agent); + let ciphertext = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + serde_json::to_string(&body).unwrap(), + Version::V2, + ) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(vec![ + Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), + Tag::parse(["g", "01"]).unwrap(), + Tag::parse(["state", "active"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(matches!( + validate_envelope(&event, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") + )); + } + + #[test] + fn projection_recovery_must_match_binding_and_coordinate() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let active = candidate.active.as_mut().unwrap(); + active.instance_projection.content_sha256 = content_sha256(b"wrong"); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .config + .definition_coordinate = + Some(format!("30175:{}:wrong-slug", owner.public_key().to_hex())); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate") + )); + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .version = 2; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .signed_event + .content + .push('!'); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event") + )); + + let mut candidate = payload(&owner, &agent); + let wrong_kind = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = wrong_kind.id.to_hex(); + definition.content_sha256 = content_sha256(wrong_kind.content.as_bytes()); + definition.recovery.signed_event = wrong_kind; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = missing_d.id.to_hex(); + definition.content_sha256 = content_sha256(missing_d.content.as_bytes()); + definition.recovery.signed_event = missing_d; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("exactly one non-empty d tag") + )); + } + + #[test] + fn projection_hash_fixture_is_stable() { + assert_eq!( + content_sha256(b"buzz-private-managed-agent-v1"), + "c3ca1603249c95343fc1766ba58d075d6bdf0e57b375bef38738729b2022cc80" + ); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95ca..9d15fccfc 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -371,7 +371,9 @@ async fn acquire_channel_membership_lock( /// Role enforcement: /// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of /// what the caller passes — callers cannot self-assign elevated roles. -/// - Private channels: requires an `invited_by` who is an active owner/admin. +/// - Private channels: requires an `invited_by` who is an active owner/admin, the channel +/// creator bootstrapping their own first membership, or the target adding themselves +/// (idempotent re-add — an active member's *role* still cannot change this way). /// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin, /// even on open channels. /// @@ -419,10 +421,14 @@ pub async fn add_member( DbError::InvalidData(format!("invalid role in database: {inviter_role_str}")) })?; - // Any member can invite others, but only owners/admins may grant elevated roles. - if role.is_elevated() && !inviter_role.is_elevated() { + // Only owners/admins may extend private-channel access to another + // identity. `inviter == pubkey` keeps a member's own idempotent + // re-add working; it is not a role-escalation hole, because the + // active-role-change guard below still rejects a self-targeted + // promotion from any non-elevated caller. + if !inviter_role.is_elevated() && inviter != pubkey { return Err(DbError::AccessDenied( - "only owners/admins may grant elevated roles".to_string(), + "only owners/admins may add private-channel members".to_string(), )); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index a670a1340..e1b45aa3a 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1541,7 +1541,7 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -1943,6 +1943,42 @@ mod tests { .expect("sign reaction event") } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_stores_wrapped_max_shortcode() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("long custom emoji target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let emoji = format!(":{}:", "a".repeat(64)); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &reaction, + None, + None, + target.id.as_bytes(), + &actor.public_key().to_bytes(), + &emoji, + ) + .await + .expect("store wrapped 64-character shortcode"); + + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + assert_eq!(emoji.chars().count(), 66); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 65ca15672..37f54d0fa 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 27); + assert_eq!(migrations.len(), 28); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,7 +919,6 @@ mod tests { assert!(heartbeat.contains("epoch")); assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); assert!(heartbeat.contains("_operator_global_tables")); - // Channel-id lookup index (0027): serves the tenant-independent // `channels` lookups that carry no community_id predicate, which no // community_id-leading index can satisfy. Covering + partial so the @@ -940,6 +939,13 @@ mod tests { desired_schema.contains("idx_channels_id_live"), "desired-state schema must carry the channel-id lookup index", ); + + assert_eq!(migrations[27].version, 28); + let long_reactions = migrations[27].sql.as_str(); + assert!( + long_reactions.contains("ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66)") + ); + assert!(desired_schema.contains("emoji VARCHAR(66) NOT NULL")); } #[test] diff --git a/crates/buzz-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index 3c5611ba0..cb3a7d1c0 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -909,18 +909,22 @@ at agent startup. ### Desktop App Import -The Buzz desktop app can import persona packs via the Import button: +The Buzz desktop app's **Agents** page does not import persona-pack `.zip` archives or +`.persona.md` files directly. It imports personas and teams as **snapshots** — files exported +from an agent or team that already exists inside the app: -- **My Agents → Import**: Accepts `.persona.md` files (individual personas) or `.zip` files - (persona packs detected by `.plugin/plugin.json`). Pack zips are resolved in a temp directory; - each persona is previewed and imported individually into the persona library. -- **My Teams → Import**: Accepts `.zip` files (persona packs). The pack name becomes the team - name; each persona becomes a team member. +- **Agents section → Import**: Accepts `.agent.json` or `.agent.png` (an agent snapshot). +- **Agent teams section → Import**: Accepts `.team.json` or `.team.png` (a `buzz-team-snapshot + v1`). A persona-pack `.zip` is rejected outright with an error directing you to export a team + snapshot instead. -> **Note**: The Import button parses and previews personas from the pack — it does not install the -> pack directory itself. For full pack installation (which copies the pack to -> `/agents/packs//` with re-validation), use the `install_persona_pack` -> Tauri command or a future "Install Pack" UI button. +> **Persona packs and desktop snapshots are two separate, non-interchangeable formats today.** +> This spec's pack format (portable, hand-authored, git-friendly) is validated and inspected via +> `buzz pack validate` / `buzz pack inspect` (Section 11). A snapshot is captured *from* an +> already-running agent or team inside the desktop app. Neither format converts into the other: +> there is no command that turns a pack into a snapshot, or a snapshot back into pack source. To +> get a pack's personas running inside the desktop app today, recreate them there by hand using +> `buzz pack inspect`'s resolved config as reference. --- diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index d3118d8a7..53e3f5946 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -224,10 +224,95 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } + deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; + Ok(GitAuth { pubkey, tenant }) } } +/// Deny banned principals on every Git HTTP request. +/// +/// Git runs outside the WebSocket authentication path, so a valid NIP-98 +/// credential and channel membership are not enough — neither reflects a +/// moderation ban. Git credentials are also deliberately reused across a +/// session (see the replay notes above), so no session expiry would close the +/// gap on its own. Re-read the durable ban per request instead. +/// +/// Cascades to the proven NIP-OA owner, matching the NIP-42 gate in +/// `handlers::auth`: banning a human must also revoke their agents, or the ban +/// is bypassable by cloning and pushing through an agent key. +async fn deny_banned_git_principal( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, + auth_tag: Option<&str>, +) -> Result<(), Response> { + let agent = git_restriction_state(db, community, pubkey).await?; + + // Skip the owner read when the agent is already banned: the denial is + // identical either way. Mirrors the WebSocket cascade's short-circuit. + let owner = if agent.banned { + None + } else { + crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + }; + let owner_state = match owner { + Some(owner) => Some(git_restriction_state(db, community, &owner).await?), + None => None, + }; + + enforce_git_ban_cascade(&agent, owner_state.as_ref()).map_err(|status| { + warn!( + pubkey = %pubkey.to_hex(), + owner = ?owner.map(|owner| owner.to_hex()), + "git: community ban denied request" + ); + (status, "blocked: banned from this community").into_response() + }) +} + +/// One restriction read, failing closed with 503. +/// +/// A restriction-store outage must not be reported to the client as a +/// permission decision — 503 says "retry", 403 would claim a ban that was +/// never read. +async fn git_restriction_state( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, +) -> Result { + db.moderation_restriction_state(community, pubkey.as_bytes()) + .await + .map_err(|error| { + warn!(pubkey = %pubkey.to_hex(), error = %error, "git: ban lookup failed closed"); + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + }) +} + +fn enforce_git_ban(restriction: &buzz_db::moderation::RestrictionState) -> Result<(), StatusCode> { + if restriction.banned { + Err(StatusCode::FORBIDDEN) + } else { + Ok(()) + } +} + +/// Either principal's ban denies the request; `None` owner means no attested +/// owner to inherit from. +/// +/// Split from the DB reads so agent→owner precedence stays unit-testable +/// without Postgres. +fn enforce_git_ban_cascade( + agent: &buzz_db::moderation::RestrictionState, + owner: Option<&buzz_db::moderation::RestrictionState>, +) -> Result<(), StatusCode> { + enforce_git_ban(agent)?; + match owner { + Some(owner) => enforce_git_ban(owner), + None => Ok(()), + } +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -2610,6 +2695,76 @@ mod sec005_read_gate_tests { assert!(!read_role_allows(Some("")), "empty role must deny"); } + #[test] + fn durable_ban_denies_git_even_with_otherwise_valid_auth() { + let restriction = buzz_db::moderation::RestrictionState { + banned: true, + muted_until: None, + }; + + assert_eq!(enforce_git_ban(&restriction), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn timeout_without_ban_does_not_revoke_git_access() { + let restriction = buzz_db::moderation::RestrictionState { + banned: false, + muted_until: Some(chrono::Utc::now()), + }; + + assert_eq!(enforce_git_ban(&restriction), Ok(())); + } + + fn restriction(banned: bool) -> buzz_db::moderation::RestrictionState { + buzz_db::moderation::RestrictionState { + banned, + muted_until: None, + } + } + + // ── Agent → owner ban cascade ──────────────────────────────────────── + // + // Git accepts NIP-OA attestations on the signed NIP-98 token, so an agent + // key can act for its owner (`deny_banned_git_principal`). The NIP-42 gate + // in `handlers::auth` cascades the ban check to the proven owner for that + // reason, and Git must agree: if only the presented key were checked, a + // banned human would keep clone and push access through any agent key. + + #[test] + fn banned_owner_denies_git_for_an_otherwise_clear_agent() { + assert_eq!( + enforce_git_ban_cascade(&restriction(false), Some(&restriction(true))), + Err(StatusCode::FORBIDDEN), + "an agent must inherit its proven owner's ban" + ); + } + + #[test] + fn banned_agent_denies_git_whatever_the_owner_state() { + for owner in [None, Some(restriction(false)), Some(restriction(true))] { + assert_eq!( + enforce_git_ban_cascade(&restriction(true), owner.as_ref()), + Err(StatusCode::FORBIDDEN), + "a directly banned agent must be denied" + ); + } + } + + #[test] + fn clear_agent_and_clear_owner_allow_git() { + assert_eq!( + enforce_git_ban_cascade(&restriction(false), Some(&restriction(false))), + Ok(()) + ); + } + + #[test] + fn clear_agent_without_attested_owner_allows_git() { + // No NIP-OA tag on the request: nothing to inherit, so the agent's own + // state decides. A missing owner must not read as a ban. + assert_eq!(enforce_git_ban_cascade(&restriction(false), None), Ok(())); + } + fn announcement(keys: &Keys, tags: Vec) -> nostr::Event { EventBuilder::new(Kind::Custom(30617), "") .tags(tags) @@ -2965,4 +3120,129 @@ mod sec005_read_gate_tests { "deleted announcement must deny reads even for channel members" ); } + + // ── Ban gate wiring (requires Postgres) ────────────────────────────── + // + // The pure tests above fix the decision table; these prove the gate is + // actually wired to the durable store — that it reads the real ban row, + // resolves the NIP-OA owner from a live attestation, and fails closed when + // the store is unreachable. `deny_banned_git_principal` runs inside the + // `GitAuth` extractor, which every Git route (`info/refs`, `git-upload-pack`, + // `git-receive-pack`) goes through, so advertise, fetch and push all + // inherit these outcomes. + + /// Community + a ban actor, without the channel/repo fixture the read-gate + /// tests need — the ban gate runs before any repo is resolved. + async fn setup_ban_community() -> (buzz_db::Db, buzz_core::CommunityId, Vec) { + let db = setup_db().await; + let host = format!("ban-git-{}.example", uuid::Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + let actor = Keys::generate().public_key().to_bytes().to_vec(); + db.ensure_user(community, &actor).await.expect("actor"); + (db, community, actor) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_denies_banned_member_and_allows_clear_member() { + let (db, community, actor) = setup_ban_community().await; + let member = Keys::generate(); + let member_pk = member.public_key().to_bytes().to_vec(); + db.ensure_user(community, &member_pk).await.expect("member"); + + assert!( + deny_banned_git_principal(&db, community, &member.public_key(), None) + .await + .is_ok(), + "precondition: an unbanned member passes the git ban gate" + ); + + db.ban_community_member(community, &member_pk, &actor, Some("test"), None) + .await + .expect("ban"); + + let (status, body) = denial_parts( + deny_banned_git_principal(&db, community, &member.public_key(), None).await, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, "blocked: banned from this community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_cascades_to_a_banned_nip_oa_owner() { + let (db, community, actor) = setup_ban_community().await; + let owner = Keys::generate(); + let agent = Keys::generate(); + let owner_pk = owner.public_key().to_bytes().to_vec(); + let agent_pk = agent.public_key().to_bytes().to_vec(); + db.ensure_user(community, &owner_pk).await.expect("owner"); + db.ensure_user(community, &agent_pk).await.expect("agent"); + + // A real attestation: the gate must verify it, not trust a claim. + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "kind=9") + .expect("auth tag"); + + assert!( + deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)) + .await + .is_ok(), + "precondition: neither agent nor owner is banned" + ); + + // Ban the human only. The agent's own row stays clear. + db.ban_community_member(community, &owner_pk, &actor, Some("test"), None) + .await + .expect("ban owner"); + + let (status, _) = denial_parts( + deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)).await, + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "banning the owner must revoke its agent's git access" + ); + + // An unattested request from the same agent key is unaffected: the + // cascade must follow a verified owner, not punish every agent. + assert!( + deny_banned_git_principal(&db, community, &agent.public_key(), None) + .await + .is_ok(), + "without an attestation there is no owner to inherit from" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_fails_closed_with_503_when_the_store_is_unreachable() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + + // Closing the pool is the cheapest faithful stand-in for the + // restriction store being unavailable mid-request. + pool.close().await; + + let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let (status, body) = denial_parts( + deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None).await, + ) + .await; + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "a store outage must deny as retryable, never allow and never claim a 403" + ); + assert_eq!(body, "authorization unavailable"); + } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d7072..cd9f20b5f 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -49,6 +49,55 @@ use crate::conformance::{ state_for_request, EmitGuard, TraceAction, Verdict, }; +fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("emoji") { + continue; + } + let shortcode = parts.get(1).ok_or_else(|| { + IngestError::Rejected("invalid: emoji tag must include a shortcode".into()) + })?; + buzz_sdk::normalize_custom_emoji_shortcode(shortcode) + .map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?; + } + Ok(()) +} + +fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError> { + let emoji_char_count = emoji.chars().count(); + if emoji_char_count <= 64 { + return Ok(()); + } + + let Some(shortcode) = emoji + .strip_prefix(':') + .and_then(|value| value.strip_suffix(':')) + else { + return Err(IngestError::Rejected(format!( + "invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})" + ))); + }; + let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode) + .map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?; + if shortcode != normalized { + return Err(IngestError::Rejected( + "invalid: long custom emoji reaction shortcode must be canonical lowercase".into(), + )); + } + let has_matching_tag = event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some("emoji") + && parts.get(1).is_some_and(|value| value == shortcode) + }); + if !has_matching_tag || emoji_char_count > buzz_sdk::MAX_CUSTOM_EMOJI_REACTION_LEN { + return Err(IngestError::Rejected(format!( + "invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})" + ))); + } + Ok(()) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -2634,6 +2683,10 @@ async fn ingest_event_inner( )); } + if kind_u32 == KIND_EMOJI_SET || kind_u32 == KIND_EMOJI_LIST { + validate_custom_emoji_tags(&event)?; + } + // Resolve the target reference, then use one DB transaction to upsert the // reaction row (dedup via ON CONFLICT) with reaction_event_id already set and // store the kind:7 event. This replaces the post-storage side-effect handler. @@ -2672,17 +2725,7 @@ async fn ingest_event_inner( &event.content }; - // Mirror the SDK's 64-character emoji limit server-side so raw clients - // cannot bypass it. Uses chars().count() (not byte len) to match the - // SDK's check_emoji_len, which also counts Unicode characters. - const MAX_REACTION_EMOJI_CHARS: usize = 64; - let emoji_char_count = emoji.chars().count(); - if emoji_char_count > MAX_REACTION_EMOJI_CHARS { - return Err(IngestError::Rejected(format!( - "invalid: reaction emoji exceeds {} characters (got {})", - MAX_REACTION_EMOJI_CHARS, emoji_char_count - ))); - } + validate_reaction_emoji(&event, emoji)?; // Atomically upsert the reaction row with this kind:7 event id, then store // the event in the same transaction. Ordering is load-bearing: active @@ -2916,6 +2959,84 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn reaction_validation_accepts_wrapped_max_shortcode() { + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(validate_reaction_emoji(&event, &event.content).is_ok()); + } + + #[test] + fn reaction_validation_rejects_mixed_case_max_shortcode() { + let shortcode = "Ab".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN / 2); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(matches!( + validate_reaction_emoji(&event, &event.content), + Err(IngestError::Rejected(_)) + )); + } + + #[test] + fn reaction_validation_rejects_case_mismatched_tag() { + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let uppercase_shortcode = shortcode.to_uppercase(); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([nostr::Tag::parse([ + "emoji", + &uppercase_shortcode, + "https://example.com/max.png", + ]) + .expect("emoji tag")]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(matches!( + validate_reaction_emoji(&event, &event.content), + Err(IngestError::Rejected(_)) + )); + } + + #[test] + fn emoji_set_validation_enforces_shortcode_boundary() { + let max_shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let valid_event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "") + .tags([ + nostr::Tag::parse(["emoji", &max_shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign valid emoji set"); + assert!(validate_custom_emoji_tags(&valid_event).is_ok()); + + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1); + let event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "") + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/long.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign emoji set"); + + assert!(matches!( + validate_custom_emoji_tags(&event), + Err(IngestError::Rejected(message)) if message.contains("exceeds 64 bytes") + )); + } + /// A banned relay admin must be refused with the same wire prefix and /// transport status as every other durable-restriction refusal: /// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never @@ -3216,6 +3337,18 @@ mod tests { } } + #[test] + fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() { + assert!( + required_scope_for_kind( + buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT, + &make_dummy_event(), + ) + .is_err(), + "kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy" + ); + } + #[test] fn ephemeral_kinds_not_in_scope_allowlist() { assert!(required_scope_for_kind(KIND_PRESENCE_UPDATE, &make_dummy_event()).is_err()); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef..98f8a9aa8 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -355,28 +355,28 @@ pub async fn validate_admin_event( .iter() .find(|m| m.pubkey == actor_bytes) .and_then(|m| m.role.parse().ok()); - - // PUT_USER: open channels allow any authenticated user; private channels - // require the actor to be an existing member (any role can invite). - if channel.visibility == "private" { - if actor_role.is_none() { - return Err(anyhow::anyhow!("actor not authorized")); - } - - // Only owners/admins may grant elevated roles. - if requested_role.is_some_and(|r| r.is_elevated()) - && !actor_role.is_some_and(|r| r.is_elevated()) - { - return Err(anyhow::anyhow!( - "only owners/admins may grant elevated roles" - )); - } - } - - // Extract target pubkey from p tag let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; + // PUT_USER: open channels allow any authenticated user. Private + // channels only let owners/admins add another identity; otherwise + // any compromised member could extend access to channel history. + // + // A self-targeted add skips this check so an idempotent re-add + // still works. That is not a way into a private channel: ingest's + // `check_channel_membership` rejects a non-member (and a + // soft-removed member) before this validator runs, and `add_member` + // independently requires the self-inviter to hold an active role. + // Self-promotion is caught by the role-change guard below. + if channel.visibility == "private" + && target_pubkey != actor_bytes + && !actor_role.is_some_and(|r| r.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may add private-channel members" + )); + } + // Changing an ACTIVE existing member's role is privileged in both // directions, on every visibility. `get_members` filters // `removed_at IS NULL`, so a soft-removed row is deliberately not an diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 9a139f037..c3b443210 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -120,6 +120,11 @@ fn check_repo_id(repo_id: &str) -> Result<(), SdkError> { Ok(()) } +/// Maximum length of a custom emoji shortcode. +pub const MAX_CUSTOM_EMOJI_SHORTCODE_LEN: usize = 64; +/// Maximum reaction payload length for a colon-wrapped custom emoji shortcode. +pub const MAX_CUSTOM_EMOJI_REACTION_LEN: usize = MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 2; + /// Validate and normalize a NIP-30 custom emoji shortcode. /// /// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase @@ -131,9 +136,9 @@ pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result 64 { + if trimmed.len() > MAX_CUSTOM_EMOJI_SHORTCODE_LEN { return Err(SdkError::InvalidInput(format!( - "emoji shortcode exceeds 64 bytes (got {})", + "emoji shortcode exceeds {MAX_CUSTOM_EMOJI_SHORTCODE_LEN} bytes (got {})", trimmed.len() ))); } @@ -2654,6 +2659,30 @@ mod tests { assert!(has_tag(&ev, "emoji", "party_parrot")); } + #[test] + fn custom_emoji_reaction_accepts_max_shortcode_length() { + let eid = event_id(); + let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let ev = sign( + build_custom_emoji_reaction(eid, &shortcode, "https://example.com/max.png").unwrap(), + ); + + assert_eq!(ev.content, format!(":{shortcode}:")); + assert_eq!(ev.content.chars().count(), MAX_CUSTOM_EMOJI_REACTION_LEN); + assert!(has_tag(&ev, "emoji", &shortcode)); + } + + #[test] + fn custom_emoji_reaction_rejects_overlong_shortcode() { + let eid = event_id(); + let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1); + + assert!(matches!( + build_custom_emoji_reaction(eid, &shortcode, "https://example.com/too-long.png"), + Err(SdkError::InvalidInput(message)) if message.contains("exceeds 64 bytes") + )); + } + #[test] fn custom_emoji_set_happy_path() { let ev = sign( diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed..5b9a50b5b 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2201,6 +2201,10 @@ async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) -> } /// Submit a kind:9000 PUT_USER event over WebSocket. +/// +/// `allow_self_tagging` keeps self-targeted adds working: EventBuilder otherwise +/// drops a `p` tag matching the signer (nostr-0.44.3 builder.rs:435-449) and the +/// event fails as "missing p tag" instead of exercising the authority check. async fn add_member_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2210,6 +2214,7 @@ async fn add_member_ws( let h_tag = Tag::parse(["h", channel_id]).unwrap(); let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag]) .sign_with_keys(signer) .unwrap(); @@ -2219,6 +2224,8 @@ async fn add_member_ws( } /// Submit a kind:9000 PUT_USER event with a role tag over WebSocket. +/// +/// See [`add_member_ws`] for why `allow_self_tagging` is required. async fn add_member_with_role_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2230,6 +2237,7 @@ async fn add_member_with_role_ws( let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let role_tag = Tag::parse(["role", role]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag, role_tag]) .sign_with_keys(signer) .unwrap(); @@ -2241,10 +2249,10 @@ async fn add_member_with_role_ws( (ok.accepted, ok.message) } -/// Any member of a private channel can invite another user (Slack model). +/// Only owners/admins can add another identity to a private channel. #[tokio::test] #[ignore] -async fn test_private_channel_any_member_can_invite() { +async fn test_private_channel_member_cannot_invite() { let url = relay_url(); let owner_keys = Keys::generate(); let member_keys = Keys::generate(); @@ -2271,7 +2279,7 @@ async fn test_private_channel_any_member_can_invite() { .await .expect("connect as member"); - // Regular member invites a third user — this should succeed. + // Regular member tries to invite a third user. let (accepted, msg) = add_member_ws( &mut member_client, &channel_id, @@ -2279,15 +2287,77 @@ async fn test_private_channel_any_member_can_invite() { &member_keys, ) .await; + assert!( + !accepted, + "regular member must not add another private-channel identity: {msg}" + ); + assert!( + msg.contains("owners/admins"), + "rejection should name the owner/admin requirement, got: {msg}" + ); + + // The same member re-adding *themselves* stays idempotent — the huddle + // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working. + let (accepted, msg) = add_member_ws( + &mut member_client, + &channel_id, + &member_keys.public_key().to_hex(), + &member_keys, + ) + .await; assert!( accepted, - "regular member should be able to invite to private channel, got: {msg}" + "self-targeted re-add must stay idempotent, got: {msg}" ); owner_client.disconnect().await.expect("disconnect owner"); member_client.disconnect().await.expect("disconnect member"); } +/// An admin — not just the owner — can still add to a private channel. +#[tokio::test] +#[ignore] +async fn test_private_channel_admin_can_invite() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let admin_keys = Keys::generate(); + let invitee_keys = Keys::generate(); + + let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; + + let (accepted, msg) = add_member_with_role_ws( + &mut owner_client, + &channel_id, + &admin_keys.public_key().to_hex(), + "admin", + &owner_keys, + ) + .await; + assert!(accepted, "owner should add an admin, got: {msg}"); + + let mut admin_client = BuzzTestClient::connect(&url, &admin_keys) + .await + .expect("connect as admin"); + + let (accepted, msg) = add_member_ws( + &mut admin_client, + &channel_id, + &invitee_keys.public_key().to_hex(), + &admin_keys, + ) + .await; + assert!( + accepted, + "admin should be able to add to a private channel, got: {msg}" + ); + + owner_client.disconnect().await.expect("disconnect owner"); + admin_client.disconnect().await.expect("disconnect admin"); +} + /// A non-member cannot invite someone to a private channel. #[tokio::test] #[ignore] diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d170..e14222116 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -956,18 +956,10 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge let kind_u32 = event_kind_u32(&event.event); let content = event.event.content.clone(); - let author = event - .event - .tags - .iter() - .find_map(|tag| { - if tag.kind().to_string() == "actor" { - tag.content().map(|value| value.to_string()) - } else { - None - } - }) - .unwrap_or_else(|| event.event.pubkey.to_hex()); + // Workflow conditions make authorization decisions from `trigger_author`, + // so it must come from the event signature. An `actor` tag is ordinary + // signer-controlled metadata and cannot speak for another pubkey. + let author = event.event.pubkey.to_hex(); // For reaction events (NIP-25), the content field holds the emoji character // or shortcode (e.g. "👍", "+", "-"). Expose it as `emoji`. @@ -1608,6 +1600,24 @@ steps: assert!(ctx.author.chars().all(|c| c.is_ascii_hexdigit())); } + #[test] + fn build_trigger_context_ignores_actor_tag() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let signer = Keys::generate(); + let impersonated = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "forged actor") + .tags([Tag::parse(["actor", &impersonated.public_key().to_hex()]).expect("actor tag")]) + .sign_with_keys(&signer) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(uuid::Uuid::new_v4())); + + let ctx = build_trigger_context(&stored); + + assert_eq!(ctx.author, signer.public_key().to_hex()); + assert_ne!(ctx.author, impersonated.public_key().to_hex()); + } + #[test] fn build_trigger_context_message_id_is_hex() { let stored = make_message_event(); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index afd119c84..fbaa547a0 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1104,6 +1104,7 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-user-notifications", "opus", "plist", "png 0.18.1", @@ -6722,6 +6723,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.0", + "block2", "objc2", "objc2-foundation", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index f0ab16e18..98b3757f7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -54,7 +54,8 @@ webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 0eb024a86..9609db5f2 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -155,7 +155,6 @@ pub async fn save_custom_harness( Ok(AcpRuntimeCatalogEntry { id: definition.id, label: definition.label, - // Security: no user-supplied avatar URL in catalog entries. avatar_url: String::new(), availability, command: command_opt, @@ -177,8 +176,8 @@ pub async fn save_custom_harness( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - // Carry definition env back so the edit form can read and preserve it. definition_env: definition.env, + max_parallelism: crate::managed_agents::harness_max_parallelism(&definition.command), }) } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index fa9a1d7a1..89eaf94a3 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1282,7 +1282,7 @@ pub async fn delete_managed_agent( mod deploy; use deploy::build_deploy_payload; #[cfg(test)] -use deploy::deploy_payload_json; +use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index b90bf49b3..d924394b2 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -14,6 +14,18 @@ use crate::{ relay::relay_ws_url_with_override, }; +/// Effective projection fields for the deploy payload — all derived from the +/// resolved descriptor and effective config so that the serialised payload and +/// the `launch` block are always internally consistent. +pub(super) struct DeployProjections { + pub effective_model: Option, + pub effective_provider: Option, + pub effective_prompt: Option, + /// Effective parallelism derived from the same resolved `descriptor.command` + /// as `launch.policy_env["BUZZ_ACP_AGENTS"]`. + pub effective_parallelism: u32, +} + /// Resolve the deploy-specific structured model/provider for a managed agent. #[cfg(test)] pub(crate) fn resolve_deploy_model_provider( @@ -60,7 +72,10 @@ pub(super) fn build_launch_block( } policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into()); policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into()); - policy_env.insert("BUZZ_ACP_AGENTS".into(), record.parallelism.to_string()); + policy_env.insert( + "BUZZ_ACP_AGENTS".into(), + crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism), + ); if let Some(value) = effective_prompt { policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); @@ -141,15 +156,21 @@ pub(super) fn build_deploy_payload( &owner_pubkey, ); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + Ok(deploy_payload_json( record, crate::relay::effective_agent_relay_url( &record.relay_url, &relay_ws_url_with_override(state), ), - effective.model.value, - effective.provider.value, - effective.system_prompt.value, + DeployProjections { + effective_model: effective.model.value, + effective_provider: effective.provider.value, + effective_prompt: effective.system_prompt.value, + effective_parallelism, + }, merged_user_env, launch, )) @@ -157,12 +178,13 @@ pub(super) fn build_deploy_payload( /// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields /// remain for display/bookkeeping; providers execute the resolved `launch` block. +/// `projections.effective_parallelism` is pre-computed from the same resolved +/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]` — the two fields are +/// always consistent regardless of stale `record.agent_command` pins. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, - effective_model: Option, - effective_provider: Option, - effective_prompt: Option, + projections: DeployProjections, merged_env: BTreeMap, launch: serde_json::Value, ) -> serde_json::Value { @@ -173,13 +195,15 @@ pub(super) fn deploy_payload_json( "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, - "system_prompt": effective_prompt, - "model": effective_model, - "provider": effective_provider, + "system_prompt": projections.effective_prompt, + "model": projections.effective_model, + "provider": projections.effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, - "parallelism": record.parallelism, + // Legacy top-level field: projected from the same resolved descriptor as + // launch.policy_env["BUZZ_ACP_AGENTS"] — the two are always consistent. + "parallelism": projections.effective_parallelism, "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, "env_vars": merged_env, @@ -261,4 +285,186 @@ mod tests { assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + + /// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5" + /// even when the record's requested parallelism is 10. This is the direct + /// `launch.policy_env` seam test — the executable contract for remote providers. + #[test] + fn launch_block_openclaw_over_cap_policy_env_is_capped() { + let mut record = record(); + record.agent_command = "openclaw".into(); + record.parallelism = 10; // above the OpenClaw spawn-time cap + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {} for OpenClaw, not 10", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Uncapped harness (goose): `launch.policy_env["BUZZ_ACP_AGENTS"]` passes + /// the requested value through unchanged. + #[test] + fn launch_block_goose_policy_env_is_not_capped() { + let mut record = record(); + record.parallelism = 8; + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], "8", + "goose: policy_env[BUZZ_ACP_AGENTS] must pass through requested value 8" + ); + } + + /// deploy_payload_json: legacy top-level `parallelism` is the effective value + /// derived from the descriptor, not `record.agent_command`. + /// + /// Stale-persona scenario: `record.agent_command` is "goose" (created before + /// the user switched the persona to OpenClaw), but the live descriptor resolves + /// OpenClaw. Both `launch.policy_env["BUZZ_ACP_AGENTS"]` and the legacy + /// top-level `parallelism` must be the effective OpenClaw value (5), not the + /// record's stale Goose identity (requested 10). + #[test] + fn deploy_payload_json_stale_goose_record_live_openclaw_descriptor_both_capped() { + let mut record = record(); + // Stale agent_command from record creation — persona has since switched to OpenClaw. + record.agent_command = "goose".into(); + record.parallelism = 10; + // Resolved descriptor reflects the live persona (OpenClaw). + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + cap.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {cap} for live OpenClaw descriptor" + ); + assert_eq!( + payload["parallelism"], cap, + "legacy top-level parallelism must match launch.policy_env — both must be {cap}" + ); + } + + /// Inverse stale-persona scenario: `record.agent_command` is "openclaw" + /// (created before the user switched the persona to Goose), but the live + /// descriptor resolves Goose. Both projections must be the uncapped requested + /// value (4), not the old OpenClaw cap. + #[test] + fn deploy_payload_json_stale_openclaw_record_live_goose_descriptor_both_uncapped() { + let mut record = record(); + // Stale agent_command from record creation — persona has since switched to Goose. + record.agent_command = "openclaw".into(); + record.parallelism = 4; + // Resolved descriptor reflects the live persona (Goose). + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + "4", + "launch.policy_env[BUZZ_ACP_AGENTS] must pass through requested 4 for live Goose descriptor" + ); + assert_eq!( + payload["parallelism"], 4, + "legacy top-level parallelism must match launch.policy_env — both must be 4 (uncapped)" + ); + } + + /// Explicit agent_command_override direction: record has an explicit override + /// pinning OpenClaw while the persona default is Goose. The override wins + /// via the descriptor — both projections must be capped at the OpenClaw limit. + #[test] + fn deploy_payload_json_explicit_openclaw_override_both_capped() { + let mut record = record(); + // Explicit override: user pinned OpenClaw on this agent. + record.agent_command_override = Some("openclaw".into()); + record.agent_command = "goose".into(); // persona default, overridden + record.parallelism = 10; + // Descriptor reflects the resolved override (OpenClaw wins). + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + cap.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be {cap} for explicit OpenClaw override" + ); + assert_eq!( + payload["parallelism"], cap, + "legacy top-level parallelism must match launch.policy_env — both must be {cap}" + ); + } } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c..20061debe 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -465,9 +465,15 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { let agent = deploy_payload_json( &record, "wss://relay.example".into(), - Some("gpt-5".into()), - Some("openai".into()), - None, + DeployProjections { + effective_model: Some("gpt-5".into()), + effective_provider: Some("openai".into()), + effective_prompt: None, + effective_parallelism: crate::managed_agents::effective_parallelism( + &descriptor.command, + record.parallelism, + ), + }, std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), launch, ); diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index c13d96ff6..79aa15f96 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -1,4 +1,4 @@ -//! Native (Linux) desktop-notification helper. +//! Native desktop-notification helpers. //! //! `tauri-plugin-notification` posts a notification by calling `notify_rust`'s //! `show()` and then immediately dropping the returned `NotificationHandle`. @@ -13,13 +13,15 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. +pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; + /// Show a desktop notification natively. /// -/// On Linux this uses the connection-preserving path described above. On other -/// platforms the bundled notification plugin already works correctly, so the -/// frontend never calls this and we simply report that it is unused. +/// Linux uses the connection-preserving D-Bus path described above. macOS uses +/// one application-lifetime `UNUserNotificationCenterDelegate`; it does not +/// allocate a listener or waiter for each notification. #[tauri::command] -pub fn show_native_notification( +pub async fn show_native_notification( app: tauri::AppHandle, title: String, body: Option, @@ -31,21 +33,24 @@ pub fn show_native_notification( Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + let _ = app; + crate::macos_notifications::show(title, body, target).await + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = (&app, &title, &body, &target); - Err("show_native_notification is only supported on Linux".to_string()) + Err("show_native_notification is only supported on Linux and macOS".to_string()) } } #[cfg(target_os = "linux")] mod linux { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; use tauri::Emitter; - /// Emitted to the frontend when the user clicks a native notification. The - /// payload is the opaque target object the frontend passed in. - const ACTIVATE_EVENT: &str = "native-notification-activated"; - pub fn show( app: tauri::AppHandle, title: String, @@ -96,7 +101,7 @@ mod linux { // The frontend focuses the window on activation (the same path // every other platform uses), so we only forward the target. - let _ = app.emit(ACTIVATE_EVENT, target); + let _ = app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target); }); }); } diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs index a9230dff9..9ccf8baac 100644 --- a/desktop/src-tauri/src/commands/relay_members.rs +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -17,8 +17,15 @@ struct RelayInformationDocument { } #[tauri::command] -pub async fn relay_requires_membership(state: State<'_, AppState>) -> Result { - let url = format!("{}/info", relay_api_base_url_with_override(&state)); +pub async fn relay_requires_membership( + relay_url: Option, + state: State<'_, AppState>, +) -> Result { + let base_url = relay_url + .as_deref() + .map(crate::relay::relay_http_base_url) + .unwrap_or_else(|| relay_api_base_url_with_override(&state)); + let url = format!("{}/info", base_url.trim_end_matches('/')); let response = state .http_client .get(url) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a63514dca..aee304572 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod identity_storage; mod initial_window; mod key_backup; mod linux_media; +#[cfg(target_os = "macos")] +mod macos_notifications; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -308,7 +310,10 @@ pub fn run() { .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] - tray_menu::init(&app_handle)?; + { + tray_menu::init(&app_handle)?; + macos_notifications::init(&app_handle)?; + } // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe @@ -712,6 +717,12 @@ pub fn run() { remove_reaction, get_event, show_native_notification, + #[cfg(target_os = "macos")] + macos_notifications::take_pending_activations, + #[cfg(target_os = "macos")] + macos_notifications::notification_permission_state, + #[cfg(target_os = "macos")] + macos_notifications::request_notification_access, upload_media, pick_and_upload_media, pick_and_upload_image, diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs new file mode 100644 index 000000000..da2312b45 --- /dev/null +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -0,0 +1,376 @@ +//! Modern macOS notification delivery and activation routing. +//! +//! Apple delivers every notification response through one process-wide +//! `UNUserNotificationCenterDelegate`. The delegate is installed once during +//! app setup and retained for the process lifetime. Notification targets live +//! in `userInfo`, so there are no per-notification listeners, waiter threads, +//! or request maps to leak when Notification Center clears a notification. + +use std::{ + collections::VecDeque, + ptr::NonNull, + sync::{mpsc, Mutex, OnceLock}, + time::Duration, +}; + +use block2::{Block, RcBlock}; +use objc2::{ + define_class, msg_send, + rc::Retained, + runtime::{AnyObject, Bool, ProtocolObject}, + AnyThread, DefinedClass, +}; +use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, + UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::{AppHandle, Emitter}; + +use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + +const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; +const MAX_PENDING_ACTIVATIONS: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NotificationPermissionState { + Default, + Denied, + Granted, +} + +fn permission_state(status: UNAuthorizationStatus) -> NotificationPermissionState { + match status { + UNAuthorizationStatus::Denied => NotificationPermissionState::Denied, + UNAuthorizationStatus::Authorized + | UNAuthorizationStatus::Provisional + | UNAuthorizationStatus::Ephemeral => NotificationPermissionState::Granted, + _ => NotificationPermissionState::Default, + } +} + +static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); + +struct NotificationDelegateIvars { + app: AppHandle, +} + +define_class!( + // SAFETY: NSObject permits AnyThread subclasses, and AppHandle is Send + + // Sync. Apple does not guarantee a queue for notification delegate calls; + // both Tauri operations used by the callbacks are thread-safe. + #[unsafe(super(NSObject))] + #[name = "BuzzNotificationCenterDelegate"] + #[thread_kind = AnyThread] + #[ivars = NotificationDelegateIvars] + struct NotificationDelegate; + + unsafe impl NSObjectProtocol for NotificationDelegate {} + + unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate { + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present_notification( + &self, + _center: &UNUserNotificationCenter, + _notification: &objc2_user_notifications::UNNotification, + completion_handler: &Block, + ) { + // Preserve the prior macOS behavior: keep foreground notifications + // in Notification Center without interrupting the user with a banner. + completion_handler.call((UNNotificationPresentationOptions::List,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive_notification_response( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion_handler: &Block, + ) { + if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } { + if let Some(target) = target_from_response(response) { + queue_activation(target); + crate::tray_menu::show_main_window(&self.ivars().app); + if let Err(error) = self + .ivars() + .app + .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, ()) + { + eprintln!( + "buzz-desktop: failed to emit macOS notification activation: {error}" + ); + } + } + } + + // Apple requires this for every response, including dismissals and + // malformed notifications that Buzz intentionally ignores. + completion_handler.call(()); + } + } +); + +impl NotificationDelegate { + fn new(app: AppHandle) -> Retained { + let delegate = Self::alloc().set_ivars(NotificationDelegateIvars { app }); + unsafe { msg_send![super(delegate), init] } + } +} + +/// Install the one application-lifetime notification response delegate. +pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { + if !is_bundled_application() { + // UNUserNotificationCenter raises an Objective-C exception when the + // current process has no application bundle (notably `tauri dev`). + // objc2 cannot turn that exception into a Rust error, so do not call + // into the framework at all in this environment. + eprintln!( + "buzz-desktop: macOS notifications disabled because the process has no bundle identifier" + ); + return Ok(()); + } + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let delegate = NotificationDelegate::new(app.clone()); + let delegate: Retained> = + ProtocolObject::from_retained(delegate); + center.setDelegate(Some(&delegate)); + + // UNUserNotificationCenter.delegate is weak. This object is deliberately + // process-lifetime state, matching the application-lifetime delegate Apple + // documents and avoiding mutable global or per-notification registrations. + std::mem::forget(delegate); + Ok(()) +} + +fn ensure_bundled_application() -> Result<(), String> { + if is_bundled_application() { + Ok(()) + } else { + Err( + "macOS notifications are unavailable when Buzz is not running from an app bundle" + .to_string(), + ) + } +} + +fn notification_permission_state_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |settings: NonNull| { + // SAFETY: Apple guarantees a live UNNotificationSettings object for + // the duration of this completion handler. + let status = unsafe { settings.as_ref() }.authorizationStatus(); + let _ = sender.send(permission_state(status)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification settings request timed out".to_string()) +} + +#[tauri::command] +pub(crate) async fn notification_permission_state() -> Result { + tokio::task::spawn_blocking(notification_permission_state_sync) + .await + .map_err(|error| format!("macOS notification settings task failed: {error}"))? +} + +fn request_notification_access_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |_granted: Bool, error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("macOS notification authorization failed: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &handler, + ); + + receiver + .recv_timeout(Duration::from_secs(60)) + .map_err(|_| "macOS notification authorization request timed out".to_string())??; + notification_permission_state_sync() +} + +#[tauri::command] +pub(crate) async fn request_notification_access() -> Result { + tokio::task::spawn_blocking(request_notification_access_sync) + .await + .map_err(|error| format!("macOS notification authorization task failed: {error}"))? +} + +fn show_sync( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + ensure_bundled_application()?; + if notification_permission_state_sync()? != NotificationPermissionState::Granted { + return Err("macOS notification permission is not granted".to_string()); + } + + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(&title)); + if let Some(body) = body { + content.setBody(&NSString::from_str(&body)); + } + + if let Some(target) = target { + let serialized = serde_json::to_string(&target) + .map_err(|error| format!("failed to serialize notification target: {error}"))?; + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let value = NSString::from_str(&serialized); + let user_info = NSDictionary::::from_slices(&[&*key], &[&*value]); + // SAFETY: Both the key and value are property-list-safe NSString values. + unsafe { + let user_info = + Retained::cast_unchecked::>(user_info); + content.setUserInfo(&user_info); + } + } + + let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); + let request = + UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); + let (sender, receiver) = mpsc::sync_channel(1); + let delivery_handler = RcBlock::new(move |error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("failed to deliver macOS notification: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler)); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification delivery request timed out".to_string())? +} + +pub(crate) async fn show( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || show_sync(title, body, target)) + .await + .map_err(|error| format!("macOS notification delivery task failed: {error}"))? +} + +fn queue_activation(target: serde_json::Value) { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let Ok(mut queue) = queue.lock() else { + eprintln!("buzz-desktop: macOS notification activation queue is unavailable"); + return; + }; + if queue.len() == MAX_PENDING_ACTIVATIONS { + queue.pop_front(); + } + queue.push_back(target); +} + +#[tauri::command] +pub(crate) fn take_pending_activations() -> Result, String> { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let mut queue = queue + .lock() + .map_err(|_| "macOS notification activation queue is unavailable".to_string())?; + Ok(queue.drain(..).collect()) +} + +fn is_bundled_application() -> bool { + NSBundle::mainBundle().bundleIdentifier().is_some() +} + +fn target_from_response(response: &UNNotificationResponse) -> Option { + let user_info = response.notification().request().content().userInfo(); + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let target = user_info.objectForKey(key.as_ref())?; + let target = target.downcast::().ok()?; + parse_target(&target.to_string()) +} + +fn parse_target(serialized: &str) -> Option { + serde_json::from_str(serialized).ok() +} + +#[cfg(test)] +mod tests { + use super::{ + is_bundled_application, parse_target, permission_state, queue_activation, + take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + }; + use objc2_user_notifications::UNAuthorizationStatus; + + #[test] + fn activation_queue_is_bounded_and_drained() { + let _ = take_pending_activations(); + for index in 0..=MAX_PENDING_ACTIVATIONS { + queue_activation(serde_json::json!({ "index": index })); + } + + let activations = take_pending_activations().expect("activation queue"); + assert_eq!(activations.len(), MAX_PENDING_ACTIVATIONS); + assert_eq!(activations[0]["index"], 1); + assert!(take_pending_activations() + .expect("drained activation queue") + .is_empty()); + } + + #[test] + fn cargo_test_process_is_not_treated_as_bundled() { + assert!(!is_bundled_application()); + } + + #[test] + fn maps_native_authorization_states_to_frontend_contract() { + assert_eq!( + permission_state(UNAuthorizationStatus::NotDetermined), + NotificationPermissionState::Default + ); + assert_eq!( + permission_state(UNAuthorizationStatus::Denied), + NotificationPermissionState::Denied + ); + for status in [ + UNAuthorizationStatus::Authorized, + UNAuthorizationStatus::Provisional, + UNAuthorizationStatus::Ephemeral, + ] { + assert_eq!( + permission_state(status), + NotificationPermissionState::Granted + ); + } + } + + #[test] + fn parses_opaque_notification_target() { + let target = + parse_target(r#"{"channelId":"channel","eventId":"event","threadRootId":"root"}"#) + .expect("valid target"); + + assert_eq!(target["channelId"], "channel"); + assert_eq!(target["eventId"], "event"); + assert_eq!(target["threadRootId"], "root"); + } + + #[test] + fn rejects_malformed_notification_target() { + assert!(parse_target("not-json").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index fafcb2589..bc0e3a6cd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1113,7 +1113,7 @@ pub fn missing_command_message(command: &str, role: &str) -> String { } format!( - "{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md." + "{role} `{command}` was not found. Make sure it is installed and on your PATH. Antivirus software can quarantine bundled binaries — if that happened, restore the file or reinstall Buzz. (Source builds: see TESTING.md.)" ) } @@ -1403,8 +1403,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, - // Builtin entries have no user-editable env; definition_env is empty. definition_env: Default::default(), + max_parallelism: super::parallelism::harness_max_parallelism(runtime.id), }, } } @@ -1565,9 +1565,8 @@ pub fn discover_acp_runtimes_from( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - // Carry definition env into the catalog so the edit form can - // read it back — prevents silently erasing env on save. - definition_env: def.env.clone(), + definition_env: def.env.clone(), // preserve for edit round-trip + max_parallelism: super::parallelism::harness_max_parallelism(&def.command), }); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index b2d8a14ef..d86e5f33f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -82,6 +82,10 @@ pub(super) fn preset_catalog_entry( login_hint: None, source: HarnessSource::Preset, definition_env: Default::default(), + // Derived from the static preset command (`def.command`). This ensures + // unavailable entries (command: null in JSON, None here) still carry + // the cap — the harness cap is command-keyed, not availability-gated. + max_parallelism: crate::managed_agents::harness_max_parallelism(def.command), } } @@ -405,4 +409,65 @@ mod tests { assert!(!entry.requires_external_cli); assert!(entry.underlying_cli_path.is_none()); } + + // ── Catalog max_parallelism: command-keyed execution policy ────────────── + + /// Unavailable OpenClaw (command not on PATH → command: null in JSON): + /// max_parallelism must still be Some(5) — derived from the static `def.command`, + /// not the probed `entry.command`. + #[test] + fn openclaw_preset_unavailable_carries_max_parallelism() { + let openclaw = PRESET_HARNESSES + .iter() + .find(|p| p.id == "openclaw") + .expect("openclaw preset must be present"); + + // Simulate "not installed" — resolver always returns None. + let entry = preset_catalog_entry(openclaw, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!( + entry.command.is_none(), + "unavailable entry must have command: null" + ); + assert_eq!( + entry.max_parallelism, + Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM), + "unavailable OpenClaw must still carry max_parallelism {}", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Available OpenClaw: max_parallelism present regardless of install status. + #[test] + fn openclaw_preset_available_carries_max_parallelism() { + let openclaw = PRESET_HARNESSES + .iter() + .find(|p| p.id == "openclaw") + .expect("openclaw preset must be present"); + + let entry = preset_catalog_entry(openclaw, |cmd| { + (cmd == openclaw.id || cmd == "openclaw") + .then(|| std::path::PathBuf::from("/usr/local/bin/openclaw")) + }); + assert_eq!( + entry.max_parallelism, + Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM), + "available OpenClaw must carry max_parallelism {}", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Uncapped preset (devin): max_parallelism must be None. + #[test] + fn uncapped_preset_has_no_max_parallelism() { + let devin = PRESET_HARNESSES + .iter() + .find(|p| p.id == "devin") + .expect("devin preset must be present"); + let entry = preset_catalog_entry(devin, |_| None); + assert_eq!( + entry.max_parallelism, None, + "uncapped preset (devin) must have max_parallelism: None" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 9956f19b2..f912d5bbc 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -5,11 +5,19 @@ //! Precedence: desktop parent env < persona env < agent env (last wins on //! key collision). See `runtime::spawn_agent_child`. //! -//! A small set of *reserved* keys — Buzz's identity and secrets — are -//! rejected at save time and stripped at runtime so a typo or malicious -//! value can't swap the agent's nsec. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain -//! freely overridable — those have dedicated UI fields, but power users -//! may want to bypass them. +//! A small set of *reserved* keys — Buzz's identity and secrets, and +//! control-plane values set by the Desktop — are rejected at save time and +//! stripped at runtime so a typo or malicious value can't swap the agent's +//! nsec or bypass a harness-specific execution cap. Behavior knobs +//! (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +//! overridable — those have dedicated UI fields, but power users may want +//! to bypass them. +//! +//! `BUZZ_ACP_AGENTS` is reserved because the Desktop resolves the effective +//! parallelism (applying per-harness caps such as OpenClaw's cap of 5) and +//! writes the result into `launch.policy_env`. A user-supplied +//! `BUZZ_ACP_AGENTS` would bypass the cap and cause OpenClaw agents to spawn +//! uncapped workers against their single shared Gateway daemon. use std::collections::BTreeMap; @@ -40,7 +48,7 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { } /// Env var keys that Buzz sets itself and users must not override from -/// the persona/agent env_vars UI. Three categories: +/// the persona/agent env_vars UI. Four categories: /// /// 1. **Identity / secrets** — overriding would swap the agent's nsec or /// leak credentials. @@ -50,11 +58,15 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { /// relay URL would silently break the saved security settings (the UI /// shows owner-only while the running agent answers anyone, for /// example), or redirect the agent to an attacker-controlled relay. +/// 4. **Control-plane execution policy** — the Desktop owns the effective +/// value, derived from structured record fields after applying per-harness +/// caps. A user-supplied override would bypass the cap and produce a +/// worker pool size that neither the record nor the UI represents. /// -/// This list is deliberately narrow — it only covers keys with security -/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely -/// overridable; those have dedicated UI fields but power users may want -/// to bypass them. +/// This list is deliberately narrow — it only covers keys with security or +/// correctness implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, +/// BUZZ_ACP_SYSTEM_PROMPT, …) remain freely overridable; those have +/// dedicated UI fields but power users may want to bypass them. pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // Identity / secrets. "BUZZ_PRIVATE_KEY", @@ -71,6 +83,11 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + // Control-plane parallelism: the Desktop resolves the effective + // worker-pool size (applying any per-harness cap) and writes it into + // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the + // harness cap and cause OpenClaw agents to spawn uncapped workers. + "BUZZ_ACP_AGENTS", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge // from the saved/UI-visible settings. diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c2e713399..65a3d8fab 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; mod nest; +pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; mod personas; @@ -61,6 +62,7 @@ pub(crate) use global_config::{ }; pub(crate) use managed_node_paths::*; pub use nest::*; +pub use parallelism::{acp_agents_value, effective_parallelism, harness_max_parallelism}; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs new file mode 100644 index 000000000..e1691575b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -0,0 +1,305 @@ +// ── Per-harness parallelism cap ─────────────────────────────────────────────── +// +// Contract: stored = requested; effective = min(requested, harness cap). +// +// `ManagedAgentRecord.parallelism` stores the user's requested value verbatim, +// never clamped at persistence. The cap is applied only where the value +// becomes a running worker-pool size: +// +// * local spawn — `BUZZ_ACP_AGENTS` in the child environment +// * remote deploy — `launch.policy_env["BUZZ_ACP_AGENTS"]` + legacy field +// * restart hash — `SpawnConfigSnapshot` stores the effective value +// * display copy — the UI derives effective for explanatory hints only +// +// `AgentDefinition.parallelism` is the portable requested value, unchanged +// at every boundary so it travels across devices and harness switches intact. + +/// Maximum parallelism for the OpenClaw harness. +/// +/// Each buzz-acp worker spawned by the Desktop is a client of the single +/// shared OpenClaw Gateway daemon — running more than this number of workers +/// is both resource-expensive and architecturally wrong per the OpenClaw +/// design. Tyler's ruling: "try 5 and lower if needed." +pub const OPENCLAW_MAX_PARALLELISM: u32 = 5; + +/// Return the maximum allowed parallelism for the given harness command, or +/// `None` when the harness has no cap. +/// +/// Keyed on [`super::discovery::normalize_command_identity`] so path prefixes, +/// the `.exe` suffix on Windows, and other cosmetic differences are ignored. +pub fn harness_max_parallelism(command: &str) -> Option { + match super::discovery::normalize_command_identity(command).as_str() { + "openclaw" => Some(OPENCLAW_MAX_PARALLELISM), + _ => None, + } +} + +/// Return the effective parallelism for the given harness command and +/// requested value: `min(value, harness_max_parallelism(command))`. +/// +/// For harnesses without a cap this is the identity function. +pub fn effective_parallelism(command: &str, value: u32) -> u32 { + match harness_max_parallelism(command) { + Some(cap) => value.min(cap), + None => value, + } +} + +/// Return the value to emit as `BUZZ_ACP_AGENTS` for a spawn command. +/// +/// Pure helper extracted from `spawn_agent_child` so both the production path +/// and tests can call it without spawning a process. The result is +/// `effective_parallelism(effective_command, record_parallelism)` formatted as +/// a decimal string ready for `command.env("BUZZ_ACP_AGENTS", …)`. +/// +/// `effective_command` must be the already-resolved harness command (override → +/// runtime → persona runtime → default). +pub fn acp_agents_value(effective_command: &str, record_parallelism: u32) -> String { + effective_parallelism(effective_command, record_parallelism).to_string() +} + +#[cfg(test)] +mod tests { + use crate::managed_agents::types::ManagedAgentRecord; + + fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: "r".to_string(), + persona_id: None, + 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, + 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: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + 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: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: runtime.map(str::to_string), + 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, + } + } + + fn persona_def( + id: &str, + runtime: Option<&str>, + ) -> crate::managed_agents::types::AgentDefinition { + use crate::managed_agents::types::AgentDefinition; + AgentDefinition { + id: id.to_string(), + display_name: String::new(), + avatar_url: None, + system_prompt: String::new(), + runtime: runtime.map(str::to_string), + 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![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + // ── Policy table: harness_max_parallelism / effective_parallelism ───────── + + #[test] + fn policy_table() { + let cap = super::OPENCLAW_MAX_PARALLELISM; + + // harness_max_parallelism: openclaw variants → Some(cap); others → None. + assert_eq!(super::harness_max_parallelism("openclaw"), Some(cap)); + assert_eq!( + super::harness_max_parallelism("/usr/local/bin/openclaw"), + Some(cap) + ); + assert_eq!(super::harness_max_parallelism("openclaw.exe"), Some(cap)); + assert_eq!( + super::harness_max_parallelism(r"C:\Tools\openclaw.exe"), + Some(cap) + ); + assert_eq!(super::harness_max_parallelism("goose"), None); + assert_eq!(super::harness_max_parallelism("buzz-agent"), None); + assert_eq!(super::harness_max_parallelism(""), None); + + // effective_parallelism: openclaw clamps above cap, honors at/below; goose passes through. + assert_eq!(super::effective_parallelism("openclaw", cap + 5), cap); + assert_eq!(super::effective_parallelism("openclaw", cap), cap); + assert_eq!(super::effective_parallelism("openclaw", cap - 2), cap - 2); + assert_eq!(super::effective_parallelism("goose", 99), 99); + assert_eq!(super::effective_parallelism("buzz-agent", 32), 32); + } + + // ── acp_agents_value: spawn-env seam ────────────────────────────────────── + // + // Drives the pure helper extracted from spawn_agent_child. + // Deleting or changing it breaks this test AND the production spawn env. + + /// Legacy OpenClaw record (parallelism 10, above cap): BUZZ_ACP_AGENTS must be "5". + #[test] + fn acp_agents_value_openclaw_above_cap_is_capped() { + assert_eq!( + super::acp_agents_value("openclaw", 10), + "5", + "BUZZ_ACP_AGENTS for openclaw with parallelism 10 must be \"5\"" + ); + assert_eq!(super::acp_agents_value("goose", 10), "10"); + } + + // ── Override-direction: summary seam agreement ──────────────────────────── + // + // Tests effective_parallelism and record_agent_command agreement for both + // override directions. Removing either direction loses the seam test for + // that cap/uncap path through the summary resolver. + + /// OpenClaw runtime + Goose override: summary resolves goose → uncapped (10). + #[test] + fn override_direction_openclaw_runtime_goose_override_is_uncapped() { + let mut record = record_with(Some("openclaw"), 10); + record.agent_command_override = Some("goose".to_string()); + let cmd = crate::managed_agents::record_agent_command(&record, &[]); + assert_eq!(cmd, "goose"); + assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10); + } + + /// Goose runtime + OpenClaw override: summary resolves openclaw → capped (5). + #[test] + fn override_direction_goose_runtime_openclaw_override_is_capped() { + let mut record = record_with(Some("goose"), 10); + record.agent_command_override = Some("openclaw".to_string()); + let cmd = crate::managed_agents::record_agent_command(&record, &[]); + assert_eq!(cmd, "openclaw"); + assert_eq!( + super::effective_parallelism(&cmd, record.parallelism), + super::OPENCLAW_MAX_PARALLELISM + ); + } + + // ── Summary: persona-inherited runtime (runtime=None) ───────────────────── + // + // Covers the case where runtime was cleared by an "inherit from persona" + // update: summary must resolve via the LIVE persona, not stale agent_command. + + /// Stale agent_command="openclaw", live persona=goose → summary resolves goose → uncapped. + #[test] + fn summary_persona_inherited_stale_openclaw_live_goose_is_uncapped() { + let persona = persona_def("p-goose", Some("goose")); + let mut record = record_with(None, 10); + record.persona_id = Some("p-goose".to_string()); + record.agent_command = "openclaw".to_string(); + let cmd = + crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona)); + assert_eq!( + cmd, "goose", + "live persona must win over stale agent_command" + ); + assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10); + } + + /// Stale agent_command="goose", live persona=openclaw → summary resolves openclaw → capped. + #[test] + fn summary_persona_inherited_stale_goose_live_openclaw_is_capped() { + let persona = persona_def("p-openclaw", Some("openclaw")); + let mut record = record_with(None, 10); + record.persona_id = Some("p-openclaw".to_string()); + record.agent_command = "goose".to_string(); + let cmd = + crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona)); + assert_eq!( + cmd, "openclaw", + "live persona must win over stale agent_command" + ); + assert_eq!( + super::effective_parallelism(&cmd, record.parallelism), + super::OPENCLAW_MAX_PARALLELISM + ); + } + + // ── Snapshot export: requested-definition / effective-instance contract ─── + + fn snapshot_record( + runtime: Option<&str>, + parallelism: u32, + definition_parallelism: Option, + ) -> ManagedAgentRecord { + use crate::managed_agents::types::{BackendKind, RespondTo}; + use std::collections::BTreeMap; + let mut r = record_with(runtime, parallelism); + r.name = "snap-test".to_string(); + r.definition_parallelism = definition_parallelism; + r.backend = BackendKind::Local; + r.respond_to = RespondTo::OwnerOnly; + r.env_vars = BTreeMap::new(); + r + } + + /// Snapshot export carries the requested definition parallelism verbatim. + #[test] + fn snapshot_export_carries_requested_definition_parallelism() { + use crate::managed_agents::agent_snapshot::{build_snapshot, MemoryLevel}; + // definition_parallelism=Some(10) stored → exported as 10 unchanged. + let snap = build_snapshot( + &snapshot_record(Some("openclaw"), 10, Some(10)), + MemoryLevel::None, + vec![], + None, + ); + assert_eq!(snap.definition.parallelism, Some(10)); + // No definition_parallelism stored → falls back to record.parallelism. + let snap2 = build_snapshot( + &snapshot_record(Some("openclaw"), 10, None), + MemoryLevel::None, + vec![], + None, + ); + assert_eq!(snap2.definition.parallelism, Some(10)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs index c34ab1739..2bd7ba3d1 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs @@ -99,3 +99,53 @@ fn apply_persona_snapshot_same_harness_path_pin_is_kept() { "same-harness path override must NOT be dropped" ); } + +// ── Stale-pin drop: builtin pin → loaded custom harness (tier-1→tier-3) ────── + +/// Persona→CustomHarness: stale Goose override dropped. +/// +/// This is the custom-direction regression: before `canonical_harness_command` +/// the destination lookup (`known_acp_runtime_exact`) only saw the four +/// tier-1 builtins, so a switch to a loaded custom harness left any stale +/// builtin pin authoritative. +/// +/// Tier-3 (loaded custom harness) is reached via `lookup_loaded_harness_by_id`, +/// which reads the in-process registry — so we must populate it via +/// `update_loaded_harness_registry` under `registry_test_lock()`. +#[test] +fn apply_persona_snapshot_goose_to_custom_harness_drops_stale_goose_pin() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, update_loaded_harness_registry, HarnessDefinition, + }; + use std::collections::BTreeMap; + + let _lock = registry_test_lock(); + + // Register a custom harness definition so the resolver finds it at tier 3. + update_loaded_harness_registry(vec![HarnessDefinition { + id: "my-custom-harness".to_string(), + label: "My Custom Harness".to_string(), + command: "my-custom-bin".to_string(), + args: vec![], + env: BTreeMap::new(), + install_instructions_url: String::new(), + install_hint: String::new(), + }]); + + let mut record = sample_record(); + record.agent_command_override = Some("goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("my-custom-harness".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale goose pin must be dropped when persona switches to a loaded custom harness" + ); + + // Clean up the registry so parallel tests start from a known state. + update_loaded_harness_registry(vec![]); +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 8c5b01fd5..8bb9e4e94 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -672,14 +672,17 @@ pub(crate) fn spawn_agent_child_at( ); } } - + // Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness + // DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated + // BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s). if let Some(idle) = record.idle_timeout_seconds { command.env("BUZZ_ACP_IDLE_TIMEOUT", idle.to_string()); } if let Some(max_dur) = record.max_turn_duration_seconds { command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string()); } - command.env("BUZZ_ACP_AGENTS", record.parallelism.to_string()); + let acp_n = super::acp_agents_value(effective_command, record.parallelism); + command.env("BUZZ_ACP_AGENTS", acp_n); command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer"); command.env("BUZZ_ACP_DEDUP", "queue"); if let Some(meta) = runtime_meta { diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 73a006e70..ba2129c98 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -167,7 +167,13 @@ impl SpawnConfigSnapshot { ), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, - parallelism: record.parallelism, + // Hash the effective parallelism so over-cap edits that don't change + // the running pool size (e.g. 10 → 8, both clamp to 5 on OpenClaw) + // do not raise a spurious "restart required" badge. Cap crossings + // (e.g. 8 → 3, where 3 is below the cap) do change the effective + // pool and must badge. The diff surface consequently displays the + // effective value — that is correct, it is what actually runs. + parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), } } 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 d76605ecf..1ceeee372 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -778,3 +778,52 @@ fn spawn_snapshot_instance_args_win_over_definition_args() { "instance args and definition args must produce different snapshots" ); } + +// ── Parallelism cap: above-cap equivalence + cap crossing ───────────────────── +// +// The snapshot stores the *effective* parallelism (min(requested, harness cap)) +// so that over-cap edits that don't change the running pool size do not raise a +// spurious "restart required" badge, while cap crossings (e.g. 8 → 3, where 3 +// is below the cap) still badge because the pool actually changes. + +/// Two over-cap parallelism values (10 and 8) produce the same snapshot for +/// OpenClaw: both clamp to OPENCLAW_MAX_PARALLELISM (5). +#[test] +fn openclaw_above_cap_parallelism_snapshots_equal() { + let mut at_10 = record(); + at_10.runtime = Some("openclaw".into()); + at_10.agent_command = "openclaw".into(); + at_10.parallelism = 10; + + let mut at_8 = record(); + at_8.runtime = Some("openclaw".into()); + at_8.agent_command = "openclaw".into(); + at_8.parallelism = 8; + + assert_eq!( + snapshot(&at_10, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()), + "parallelism 10 and 8 both clamp to 5 for OpenClaw — snapshots must be equal, no restart badge" + ); +} + +/// A cap-crossing edit (8 → 3) produces different snapshots: 8 clamps to 5, +/// but 3 is below the cap and runs as 3 — the pool changes, so the badge fires. +#[test] +fn openclaw_cap_crossing_parallelism_snapshots_differ() { + let mut at_8 = record(); + at_8.runtime = Some("openclaw".into()); + at_8.agent_command = "openclaw".into(); + at_8.parallelism = 8; + + let mut at_3 = record(); + at_3.runtime = Some("openclaw".into()); + at_3.agent_command = "openclaw".into(); + at_3.parallelism = 3; + + assert_ne!( + snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&at_3, &[], &[], "wss://ws.example", &Default::default()), + "parallelism 8 (clamps to 5) and 3 (runs as 3) must produce different snapshots" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index c5bb6173d..e5be105fe 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -663,16 +663,14 @@ pub struct AcpRuntimeCatalogEntry { /// Whether this entry came from the compiled-in catalog or a user-supplied /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. pub source: HarnessSource, - /// Definition-level environment variables for `source: custom` entries. - /// - /// Populated from `HarnessDefinition.env` so the edit form can read them - /// back and the user doesn't silently lose env vars when saving. Always - /// empty for `builtin` and `preset` entries (those env values come from the - /// runtime metadata path, not user-editable JSON). - /// - /// Skipped in serialization when empty to keep the catalog payload compact. + /// Definition-level env vars for `source: custom` entries; populated from + /// `HarnessDefinition.env` so saves don't silently erase existing vars. + /// Absent for builtin/preset entries. Skipped when empty in serialization. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub definition_env: BTreeMap, + /// Spawn-time parallelism cap; absent for uncapped harnesses. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_parallelism: Option, } /// Result of a single install step (CLI or adapter). diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 104edcbaa..811da043f 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -18,6 +18,7 @@ import { } from "@/app/communityViewTransition"; import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; +import { CommunityThemeController } from "@/shared/theme/CommunityThemeController"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; @@ -41,6 +42,7 @@ import { PendingInviteGate } from "@/features/onboarding/ui/PendingInviteGate"; import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen"; import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen"; import { ResetFailedScreen } from "@/features/onboarding/ui/ResetFailedScreen"; +import { loadCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage"; import { useCommunityInit } from "@/features/communities/useCommunityInit"; import { useNestNotifications } from "@/features/communities/useNestNotifications"; import { useCommunities } from "@/features/communities/useCommunities"; @@ -319,6 +321,8 @@ function CommunityApp({ const [isCommunityChangeOpen, setIsCommunityChangeOpen] = useState(false); const [resumeFirstCommunityPage, setResumeFirstCommunityPage] = useState(null); + const isFindingCommunityAfterLeave = + activeCommunity === null && loadCommunityDiscoveryAfterLeave(); // Surface nest-related backend events (repos-dir errors, legacy migration) // as toasts. Mounted before useCommunityInit so the listeners are registered @@ -343,6 +347,7 @@ function CommunityApp({ activeCommunity, communityKey, sharedIdentity, + isFindingCommunityAfterLeave, ); const transitionCommunity = useCallback( @@ -512,7 +517,9 @@ function CommunityApp({ appContent = ( ); } else if ("error" in community && community.error) { @@ -547,6 +554,7 @@ function CommunityApp({ if (appContent === null && (!transaction || isEnteringCurtain)) { appContent = communityApplied ? ( + void handleRemoveCommunity(id)} onReorderCommunities={communitiesHook.reorderCommunities} onSwitchCommunity={handleSwitchCommunity} onUpdateCommunity={communitiesHook.updateCommunity} @@ -872,9 +871,7 @@ export function AppShell() { onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } + onRemoveCommunity={handleRemoveCommunity} onSwitchCommunity={handleSwitchCommunity} onCreateAgent={() => requestOpenCreateAgent()} selfPresenceStatus={presenceSession.currentStatus} diff --git a/desktop/src/app/useCommunityNavigationTransitions.ts b/desktop/src/app/useCommunityNavigationTransitions.ts index 88acb30ad..d2cf39fbc 100644 --- a/desktop/src/app/useCommunityNavigationTransitions.ts +++ b/desktop/src/app/useCommunityNavigationTransitions.ts @@ -12,7 +12,9 @@ import { markPendingCommunityRestore, saveCommunityDestination, } from "@/features/communities/communityNavigationStorage"; +import { markCommunityDiscoveryAfterLeave } from "@/features/communities/communityStorage"; import type { useCommunities } from "@/features/communities/useCommunities"; +import { leaveCommunity } from "@/features/communities/leaveCommunity"; type Communities = ReturnType; type ShellRoute = ReturnType; @@ -71,14 +73,38 @@ export function useCommunityNavigationTransitions({ const removeCommunity = React.useCallback( async (id: string) => { - if (id !== communities.activeCommunity?.id) { - communities.removeCommunity(id); - return; - } + const target = communities.communities.find( + (community) => community.id === id, + ); + if (!target) return; + const fallback = communities.communities.find( (community) => community.id !== id, ); - if (!fallback) return; + + // Do not touch local state until this relay has explicitly accepted the + // signed NIP-43 leave request. Rejections and timeouts bubble back to the + // dialog so the person can retry without losing their community config. + const leaveResult = await leaveCommunity( + target.relayUrl, + communities.activeCommunity?.relayUrl, + ); + + if (id !== communities.activeCommunity?.id) { + communities.removeCommunity(id); + return leaveResult; + } + + if (!fallback) { + if (!markCommunityDiscoveryAfterLeave()) { + throw new Error( + "Membership was removed, but community discovery state could not be saved. Restart Buzz and try again.", + ); + } + await goHome({ replace: true }); + communities.removeCommunity(id); + return leaveResult; + } await runCommunityViewTransition(async () => { saveActiveDestination(); @@ -93,6 +119,7 @@ export function useCommunityNavigationTransitions({ } communities.removeCommunity(id); }); + return leaveResult; }, [communities, goHome, router.history, saveActiveDestination], ); diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index f2eb7f285..2cb1d82df 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -19,6 +19,18 @@ never maintains a rival copy of this table. Setup guidance follows the same rule: `requires_external_cli` is derived from `KnownAcpRuntime` and projected to the UI rather than inferred from a runtime ID in a component. +**Second metadata source: command-keyed execution policy.** +`harness_max_parallelism` (`managed_agents/parallelism.rs`) maps the harness's +static command string to a spawn-time cap (`OPENCLAW_MAX_PARALLELISM = 5` for +OpenClaw). This cap is not a `KnownAcpRuntime` field because it applies to +preset harnesses (like OpenClaw) that are not in the builtin catalog. It is +projected onto `AcpRuntimeCatalogEntry.max_parallelism` by all four +catalog-producing constructors (builtin discovery, preset catalog, custom +discovery, custom-save response) using the **static definition command**, not +the resolved `entry.command` (which may be `null` for unavailable entries). +The frontend reads `maxParallelism` from the catalog entry and never keeps a +separate constant. + If you need a new capability fact (a new env key, a native option, a "supports X" flag): add it to `KnownAcpRuntime` first, expose it on `AcpRuntimeCatalogEntry`, then project it through the core. Do not shortcut diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd6..2cb2068d5 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -3,11 +3,15 @@ import test from "node:test"; import { coalesceAgentAutocompleteCandidates, + filterCachedAgentSuggestions, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInAllowedList, + isAgentMentionChannelType, + relayAgentCanRespondInChannel, relayAgentIsSharedWithUser, shouldHideAgentFromMentions, + uniqueAutocompleteLabels, } from "./agentAutocompleteEligibility.ts"; const CURRENT_PUBKEY = "a".repeat(64); @@ -106,8 +110,30 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" ); }); +test("relayAgentCanRespondInChannel: requires exact channel membership and viewer access", () => { + const agent = { + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: ["general"], + }; + + assert.equal( + relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY), + true, + ); + assert.equal( + relayAgentCanRespondInChannel(agent, "other", CURRENT_PUBKEY), + false, + ); + assert.equal( + relayAgentCanRespondInChannel(agent, "general", OTHER_OWNER_PUBKEY), + false, + ); +}); + test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => { const result = getMentionableAgentPubkeys({ + eligibilityScope: { type: "community" }, managedAgentPubkeys: [PUB_A], currentPubkey: CURRENT_PUBKEY, relayAgents: [ @@ -136,27 +162,94 @@ test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", assert.deepEqual(result, new Set([PUB_A, PUB_B, PUB_C])); }); -test("isAgentIdentityInManagedList: keeps people and only current managed agent identities", () => { - const managedAgentPubkeys = new Set([PUB_A]); +test("getMentionableAgentPubkeys: scopes channel composers and fails closed without context", () => { + const relayAgents = [ + { + pubkey: PUB_B, + respondTo: "allowlist", + respondToAllowlist: [CURRENT_PUBKEY], + channelIds: ["general"], + }, + ]; + const base = { + currentPubkey: CURRENT_PUBKEY, + managedAgentPubkeys: [PUB_A], + relayAgents, + sharedChannelIds: new Set(["general"]), + }; + + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "channel", channelId: "general" }, + }), + new Set([PUB_A, PUB_B]), + ); + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "channel", channelId: "other" }, + }), + new Set([PUB_A]), + ); + assert.deepEqual( + getMentionableAgentPubkeys({ + ...base, + eligibilityScope: { type: "managed-only" }, + }), + new Set([PUB_A]), + ); +}); + +test("autocomplete helper extraction preserves safe filtering and labels", () => { + assert.equal(isAgentMentionChannelType("stream"), true); + assert.equal(isAgentMentionChannelType("forum"), true); + assert.equal(isAgentMentionChannelType("dm"), false); + assert.equal(isAgentMentionChannelType(null), false); + + assert.deepEqual( + uniqueAutocompleteLabels([ + { displayName: " Alice ", personaName: "alice" }, + { displayName: null, secondaryLabel: "Bob" }, + { displayName: "BOB" }, + ]), + ["Alice", "Bob"], + ); + + const person = { pubkey: PUB_A, isAgent: false }; + const admittedAgent = { pubkey: PUB_B.toUpperCase(), isAgent: true }; + const removedAgent = { pubkey: PUB_C, isAgent: true }; + const persona = { isAgent: true }; + assert.deepEqual( + filterCachedAgentSuggestions( + [person, admittedAgent, removedAgent, persona], + [{ pubkey: PUB_B, isAgent: true }], + ), + [person, admittedAgent, persona], + ); +}); + +test("isAgentIdentityInAllowedList: keeps people and only explicitly allowed agent identities", () => { + const allowedAgentPubkeys = new Set([PUB_A]); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInAllowedList( { isAgent: false, pubkey: PUB_B }, - managedAgentPubkeys, + allowedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInAllowedList( { isAgent: true, pubkey: PUB_A.toUpperCase() }, - managedAgentPubkeys, + allowedAgentPubkeys, ), true, ); assert.equal( - isAgentIdentityInManagedList( + isAgentIdentityInAllowedList( { isAgent: true, pubkey: PUB_B }, - managedAgentPubkeys, + allowedAgentPubkeys, ), false, ); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index e4afe7fea..0abdad82f 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -30,13 +30,31 @@ export function relayAgentIsSharedWithUser( ); } +export function relayAgentCanRespondInChannel( + agent: Pick, + channelId: string, + currentPubkey?: string | null, +) { + return ( + agent.channelIds.includes(channelId) && + relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey) + ); +} + +export type AgentEligibilityScope = + | { type: "community" } + | { type: "channel"; channelId: string } + | { type: "managed-only" }; + export function getMentionableAgentPubkeys({ currentPubkey, + eligibilityScope, managedAgentPubkeys, relayAgents, sharedChannelIds, }: { currentPubkey?: string | null; + eligibilityScope: AgentEligibilityScope; managedAgentPubkeys: Iterable; relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; @@ -46,7 +64,17 @@ export function getMentionableAgentPubkeys({ ); for (const agent of relayAgents ?? []) { - if (relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + const isAllowed = + eligibilityScope.type === "managed-only" + ? false + : eligibilityScope.type === "community" + ? relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey) + : relayAgentCanRespondInChannel( + agent, + eligibilityScope.channelId, + currentPubkey, + ); + if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); } } @@ -54,13 +82,13 @@ export function getMentionableAgentPubkeys({ return pubkeys; } -export function isAgentIdentityInManagedList( +export function isAgentIdentityInAllowedList( candidate: { isAgent?: boolean; pubkey: string }, - managedAgentPubkeys: ReadonlySet, + allowedAgentPubkeys: ReadonlySet, ) { return ( candidate.isAgent !== true || - managedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) + allowedAgentPubkeys.has(normalizePubkey(candidate.pubkey)) ); } @@ -97,9 +125,58 @@ export function shouldHideAgentFromMentions({ return directoryAgentPubkeys.has(normalized); } +export function isAgentMentionChannelType(type?: string | null) { + return type === "stream" || type === "forum"; +} + +export function uniqueAutocompleteLabels( + candidates: readonly AgentAutocompleteCandidate[], +) { + const unique = new Map(); + for (const candidate of candidates) { + for (const label of [ + candidate.displayName, + candidate.personaName, + candidate.secondaryLabel, + ]) { + const trimmed = label?.trim(); + if (trimmed && !unique.has(trimmed.toLowerCase())) { + unique.set(trimmed.toLowerCase(), trimmed); + } + } + } + return [...unique.values()]; +} + +export function filterCachedAgentSuggestions< + T extends { + isAgent?: boolean; + pubkey?: string; + }, +>( + suggestions: readonly T[], + currentCandidates: readonly AgentAutocompleteCandidate[], +) { + const admittedAgentPubkeys = new Set( + currentCandidates.flatMap((candidate) => + candidate.isAgent && candidate.pubkey + ? [normalizePubkey(candidate.pubkey)] + : [], + ), + ); + return suggestions.filter( + (suggestion) => + !suggestion.isAgent || + !suggestion.pubkey || + admittedAgentPubkeys.has(normalizePubkey(suggestion.pubkey)), + ); +} + type AgentAutocompleteCandidate = { pubkey?: string; displayName?: string | null; + personaName?: string | null; + secondaryLabel?: string | null; ownerPubkey?: string | null; isAgent?: boolean; isManagedAgent?: boolean; diff --git a/desktop/src/features/agents/lib/agentParallelism.test.mjs b/desktop/src/features/agents/lib/agentParallelism.test.mjs index 8d03d087e..7f7ac3e09 100644 --- a/desktop/src/features/agents/lib/agentParallelism.test.mjs +++ b/desktop/src/features/agents/lib/agentParallelism.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { DEFAULT_AGENT_PARALLELISM, resolveAgentParallelism, + parallelismCapHint, } from "./agentParallelism.ts"; test("parallelism uses the app default only when input and definition omit it", () => { @@ -18,3 +19,38 @@ test("parallelism uses the app default only when input and definition omit it", assert.equal(resolveAgentParallelism(undefined, 4), 4); assert.equal(resolveAgentParallelism(2, 4), 2); }); + +// ── parallelismCapHint: persona/instance hint data path ─────────────────────── + +test("parallelismCapHint returns null when requested is at or below the cap", () => { + assert.equal(parallelismCapHint("OpenClaw", 5, 5), null); + assert.equal(parallelismCapHint("OpenClaw", 5, 3), null); + assert.equal(parallelismCapHint("OpenClaw", 5, 1), null); +}); + +test("parallelismCapHint returns hint string when requested exceeds cap", () => { + const hint = parallelismCapHint("OpenClaw", 5, 10); + assert.ok(hint !== null, "hint must be non-null when 10 > 5"); + assert.ok(hint.includes("OpenClaw"), "hint must include the harness label"); + assert.ok(hint.includes("5"), "hint must include the cap value"); +}); + +test("parallelismCapHint uses singular form for cap of 1", () => { + const hint = parallelismCapHint("SomeHarness", 1, 5); + assert.ok(hint !== null); + assert.ok( + hint.includes("conversation") && !hint.includes("conversations"), + "cap=1 must use singular 'conversation'", + ); +}); + +// Stored-10 OpenClaw → Goose: hint clears when the harness has no cap. +// The UI derives: if selectedRuntime.maxParallelism is undefined, hint is null. +// This test validates the helper contract that makes that work. +test("parallelismCapHint returns null when cap equals or exceeds any common parallelism value", () => { + // Simulates an uncapped harness: the caller passes a very large cap + // OR simply doesn't call parallelismCapHint at all (guarded by maxParallelism check). + // When stored=10 and harness switches to goose (no cap), no hint is shown. + assert.equal(parallelismCapHint("Goose", 32, 10), null); + assert.equal(parallelismCapHint("Goose", 32, 32), null); +}); diff --git a/desktop/src/features/agents/lib/agentParallelism.ts b/desktop/src/features/agents/lib/agentParallelism.ts index 89544c5bb..6b1338364 100644 --- a/desktop/src/features/agents/lib/agentParallelism.ts +++ b/desktop/src/features/agents/lib/agentParallelism.ts @@ -16,3 +16,24 @@ export function resolveAgentParallelism( ): number { return input ?? definition ?? DEFAULT_AGENT_PARALLELISM; } + +/** + * Return an explanatory hint string when a harness cap would reduce the + * requested parallelism, or `null` when the value is within the cap. + * + * The hint carries both facts: what was requested and what will run, so users + * understand the effective value without needing to look elsewhere. + * + * @param harnessLabel - Human-readable harness name (e.g. "OpenClaw"). + * @param cap - The harness's maximum parallelism (from catalog maxParallelism). + * @param requested - The user's requested parallelism value (1–32). + * @returns A hint string, or null when requested <= cap. + */ +export function parallelismCapHint( + harnessLabel: string, + cap: number, + requested: number, +): string | null { + if (requested <= cap) return null; + return `${harnessLabel} runs at most ${cap} parallel conversation${cap === 1 ? "" : "s"} — this agent will run ${cap}.`; +} diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 56685fd6c..6b42b1049 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -19,7 +19,10 @@ import { isBuzzAgentRuntime, BUZZ_AGENT_THINKING_EFFORT, } from "./buzzAgentConfig"; -import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism"; +import { + EDIT_AGENT_PARALLELISM_HELP, + parallelismCapHint, +} from "../lib/agentParallelism"; import { deriveNumericDescriptors, structuredEnvKeys, @@ -128,6 +131,22 @@ export function EditAgentAdvancedFields({ [hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors], ); + // Harness cap hint: show only when the selected runtime has a cap and the + // current parallelism value exceeds it. Cap and label come from the catalog + // entry — no hardcoded constant in TS. + const parallelismHint = React.useMemo(() => { + if (selectedRuntime?.maxParallelism === undefined || parallelism === "") { + return null; + } + const requested = parseInt(parallelism, 10); + if (Number.isNaN(requested)) return null; + return parallelismCapHint( + selectedRuntime.label, + selectedRuntime.maxParallelism, + requested, + ); + }, [selectedRuntime, parallelism]); + return (
{/* Inherit runtime from template */} @@ -238,6 +257,11 @@ export function EditAgentAdvancedFields({

{EDIT_AGENT_PARALLELISM_HELP}

+ {parallelismHint !== null ? ( +

+ {parallelismHint} +

+ ) : null}
{/* Relay URL: intentionally no editor. The legacy per-record relay pin diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 1ecd98b83..62cb8a240 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -11,6 +11,7 @@ import { import { AGENT_PARALLELISM_HELP, AGENT_PARALLELISM_PLACEHOLDER, + parallelismCapHint, } from "../lib/agentParallelism"; import { BuzzAgentModelTuningFields, @@ -102,6 +103,26 @@ export function PersonaAdvancedFields({ ], [hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors], ); + + // Persona hint: definitions keep a portable requested value across harnesses. + // When the selected harness has a cap and the draft's parallelism exceeds it, + // explain that the agent will run at the cap — without clamping the stored value. + const personaParallelismHint = React.useMemo(() => { + if ( + selectedRuntime?.maxParallelism === undefined || + behaviorDraft.parallelism === "" + ) { + return null; + } + const requested = parseInt(behaviorDraft.parallelism, 10); + if (Number.isNaN(requested)) return null; + return parallelismCapHint( + selectedRuntime.label, + selectedRuntime.maxParallelism, + requested, + ); + }, [selectedRuntime, behaviorDraft.parallelism]); + return (
{AGENT_PARALLELISM_HELP}

+ {personaParallelismHint !== null ? ( +

+ {personaParallelismHint} +

+ ) : null}
diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 8829edab3..51297b915 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -32,7 +32,9 @@ import type { SetChannelTopicInput, UpdateChannelInput, } from "@/shared/api/types"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { useCommunities } from "@/features/communities/useCommunities"; +import { canAddChannelMembers } from "@/features/channels/lib/channelMemberAdmission"; import { readChannelSnapshot, writeChannelSnapshot, @@ -501,6 +503,32 @@ export function useDeleteChannelMutation(channelId: string | null) { }); } +/** + * Whether the signed-in identity may add *another* identity to this channel, + * per {@link canAddChannelMembers}. Both queries are the ones the channel UI + * already holds, so this shares their cache rather than fetching again. + */ +export function useCanAddChannelMembers(channelId: string | null) { + const channelsQuery = useChannelsQuery(); + const membersQuery = useChannelMembersQuery(channelId); + const identityQuery = useIdentityQuery(); + + const channel = + channelsQuery.data?.find((candidate) => candidate.id === channelId) ?? null; + const selfPubkey = identityQuery.data?.pubkey ?? null; + const selfRole = selfPubkey + ? (membersQuery.data?.find( + (member) => member.pubkey.toLowerCase() === selfPubkey.toLowerCase(), + )?.role ?? null) + : null; + + return canAddChannelMembers({ + channelType: channel?.channelType, + visibility: channel?.visibility, + selfRole, + }); +} + export function useAddChannelMembersMutation(channelId: string | null) { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs b/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs new file mode 100644 index 000000000..0af22459f --- /dev/null +++ b/desktop/src/features/channels/lib/channelMemberAdmission.test.mjs @@ -0,0 +1,79 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import { canAddChannelMembers } from "./channelMemberAdmission.ts"; + +test("open channels accept adds from anyone, member or not", () => { + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "open", + selfRole: null, + }), + true, + ); + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "open", + selfRole: "member", + }), + true, + ); +}); + +test("private channels accept adds only from owners/admins", () => { + for (const selfRole of ["owner", "admin"]) { + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "private", + selfRole, + }), + true, + `${selfRole} should be able to add`, + ); + } + + for (const selfRole of ["member", "bot", "guest", null]) { + assert.equal( + canAddChannelMembers({ + channelType: "stream", + visibility: "private", + selfRole, + }), + false, + `${selfRole} must not be able to add`, + ); + } +}); + +test("DMs never accept adds, even from an owner", () => { + assert.equal( + canAddChannelMembers({ + channelType: "dm", + visibility: "private", + selfRole: "owner", + }), + false, + ); + assert.equal( + canAddChannelMembers({ + channelType: "dm", + visibility: "open", + selfRole: "owner", + }), + false, + ); +}); + +test("unknown visibility fails closed for non-elevated callers", () => { + assert.equal( + canAddChannelMembers({ channelType: "stream", selfRole: "member" }), + false, + ); + assert.equal( + canAddChannelMembers({ channelType: "stream", selfRole: "owner" }), + true, + ); +}); diff --git a/desktop/src/features/channels/lib/channelMemberAdmission.ts b/desktop/src/features/channels/lib/channelMemberAdmission.ts new file mode 100644 index 000000000..b01c6f521 --- /dev/null +++ b/desktop/src/features/channels/lib/channelMemberAdmission.ts @@ -0,0 +1,36 @@ +/** + * Client mirror of the relay's kind:9000 authority for adding *another* + * identity to a channel (`validate_admin_event` + `buzz_db::channel::add_member`): + * + * - DMs: nobody — membership is fixed at creation. + * - Open channels: anyone, member or not. + * - Private channels: owners/admins only. A plain member extending access to + * channel history is exactly what the relay now rejects, so the affordance + * must not be offered. + * + * Unknown visibility fails closed — the relay is the authority and a hidden + * button is cheaper than an opaque rejection. + */ +export function canAddChannelMembers({ + channelType, + visibility, + selfRole, +}: { + channelType?: string | null; + visibility?: string | null; + selfRole?: string | null; +}): boolean { + if (channelType === "dm") { + return false; + } + + if (visibility === "open") { + return true; + } + + return selfRole === "owner" || selfRole === "admin"; +} + +/** Explains a denied add so the user isn't left guessing at a missing button. */ +export const PRIVATE_CHANNEL_ADD_DENIED_MESSAGE = + "Only channel owners and admins can add people to a private channel."; diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c6349546a..2659a9c1d 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -5,15 +5,22 @@ import { invalidateChannelState, useAddChannelMembersMutation, useChannelMembersQuery, + useChannelsQuery, } from "@/features/channels/hooks"; import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; import { coalesceAgentAutocompleteCandidates, - isAgentIdentityInManagedList, + getMentionableAgentPubkeys, + getSharedChannelIds, + isAgentIdentityInAllowedList, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; +import { + canAddChannelMembers, + PRIVATE_CHANNEL_ADD_DENIED_MESSAGE, +} from "@/features/channels/lib/channelMemberAdmission"; import { useFlattenedUserSearchResults, useInfiniteUserSearchQuery, @@ -155,6 +162,7 @@ export function MembersSidebar({ >(() => new Set()); const identityQuery = useIdentityQuery(); const membersQuery = useChannelMembersQuery(channelId, open); + const channelsQuery = useChannelsQuery({ enabled: open }); const addMembersMutation = useAddChannelMembersMutation(channelId); const changeRoleMutation = useMutation({ mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { @@ -240,9 +248,18 @@ export function MembersSidebar({ () => new Set(rawMembers.map((member) => normalizePubkey(member.pubkey))), [rawMembers], ); - const canAddMembers = - (selfMember !== null || channel?.visibility === "open") && - channel?.channelType !== "dm"; + const canAddMembers = canAddChannelMembers({ + channelType: channel?.channelType, + visibility: channel?.visibility, + selfRole: selfMember?.role, + }); + // Distinguish "you can't add here" from "nothing to add" so a plain member of + // a private channel gets the reason instead of a silently missing affordance. + const showPrivateAddDeniedNotice = + !canAddMembers && + selfMember !== null && + channel?.channelType !== "dm" && + channel?.visibility !== "open"; const userSearchQuery = useInfiniteUserSearchQuery(deferredSearchQuery, { allowEmpty: false, enabled: @@ -271,7 +288,14 @@ export function MembersSidebar({ .map((member) => member.displayName?.trim().toLowerCase()) .filter((label): label is string => Boolean(label)), ); - const managedAgentPubkeys = new Set(managedAgentsByPubkey.keys()); + const sharedChannelIds = getSharedChannelIds(channelsQuery.data); + const allowedAgentPubkeys = getMentionableAgentPubkeys({ + currentPubkey, + eligibilityScope: { type: "community" }, + managedAgentPubkeys: managedAgentsByPubkey.keys(), + relayAgents: relayAgentsQuery.data, + sharedChannelIds, + }); const addCandidate = (candidate: AddMemberSearchCandidate) => { const pubkey = normalizePubkey(candidate.pubkey); @@ -282,7 +306,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInAllowedList(candidate, allowedAgentPubkeys) ) { return; } @@ -361,6 +385,7 @@ export function MembersSidebar({ }); }, [ canAddMembers, + channelsQuery.data, isArchivedDiscovery, currentPubkey, managedAgentsQuery.data, @@ -373,7 +398,8 @@ export function MembersSidebar({ const isAddSearchLoading = userSearchQuery.isLoading || managedAgentsQuery.isLoading || - relayAgentsQuery.isLoading; + relayAgentsQuery.isLoading || + channelsQuery.isLoading; const handlePeopleSearchScroll = useUserSearchFetchMoreOnScroll( userSearchQuery, canAddMembers && normalizedDeferredSearchQuery.length > 0, @@ -723,6 +749,14 @@ export function MembersSidebar({ value={searchQuery} /> + {showPrivateAddDeniedNotice ? ( +

+ {PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} +

+ ) : null}
diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs index 633fccd20..fca190913 100644 --- a/desktop/src/features/communities/communityStorage.test.mjs +++ b/desktop/src/features/communities/communityStorage.test.mjs @@ -4,7 +4,11 @@ import test from "node:test"; import { clearCommunityStorage, initFirstCommunity, + loadCommunities, + loadCommunityDiscoveryAfterLeave, + markCommunityDiscoveryAfterLeave, migrateLegacyCommunityStorage, + saveCommunities, shouldAutoConnectDefaultRelay, } from "./communityStorage.ts"; @@ -89,16 +93,42 @@ test("failed first-community write preserves existing community data", () => { assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy"); }); -test("clearCommunityStorage removes new and legacy state", () => { +test("loading an existing community clears stale final-leave discovery", () => { + const storage = createMemoryStorage({ + "buzz-communities": '[{"id":"joined"}]', + "buzz-community-discovery-after-leave": "1", + }); + globalThis.localStorage = storage; + globalThis.window = { localStorage: storage }; + + assert.deepEqual(loadCommunities(), [{ id: "joined" }]); + assert.equal(loadCommunityDiscoveryAfterLeave(storage), false); +}); + +test("completed final leave persists discovery until a community is saved", () => { + const storage = createMemoryStorage(); + globalThis.localStorage = storage; + globalThis.window = { localStorage: storage }; + + assert.equal(markCommunityDiscoveryAfterLeave(storage), true); + assert.equal(loadCommunityDiscoveryAfterLeave(storage), true); + + assert.equal(saveCommunities([{ id: "joined" }]), true); + assert.equal(loadCommunityDiscoveryAfterLeave(storage), false); +}); + +test("clearCommunityStorage preserves completed final-leave discovery", () => { const storage = createMemoryStorage({ "buzz-communities": "new", "buzz-active-community-id": "new", "buzz-workspaces": "old", "buzz-active-workspace-id": "old", + "buzz-community-discovery-after-leave": "1", }); clearCommunityStorage(storage); migrateLegacyCommunityStorage(storage); - assert.equal(storage.length, 0); + assert.equal(storage.length, 1); + assert.equal(loadCommunityDiscoveryAfterLeave(storage), true); }); diff --git a/desktop/src/features/communities/communityStorage.ts b/desktop/src/features/communities/communityStorage.ts index 4a99e1f2e..7c8778712 100644 --- a/desktop/src/features/communities/communityStorage.ts +++ b/desktop/src/features/communities/communityStorage.ts @@ -6,6 +6,8 @@ const COMMUNITIES_KEY = "buzz-communities"; const ACTIVE_COMMUNITY_KEY = "buzz-active-community-id"; const LEGACY_WORKSPACES_KEY = "buzz-workspaces"; const LEGACY_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id"; +const COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY = + "buzz-community-discovery-after-leave"; /** * Expand a leading `~` to the user's home directory. The backend rejects @@ -57,6 +59,9 @@ export function loadCommunities(): Community[] { if (!Array.isArray(parsed)) { return []; } + if (parsed.length > 0) { + localStorage.removeItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY); + } // Migration: older builds stored the user's `nsec` in localStorage and // re-applied it to the backend on every reload, which silently overwrote // any `import_identity` result with the original generated key. The @@ -82,10 +87,37 @@ export function loadCommunities(): Community[] { } export function saveCommunities(communities: Community[]): boolean { - return setLocalStorageItemWithRecovery( + const didSave = setLocalStorageItemWithRecovery( COMMUNITIES_KEY, JSON.stringify(communities), ); + if (didSave && communities.length > 0) { + localStorage.removeItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY); + } + return didSave; +} + +export function loadCommunityDiscoveryAfterLeave( + storage: Storage = localStorage, +): boolean { + return storage.getItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY) === "1"; +} + +export function markCommunityDiscoveryAfterLeave( + storage: Storage = localStorage, +): boolean { + if (typeof window !== "undefined" && storage === window.localStorage) { + return setLocalStorageItemWithRecovery( + COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY, + "1", + ); + } + try { + storage.setItem(COMMUNITY_DISCOVERY_AFTER_LEAVE_KEY, "1"); + return true; + } catch { + return false; + } } export function clearCommunityStorage(storage: Storage = localStorage): void { diff --git a/desktop/src/features/communities/leaveCommunity.test.mjs b/desktop/src/features/communities/leaveCommunity.test.mjs new file mode 100644 index 000000000..50f077592 --- /dev/null +++ b/desktop/src/features/communities/leaveCommunity.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { KIND_NIP43_LEAVE_REQUEST, leaveCommunity } from "./leaveCommunity.ts"; + +const signedEvent = { + id: "event-id", + pubkey: "a".repeat(64), + created_at: 1, + kind: KIND_NIP43_LEAVE_REQUEST, + tags: [["-"]], + content: "", + sig: "b".repeat(128), +}; + +function dependencies(overrides = {}) { + return { + requiresMembership: async () => true, + sign: async (input) => ({ ...signedEvent, ...input }), + publishActive: async () => {}, + createRelayClient: () => ({ + publishEvent: async () => {}, + disconnect() {}, + }), + ...overrides, + }; +} + +test("skips relay publishing when the relay does not enforce membership", async () => { + let checkedRelay; + await leaveCommunity( + "wss://open.example", + "wss://open.example", + dependencies({ + requiresMembership: async (relayUrl) => { + checkedRelay = relayUrl; + return false; + }, + sign: async () => { + throw new Error("open relay leave should not be signed"); + }, + publishActive: async () => { + throw new Error("open relay leave should not be published"); + }, + }), + ); + + assert.equal(checkedRelay, "wss://open.example"); +}); + +test("signs the protected NIP-43 leave request and awaits active relay acceptance", async () => { + let signInput; + let published; + await leaveCommunity( + "wss://active.example", + "wss://active.example", + dependencies({ + sign: async (input) => { + signInput = input; + return signedEvent; + }, + publishActive: async (event) => { + published = event; + }, + createRelayClient: () => { + throw new Error("inactive client should not be created"); + }, + }), + ); + + assert.deepEqual(signInput, { + kind: KIND_NIP43_LEAVE_REQUEST, + content: "", + tags: [["-"]], + }); + assert.equal(published, signedEvent); +}); + +test("targets an inactive community relay and always disconnects", async () => { + const calls = []; + await leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + publishActive: async () => { + throw new Error("active relay should not be used"); + }, + createRelayClient: (relayUrl) => ({ + publishEvent: async (event) => calls.push(["publish", relayUrl, event]), + disconnect: () => calls.push(["disconnect"]), + }), + }), + ); + + assert.deepEqual(calls, [ + ["publish", "wss://inactive.example", signedEvent], + ["disconnect"], + ]); +}); + +test("treats an already-absent active membership as successful cleanup", async () => { + const result = await leaveCommunity( + "wss://active.example", + "wss://active.example", + dependencies({ + publishActive: async () => { + throw new Error("invalid: you are not a relay member"); + }, + }), + ); + + assert.deepEqual(result, { status: "already-absent" }); +}); + +test("treats an already-absent inactive membership as successful cleanup and disconnects", async () => { + let disconnected = false; + const result = await leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + createRelayClient: () => ({ + publishEvent: async () => { + throw new Error("invalid: you are not a relay member"); + }, + disconnect: () => { + disconnected = true; + }, + }), + }), + ); + assert.deepEqual(result, { status: "already-absent" }); + assert.equal(disconnected, true); +}); + +test("preserves other relay rejections and disconnects without falling through", async () => { + const rejection = new Error("invalid: relay owner cannot leave"); + let disconnected = false; + + await assert.rejects( + leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + createRelayClient: () => ({ + publishEvent: async () => { + throw rejection; + }, + disconnect: () => { + disconnected = true; + }, + }), + }), + ), + rejection, + ); + assert.equal(disconnected, true); +}); + +test("turns an inactive relay timeout into an actionable leave error", async () => { + await assert.rejects( + leaveCommunity( + "wss://inactive.example", + "wss://active.example", + dependencies({ + createRelayClient: () => ({ + publishEvent: async () => { + throw new Error("Timed out publishing to observer relay."); + }, + disconnect() {}, + }), + }), + ), + /Timed out while leaving the community\. Try again\./, + ); +}); diff --git a/desktop/src/features/communities/leaveCommunity.ts b/desktop/src/features/communities/leaveCommunity.ts new file mode 100644 index 000000000..15e3f8ba8 --- /dev/null +++ b/desktop/src/features/communities/leaveCommunity.ts @@ -0,0 +1,88 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { ReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; +import { relayRequiresMembership } from "@/shared/api/relayMembers"; +import { signRelayEvent } from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; + +export const KIND_NIP43_LEAVE_REQUEST = 28936; + +type LeaveCommunityDependencies = { + requiresMembership: (relayUrl: string) => Promise; + sign: typeof signRelayEvent; + publishActive: (event: RelayEvent) => Promise; + createRelayClient: (relayUrl: string) => { + publishEvent: (event: RelayEvent) => Promise; + disconnect: () => void; + }; +}; + +const defaultDependencies: LeaveCommunityDependencies = { + requiresMembership: relayRequiresMembership, + sign: signRelayEvent, + publishActive: (event) => + relayClient.publishEvent( + event, + "Timed out while leaving the community. Try again.", + "Couldn't send the leave request. Check your connection and try again.", + ), + createRelayClient: (relayUrl) => new ReadOnlyRelayClient(relayUrl), +}; + +function membershipIsAlreadyAbsent(error: unknown): boolean { + return ( + error instanceof Error && + error.message.toLowerCase().includes("not a relay member") + ); +} + +export type LeaveCommunityResult = + | { status: "left" } + | { status: "already-absent" }; + +async function publishLeaveRequest( + publish: () => Promise, +): Promise { + try { + await publish(); + return { status: "left" }; + } catch (error) { + if (!membershipIsAlreadyAbsent(error)) throw error; + return { status: "already-absent" }; + } +} + +/** Revoke relay membership and resolve only after the relay accepts the request. */ +export async function leaveCommunity( + relayUrl: string, + activeRelayUrl: string | undefined, + dependencies: LeaveCommunityDependencies = defaultDependencies, +): Promise { + if (!(await dependencies.requiresMembership(relayUrl))) { + return { status: "left" }; + } + + const event = await dependencies.sign({ + kind: KIND_NIP43_LEAVE_REQUEST, + content: "", + tags: [["-"]], + }); + + if (relayUrl === activeRelayUrl) { + return publishLeaveRequest(() => dependencies.publishActive(event)); + } + + const client = dependencies.createRelayClient(relayUrl); + try { + return await publishLeaveRequest(() => client.publishEvent(event)); + } catch (error) { + if ( + error instanceof Error && + error.message.toLowerCase().includes("timed out") + ) { + throw new Error("Timed out while leaving the community. Try again."); + } + throw error; + } finally { + client.disconnect(); + } +} diff --git a/desktop/src/features/communities/resolveCommunityRemoval.test.mjs b/desktop/src/features/communities/resolveCommunityRemoval.test.mjs new file mode 100644 index 000000000..6d24ee4db --- /dev/null +++ b/desktop/src/features/communities/resolveCommunityRemoval.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCommunityRemoval } from "./useCommunities.tsx"; + +const alpha = { id: "alpha", name: "Alpha", relayUrl: "wss://alpha" }; +const beta = { id: "beta", name: "Beta", relayUrl: "wss://beta" }; + +test("removing the final community clears the active community", () => { + assert.deepEqual(resolveCommunityRemoval([alpha], "alpha", "alpha"), { + communities: [], + activeId: null, + }); +}); + +test("removing the active community selects a clean fallback", () => { + assert.deepEqual(resolveCommunityRemoval([alpha, beta], "alpha", "alpha"), { + communities: [beta], + activeId: "beta", + }); +}); + +test("removing an inactive community preserves the active community", () => { + assert.deepEqual(resolveCommunityRemoval([alpha, beta], "alpha", "beta"), { + communities: [alpha], + activeId: "alpha", + }); +}); diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index e4f105471..a83456652 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -55,12 +55,10 @@ export function AddCommunityDialog({ relayUrl, inviteCode, policyReceipt, - token, }: { relayUrl: string; inviteCode?: string; policyReceipt?: string; - token?: string; }) => { const started = communityOnboarding.start({ source: "add-community", @@ -68,7 +66,6 @@ export function AddCommunityDialog({ inviteCode, communityName: prefill?.name, policyReceipt, - token, }); if (!started) { setJoinError( @@ -185,9 +182,7 @@ export function AddCommunityDialog({ setJoinError(null); setMode("choose"); }} - onConnect={(relayUrl, token) => - startConnection({ relayUrl, token }) - } + onConnect={(relayUrl) => startConnection({ relayUrl })} onRedeem={(relayUrl, inviteCode, policyReceipt) => startConnection({ relayUrl, inviteCode, policyReceipt }) } diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index 985d28fa8..cd530e690 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -6,11 +6,14 @@ import { MoreHorizontal, Plus, Settings2, + LogOut, Ticket, WifiOff, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; +import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity"; import type { Community } from "@/features/communities/types"; import { DropdownMenu, @@ -57,7 +60,7 @@ type CommunitySwitcherProps = { id: string, updates: Partial>, ) => void; - onRemoveCommunity: (id: string) => void; + onRemoveCommunity: (id: string) => Promise; }; export function CommunityEmojiIcon({ @@ -103,6 +106,8 @@ export function CommunitySwitcher({ const [editingCommunity, setEditingCommunity] = React.useState(null); const [dropdownOpen, setDropdownOpen] = React.useState(false); + const [leaveError, setLeaveError] = React.useState(null); + const [isLeaving, setIsLeaving] = React.useState(false); const profileMenuHoverTimer = React.useRef(null); const connectionState = useRelayConnection(); const degraded = isRelayConnectionDegraded(connectionState); @@ -147,6 +152,36 @@ export function CommunitySwitcher({ [], ); + const handleLeaveCommunity = React.useCallback(async () => { + if (!activeCommunity || isLeaving) return; + + if (profileMenuHoverTimer.current !== null) { + window.clearTimeout(profileMenuHoverTimer.current); + profileMenuHoverTimer.current = null; + } + setIsLeaving(true); + setLeaveError(null); + try { + const result = await onRemoveCommunity(activeCommunity.id); + setDropdownOpen(false); + if (result?.status === "already-absent") { + toast("Community removed", { + description: + "You were no longer a member, so Buzz removed the community from this device.", + }); + } + } catch (error) { + setLeaveError( + error instanceof Error + ? error.message + : "Couldn't leave the community. Try again.", + ); + setDropdownOpen(true); + } finally { + setIsLeaving(false); + } + }, [activeCommunity, isLeaving, onRemoveCommunity]); + const triggerContent = ( <> {degraded ? ( @@ -278,6 +313,24 @@ export function CommunitySwitcher({ Community settings + + {leaveError ? ( +

+ {leaveError} +

+ ) : null}
) : null} @@ -391,11 +444,9 @@ export function CommunitySwitcher({ )} 1} onOpenChange={(open) => { if (!open) setEditingCommunity(null); }} - onRemove={onRemoveCommunity} onSave={onUpdateCommunity} open={editingCommunity !== null} community={editingCommunity} diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx index 3b963979b..3baa52372 100644 --- a/desktop/src/features/communities/ui/EditCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/EditCommunityDialog.tsx @@ -28,8 +28,6 @@ type EditCommunityDialogProps = { Pick >, ) => void; - onRemove?: (id: string) => void; - canRemove?: boolean; showIconEditor?: boolean; }; @@ -38,8 +36,6 @@ export function EditCommunityDialog({ open, onOpenChange, onSave, - onRemove, - canRemove, showIconEditor = false, }: EditCommunityDialogProps) { const [name, setName] = React.useState(""); @@ -122,13 +118,6 @@ export function EditCommunityDialog({ [community, name, relayUrl, token, reposDir, onSave, handleClose], ); - const handleRemove = React.useCallback(() => { - if (community && onRemove) { - onRemove(community.id); - handleClose(); - } - }, [community, onRemove, handleClose]); - if (!community) { return null; } @@ -235,28 +224,13 @@ export function EditCommunityDialog({ the default location.

-
-
- {canRemove && onRemove ? ( - - ) : null} -
-
- - -
+
+ +
diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index bde145822..530933acc 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -27,7 +27,7 @@ type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection; type WelcomeSetupProps = { initialPage?: WelcomeSetupPage; initialTransitionMode?: WelcomeTransitionMode; - onBack: () => void; + onBack?: () => void; }; const COMMUNITY_OPTION_CARD_CLASS = @@ -164,17 +164,19 @@ export function WelcomeSetup({
- - - + {onBack ? ( + + + + ) : null} ) : page === "existing" ? ( community.id !== id); + return { + communities: next, + activeId: activeId === id ? (next[0]?.id ?? null) : activeId, + }; +} + export type UseCommunitiesReturn = { communities: Community[]; activeCommunity: Community | null; @@ -206,40 +223,39 @@ function useCommunitiesInternal(): UseCommunitiesReturn { const removeCommunity = useCallback( (id: string) => { - // GC self-profile caches for the removed community's relay. Mirror the - // updater guard (length > 1) so we only GC when removal will actually - // proceed. Runs outside the updater — updaters can execute twice under + const removed = communitiesRef.current.find( + (community) => community.id === id, + ); + if (!removed) return; + + // Relay membership is revoked by the caller before this local cleanup. + // Keep side effects outside the updater — updaters can execute twice under // React StrictMode. - if (communities.length > 1) { - const removed = communities.find((w) => w.id === id); - if (removed) { - removeSelfProfileCachesForRelay(removed.relayUrl); - removeUserLabelCacheForRelay(removed.relayUrl); - removeChannelSnapshotForRelay(removed.relayUrl); - removeMessageSnapshotsForRelay(removed.relayUrl); - clearSavedCommunitySnapshot(id); - removeCommunityDestination(id); - } - } + removeSelfProfileCachesForRelay(removed.relayUrl); + removeUserLabelCacheForRelay(removed.relayUrl); + removeChannelSnapshotForRelay(removed.relayUrl); + removeMessageSnapshotsForRelay(removed.relayUrl); + clearSavedCommunitySnapshot(id); + removeCommunityDestination(id); setCommunitiesState((prev) => { - // Never allow removing the last community - if (prev.length <= 1) { - return prev; - } - const next = prev.filter((w) => w.id !== id); - saveCommunities(next); + const result = resolveCommunityRemoval(prev, activeId, id); + if (result.communities.length === 0) { + clearCommunityStorage(); + setActiveId(null); + } else { + saveCommunities(result.communities); - // If removing the active community, switch to first remaining - if (activeId === id && next.length > 0) { - saveActiveCommunityId(next[0].id); - setActiveId(next[0].id); + if (result.activeId !== activeId && result.activeId) { + saveActiveCommunityId(result.activeId); + setActiveId(result.activeId); + } } - return next; + return result.communities; }); }, - [activeId, communities], + [activeId], ); const switchCommunity = useCallback( diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index a2214416c..6519c6754 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -98,6 +98,7 @@ export function useCommunityInit( activeCommunity: Community | null, communityKey: string, isSharedIdentity: boolean, + suppressAutoConnect = false, ): CommunityInitResult { const [result, setResult] = useState({ isReady: false, @@ -123,6 +124,15 @@ export function useCommunityInit( async function init() { if (!activeCommunity) { + if (hasInitializedRef.current) { + if (prevCommunityIdRef.current) { + saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); + prevCommunityIdRef.current = null; + } + resetCommunityState({ resetAvatarState: true }); + appliedRelayUrlRef.current = null; + hasInitializedRef.current = false; + } try { const defaultRelayUrl = await getDefaultRelayUrl(); const autoConnectDefaultRelay = @@ -132,9 +142,10 @@ export function useCommunityInit( // relay as the first community. Public builds retain community // selection even when BUZZ_RELAY_URL is overridden at runtime. if ( - isSharedIdentity || - (autoConnectDefaultRelay && - shouldAutoConnectDefaultRelay(defaultRelayUrl)) + !suppressAutoConnect && + (isSharedIdentity || + (autoConnectDefaultRelay && + shouldAutoConnectDefaultRelay(defaultRelayUrl))) ) { const identity = await getIdentity(); if (cancelled) return; @@ -326,6 +337,7 @@ export function useCommunityInit( activeCommunity?.token, activeCommunity?.reposDir, isSharedIdentity, + suppressAutoConnect, communityKey, ]); diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 79dab559c..625dc1736 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -36,6 +36,7 @@ import { useCompactComposerInteractions } from "./useCompactComposerInteractions export function ForumComposer({ channelId = null, + channelType, members, className, placeholder, @@ -69,7 +70,7 @@ export function ForumComposer({ if (compact) setIsCompactExpanded(true); }, [compact]); - const mentions = useMentions(channelId, members, profiles); + const mentions = useMentions(channelId, members, profiles, { channelType }); const channelLinks = useChannelLinks(); const media = useMediaUpload(); const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } = diff --git a/desktop/src/features/forum/ui/ForumComposer.types.ts b/desktop/src/features/forum/ui/ForumComposer.types.ts index a20e75018..bd3d9d2ef 100644 --- a/desktop/src/features/forum/ui/ForumComposer.types.ts +++ b/desktop/src/features/forum/ui/ForumComposer.types.ts @@ -1,10 +1,12 @@ import type * as React from "react"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { ChannelMember } from "@/shared/api/types"; +import type { ChannelMember, ChannelType } from "@/shared/api/types"; export type ForumComposerProps = { channelId?: string | null; + /** Known channel type for channel-backed composers; omitted uses fail closed. */ + channelType?: ChannelType | null; /** Override mention source when no channel is available (e.g. Pulse). */ members?: ChannelMember[]; className?: string; diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index c6f1bfa6c..0e884dd24 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -293,6 +293,7 @@ export function ForumThreadPanel({
setIsComposerOpen(false)} onSubmit={async (content, mentionPubkeys, mediaTags) => { diff --git a/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts b/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts new file mode 100644 index 000000000..14ba5ec4e --- /dev/null +++ b/desktop/src/features/messages/lib/dmThreadAgentMentionError.ts @@ -0,0 +1,57 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { ChannelType } from "@/shared/api/types"; + +export const DM_THREAD_AGENT_MENTION_ERROR = + "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; +export const DM_THREAD_MEMBERS_LOADING_ERROR = + "Checking conversation members. Try again in a moment."; + +/** + * Why a DM thread reply may not mention an agent, or null when it may. + * + * A DM's participant set is fixed at creation, so a thread reply can only + * mention agents already in it — persona mentions (which would create a new + * agent) are always refused. + */ +export function dmThreadAgentMentionError({ + trimmed, + isThreadReply, + channelType, + extractMentionPersonas, + extractMentionPubkeys, + isAgentPubkey, + hasResolvedMembers, + memberPubkeys, +}: { + trimmed: string; + isThreadReply: boolean; + channelType: ChannelType | null; + extractMentionPersonas: (text: string) => unknown[]; + extractMentionPubkeys: (text: string) => string[]; + isAgentPubkey: (pubkey: string) => boolean; + hasResolvedMembers: boolean; + memberPubkeys: ReadonlySet; +}): string | null { + if (channelType !== "dm" || !isThreadReply) { + return null; + } + + if (extractMentionPersonas(trimmed).length > 0) { + return DM_THREAD_AGENT_MENTION_ERROR; + } + + const agentPubkeys = extractMentionPubkeys(trimmed).filter(isAgentPubkey); + if (agentPubkeys.length === 0) { + return null; + } + + if (!hasResolvedMembers) { + return DM_THREAD_MEMBERS_LOADING_ERROR; + } + + return agentPubkeys.some( + (pubkey) => !memberPubkeys.has(normalizePubkey(pubkey)), + ) + ? DM_THREAD_AGENT_MENTION_ERROR + : null; +} diff --git a/desktop/src/features/messages/lib/imetaSlots.test.mjs b/desktop/src/features/messages/lib/imetaSlots.test.mjs new file mode 100644 index 000000000..55307f08c --- /dev/null +++ b/desktop/src/features/messages/lib/imetaSlots.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots.ts"; + +// An immediate upload reserves a `null` placeholder and later fills it *by +// index*. Updates written against the compacted list must therefore be mapped +// back onto the slot layout: replacing the array renumbers it under an +// in-flight upload, whose fillSlot would then overwrite an unrelated +// attachment. + +const SNAPSHOT = { url: "snapshot.png", sha256: "5555" }; +const append = (descriptor) => (current) => [...current, descriptor]; + +test("compaction hides in-flight placeholders", () => { + const only = { url: "only.png", sha256: "1111" }; + assert.deepEqual(compactImetaSlots([null, only, null]), [only]); + assert.deepEqual(compactImetaSlots([]), []); +}); + +test("a snapshot paste during an in-flight upload does not take its slot", () => { + // Repro: attach a photo (slot 0 reserved, still uploading), then paste an + // agent snapshot. The snapshot must land after the placeholder so the + // photo's fillSlot(0, ...) cannot overwrite it. + const slots = applyImetaUpdate([null], append(SNAPSHOT)); + assert.deepEqual(slots, [null, SNAPSHOT]); + + // The upload completes and fills its own reserved index. + const photo = { url: "photo.png", sha256: "aaaa" }; + const filled = [...slots]; + filled[0] = photo; + assert.deepEqual(filled, [photo, SNAPSHOT]); +}); + +test("an append keeps already-filled attachments at their own indexes", () => { + const first = { url: "first.png", sha256: "1111" }; + assert.deepEqual(applyImetaUpdate([first, null], append(SNAPSHOT)), [ + first, + null, + SNAPSHOT, + ]); +}); + +test("an updater returning its input leaves the slots untouched", () => { + // handleSnapshotPaste returns `current` unchanged when the snapshot is + // already attached; that must not disturb a reserved placeholder. + const existing = [SNAPSHOT, null]; + const slots = applyImetaUpdate(existing, (current) => current); + assert.equal(slots, existing, "same array identity, no re-render churn"); +}); + +test("a removal nulls its slot instead of renumbering", () => { + // Removing an attachment must not shift the index a pending upload holds. + const keep = { url: "keep.png", sha256: "1111" }; + const drop = { url: "drop.png", sha256: "2222" }; + const slots = applyImetaUpdate([keep, drop, null], (current) => + current.filter((d) => d.url !== "drop.png"), + ); + assert.deepEqual(slots, [keep, null, null]); +}); + +test("clearing every attachment keeps the reserved placeholders", () => { + const one = { url: "one.png", sha256: "1111" }; + assert.deepEqual( + applyImetaUpdate([one, null], () => []), + [null, null], + ); +}); + +test("the updater only ever sees real attachments", () => { + const only = { url: "only.png", sha256: "1111" }; + let seen = null; + applyImetaUpdate([null, only, null], (current) => { + seen = current; + return current; + }); + assert.deepEqual(seen, [only]); +}); + +test("descriptors are matched on url and digest together", () => { + // Same url, different bytes: the new descriptor is an append, not a survivor. + const original = { url: "same.png", sha256: "1111" }; + const reuploaded = { url: "same.png", sha256: "2222" }; + assert.deepEqual(applyImetaUpdate([original, null], append(reuploaded)), [ + original, + null, + reuploaded, + ]); +}); + +test("a reorder does not move descriptors out of their slots", () => { + // Reordering cannot be honored while an upload holds an index; keeping the + // existing positions is what protects the pending fillSlot. + const a = { url: "a.png", sha256: "1111" }; + const b = { url: "b.png", sha256: "2222" }; + const slots = applyImetaUpdate([a, b, null], (current) => + [...current].reverse(), + ); + assert.deepEqual(slots, [a, b, null]); +}); diff --git a/desktop/src/features/messages/lib/imetaSlots.ts b/desktop/src/features/messages/lib/imetaSlots.ts new file mode 100644 index 000000000..d8aa10e49 --- /dev/null +++ b/desktop/src/features/messages/lib/imetaSlots.ts @@ -0,0 +1,65 @@ +import type { BlobDescriptor } from "@/shared/api/tauri"; + +/** + * Slot bookkeeping for composer attachments. + * + * Attachments live in a sparse array: an immediate upload calls `reserveSlots` + * to claim an index up front and fills it by that index when it completes, so + * concurrent uploads publish in the order they were attached. A `null` is a + * placeholder for an upload still in flight. + * + * Consumers of the composer only ever see the compacted list of real + * attachments, so any update expressed against that view has to be mapped back + * onto the slot layout — never applied to it directly. + */ + +/** + * Identity of a descriptor. `url` alone can repeat across re-uploads of + * identical bytes, so pair it with the digest. + */ +function descriptorKey(descriptor: BlobDescriptor): string { + return `${descriptor.url}\u0000${descriptor.sha256 ?? ""}`; +} + +/** The real attachments, in order, with in-flight placeholders dropped. */ +export function compactImetaSlots( + slots: (BlobDescriptor | null)[], +): BlobDescriptor[] { + return slots.filter((d): d is BlobDescriptor => d !== null); +} + +/** + * Apply an updater written against the compacted list back onto `slots`. + * + * Replacing the array with the updater's result would renumber it while an + * in-flight upload still holds an index from `reserveSlots`, so that upload's + * `fillSlot` would overwrite an unrelated attachment. Instead: + * + * - survivors stay at the index they already occupy; + * - removals become `null` rather than shifting their neighbours; + * - genuinely new descriptors append after the reserved tail, where no pending + * `fillSlot` can reach them. + * + * An updater that returns its input unchanged (e.g. the snapshot-paste dedupe) + * leaves `slots` exactly as it was, identity included. + */ +export function applyImetaUpdate( + slots: (BlobDescriptor | null)[], + update: (current: BlobDescriptor[]) => BlobDescriptor[], +): (BlobDescriptor | null)[] { + const current = compactImetaSlots(slots); + const next = update(current); + if (next === current) return slots; + + const survivingKeys = new Set(next.map(descriptorKey)); + const preserved = slots.map((descriptor) => + descriptor === null || survivingKeys.has(descriptorKey(descriptor)) + ? descriptor + : null, + ); + const presentKeys = new Set(current.map(descriptorKey)); + const appended = next.filter( + (descriptor) => !presentKeys.has(descriptorKey(descriptor)), + ); + return appended.length > 0 ? [...preserved, ...appended] : preserved; +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.test.mjs b/desktop/src/features/messages/lib/useMediaUpload.test.mjs index 674cce5ff..f4ced7c60 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.test.mjs +++ b/desktop/src/features/messages/lib/useMediaUpload.test.mjs @@ -142,3 +142,280 @@ test("reserveSlots pads if slots array is shorter than expected start index", () assert.equal(next[3], null); // reserved assert.equal(next[4], null); // reserved }); + +// ── Draft-boundary epoch guard (pure logic) ─────────────────────────── +// Photos/files upload immediately, so an upload can still be in flight when +// the composer swaps drafts (channel switch, post-send clear, edit restore). +// Every wholesale `setPendingImeta` replacement bumps an epoch; uploads pin +// the epoch at start and discard their descriptor if it no longer matches, so +// one draft's attachment can never land in — or overwrite a slot reserved by — +// another draft. Mirrors `isUploadStale` + `fillSlot`/`onUploaded`. + +function fillSlotIfCurrent(slots, index, descriptor, epoch, currentEpoch) { + if (epoch !== currentEpoch) return slots; + const next = [...slots]; + next[index] = descriptor; + return next; +} + +test("upload completing in the same draft fills its slot", () => { + const a = { url: "a.png", sha256: "aaaa" }; + const next = fillSlotIfCurrent([null], 0, a, 0, 0); + assert.deepEqual(next, [a]); +}); + +test("upload completing after a draft switch is discarded", () => { + // Draft A reserves slot 0 at epoch 0, user switches channels (epoch → 1), + // then the upload resolves. It must not write into draft B's slots. + const a = { url: "a.png", sha256: "aaaa" }; + const draftBSlots = [null]; + const next = fillSlotIfCurrent(draftBSlots, 0, a, 0, 1); + assert.deepEqual(next, [null]); + assert.equal(next, draftBSlots); +}); + +test("stale upload cannot overwrite a slot the new draft already filled", () => { + // Draft B has its own attachment in slot 0; draft A's late upload targets + // the same index and must leave B's descriptor intact. + const stale = { url: "stale.png", sha256: "aaaa" }; + const current = { url: "current.png", sha256: "bbbb" }; + const next = fillSlotIfCurrent([current], 0, stale, 0, 2); + assert.deepEqual(next, [current]); +}); + +test("appending to the current draft does not bump the epoch", () => { + // Only wholesale replacement (`setPendingImeta(array)`) is a draft boundary. + // The updater form appends within the current draft, so in-flight uploads + // for that same draft must still be considered current. + let epoch = 0; + const bumpIfReplacement = (action) => { + if (typeof action !== "function") epoch += 1; + }; + bumpIfReplacement((current) => [...current, { url: "pasted.png" }]); + assert.equal(epoch, 0); + bumpIfReplacement([]); + assert.equal(epoch, 1); +}); + +// ── Cancel guard for stale previews (pure logic) ─────────────────────── +// The epoch bump makes completions discard their descriptors, but the old +// preview row (and its cancel button) can still be on screen. Cancelling it +// must not null a slot in the draft now on screen, because the preview carries +// the *previous* draft's slotIndex. Mirrors `cancelUpload`'s `isStalePreview`. + +function cancelSlotIndex(preview, currentEpoch) { + if (preview?.slotIndex === undefined) return undefined; + const isStale = + preview.uploadEpoch !== undefined && preview.uploadEpoch !== currentEpoch; + return isStale ? undefined : preview.slotIndex; +} + +test("cancelling a preview from the current draft nulls its slot", () => { + assert.equal(cancelSlotIndex({ slotIndex: 1, uploadEpoch: 3 }, 3), 1); +}); + +test("cancelling a stale preview does not null the new draft's slot", () => { + // Draft A reserved slot 0 at epoch 0; draft B now owns slot 0. Cancelling + // A's leftover preview must leave B's attachment intact. + assert.equal(cancelSlotIndex({ slotIndex: 0, uploadEpoch: 0 }, 1), undefined); +}); + +test("cancelling a preview with no slot is a no-op for slots", () => { + // `handlePaperclip`'s native-picker preview has no reserved slot. + assert.equal(cancelSlotIndex({ uploadEpoch: 0 }, 0), undefined); +}); + +// ── Retiring in-flight uploads at a draft boundary (pure logic) ──────── +// Bumping the epoch alone discards descriptors but leaves the previous draft's +// preview rows on screen and its uploads counted, which keeps `isUploading` +// true and holds the *new* draft's send gate closed. A wholesale replacement +// must therefore retire those uploads outright. Mirrors `beginNewDraftEpoch`. + +function beginNewDraftEpoch(state) { + const next = { + epoch: state.epoch + 1, + active: new Set(state.active), + canceled: new Set(state.canceled), + previews: state.previews, + uploadingCount: state.uploadingCount, + }; + if (next.active.size === 0) return next; + // Mirrors the real callback: snapshot, clear the live set, then schedule the + // updaters. `applyUpdates` below runs them afterwards, the way React does. + const retiredIds = new Set(next.active); + const retiredCount = retiredIds.size; + next.active.clear(); + for (const id of retiredIds) next.canceled.add(id); + next.pendingUpdates = [ + (s) => { + s.previews = s.previews.filter((preview) => !retiredIds.has(preview.id)); + }, + (s) => { + s.uploadingCount = Math.max(0, s.uploadingCount - retiredCount); + }, + ]; + return next; +} + +/** Run the scheduled state updaters, as React does after the event handler. */ +function applyUpdates(state) { + for (const update of state.pendingUpdates ?? []) update(state); + state.pendingUpdates = []; + return state; +} + +test("a draft boundary retires in-flight uploads so the new draft can send", () => { + // Draft A has one upload in flight; switching to draft B must leave B with + // no previews and nothing counted as uploading. + const after = applyUpdates( + beginNewDraftEpoch({ + epoch: 0, + active: new Set([1]), + canceled: new Set(), + previews: [{ id: 1, slotIndex: 0, uploadEpoch: 0 }], + uploadingCount: 1, + }), + ); + assert.equal(after.epoch, 1); + assert.deepEqual(after.previews, []); + assert.equal(after.uploadingCount, 0); + assert.equal(after.active.size, 0); + // Canceled so the late completion/error paths stay silent in the new draft. + assert.ok(after.canceled.has(1)); +}); + +test("retiring several concurrent uploads clears the count exactly once each", () => { + const after = applyUpdates( + beginNewDraftEpoch({ + epoch: 4, + active: new Set([7, 8, 9]), + canceled: new Set(), + previews: [{ id: 7 }, { id: 8 }, { id: 9 }], + uploadingCount: 3, + }), + ); + assert.equal(after.uploadingCount, 0); + assert.deepEqual(after.previews, []); +}); + +test("a draft boundary with no uploads in flight still advances the epoch", () => { + const after = applyUpdates( + beginNewDraftEpoch({ + epoch: 2, + active: new Set(), + canceled: new Set(), + previews: [], + uploadingCount: 0, + }), + ); + assert.equal(after.epoch, 3); + assert.equal(after.uploadingCount, 0); +}); + +test("the retired count never drives uploadingCount negative", () => { + // Defensive: a preview already settled by finishUpload must not be + // double-decremented into a negative count that would wedge the gate. + const after = applyUpdates( + beginNewDraftEpoch({ + epoch: 0, + active: new Set([1, 2]), + canceled: new Set(), + previews: [{ id: 1 }, { id: 2 }], + uploadingCount: 1, + }), + ); + assert.equal(after.uploadingCount, 0); +}); + +test("retirement holds even though the live active set is cleared first", () => { + // Regression: the updaters must not read the live `active` set, which is + // emptied before React runs them. Closing over it filtered against an empty + // set and subtracted 0, leaving the stale preview and a stuck send gate. + const state = beginNewDraftEpoch({ + epoch: 0, + active: new Set([1]), + canceled: new Set(), + previews: [{ id: 1 }], + uploadingCount: 1, + }); + assert.equal(state.active.size, 0, "live set is cleared before updates run"); + // Updates land only now — after the clear — exactly as React schedules them. + applyUpdates(state); + assert.deepEqual(state.previews, []); + assert.equal(state.uploadingCount, 0); +}); + +test("replayed updaters stay idempotent", () => { + // React may invoke an updater more than once (StrictMode double-render). + const state = beginNewDraftEpoch({ + epoch: 0, + active: new Set([1]), + canceled: new Set(), + previews: [{ id: 1 }], + uploadingCount: 1, + }); + const updates = state.pendingUpdates; + for (const update of updates) update(state); + for (const update of updates) update(state); + assert.deepEqual(state.previews, []); + assert.equal(state.uploadingCount, 0); +}); + +// ── Edit mode while an upload is in flight ──────────────────────────── +// Immediate photo/file uploads reserve null slots that are absent from the +// compacted `pendingImeta` snapshot. MessageComposer therefore rejects edit +// entry while an upload is active, leaving the current draft and upload epoch +// untouched. Once the upload settles, normal edit snapshot/restore proceeds. + +function attemptEditModeRoundTrip({ isUploading, draft = [] }) { + const uploaded = { sha256: "ffff", url: "in-flight.png" }; + const editTargetImeta = [{ sha256: "eeee", url: "edit-target.png" }]; + let slots = [...draft]; + let epoch = 0; + + if (isUploading) { + slots = [...slots, null]; + return { + editEntered: false, + epoch, + restoredDraft: slots, + }; + } + + const snapshot = [...slots]; + epoch += 1; + slots = editTargetImeta; + epoch += 1; + slots = snapshot; + + return { + editEntered: true, + epoch, + restoredDraft: slots, + uploaded, + }; +} + +test("edit entry is rejected without replacing a draft that is uploading", () => { + const existing = { sha256: "aaaa", url: "already-there.png" }; + const result = attemptEditModeRoundTrip({ + draft: [existing], + isUploading: true, + }); + + assert.equal(result.editEntered, false); + assert.equal(result.epoch, 0, "the current draft epoch must not be retired"); + assert.deepEqual(result.restoredDraft, [existing, null]); +}); + +test("edit entry proceeds normally after uploads settle", () => { + const existing = { sha256: "aaaa", url: "already-there.png" }; + const result = attemptEditModeRoundTrip({ + draft: [existing], + isUploading: false, + }); + + assert.equal(result.editEntered, true); + assert.equal(result.epoch, 2); + assert.deepEqual(result.restoredDraft, [existing]); +}); diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index b4c3cae44..374d39281 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -5,7 +5,10 @@ import { pickAndUploadMedia, uploadMediaBytes, } from "@/shared/api/tauri"; +import { uploadMediaFile } from "@/shared/api/tauriMedia"; import type { QueuedMediaAttachment } from "./backgroundMediaUploadStore"; +import { applyImetaUpdate, compactImetaSlots } from "./imetaSlots"; +import { isVideoFile, videoMimeForFile } from "./videoFileType"; /** * First 4 hex chars of the sha256 — used as a short display name. @@ -33,6 +36,12 @@ export type UploadingAttachmentPreview = { slotIndex?: number; spoilered?: boolean; type?: string; + /** + * Upload epoch this preview was created in. Cancel handling compares it + * against the current epoch so a preview left over from a replaced draft + * cannot null a slot belonging to the draft now on screen. + */ + uploadEpoch?: number; }; /** Correlation id for the Rust `media-upload-progress` events. */ @@ -85,9 +94,16 @@ type CapturedVideoPoster = { async function captureVideoPosterFrame( file: File, ): Promise { - if (!file.type.startsWith("video/")) return null; + const videoMime = videoMimeForFile(file); + if (!videoMime) return null; - const objectUrl = URL.createObjectURL(file); + // A blob URL inherits the File's own MIME type, so a video whose type is + // empty or `application/octet-stream` would be rejected by the
); @@ -589,9 +648,10 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ @@ -620,13 +680,13 @@ export const ComposerAttachments = React.memo(function ComposerAttachments({ ); })} {queuedPreviews.map((preview) => { - const isMedia = - preview.type?.startsWith("image/") || - preview.type?.startsWith("video/"); + const isVideo = preview.type?.startsWith("video/") ?? false; + const isMedia = preview.type?.startsWith("image/") || isVideo; return ( {preview.spoilered ? "Remove spoiler" : "Mark as spoiler"} diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6f79daa60..9e1fee53d 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -329,8 +329,8 @@ function MessageComposerImpl({ }, [isDeferredEditPending, onDeferredEditPendingChange]); // biome-ignore lint/correctness/useExhaustiveDependencies: editTarget?.id is the trigger React.useEffect(() => { + if (editTarget && media.isUploading) return onCancelEdit?.(); if (editTarget) { - // Preserve the user's in-flight draft while editing another message. preEditSnapshotRef.current = { content: syncComposerContentFromEditor(), pendingImeta: [...media.pendingImetaRef.current], @@ -555,6 +555,7 @@ function MessageComposerImpl({ (!trimmed && !hasMedia) || disabledRef.current || isSendingRef.current || + isUploadingRef.current || mentionSendFlow.isPreparingMentionSend ) { return; @@ -804,14 +805,13 @@ function MessageComposerImpl({ const sendDisabled = React.useMemo( () => composerDisabled || - (editTarget !== null && media.isUploading) || + media.isUploading || mentionSendFlow.isPreparingMentionSend || (isContentEmpty && media.pendingImeta.length === 0 && media.queuedAttachments.length === 0), [ composerDisabled, - editTarget, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, @@ -1007,15 +1007,7 @@ function MessageComposerImpl({ - + {linkEditor.card} {linkEditor.dialog} diff --git a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx index 20f50924f..c72686a64 100644 --- a/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx +++ b/desktop/src/features/messages/ui/NonMemberMentionDialog.tsx @@ -7,8 +7,11 @@ import { AlertDialogTitle, } from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; +import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; type NonMemberMentionDialogProps = { + /** False in a private channel the viewer doesn't own/administer. */ + canInvite: boolean; error: string | null; isInvitePending: boolean; names: string[]; @@ -19,6 +22,7 @@ type NonMemberMentionDialogProps = { }; export function NonMemberMentionDialog({ + canInvite, error, isInvitePending, names, @@ -43,7 +47,10 @@ export function NonMemberMentionDialog({ {names.join(", ")} {names.length === 1 ? "is" : "are"} not in this - channel. Invite them to the channel, or send without inviting them. + channel.{" "} + {canInvite + ? "Invite them to the channel, or send without inviting them." + : `${PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} You can still send without inviting them.`} {error ? ( @@ -59,16 +66,18 @@ export function NonMemberMentionDialog({ type="button" variant="outline" > - Do nothing - - + {canInvite ? ( + + ) : null} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6ba9f6905..647ad4cbe 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -10,7 +10,12 @@ import { useStartManagedAgentMutation, } from "@/features/agents/hooks"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; -import { useAddChannelMembersMutation } from "@/features/channels/hooks"; +import { + useAddChannelMembersMutation, + useCanAddChannelMembers, +} from "@/features/channels/hooks"; +import { PRIVATE_CHANNEL_ADD_DENIED_MESSAGE } from "@/features/channels/lib/channelMemberAdmission"; +import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgentMentionError"; import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/effectiveExplicitAgentPubkeys"; import { prepareBackgroundMediaUpload, @@ -87,10 +92,6 @@ type UseMentionSendFlowOptions = { }) => void; resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; }; -const DM_THREAD_AGENT_MENTION_ERROR = - "Agents must already be in a DM to be mentioned in its threads. Start a new conversation that includes the agent."; -const DM_THREAD_MEMBERS_LOADING_ERROR = - "Checking conversation members. Try again in a moment."; export function useMentionSendFlow({ channelId, channelLinks, @@ -136,6 +137,7 @@ export function useMentionSendFlow({ }; }, []); const addMembersMutation = useAddChannelMembersMutation(channelId); + const canInviteNonMembers = useCanAddChannelMembers(channelId); const attachAgentMutation = useAttachManagedAgentToChannelMutation(channelId); const createPersonaAgentMutation = useCreateChannelManagedAgentMutation(channelId); @@ -684,32 +686,17 @@ export function useMentionSendFlow({ ( trimmed: string, capturedThreadContext: SendMessageWithMentionFlowInput["capturedThreadContext"], - ) => { - if (channelType !== "dm" || capturedThreadContext == null) { - return null; - } - - if (mentions.extractMentionPersonas(trimmed).length > 0) { - return DM_THREAD_AGENT_MENTION_ERROR; - } - - const agentPubkeys = mentions - .extractMentionPubkeys(trimmed) - .filter(mentions.isAgentPubkey); - if (agentPubkeys.length === 0) { - return null; - } - - if (!mentions.hasResolvedMembers) { - return DM_THREAD_MEMBERS_LOADING_ERROR; - } - - return agentPubkeys.some( - (pubkey) => !mentions.memberPubkeys.has(normalizePubkey(pubkey)), - ) - ? DM_THREAD_AGENT_MENTION_ERROR - : null; - }, + ) => + dmThreadAgentMentionError({ + trimmed, + isThreadReply: capturedThreadContext != null, + channelType, + extractMentionPersonas: mentions.extractMentionPersonas, + extractMentionPubkeys: mentions.extractMentionPubkeys, + isAgentPubkey: mentions.isAgentPubkey, + hasResolvedMembers: mentions.hasResolvedMembers, + memberPubkeys: mentions.memberPubkeys, + }), [ channelType, mentions.extractMentionPersonas, @@ -889,6 +876,12 @@ export function useMentionSendFlow({ const handleInviteNonMembers = React.useCallback(() => { if (!pendingNonMemberSend) return; + // The dialog hides Invite in this case; this guards the keyboard/programmatic + // path so we surface the reason instead of a raw relay rejection. + if (!canInviteNonMembers) { + setNonMemberPromptError(PRIVATE_CHANNEL_ADD_DENIED_MESSAGE); + return; + } const invitedPubkeys = new Set( pendingNonMemberSend.nonMemberPubkeys.map(normalizePubkey), @@ -963,6 +956,7 @@ export function useMentionSendFlow({ }); }, [ addMembersMutation, + canInviteNonMembers, completeSend, getManagedAgentsByPubkey, mentions.isAgentPubkey, @@ -975,25 +969,29 @@ export function useMentionSendFlow({ }, []); return { - dismissNonMemberPrompt, - isInvitePending: - isMentionSendPending || - isCompleteSendPending || - addMembersMutation.isPending || - attachAgentMutation.isPending || - createPersonaAgentMutation.isPending || - startAgentMutation.isPending, isPreparingMentionSend: isMentionSendPending || isCompleteSendPending || attachAgentMutation.isPending || createPersonaAgentMutation.isPending || startAgentMutation.isPending, - nonMemberPromptError, - pendingNonMemberNames, - pendingNonMemberSend, + /** Spread straight into `NonMemberMentionDialog`. */ + nonMemberPromptProps: { + canInvite: canInviteNonMembers, + error: nonMemberPromptError, + isInvitePending: + isMentionSendPending || + isCompleteSendPending || + addMembersMutation.isPending || + attachAgentMutation.isPending || + createPersonaAgentMutation.isPending || + startAgentMutation.isPending, + names: pendingNonMemberNames, + onDismiss: dismissNonMemberPrompt, + onDoNothing: handleSendWithoutInviting, + onInvite: handleInviteNonMembers, + open: pendingNonMemberSend !== null, + }, sendMessageWithMentionFlow, - sendWithoutInviting: handleSendWithoutInviting, - inviteNonMembers: handleInviteNonMembers, }; } diff --git a/desktop/src/features/messages/ui/useNewMessageRecipients.ts b/desktop/src/features/messages/ui/useNewMessageRecipients.ts index c9ea05e42..494ef124d 100644 --- a/desktop/src/features/messages/ui/useNewMessageRecipients.ts +++ b/desktop/src/features/messages/ui/useNewMessageRecipients.ts @@ -111,6 +111,7 @@ export function useNewMessageRecipients({ : null; const eligibleAgentPubkeys = getMentionableAgentPubkeys({ currentPubkey, + eligibilityScope: { type: "community" }, managedAgentPubkeys: (managedAgentsQuery.data ?? []).map( (agent) => agent.pubkey, ), diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 72d1a0338..d70ac60b2 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -209,6 +209,29 @@ export function useNotificationSettings(pubkey?: string) { void refreshPermission(); }, [normalizedPubkey]); + React.useEffect(() => { + const refreshWhenVisible = () => { + if (document.visibilityState === "visible") { + void refreshPermission(); + } + }; + document.addEventListener("visibilitychange", refreshWhenVisible); + window.addEventListener("focus", refreshWhenVisible); + return () => { + document.removeEventListener("visibilitychange", refreshWhenVisible); + window.removeEventListener("focus", refreshWhenVisible); + }; + }, []); + + React.useEffect(() => { + if ( + settings.desktopEnabled && + (permission === "denied" || permission === "unsupported") + ) { + setSettings((current) => ({ ...current, desktopEnabled: false })); + } + }, [permission, settings.desktopEnabled]); + const setDesktopEnabled = React.useCallback(async (enabled: boolean) => { if (!enabled) { setErrorMessage(null); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 380521e0f..dbc21d9d2 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -8,9 +8,12 @@ import { } from "@tauri-apps/plugin-notification"; import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; -// Backend event emitted when the user clicks a native (Linux) notification. -// See src-tauri/src/commands/notifications.rs. +// Backend event emitted when a native Linux notification is clicked or a +// queued macOS activation becomes available. See src-tauri notification code. const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; +const TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS = "take_pending_activations"; +const MACOS_NOTIFICATION_PERMISSION_STATE = "notification_permission_state"; +const REQUEST_MACOS_NOTIFICATION_ACCESS = "request_notification_access"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -120,11 +123,29 @@ function dispatchDesktopNotificationTarget(target: DesktopNotificationTarget) { ); } +function shouldUseMacDevelopmentFallback(error: unknown): boolean { + return String(error).includes("not running from an app bundle"); +} + export async function getDesktopNotificationPermissionState(): Promise { if (!hasNotificationApi()) { return "unsupported"; } + if (isTauri() && isMacPlatform()) { + try { + return await invoke( + MACOS_NOTIFICATION_PERMISSION_STATE, + ); + } catch (error) { + // The native API rejects the unbundled executable used by `tauri dev`. + // Preserve that development path through the plugin-backed shim. + if (!shouldUseMacDevelopmentFallback(error)) { + return "default"; + } + } + } + if (window.Notification.permission !== "default") { return window.Notification.permission; } @@ -152,7 +173,18 @@ export async function requestDesktopNotificationAccess(): Promise { + const request = + isTauri() && isMacPlatform() + ? invoke(REQUEST_MACOS_NOTIFICATION_ACCESS).catch( + (error) => { + if (shouldUseMacDevelopmentFallback(error)) { + return requestPermission(); + } + throw error; + }, + ) + : requestPermission(); + pendingPermissionRequest = request.finally(() => { pendingPermissionRequest = null; }); @@ -180,38 +212,73 @@ export async function listenForDesktopNotificationActions( let nativeUnlisten: (() => void) | null = null; if (isTauri()) { - try { - pluginListener = await onAction((notification) => { - const target = parseNotificationTarget( - notification.extra?.buzzNotificationTarget, - ); - if (!target) { - return; - } + const usesMacActivationQueue = isMacPlatform(); - dispatchDesktopNotificationTarget(target); - }); - } catch { - pluginListener = null; - } - - // Clicks on Linux notifications come back via a backend event rather than - // the plugin's onAction (whose connection is torn down before it can fire). - try { - nativeUnlisten = await listen( - NATIVE_NOTIFICATION_ACTIVATED_EVENT, - (event) => { - const target = parseNotificationTarget(event.payload); + if (!isLinuxPlatform() && !usesMacActivationQueue) { + try { + pluginListener = await onAction((notification) => { + const target = parseNotificationTarget( + notification.extra?.buzzNotificationTarget, + ); if (!target) { return; } dispatchDesktopNotificationTarget(target); + }); + } catch { + pluginListener = null; + } + } + + // Linux forwards the target as the event payload. macOS queues targets in + // Rust first so cold-start clicks survive until this listener is mounted. + const dispatchNativeActivations = async (payload?: unknown) => { + if (usesMacActivationQueue) { + const targets = await invoke( + TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, + ); + for (const pendingTarget of targets) { + const target = parseNotificationTarget(pendingTarget); + if (target) { + dispatchDesktopNotificationTarget(target); + } + } + return; + } + + const target = parseNotificationTarget(payload); + if (target) { + dispatchDesktopNotificationTarget(target); + } + }; + + try { + nativeUnlisten = await listen( + NATIVE_NOTIFICATION_ACTIVATED_EVENT, + (event) => { + void dispatchNativeActivations(event.payload).catch((error) => { + console.error( + "Failed to dispatch native notification activation", + error, + ); + }); }, ); } catch { nativeUnlisten = null; } + + if (nativeUnlisten && usesMacActivationQueue) { + try { + await dispatchNativeActivations(); + } catch (error) { + console.error( + "Failed to drain pending macOS notification activations", + error, + ); + } + } } return () => { @@ -293,11 +360,10 @@ export async function sendDesktopNotification( return false; } - // On Linux the bundled notification plugin posts via a D-Bus connection that - // it drops immediately; GNOME 46+ then dismisses the notification before it - // is seen. Route through a backend command that keeps the connection alive. + // Linux needs a retained D-Bus connection. macOS needs a native notification + // center delegate because the Tauri plugin does not deliver desktop clicks. // See src-tauri/src/commands/notifications.rs. - if (isTauri() && isLinuxPlatform()) { + if (isTauri() && (isLinuxPlatform() || isMacPlatform())) { try { await invoke("show_native_notification", { title: payload.title, @@ -306,7 +372,12 @@ export async function sendDesktopNotification( }); return true; } catch { - return false; + if (!isMacPlatform()) { + return false; + } + // UNUserNotificationCenter is unavailable to the unbundled executable + // used by Tauri dev. Preserve the previous macOS development behavior by + // falling through to the notification plugin; packaged apps use native UN. } } diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index fa50552ba..6028b36c6 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -54,7 +54,7 @@ type InviteRedeemFormProps = { initialValue?: string; isRedeeming: boolean; onCancel: () => void; - onConnect?: (relayWsUrl: string, token?: string) => void; + onConnect?: (relayWsUrl: string) => void; onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void; placeholder?: string; variant?: "add-community" | "default" | "onboarding-spotlight"; @@ -76,8 +76,6 @@ export function InviteRedeemForm({ const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState( defaultRelayUrl ?? "", ); - const [apiToken, setApiToken] = React.useState(""); - const [showApiToken, setShowApiToken] = React.useState(false); const [joinPolicy, setJoinPolicy] = React.useState(null); const [policyTarget, setPolicyTarget] = React.useState<{ relayWsUrl: string; @@ -158,7 +156,7 @@ export function InviteRedeemForm({ try { const policy = await getJoinPolicy(normalizedRelayUrl, "native"); if (!policy) { - onConnect?.(normalizedRelayUrl, apiToken.trim() || undefined); + onConnect?.(normalizedRelayUrl); return; } @@ -187,7 +185,7 @@ export function InviteRedeemForm({ return; } - onConnect?.(normalizedRelayUrl, apiToken.trim() || undefined); + onConnect?.(normalizedRelayUrl); } catch (policyFetchError) { setPolicyError(inviteErrorMessage(policyFetchError)); } finally { @@ -252,7 +250,6 @@ export function InviteRedeemForm({ [ ageConfirmed, agreementConfirmed, - apiToken, bareCodeRelayUrl, joinPolicy, normalizedRelayUrl, @@ -469,55 +466,6 @@ export function InviteRedeemForm({ ) : null} - {isAddCommunity && normalizedRelayUrl ? ( - showApiToken ? ( -
-
- - -
- setApiToken(event.target.value)} - placeholder="buzz_…" - type="password" - value={apiToken} - /> -
- ) : ( - - ) - ) : null} - {policyError ? (

{policyError}

) : null} diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 52245da57..55048a638 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -148,6 +148,7 @@ function useAgentCandidates() { ); const mentionable = getMentionableAgentPubkeys({ currentPubkey: identityQuery.data?.pubkey, + eligibilityScope: { type: "community" }, managedAgentPubkeys: managedByPubkey.keys(), relayAgents, sharedChannelIds: getSharedChannelIds(channelsQuery.data), diff --git a/desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs b/desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs new file mode 100644 index 000000000..94a554b8c --- /dev/null +++ b/desktop/src/features/settings/lib/appearanceScopeCopy.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { appearanceCommunityLabel } from "./appearanceScopeCopy.ts"; + +test("labels the active community by name", () => { + assert.equal(appearanceCommunityLabel("Block Builders"), "Block Builders"); +}); + +test("trims surrounding whitespace from the community name", () => { + assert.equal(appearanceCommunityLabel(" Buzz HQ "), "Buzz HQ"); +}); + +test("falls back to a generic label when no community is active", () => { + assert.equal(appearanceCommunityLabel(null), "this community"); + assert.equal(appearanceCommunityLabel(undefined), "this community"); + assert.equal(appearanceCommunityLabel(""), "this community"); + assert.equal(appearanceCommunityLabel(" "), "this community"); +}); diff --git a/desktop/src/features/settings/lib/appearanceScopeCopy.ts b/desktop/src/features/settings/lib/appearanceScopeCopy.ts new file mode 100644 index 000000000..42de7f7fd --- /dev/null +++ b/desktop/src/features/settings/lib/appearanceScopeCopy.ts @@ -0,0 +1,20 @@ +/** + * Copy helper for the Appearance settings panel's per-community scoping. + * + * Theme, mode, and accent are saved per community (see + * `shared/theme/CommunityThemeController`), so the panel badges the community + * being customized. Kept as a pure function so the copy is unit-testable + * without rendering the settings tree. + */ + +/** + * Display label for the community whose appearance is being edited. + * Falls back to a generic phrase when no community is active or the + * stored name is blank. + */ +export function appearanceCommunityLabel( + communityName: string | null | undefined, +): string { + const trimmed = communityName?.trim(); + return trimmed ? trimmed : "this community"; +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 162150e7c..901c9152b 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -38,7 +38,10 @@ import { type ThreadViewMode, } from "@/features/channels/lib/threadViewModePreference"; import { cn } from "@/shared/lib/cn"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; +import { SectionHeader } from "@/shared/ui/PageHeader"; import { DropdownMenu, DropdownMenuContent, @@ -69,6 +72,7 @@ import { useThemePreviewVars, withAccentPreviewVars, } from "@/shared/theme/useThemePreviewVars"; +import { appearanceCommunityLabel } from "../lib/appearanceScopeCopy"; import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard"; import { HarnessesSettingsPanel } from "./HarnessesSettingsPanel"; import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; @@ -429,6 +433,13 @@ function ThemeSettingsCard() { setFollowSystem, } = useTheme(); + // Per-community scoping labels only earn their place when the user is + // actually in more than one community; with a single community there is + // nothing to disambiguate. + const { activeCommunity, communities } = useCommunities(); + const showCommunityScope = communities.length > 1; + const communityLabel = appearanceCommunityLabel(activeCommunity?.name); + // Buzz themes pin a neutral accent (GitHub black in light, white in dark), // so the accent picker is hidden while a Buzz theme is active. `themeName` is // the effective theme, so this also covers System mode resolving to Buzz. @@ -529,6 +540,34 @@ function ThemeSettingsCard() { description="Choose a theme for Buzz." /> + {/* Mode, theme, and accent are saved per community + (CommunityThemeController restores them on switch). When the user is + in multiple communities, a subheader with an inline badge names the + community being edited; with one community there is nothing to + disambiguate, so no scoping labels are shown. */} + {showCommunityScope ? ( + + Theme{" "} + + (per community) + + {activeCommunity ? ( + + {communityLabel} + + ) : null} + + } + /> + ) : null} + {/* Mode selector: System / Light / Dark */}
{( @@ -684,6 +723,11 @@ const THREAD_VIEW_MODE_OPTIONS: { */ function ThreadLayoutSetting() { const threadViewMode = useThreadViewMode(); + // The "(all communities)" qualifier contrasts with the per-community theme + // controls above; it's only meaningful when the user has multiple + // communities. + const { communities } = useCommunities(); + const showCommunityScope = communities.length > 1; const activeOption = THREAD_VIEW_MODE_OPTIONS.find( (option) => option.value === threadViewMode, @@ -693,7 +737,15 @@ function ThreadLayoutSetting() {
-

Thread layout

+

+ Thread layout + {showCommunityScope ? ( + + {" "} + (all communities) + + ) : null} +

{activeOption.description}

diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 7d9472f70..753451f92 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { FeatureGate } from "@/shared/features"; import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; +import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity"; import type { Community } from "@/features/communities/types"; import { AddCommunityDialog } from "@/features/communities/ui/AddCommunityDialog"; import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; @@ -138,7 +139,7 @@ type AppSidebarProps = { id: string, updates: Partial>, ) => void; - onRemoveCommunity: (id: string) => void; + onRemoveCommunity: (id: string) => Promise; onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index a87c8e80b..26a0b324e 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -48,7 +48,6 @@ type CommunityRailProps = { id: string, updates: Partial>, ) => void; - onRemoveCommunity: (id: string) => void; onReorderCommunities: (orderedIds: string[]) => void; }; @@ -305,7 +304,6 @@ export function CommunityRail({ onSwitchCommunity, onAddCommunity, onUpdateCommunity, - onRemoveCommunity, onReorderCommunities, }: CommunityRailProps) { const { unreadByCommunity, markCommunityRead } = useCommunityUnread( @@ -423,11 +421,9 @@ export function CommunityRail({ Add community 1} onOpenChange={(open) => { if (!open) setEditingCommunity(null); }} - onRemove={onRemoveCommunity} onSave={onUpdateCommunity} open={editingCommunity !== null} community={editingCommunity} diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 9c6ba0f9b..80ced6570 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -10,6 +10,7 @@ import { } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; +import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity"; import type { Community } from "@/features/communities/types"; import { CommunitySwitcher } from "@/features/communities/ui/CommunitySwitcher"; import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; @@ -22,7 +23,7 @@ type SidebarProfileCardProps = { isPresencePending?: boolean; onOpenAddCommunity: () => void; onOpenSettings: (section?: SettingsSection) => void; - onRemoveCommunity: (id: string) => void; + onRemoveCommunity: (id: string) => Promise; onSendFeedback?: () => void; onSetPresenceStatus?: (status: PresenceStatus) => void; onSetUserStatus: (text: string, emoji: string) => void; diff --git a/desktop/src/shared/api/customEmoji.test.mjs b/desktop/src/shared/api/customEmoji.test.mjs index d499386b5..0dcfc5bdf 100644 --- a/desktop/src/shared/api/customEmoji.test.mjs +++ b/desktop/src/shared/api/customEmoji.test.mjs @@ -85,6 +85,11 @@ test("normalizeShortcode rejects invalid chars and empties", () => { assert.equal(normalizeShortcode(""), null); }); +test("normalizeShortcode enforces the 64-character boundary", () => { + assert.equal(normalizeShortcode("a".repeat(64)), "a".repeat(64)); + assert.equal(normalizeShortcode("a".repeat(65)), null); +}); + test("suggestShortcodeFromFilename derives a valid name from common filenames", () => { assert.equal( suggestShortcodeFromFilename("Party Parrot.gif"), diff --git a/desktop/src/shared/api/customEmoji.ts b/desktop/src/shared/api/customEmoji.ts index 6020ec592..ccd618f26 100644 --- a/desktop/src/shared/api/customEmoji.ts +++ b/desktop/src/shared/api/customEmoji.ts @@ -44,6 +44,7 @@ export function reactionEmojiUrl( /** NIP-30 shortcode chars. Matches the relay's `[A-Za-z0-9_-]` validation. */ const SHORTCODE_RE = /^[a-z0-9_-]+$/; +const MAX_SHORTCODE_LENGTH = 64; /** * Normalize a shortcode the same way the relay does: strip surrounding colons @@ -52,7 +53,9 @@ const SHORTCODE_RE = /^[a-z0-9_-]+$/; export function normalizeShortcode(raw: string): string | null { const stripped = raw.trim().replace(/^:+/, "").replace(/:+$/, ""); const lower = stripped.toLowerCase(); - return SHORTCODE_RE.test(lower) ? lower : null; + return lower.length <= MAX_SHORTCODE_LENGTH && SHORTCODE_RE.test(lower) + ? lower + : null; } /** diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts index 62cae9005..bc2a473a3 100644 --- a/desktop/src/shared/api/relayMembers.ts +++ b/desktop/src/shared/api/relayMembers.ts @@ -127,8 +127,10 @@ export async function listRelayMembers(): Promise { return event ? relayMembersFromEvent(event) : []; } -async function relayRequiresMembership(): Promise { - return invokeTauri("relay_requires_membership"); +export async function relayRequiresMembership( + relayUrl?: string, +): Promise { + return invokeTauri("relay_requires_membership", { relayUrl }); } export async function getMyRelayMembershipLookup(): Promise { diff --git a/desktop/src/shared/api/tauri.test.mjs b/desktop/src/shared/api/tauri.test.mjs index f4692a7d2..2273b55fc 100644 --- a/desktop/src/shared/api/tauri.test.mjs +++ b/desktop/src/shared/api/tauri.test.mjs @@ -211,6 +211,52 @@ test("fromRawAcpRuntimeCatalogEntry env round-trips through edit payload shape", ); }); +// ── max_parallelism → maxParallelism mapping ────────────────────────────────── + +test("fromRawAcpRuntimeCatalogEntry maps max_parallelism to maxParallelism when present", () => { + const raw = { + id: "openclaw", + label: "OpenClaw", + availability: "not_installed", + command: null, + source: "preset", + default_args: [], + can_auto_install: false, + requires_external_cli: false, + install_hint: "", + install_instructions_url: "", + max_parallelism: 5, + }; + const entry = fromRawAcpRuntimeCatalogEntry(raw); + assert.equal( + entry.maxParallelism, + 5, + "max_parallelism: 5 must map to maxParallelism: 5", + ); +}); + +test("fromRawAcpRuntimeCatalogEntry omits maxParallelism when max_parallelism is absent", () => { + const raw = { + id: "goose", + label: "Goose", + availability: "available", + command: "goose", + source: "builtin", + default_args: [], + can_auto_install: false, + requires_external_cli: false, + install_hint: "", + install_instructions_url: "", + // No max_parallelism field — uncapped harness. + }; + const entry = fromRawAcpRuntimeCatalogEntry(raw); + assert.equal( + entry.maxParallelism, + undefined, + "uncapped harness must have maxParallelism: undefined", + ); +}); + // ── Teardown ────────────────────────────────────────────────────────────────── test("teardown — restore Date.now", () => { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 0993853c6..2a5667e9e 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -62,8 +62,6 @@ type RawFeedItem = { created_at: number; channel_id: string | null; channel_name: string; - // Native FeedItemInfo.channel_type is Option: serde emits `null`, - // never omits the key. channel_type: string | null; tags: string[][]; category: "mention" | "needs_action" | "activity" | "agent_activity"; @@ -206,6 +204,7 @@ export type RawAcpRuntimeCatalogEntry = { source: "builtin" | "preset" | "custom"; /** Definition-level env vars for `source: custom` entries; absent for builtin/preset. */ definition_env?: Record; + max_parallelism?: number; }; export type { @@ -761,9 +760,10 @@ export function fromRawAcpRuntimeCatalogEntry( authStatus: entry.auth_status, loginHint: entry.login_hint ?? null, source: entry.source, - // Map definition_env (snake_case from Rust) to definitionEnv (camelCase). - // Absent when empty (Rust serialization skips empty BTreeMap) — default to {}. definitionEnv: entry.definition_env ?? {}, + ...(entry.max_parallelism !== undefined && { + maxParallelism: entry.max_parallelism, + }), }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 78f5d1aa3..24ef62578 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -535,13 +535,13 @@ export type AcpRuntimeCatalogEntry = { /** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */ source: "builtin" | "preset" | "custom"; /** - * Definition-level environment variables for `source: custom` entries. - * - * Populated by the backend from `HarnessDefinition.env` so the edit form can - * read them back without losing existing env vars on save. Always absent/empty - * for `builtin` and `preset` entries. + * Definition-level env vars for `source: custom` entries. Populated from + * `HarnessDefinition.env` so saves don't erase existing vars. Absent for + * builtin/preset entries. */ definitionEnv?: Record; + /** Spawn-time parallelism cap; absent for uncapped harnesses. */ + maxParallelism?: number; }; /** An AcpRuntimeCatalogEntry that is confirmed available — command and binaryPath are non-null. */ diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index f851e459a..f995a6359 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -46,6 +46,7 @@ export const KIND_CHANNEL_SECTIONS = 30078; export const KIND_CHANNEL_MUTES = 30078; export const KIND_CHANNEL_STARS = 30078; export const KIND_CHANNEL_SORT = 30078; +export const KIND_COMMUNITY_THEME = 30078; // NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published // backend-side as secrets-stripped snapshots; the inbound sync hook subscribes // to all three to patch local records. Mirror of buzz-core's KIND_PERSONA etc. diff --git a/desktop/src/shared/theme/CommunityThemeController.tsx b/desktop/src/shared/theme/CommunityThemeController.tsx new file mode 100644 index 000000000..518883a47 --- /dev/null +++ b/desktop/src/shared/theme/CommunityThemeController.tsx @@ -0,0 +1,208 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { relayClient } from "@/shared/api/relayClient"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { + DEFAULT_COMMUNITY_THEME, + cacheAndApplyCommunityTheme, + clearCommunityThemeOutbox, + communityThemeApplyExpectation, + communityThemePersistenceAction, + hasMigratedCommunityTheme, + markCommunityThemeMigrated, + readCommunityThemeOutbox, + readCommunityThemePreference, + sameCommunityThemePreference, + writeCommunityThemeOutbox, + writeCommunityThemePreference, + type CommunityThemePreference, +} from "./communityThemePreference"; +import { + CommunityThemeSyncManager, + isNewerCommunityThemeCoordinate, + shouldSeedCommunityTheme, + type RemoteCommunityTheme, +} from "./communityThemeSync"; +import { useTheme } from "./ThemeProvider"; + +export function CommunityThemeController() { + const { activeCommunity } = useCommunities(); + const identity = useIdentityQuery(); + const theme = useTheme(); + const pubkey = identity.data?.pubkey; + const relayUrl = activeCommunity?.relayUrl; + const managerRef = useRef(null); + const scopeRef = useRef(""); + const expectedAppliedRef = useRef(null); + const scopedPreferenceRef = useRef(null); + const lastRemoteRef = useRef({ createdAt: 0, eventId: "" }); + const initialPreferenceRef = useRef({ + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }); + + const currentPreferenceRef = useRef({ + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }); + currentPreferenceRef.current = { + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }; + + const applyPreference = useCallback( + (preference: CommunityThemePreference) => { + expectedAppliedRef.current = communityThemeApplyExpectation( + preference, + currentPreferenceRef.current, + ); + theme.applyAppearance(preference); + }, + [theme.applyAppearance], + ); + + useLayoutEffect(() => { + if (!pubkey || !relayUrl) return; + const local = readCommunityThemePreference(pubkey, relayUrl); + const dirty = readCommunityThemeOutbox(pubkey, relayUrl); + // Preserve the user's existing global appearance the first time this + // feature sees their current community. Later missing/malformed target + // records use the stable default so the previous community never leaks. + const fallback = hasMigratedCommunityTheme(pubkey) + ? DEFAULT_COMMUNITY_THEME + : initialPreferenceRef.current; + const scopedPreference = dirty ?? local ?? fallback; + scopedPreferenceRef.current = scopedPreference; + applyPreference(scopedPreference); + }, [pubkey, relayUrl, applyPreference]); + + useEffect(() => { + if (!pubkey || !relayUrl) return; + const scope = `${pubkey}:${relayUrl}`; + scopeRef.current = scope; + lastRemoteRef.current = { createdAt: 0, eventId: "" }; + const manager = new CommunityThemeSyncManager(pubkey, (published) => { + const last = lastRemoteRef.current; + if (isNewerCommunityThemeCoordinate(published, last)) { + lastRemoteRef.current = { + createdAt: published.createdAt, + eventId: published.eventId, + }; + } + clearCommunityThemeOutbox(pubkey, relayUrl, published.preference); + }); + managerRef.current = manager; + const durablePending = readCommunityThemeOutbox(pubkey, relayUrl); + if (durablePending) manager.publish(durablePending); + + const applyRemote = (remote: RemoteCommunityTheme) => { + if (scopeRef.current !== scope) return; + const last = lastRemoteRef.current; + if (!isNewerCommunityThemeCoordinate(remote, last)) { + return; + } + lastRemoteRef.current = { + createdAt: remote.createdAt, + eventId: remote.eventId, + }; + manager.acceptRemote(remote); + const dirty = readCommunityThemeOutbox(pubkey, relayUrl); + if (dirty) { + manager.publish(dirty); + return; + } + scopedPreferenceRef.current = remote.preference; + manager.cancelPendingPublish(); + cacheAndApplyCommunityTheme( + pubkey, + relayUrl, + remote.preference, + applyPreference, + ); + }; + + void manager.fetchRemote().then((result) => { + if (scopeRef.current !== scope) return; + if (result.status === "valid") { + applyRemote(result.remote); + markCommunityThemeMigrated(pubkey); + } else if (shouldSeedCommunityTheme(result)) { + const local = + readCommunityThemeOutbox(pubkey, relayUrl) ?? + readCommunityThemePreference(pubkey, relayUrl) ?? + scopedPreferenceRef.current ?? + DEFAULT_COMMUNITY_THEME; + writeCommunityThemePreference(pubkey, relayUrl, local); + writeCommunityThemeOutbox(pubkey, relayUrl, local); + markCommunityThemeMigrated(pubkey); + manager.publish(local); + } + // Invalid/future or unavailable records use the already-applied local + // fallback without publishing over relay state we cannot safely read. + }); + + let unsubscribe: (() => Promise) | null = null; + void manager.subscribe(applyRemote).then((dispose) => { + if (scopeRef.current !== scope) void dispose(); + else unsubscribe = dispose; + }); + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + void manager.fetchRemote().then((result) => { + if (result.status === "valid") { + applyRemote(result.remote); + return; + } + if (result.status !== "absent") return; + const pending = readCommunityThemeOutbox(pubkey, relayUrl); + if (pending) manager.publish(pending); + }); + }); + + return () => { + if (scopeRef.current === scope) scopeRef.current = ""; + manager.destroy(); + if (managerRef.current === manager) managerRef.current = null; + unsubscribeReconnect(); + if (unsubscribe) void unsubscribe(); + }; + }, [pubkey, relayUrl, applyPreference]); + + useEffect(() => { + if (!pubkey || !relayUrl) return; + const preference: CommunityThemePreference = { + version: 1, + theme: theme.selectedThemeName as CommunityThemePreference["theme"], + accent: theme.accentColor, + followSystem: theme.followSystem, + }; + const persistenceAction = communityThemePersistenceAction( + expectedAppliedRef.current, + preference, + ); + if (persistenceAction === "defer") return; + if (persistenceAction === "acknowledge") { + expectedAppliedRef.current = null; + return; + } + const stored = readCommunityThemePreference(pubkey, relayUrl); + if (stored && sameCommunityThemePreference(stored, preference)) return; + scopedPreferenceRef.current = preference; + if (!writeCommunityThemePreference(pubkey, relayUrl, preference)) return; + if (!writeCommunityThemeOutbox(pubkey, relayUrl, preference)) return; + managerRef.current?.publish(preference); + }, [ + pubkey, + relayUrl, + theme.selectedThemeName, + theme.accentColor, + theme.followSystem, + ]); + + return null; +} diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 5704676b5..459669434 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -60,6 +60,11 @@ type ThemeContextValue = { setTheme: (name: string) => void; setAccentColor: (color: string) => void; setFollowSystem: (enabled: boolean) => void; + applyAppearance: (appearance: { + theme: SyntaxThemeName; + accent: string; + followSystem: boolean; + }) => void; }; type ThemeProviderProps = { @@ -618,6 +623,31 @@ export function ThemeProvider({ setFollowSystemState(enabled); }, []); + const applyAppearance = useCallback( + (appearance: { + theme: SyntaxThemeName; + accent: string; + followSystem: boolean; + }) => { + // Write the complete preference before updating state so applyTheme reads + // the target community's accent in the same batch, never the previous one. + try { + window.localStorage.setItem(THEME_STORAGE_KEY, appearance.theme); + window.localStorage.setItem(ACCENT_STORAGE_KEY, appearance.accent); + window.localStorage.setItem( + FOLLOW_SYSTEM_KEY, + appearance.followSystem ? "true" : "false", + ); + } catch { + // Keep the active appearance responsive even if the local cache is full. + } + setSelectedTheme(appearance.theme); + setAccentColorState(appearance.accent); + setFollowSystemState(appearance.followSystem); + }, + [], + ); + const value: ThemeContextValue = { themeName: effectiveTheme, selectedThemeName: selectedTheme, @@ -630,6 +660,7 @@ export function ThemeProvider({ setTheme, setAccentColor, setFollowSystem, + applyAppearance, }; return ( diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs new file mode 100644 index 000000000..4764de06f --- /dev/null +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DEFAULT_COMMUNITY_THEME, + cacheAndApplyCommunityTheme, + clearCommunityThemeOutbox, + communityThemeApplyExpectation, + communityThemeOutboxKey, + communityThemePersistenceAction, + communityThemeStorageKey, + parseCommunityThemePreference, + readCommunityThemeOutbox, + readCommunityThemePreference, + writeCommunityThemeOutbox, + writeCommunityThemePreference, +} from "./communityThemePreference.ts"; + +function localStorageStub() { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => data.set(key, String(value)), + removeItem: (key) => data.delete(key), + }; +} + +test("parses only the versioned stable appearance contract", () => { + const valid = { + version: 1, + theme: "houston", + accent: "#a855f7", + followSystem: false, + }; + assert.deepEqual(parseCommunityThemePreference(valid), valid); + assert.equal(parseCommunityThemePreference({ ...valid, version: 2 }), null); + assert.equal( + parseCommunityThemePreference({ ...valid, theme: "future-theme" }), + null, + ); + assert.equal( + parseCommunityThemePreference({ ...valid, accent: "url(image)" }), + null, + ); + assert.equal( + parseCommunityThemePreference({ ...valid, followSystem: "false" }), + null, + ); +}); + +test("local preferences are isolated by pubkey and normalized relay", () => { + globalThis.window = { localStorage: localStorageStub() }; + const aliceA = { + ...DEFAULT_COMMUNITY_THEME, + theme: "houston", + followSystem: false, + }; + const aliceB = { ...DEFAULT_COMMUNITY_THEME, theme: "catppuccin-latte" }; + const bobA = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" }; + assert.equal( + writeCommunityThemePreference("alice", "WSS://A.EXAMPLE/", aliceA), + true, + ); + assert.equal( + writeCommunityThemePreference("alice", "wss://b.example", aliceB), + true, + ); + assert.equal( + writeCommunityThemePreference("bob", "wss://a.example", bobA), + true, + ); + assert.deepEqual( + readCommunityThemePreference("alice", "wss://a.example"), + aliceA, + ); + assert.deepEqual( + readCommunityThemePreference("alice", "wss://b.example/"), + aliceB, + ); + assert.deepEqual( + readCommunityThemePreference("bob", "wss://a.example"), + bobA, + ); + assert.notEqual( + communityThemeStorageKey("alice", "wss://a.example"), + communityThemeStorageKey("alice", "wss://b.example"), + ); +}); + +test("dirty outbox survives restart and clears only its exact revision", () => { + globalThis.window = { localStorage: localStorageStub() }; + const first = { ...DEFAULT_COMMUNITY_THEME, theme: "houston" }; + const second = { ...DEFAULT_COMMUNITY_THEME, accent: "#ef4444" }; + + assert.equal( + writeCommunityThemeOutbox("alice", "WSS://A.EXAMPLE/", first), + true, + ); + assert.deepEqual(readCommunityThemeOutbox("alice", "wss://a.example"), first); + writeCommunityThemeOutbox("alice", "wss://a.example", second); + clearCommunityThemeOutbox("alice", "wss://a.example", first); + assert.deepEqual( + readCommunityThemeOutbox("alice", "wss://a.example"), + second, + ); + clearCommunityThemeOutbox("alice", "wss://a.example", second); + assert.equal(readCommunityThemeOutbox("alice", "wss://a.example"), null); + assert.notEqual( + communityThemeOutboxKey("alice", "wss://a.example"), + communityThemeStorageKey("alice", "wss://a.example"), + ); +}); + +test("malformed local data returns null so switching can apply the safe default", () => { + globalThis.window = { localStorage: localStorageStub() }; + const key = communityThemeStorageKey("alice", "wss://broken.example"); + window.localStorage.setItem( + key, + JSON.stringify({ version: 1, theme: "missing" }), + ); + assert.equal( + readCommunityThemePreference("alice", "wss://broken.example"), + null, + ); + window.localStorage.setItem(key, "{"); + assert.equal( + readCommunityThemePreference("alice", "wss://broken.example"), + null, + ); +}); + +test("remote preference still applies when its local cache write fails", () => { + globalThis.window = { + localStorage: { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + }, + }; + let applied = null; + cacheAndApplyCommunityTheme( + "alice", + "wss://a.example", + DEFAULT_COMMUNITY_THEME, + (preference) => { + applied = preference; + }, + ); + assert.deepEqual(applied, DEFAULT_COMMUNITY_THEME); +}); + +test("already-applied relay state leaves the next user edit publishable", () => { + const applied = { + ...DEFAULT_COMMUNITY_THEME, + theme: "catppuccin-latte", + followSystem: false, + }; + + assert.equal(communityThemeApplyExpectation(applied, applied), null); + assert.deepEqual( + communityThemeApplyExpectation(applied, DEFAULT_COMMUNITY_THEME), + applied, + ); +}); + +test("community switch defers stale outgoing appearance persistence", () => { + const outgoing = { + ...DEFAULT_COMMUNITY_THEME, + theme: "houston", + followSystem: false, + }; + const incoming = { + ...DEFAULT_COMMUNITY_THEME, + theme: "catppuccin-latte", + }; + + assert.equal(communityThemePersistenceAction(incoming, outgoing), "defer"); + assert.equal( + communityThemePersistenceAction(incoming, incoming), + "acknowledge", + ); + assert.equal(communityThemePersistenceAction(null, incoming), "persist"); +}); diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts new file mode 100644 index 000000000..e525c95d4 --- /dev/null +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -0,0 +1,199 @@ +import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; +import { ACCENT_COLORS } from "./ThemeProvider"; +import { SYNTAX_THEMES, type SyntaxThemeName } from "./theme-loader"; + +const STORAGE_KEY_PREFIX = "buzz-community-theme.v1"; +const OUTBOX_KEY_PREFIX = "buzz-community-theme-outbox.v1"; +const MIGRATION_KEY_PREFIX = "buzz-community-theme-migrated.v1"; + +export type CommunityThemePreference = { + version: 1; + theme: SyntaxThemeName; + accent: string; + followSystem: boolean; +}; + +export const DEFAULT_COMMUNITY_THEME: CommunityThemePreference = Object.freeze({ + version: 1, + theme: "buzz", + accent: "#3b82f6", + followSystem: true, +}); + +const THEME_NAMES = new Set(SYNTAX_THEMES); +const ACCENTS = new Set(ACCENT_COLORS.map(({ value }) => value)); + +export function communityThemeStorageKey( + pubkey: string, + relayUrl: string, +): string { + return `${STORAGE_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +export function communityThemeOutboxKey( + pubkey: string, + relayUrl: string, +): string { + return `${OUTBOX_KEY_PREFIX}:${pubkey}:${encodeURIComponent(normalizeRelayUrl(relayUrl))}`; +} + +export function parseCommunityThemePreference( + value: unknown, +): CommunityThemePreference | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const candidate = value as Record; + if ( + candidate.version !== 1 || + typeof candidate.theme !== "string" || + !THEME_NAMES.has(candidate.theme) || + typeof candidate.accent !== "string" || + !ACCENTS.has(candidate.accent) || + typeof candidate.followSystem !== "boolean" + ) { + return null; + } + return { + version: 1, + theme: candidate.theme as SyntaxThemeName, + accent: candidate.accent, + followSystem: candidate.followSystem, + }; +} + +export function readCommunityThemePreference( + pubkey: string, + relayUrl: string, +): CommunityThemePreference | null { + try { + const raw = window.localStorage.getItem( + communityThemeStorageKey(pubkey, relayUrl), + ); + return raw ? parseCommunityThemePreference(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +export function readCommunityThemeOutbox( + pubkey: string, + relayUrl: string, +): CommunityThemePreference | null { + try { + const raw = window.localStorage.getItem( + communityThemeOutboxKey(pubkey, relayUrl), + ); + return raw ? parseCommunityThemePreference(JSON.parse(raw)) : null; + } catch { + return null; + } +} + +export function writeCommunityThemeOutbox( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, +): boolean { + try { + window.localStorage.setItem( + communityThemeOutboxKey(pubkey, relayUrl), + JSON.stringify(preference), + ); + return true; + } catch { + return false; + } +} + +export function clearCommunityThemeOutbox( + pubkey: string, + relayUrl: string, + acknowledged: CommunityThemePreference, +): void { + const pending = readCommunityThemeOutbox(pubkey, relayUrl); + if (!pending || !sameCommunityThemePreference(pending, acknowledged)) return; + try { + window.localStorage.removeItem(communityThemeOutboxKey(pubkey, relayUrl)); + } catch { + // A later retry can safely publish the same replaceable event again. + } +} + +export function hasMigratedCommunityTheme(pubkey: string): boolean { + try { + return ( + window.localStorage.getItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`) === + "true" + ); + } catch { + return false; + } +} + +export function markCommunityThemeMigrated(pubkey: string): void { + try { + window.localStorage.setItem(`${MIGRATION_KEY_PREFIX}:${pubkey}`, "true"); + } catch { + // The preference itself remains usable in memory when storage is full. + } +} + +export function writeCommunityThemePreference( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, +): boolean { + try { + window.localStorage.setItem( + communityThemeStorageKey(pubkey, relayUrl), + JSON.stringify(preference), + ); + return true; + } catch { + return false; + } +} + +export function cacheAndApplyCommunityTheme( + pubkey: string, + relayUrl: string, + preference: CommunityThemePreference, + apply: (preference: CommunityThemePreference) => void, +): void { + writeCommunityThemePreference(pubkey, relayUrl, preference); + apply(preference); +} + +export function sameCommunityThemePreference( + left: CommunityThemePreference, + right: CommunityThemePreference, +): boolean { + return ( + left.theme === right.theme && + left.accent === right.accent && + left.followSystem === right.followSystem + ); +} + +export function communityThemeApplyExpectation( + preference: CommunityThemePreference, + current: CommunityThemePreference, +): CommunityThemePreference | null { + return sameCommunityThemePreference(preference, current) ? null : preference; +} + +/** + * Decide whether the current context value is safe to persist for this scope. + * Applying a scoped preference updates the outer ThemeProvider asynchronously, + * so renders that still expose the previous scope must be deferred. + */ +export function communityThemePersistenceAction( + expectedApplied: CommunityThemePreference | null, + current: CommunityThemePreference, +): "persist" | "defer" | "acknowledge" { + if (!expectedApplied) return "persist"; + return sameCommunityThemePreference(expectedApplied, current) + ? "acknowledge" + : "defer"; +} diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs new file mode 100644 index 000000000..567319d58 --- /dev/null +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -0,0 +1,445 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; +import { relayClient } from "@/shared/api/relayClient"; +import { + CommunityThemeSyncManager, + isNewerCommunityThemeCoordinate, + shouldSeedCommunityTheme, +} from "./communityThemeSync.ts"; + +const preference = { + version: 1, + theme: "houston", + accent: "#3b82f6", + followSystem: false, +}; + +function installFakeTimer() { + globalThis.window ??= {}; + let callback = null; + let delay = null; + const originalSet = window.setTimeout; + const originalClear = window.clearTimeout; + window.setTimeout = (fn, requestedDelay) => { + callback = fn; + delay = requestedDelay; + return 1; + }; + window.clearTimeout = () => { + callback = null; + delay = null; + }; + return { + fire: () => { + const fn = callback; + callback = null; + delay = null; + fn?.(); + }, + pending: () => callback !== null, + delay: () => delay, + restore: () => { + window.setTimeout = originalSet; + window.clearTimeout = originalClear; + }, + }; +} + +test("destroy cancels a debounced community write before relay teardown", () => { + const timer = installFakeTimer(); + const publishes = []; + mock.method(relayClient, "publishEvent", (...args) => { + publishes.push(args); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice"); + manager.publish(preference); + assert.equal(timer.pending(), true); + manager.destroy(); + assert.equal(timer.pending(), false); + timer.fire(); + assert.equal(publishes.length, 0); + } finally { + timer.restore(); + mock.reset(); + } +}); + +test("destroy is safe without a pending community write", () => { + const manager = new CommunityThemeSyncManager("alice"); + assert.doesNotThrow(() => manager.destroy()); + assert.equal(manager.getPending(), null); +}); + +function relayEvent(overrides = {}) { + return { + id: "event-id", + pubkey: "alice", + kind: 30078, + content: "not-decryptable", + created_at: 123, + tags: [["d", "community-theme"]], + ...overrides, + }; +} + +test("fetch distinguishes absent remote state from unreadable existing state", async () => { + mock.method(relayClient, "fetchEvents", () => Promise.resolve([])); + try { + const manager = new CommunityThemeSyncManager("alice"); + assert.deepEqual(await manager.fetchRemote(), { status: "absent" }); + } finally { + mock.reset(); + } + + mock.method(relayClient, "fetchEvents", () => + Promise.resolve([relayEvent()]), + ); + try { + const manager = new CommunityThemeSyncManager("alice"); + assert.deepEqual(await manager.fetchRemote(), { status: "invalid" }); + } finally { + mock.reset(); + } +}); + +test("fetch reports relay failures as unavailable rather than absent", async () => { + mock.method(relayClient, "fetchEvents", () => + Promise.reject(new Error("offline")), + ); + try { + const manager = new CommunityThemeSyncManager("alice"); + assert.deepEqual(await manager.fetchRemote(), { status: "unavailable" }); + } finally { + mock.reset(); + } +}); + +test("only confirmed absence permits seeding relay state", () => { + assert.equal(shouldSeedCommunityTheme({ status: "absent" }), true); + assert.equal(shouldSeedCommunityTheme({ status: "invalid" }), false); + assert.equal(shouldSeedCommunityTheme({ status: "unavailable" }), false); +}); + +test("acknowledged coordinates use relay same-second ordering", () => { + const acknowledged = { createdAt: 123, eventId: "published" }; + assert.equal( + isNewerCommunityThemeCoordinate( + { createdAt: 123, eventId: "a-winner" }, + acknowledged, + ), + true, + ); + assert.equal( + isNewerCommunityThemeCoordinate( + { createdAt: 123, eventId: "z-loser" }, + acknowledged, + ), + false, + ); +}); + +test("new remote invalidates no-op suppression for A to B to A", async () => { + const timer = installFakeTimer(); + const published = []; + const acknowledgements = []; + let signedEventId = "published-z"; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: signedEventId, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + manager.acceptRemote({ + preference: { ...preference, theme: "dracula" }, + createdAt: published[0].created_at, + eventId: "remote-a", + }); + signedEventId = "republished-a"; + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 2); + + assert.equal(acknowledgements.length, 2); + assert.equal(acknowledgements[1].eventId, "republished-a"); + assert.equal(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +test("serializes an in-flight publish before sending the latest edit", async () => { + const timer = installFakeTimer(); + const first = Promise.withResolvers(); + const published = []; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? first.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice"); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + const latest = { ...preference, theme: "dracula" }; + manager.publish(latest); + timer.fire(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(published.length, 1); + + first.resolve(); + await waitUntil(() => timer.pending()); + assert.equal(timer.delay(), 0); + timer.fire(); + await waitUntil(() => published.length === 2); + + assert.ok(published[1].created_at > published[0].created_at); + assert.deepEqual(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +test("republishes above a newer remote observed while publish is in flight", async () => { + const timer = installFakeTimer(); + const first = Promise.withResolvers(); + const published = []; + const acknowledgements = []; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? first.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + manager.acceptRemote({ + preference: { ...preference, theme: "dracula" }, + createdAt: published[0].created_at + 100, + eventId: "remote-winner", + }); + first.resolve(); + await waitUntil(() => timer.pending()); + assert.equal(acknowledgements.length, 0); + assert.deepEqual(manager.getPending(), preference); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(published.length, 2); + assert.ok(published[1].created_at > published[0].created_at + 100); + assert.deepEqual(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +test("delayed live decryption fences publish acknowledgement and preserves local intent", async () => { + const timer = installFakeTimer(); + const firstPublish = Promise.withResolvers(); + const remotePlaintext = Promise.withResolvers(); + const published = []; + const acknowledgements = []; + let liveCallback; + let signed = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "nip44_decrypt_from_self") return remotePlaintext.promise; + if (command === "sign_event") { + signed += 1; + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: `event-${signed}`, + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "subscribeLive", (_filter, callback) => { + liveCallback = callback; + return Promise.resolve(async () => {}); + }); + mock.method(relayClient, "publishEvent", (event) => { + published.push(event); + return published.length === 1 ? firstPublish.promise : Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + await manager.subscribe(() => {}); + manager.publish(preference); + timer.fire(); + await waitUntil(() => published.length === 1); + + liveCallback( + relayEvent({ + id: "remote-winner", + content: "delayed-ciphertext", + created_at: published[0].created_at + 100, + }), + ); + firstPublish.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(acknowledgements.length, 0); + assert.deepEqual(manager.getPending(), preference); + + remotePlaintext.resolve( + JSON.stringify({ ...preference, theme: "dracula" }), + ); + await waitUntil(() => timer.pending()); + assert.equal(acknowledgements.length, 0); + assert.deepEqual(manager.getPending(), preference); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(published.length, 2); + assert.ok(published[1].created_at > published[0].created_at + 100); + assert.deepEqual(manager.getPending(), null); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +test("transient publish failure retries and acknowledges exact event", async () => { + const timer = installFakeTimer(); + const published = []; + const acknowledgements = []; + let attempts = 0; + globalThis.window.__TAURI_INTERNALS__ = { + invoke(command, args) { + if (command === "nip44_encrypt_to_self") return Promise.resolve("cipher"); + if (command === "sign_event") { + return Promise.resolve( + JSON.stringify( + relayEvent({ + id: "published-event", + content: args.content, + created_at: args.createdAt, + }), + ), + ); + } + throw new Error(`unexpected command: ${command}`); + }, + }; + mock.method(relayClient, "publishEvent", (event) => { + attempts += 1; + if (attempts === 1) return Promise.reject(new Error("timeout")); + published.push(event); + return Promise.resolve(); + }); + try { + const manager = new CommunityThemeSyncManager("alice", (event) => { + acknowledgements.push(event); + }); + manager.publish(preference); + timer.fire(); + await waitUntil(() => timer.pending()); + assert.equal(timer.delay(), 1_000); + assert.deepEqual(manager.getPending(), preference); + + timer.fire(); + await waitUntil(() => acknowledgements.length === 1); + assert.equal(attempts, 2); + assert.equal(published.length, 1); + assert.equal(manager.getPending(), null); + assert.deepEqual(acknowledgements[0], { + preference, + createdAt: published[0].created_at, + eventId: "published-event", + }); + } finally { + delete globalThis.window.__TAURI_INTERNALS__; + timer.restore(); + mock.reset(); + } +}); + +async function waitUntil(condition) { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (condition()) return; + await new Promise((resolve) => setImmediate(resolve)); + } + assert.fail("condition not met"); +} diff --git a/desktop/src/shared/theme/communityThemeSync.ts b/desktop/src/shared/theme/communityThemeSync.ts new file mode 100644 index 000000000..934e3d55c --- /dev/null +++ b/desktop/src/shared/theme/communityThemeSync.ts @@ -0,0 +1,321 @@ +import { relayClient } from "@/shared/api/relayClient"; +import { + nip44DecryptFromSelf, + nip44EncryptToSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_COMMUNITY_THEME } from "@/shared/constants/kinds"; +import { + parseCommunityThemePreference, + sameCommunityThemePreference, + type CommunityThemePreference, +} from "./communityThemePreference"; + +const D_TAG = "community-theme"; +const DEBOUNCE_MS = 2_000; +const PUBLISH_RETRY_BASE_MS = 1_000; +const PUBLISH_RETRY_MAX_MS = 30_000; + +export type PublishedCommunityTheme = { + preference: CommunityThemePreference; + createdAt: number; + eventId: string; +}; + +export type RemoteCommunityTheme = { + preference: CommunityThemePreference; + createdAt: number; + eventId: string; +}; + +export type RemoteCommunityThemeResult = + | { status: "valid"; remote: RemoteCommunityTheme } + | { status: "absent" | "invalid" | "unavailable" }; + +export function isNewerCommunityThemeCoordinate( + candidate: { createdAt: number; eventId: string }, + current: { createdAt: number; eventId: string }, +): boolean { + return ( + candidate.createdAt > current.createdAt || + (candidate.createdAt === current.createdAt && + (current.eventId === "" || candidate.eventId < current.eventId)) + ); +} + +export function shouldSeedCommunityTheme( + result: RemoteCommunityThemeResult, +): boolean { + return result.status === "absent"; +} + +async function decryptAndParse( + event: RelayEvent, +): Promise { + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const preference = parseCommunityThemePreference(JSON.parse(plaintext)); + return preference + ? { preference, createdAt: event.created_at, eventId: event.id } + : null; + } catch { + return null; + } +} + +export class CommunityThemeSyncManager { + private readonly pubkey: string; + private debounceTimer: number | null = null; + private destroyed = false; + private lastRemoteCreatedAt = 0; + private lastRemoteEventId = ""; + private lastPublished: PublishedCommunityTheme | null = null; + private pending: CommunityThemePreference | null = null; + private publishInFlight = false; + private publishRetryAttempt = 0; + private readonly remoteProcessing = new Set>(); + private readonly onPublished: (published: PublishedCommunityTheme) => void; + + constructor( + pubkey: string, + onPublished: (published: PublishedCommunityTheme) => void = () => {}, + ) { + this.pubkey = pubkey; + this.onPublished = onPublished; + } + + async fetchRemote(): Promise { + try { + const events = await relayClient.fetchEvents({ + kinds: [KIND_COMMUNITY_THEME], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 1, + }); + if (events.length === 0) return { status: "absent" }; + if (events[0].pubkey !== this.pubkey) return { status: "invalid" }; + const processing = decryptAndParse(events[0]); + this.trackRemoteProcessing(processing); + const remote = await processing; + if (!remote) return { status: "invalid" }; + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } + return { status: "valid", remote }; + } catch { + return { status: "unavailable" }; + } + } + + publish(preference: CommunityThemePreference): void { + if (this.destroyed) return; + this.pending = preference; + this.publishRetryAttempt = 0; + this.schedulePublish(DEBOUNCE_MS); + } + + private schedulePublish(delayMs: number): void { + if (this.destroyed) return; + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + this.startPublish(); + }, delayMs); + } + + private startPublish(): void { + if (this.destroyed || this.publishInFlight || !this.pending) return; + this.publishInFlight = true; + const preference = this.pending; + void this.doPublish(preference).finally(() => { + this.publishInFlight = false; + if ( + !this.destroyed && + this.pending && + !sameCommunityThemePreference(this.pending, preference) && + this.debounceTimer === null + ) { + this.schedulePublish(0); + } + }); + } + + getPending(): CommunityThemePreference | null { + return this.pending; + } + + acceptRemote(remote: RemoteCommunityTheme): void { + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } + const lastPublished = this.lastPublished; + if ( + lastPublished && + (lastPublished.createdAt !== remote.createdAt || + lastPublished.eventId !== remote.eventId) + ) { + this.lastPublished = null; + } + } + + cancelPendingPublish(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + this.pending = null; + } + + private async doPublish(preference: CommunityThemePreference): Promise { + try { + const lastPublished = this.lastPublished; + if ( + this.destroyed || + (lastPublished && + sameCommunityThemePreference(lastPublished.preference, preference)) + ) { + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.pending = null; + if (lastPublished) this.onPublished(lastPublished); + } + return; + } + const ciphertext = await nip44EncryptToSelf(JSON.stringify(preference)); + if (this.destroyed) return; + const event = await signRelayEvent({ + kind: KIND_COMMUNITY_THEME, + content: ciphertext, + createdAt: Math.max( + Math.floor(Date.now() / 1_000), + this.lastRemoteCreatedAt + 1, + ), + tags: [ + ["d", D_TAG], + ["t", D_TAG], + ], + }); + if (this.destroyed) return; + await relayClient.publishEvent( + event, + "Timed out publishing community theme.", + "Failed to publish community theme.", + ); + await this.waitForDeliveredRemotes(); + if (this.destroyed) return; + const published = { + preference, + createdAt: event.created_at, + eventId: event.id, + }; + const eventLostToRemote = isNewerCommunityThemeCoordinate( + { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }, + published, + ); + if (eventLostToRemote) { + this.lastPublished = null; + this.publishRetryAttempt = 0; + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.schedulePublish(0); + } + return; + } + this.lastRemoteCreatedAt = event.created_at; + this.lastRemoteEventId = event.id; + this.lastPublished = published; + this.publishRetryAttempt = 0; + if ( + this.pending && + sameCommunityThemePreference(this.pending, preference) + ) { + this.pending = null; + } + this.onPublished(published); + } catch (error) { + console.warn("[communityThemeSync] publish failed:", error); + if ( + this.destroyed || + !this.pending || + !sameCommunityThemePreference(this.pending, preference) + ) { + return; + } + const delay = Math.min( + PUBLISH_RETRY_BASE_MS * 2 ** this.publishRetryAttempt, + PUBLISH_RETRY_MAX_MS, + ); + this.publishRetryAttempt += 1; + this.schedulePublish(delay); + } + } + + private trackRemoteProcessing(task: Promise): void { + this.remoteProcessing.add(task); + void task.then( + () => this.remoteProcessing.delete(task), + () => this.remoteProcessing.delete(task), + ); + } + + private async waitForDeliveredRemotes(): Promise { + await Promise.allSettled([...this.remoteProcessing]); + } + + async subscribe( + onUpdate: (remote: RemoteCommunityTheme) => void, + ): Promise<() => Promise> { + return relayClient.subscribeLive( + { + kinds: [KIND_COMMUNITY_THEME], + authors: [this.pubkey], + "#d": [D_TAG], + limit: 0, + }, + (event: RelayEvent) => { + if (event.pubkey !== this.pubkey || this.destroyed) return; + const processing = decryptAndParse(event).then((remote) => { + if (!remote || this.destroyed) return; + if ( + isNewerCommunityThemeCoordinate(remote, { + createdAt: this.lastRemoteCreatedAt, + eventId: this.lastRemoteEventId, + }) + ) { + this.lastRemoteCreatedAt = remote.createdAt; + this.lastRemoteEventId = remote.eventId; + } + onUpdate(remote); + }); + this.trackRemoteProcessing(processing); + }, + ); + } + + destroy(): void { + this.destroyed = true; + this.cancelPendingPublish(); + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 128413f7b..8c4d1fd98 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9690,6 +9690,11 @@ function sendToMockSocket(args: { if (type === "EVENT") { const event = rest[0] as RelayEvent; + if (event.kind === 28936) { + sendWsText(socket.handler, ["OK", event.id, true, ""]); + return; + } + if ([9030, 9031, 9032].includes(event.kind)) { const accepted = updateMockRelayMembershipFromAdminEvent(event); sendWsText(socket.handler, [ diff --git a/desktop/tests/e2e/add-community-screenshots.spec.ts b/desktop/tests/e2e/add-community-screenshots.spec.ts index f2aa49a20..389e182e7 100644 --- a/desktop/tests/e2e/add-community-screenshots.spec.ts +++ b/desktop/tests/e2e/add-community-screenshots.spec.ts @@ -55,7 +55,11 @@ test("capture: add-community choices", async ({ page }) => { test("capture: join an existing community", async ({ page }) => { await page.getByTestId("add-community-join").click(); const dialog = page.getByTestId("add-community-dialog"); - await page.getByLabel("Community URL or invite link").waitFor(); + const communityUrl = page.getByLabel("Community URL or invite link"); + await communityUrl.fill("community.example.com"); + await page.getByTestId("community-api-token-reveal").waitFor({ + state: "detached", + }); await waitForAnimations(page); await dialog.screenshot({ path: `${OUTDIR}/02-join.png` }); }); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0bf65c16d..ac9d5318e 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3394,6 +3394,50 @@ test("home inbox manage affordance opens management without leaving home", async await expect(page).not.toHaveURL(/#\/channels\//); }); +test("members sidebar can invite relay-authorized agents", async ({ page }) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: DM_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_IDENTITY_PUBKEY], + }, + ], + }); + await page.goto("/"); + await openMembersSidebar(page, "general"); + + await page.getByTestId("channel-management-search-users").fill("quinn"); + + await expect( + page.getByTestId(`channel-user-search-result-${DM_RELAY_AGENT_PUBKEY}`), + ).toBeVisible(); +}); + +test("members sidebar hides relay agents that are not authorized", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: DM_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey], + }, + ], + }); + await page.goto("/"); + await openMembersSidebar(page, "general"); + + await page.getByTestId("channel-management-search-users").fill("quinn"); + + await expect( + page.getByTestId(`channel-user-search-result-${DM_RELAY_AGENT_PUBKEY}`), + ).toHaveCount(0); +}); + test("members sidebar can invite and remove managed agents", async ({ page, }) => { @@ -3750,7 +3794,7 @@ test("members sidebar collapses same-persona managed agents", async ({ await expect(page.getByText("Pinky", { exact: true })).toHaveCount(1); }); -test("private-channel members can add people and managed agents without admin", async ({ +test("private-channel members cannot add people without owner/admin", async ({ page, }) => { await installMockBridge(page, { @@ -3764,14 +3808,51 @@ test("private-channel members can add people and managed agents without admin", }); await page.goto("/"); // secret-projects is a private (non-DM) channel where the current user is a - // plain member, not owner/admin. They should still be able to add members - // and bots — only granting elevated roles is reserved for owners/admins. + // plain member. The relay rejects their kind:9000, so the affordance is + // withheld and the reason shown instead of failing after the fact. await openMembersSidebar(page, "secret-projects"); - // The invite card is shown to any member, not just owners/admins. + await expect(page.getByTestId("members-sidebar-add-denied")).toBeVisible(); + // The field stays, but only as a filter over existing members. + await expect( + page.getByTestId("channel-management-search-users"), + ).toHaveAttribute("placeholder", "Search people and agents"); + + await page.getByTestId("channel-management-search-users").fill("char"); + await expect(page.getByText("Not in this channel")).toHaveCount(0); + await expect( + page.getByTestId( + `channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`, + ), + ).toHaveCount(0); + await expect( + page.getByTestId(`sidebar-member-${TEST_IDENTITIES.charlie.pubkey}`), + ).toHaveCount(0); +}); + +test("open-channel members can add people and managed agents without admin", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.charlie.pubkey, + name: "charlie", + status: "stopped", + }, + ], + }); + await page.goto("/"); + // random is open and the current user is a plain member there, so the + // owner/admin requirement must not leak outside private channels. + await openMembersSidebar(page, "random"); + + // The invite card is shown to any member of an open channel, not just + // owners/admins. await expect( page.getByTestId("channel-management-search-users"), ).toBeVisible(); + await expect(page.getByTestId("members-sidebar-add-denied")).toHaveCount(0); await page.getByTestId("channel-management-search-users").fill("char"); await page .getByTestId(`channel-user-search-result-${TEST_IDENTITIES.charlie.pubkey}`) diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index b432d17f2..19bcfc23e 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -230,6 +230,9 @@ test.describe("community rail", () => { await expect( menu.getByRole("menuitem", { name: "Community settings" }), ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Leave community" }), + ).toBeVisible(); await expect( menu.getByRole("menuitem", { name: "Add a community" }), ).toBeVisible(); @@ -290,6 +293,9 @@ test.describe("community rail", () => { await expect( page.getByRole("dialog", { name: "Edit Community" }), ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Leave Community" }), + ).toHaveCount(0); }); test("switches the active community on click", async ({ page }) => { @@ -717,11 +723,12 @@ test.describe("community rail", () => { await page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`).click(); await page.getByTestId("channel-general").click(); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); await page - .getByTestId(`community-rail-button-${COMMUNITY_A.id}`) - .click({ button: "right" }); - await page.getByRole("menuitem", { name: "Community settings" }).click(); - await page.getByRole("button", { name: "Remove Community" }).click(); + .getByRole("menu", { name: "Community actions" }) + .getByRole("menuitem", { name: "Leave community" }) + .click(); await expect(page).toHaveURL(randomUrl); await expect @@ -761,6 +768,74 @@ test.describe("community rail", () => { await expect(buttonB).toHaveAttribute("aria-current", "true"); }); + test("leaving the final community returns to setup without resetting identity", async ({ + context, + page, + }) => { + await installMockBridge(page, undefined, { + autoConnectDefaultRelay: true, + skipCommunitySeed: true, + }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await expect + .poll(() => + page.evaluate(() => typeof window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__), + ) + .toBe("function"); + const identityBefore = await page.evaluate(async () => + window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"), + ); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await page + .getByRole("menu", { name: "Community actions" }) + .getByRole("menuitem", { name: "Leave community" }) + .click(); + + await expect(page.getByText("Join or create a community")).toBeVisible(); + await expect(page.getByTestId("welcome-setup-back")).toHaveCount(0); + await expect(page.getByTestId("community-choice-join")).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => window.localStorage.getItem("buzz-communities")), + ) + .toBeNull(); + await expect + .poll(() => + page.evaluate(() => + window.localStorage.getItem("buzz-community-discovery-after-leave"), + ), + ) + .toBe("1"); + + const relaunchPage = await context.newPage(); + await installMockBridge(relaunchPage, undefined, { + autoConnectDefaultRelay: true, + skipCommunitySeed: true, + }); + await relaunchPage.goto("/"); + await expect( + relaunchPage.getByText("Join or create a community"), + ).toBeVisible(); + await expect(relaunchPage.getByTestId("welcome-setup-back")).toHaveCount(0); + await expect + .poll(() => + relaunchPage.evaluate(() => + window.localStorage.getItem("buzz-communities"), + ), + ) + .toBeNull(); + await expect + .poll(() => + relaunchPage.evaluate(async () => + window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__("get_identity"), + ), + ) + .toEqual(identityBefore); + }); + test("hides the rail with a single community", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); diff --git a/desktop/tests/e2e/composer-image-draw.spec.ts b/desktop/tests/e2e/composer-image-draw.spec.ts index ea0ce16c8..1ec989079 100644 --- a/desktop/tests/e2e/composer-image-draw.spec.ts +++ b/desktop/tests/e2e/composer-image-draw.spec.ts @@ -1,11 +1,13 @@ import { expect, type Page, test } from "@playwright/test"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; const ORIGINAL_SHA = "a".repeat(64); const EDITED_SHA = "b".repeat(64); const ORIGINAL_URL = "https://example.com/e2e/draw-original.svg"; const EDITED_URL = "https://example.com/e2e/draw-edited.svg"; +const PR_SNAPSHOT_DIR = "test-results/video-upload-photo-scope"; const ORIGINAL_DESCRIPTOR = { url: ORIGINAL_URL, @@ -67,6 +69,31 @@ test.beforeEach(async ({ page }) => { }); }); +test("image annotation overlay and editor controls", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByRole("button", { name: "Attach image" }).click(); + + const composer = page.getByTestId("message-composer"); + await expect(composer.getByAltText("Attachment aaaa")).toBeVisible(); + await composer.getByTestId("composer-media-attachment").hover(); + await expect(page.getByTestId("composer-attachment-annotate")).toBeVisible(); + await waitForAnimations(page); + await composer.screenshot({ + path: `${PR_SNAPSHOT_DIR}/01-image-annotation-overlay.png`, + }); + + await page.getByTestId("composer-attachment-annotate").click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + await expect(page.getByTestId("composer-attachment-edit")).toBeVisible(); + await expect(page.getByTestId("composer-attachment-spoiler")).toBeVisible(); + await waitForAnimations(page); + await dialog.screenshot({ + path: `${PR_SNAPSHOT_DIR}/02-image-editor-controls.png`, + }); +}); + test("draw on an uploaded image, save replaces it, revert restores in place", async ({ page, }) => { @@ -80,7 +107,8 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as await expect(composer.getByAltText("Attachment aaaa")).toBeVisible(); // Open the composer lightbox. - await composer.getByAltText("Attachment aaaa").click(); + await composer.getByTestId("composer-media-attachment").hover(); + await page.getByTestId("composer-attachment-annotate").click(); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog.locator(`img[src="${ORIGINAL_URL}"]`)).toBeVisible(); @@ -132,7 +160,8 @@ test("draw on an uploaded image, save replaces it, revert restores in place", as expect(uploadCommandCount).toBe(1); // Reopen the lightbox on the annotated attachment to revert. - await composer.getByAltText("Attachment bbbb").click(); + await composer.getByTestId("composer-media-attachment").hover(); + await page.getByTestId("composer-attachment-annotate").click(); await expect(dialog).toBeVisible(); await expect(dialog.locator(`img[src="${EDITED_URL}"]`)).toBeVisible(); @@ -160,12 +189,14 @@ test("spoiler marking survives drawing on the attachment", async ({ page }) => { // Spoiler the attachment from its lightbox (media spoilers are // per-attachment; the text spoiler control no longer affects media), // then draw on it. - await composer.getByAltText("Attachment aaaa").click(); + await composer.getByTestId("composer-media-attachment").hover(); + await page.getByTestId("composer-attachment-annotate").click(); await page.getByTestId("composer-attachment-spoiler").click(); await page.keyboard.press("Escape"); await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible(); - await composer.getByAltText("Attachment aaaa").click(); + await composer.getByTestId("composer-media-attachment").hover(); + await page.getByTestId("composer-attachment-annotate").click(); await page.getByTestId("composer-attachment-edit").click(); await drawStrokeOnCanvas(page); diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index 699e71198..21237f06b 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -5,6 +5,15 @@ import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css"; +async function openMoreActionsMenu(page: Page, messageId: string) { + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); +} + // Exercises the generic file-attachment UI contract end-to-end through the // mock Tauri bridge: paperclip upload → composer chip → send → FileCard in the // timeline. This guards the frontend wiring (the riskiest, previously @@ -51,12 +60,100 @@ async function chooseLargeVideo(page: Page) { }); } +async function choosePhoto(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach image" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("photo"), + mimeType: "image/png", + name: "photo.png", + }); +} + +test("photos upload before Send without a queued spoiler control", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await choosePhoto(page); + + await expect(page.getByTestId("upload-progress")).toBeVisible(); + await expect(page.getByTestId("composer-queued-video-spoiler")).toHaveCount( + 0, + ); + // Photos upload immediately, so they are in neither `pendingImeta` nor the + // queued list until the upload lands: Send stays blocked so the message + // cannot publish without the attachment. + await expect(page.getByTestId("send-message")).toBeDisabled(); + await expect(page.getByTestId("upload-progress")).toHaveCount(0, { + timeout: 5_000, + }); + await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await expect(page.getByTestId("send-message")).toBeEnabled(); + + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("upload_media_bytes_raw"); +}); + +test("opening edit during an immediate photo upload preserves the draft", async ({ + page, +}) => { + await page.goto("/"); + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { mock?: { uploadDelayMs?: number } }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; + }); + await page.getByTestId("channel-general").click(); + await choosePhoto(page); + await expect(page.getByTestId("upload-progress")).toBeVisible(); + + await openMoreActionsMenu(page, "mock-general-welcome"); + await page.getByTestId("edit-message-mock-general-welcome").click(); + + // Edit entry is rejected while the compacted draft cannot represent the + // reserved upload slot. The upload remains current and lands in the draft. + await expect(page.getByTestId("edit-target")).toHaveCount(0); + await expect(page.getByTestId("upload-progress")).toBeVisible(); + await expect(page.getByTestId("upload-progress")).toHaveCount(0, { + timeout: 5_000, + }); + await expect(page.getByTestId("message-composer")).toContainText( + "quarterly-report.pdf", + ); + + // Once settled, the same edit action enters edit mode normally. + await openMoreActionsMenu(page, "mock-general-welcome"); + await page.getByTestId("edit-message-mock-general-welcome").click(); + await expect(page.getByTestId("edit-target")).toBeVisible(); +}); + test("upload a file and see a FileCard in the timeline", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // The paperclip queues the local file without starting its upload. + // Non-video files keep the established immediate-upload behavior. await chooseQuarterlyReport(page); // The composer shows a chip with the original filename. @@ -103,13 +200,23 @@ test("sends immediately and keeps upload progress across channels", async ({ if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; }); await page.getByTestId("channel-general").click(); - await chooseQuarterlyReport(page); + await chooseLargeVideo(page); await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await expect(page.getByTestId("composer-video-spoiler")).toHaveCount(0); + const queuedSpoiler = page.getByTestId("composer-queued-video-spoiler"); + // Revealed on hover rather than removed from the DOM: the control stays + // focusable so keyboard users can reach it, but is transparent and + // click-through until the thumbnail is hovered or focused. + await expect(queuedSpoiler).toHaveCSS("opacity", "0"); + await expect(queuedSpoiler).toHaveCSS("pointer-events", "none"); + await page.getByTestId("composer-queued-media-attachment").hover(); + await expect(queuedSpoiler).toBeVisible(); + await expect(queuedSpoiler).toHaveCSS("opacity", "1"); await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-composer")).not.toContainText( - "quarterly-report.pdf", + "large-video.mp4", ); await expect(page.getByTestId("composer-upload-progress")).toBeVisible(); @@ -226,7 +333,7 @@ test("canceling a background upload prevents the message from publishing", async if (e2e?.mock) e2e.mock.uploadDelayMs = 1_000; }); await page.getByTestId("channel-general").click(); - await chooseQuarterlyReport(page); + await chooseLargeVideo(page); await page.getByTestId("send-message").click(); await page.getByTestId("composer-upload-cancel").click(); @@ -259,7 +366,7 @@ test("upload progress floats above the dock and lifts Jump to latest", async ({ await expect(jumpToLatest).toBeVisible(); const restingBox = await jumpToLatest.boundingBox(); - await chooseQuarterlyReport(page); + await chooseLargeVideo(page); await page.getByTestId("send-message").click(); const uploadMotion = page.getByTestId("composer-upload-progress-motion"); await expect(uploadMotion).toBeVisible(); @@ -458,3 +565,89 @@ test("forum posts emit a FileCard for generic attachments, not a broken image", ) .toContain("download_file"); }); + +test("a queued attachment can be removed without a mouse", async ({ page }) => { + // Regression: the queued remove badge is revealed on hover, but hiding it + // with `display: none` made it unfocusable, leaving keyboard-only users no + // way to drop a queued video before sending. + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await chooseLargeVideo(page); + + const queued = page.getByTestId("composer-queued-media-attachment"); + await expect(queued).toBeVisible(); + + const remove = queued.getByRole("button", { name: "Remove attachment" }); + // Focusable while transparent — this is what `display: none` prevented. + await remove.focus(); + await expect(remove).toBeFocused(); + // Focus reveals it, so the user can see what they are about to activate. + await expect(remove).toHaveCSS("opacity", "1"); + + await page.keyboard.press("Enter"); + await expect(queued).toHaveCount(0); + await expect(page.getByTestId("message-composer")).not.toContainText( + "large-video.mp4", + ); +}); + +test("an uploaded attachment's remove button is named and keyboard-operable", async ({ + page, +}) => { + // Companion to the queued case: these badges are now in the tab order, so + // every icon-only remove button needs an accessible name a screen reader can + // read. Images and non-media files render through different branches, so + // both are checked here. + await installMockBridge(page, { + deferredComposerUploads: true, + uploadDescriptors: [ + { + url: `https://mock.relay/media/${"b".repeat(64)}.png`, + sha256: "b".repeat(64), + size: 2048, + type: "image/png", + uploaded: Math.floor(Date.now() / 1000), + dim: "320x200", + filename: "photo.png", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + const composer = page.getByTestId("message-composer"); + const remove = composer.getByRole("button", { name: "Remove attachment" }); + + // Image attachment (MediaAttachmentItem). + await choosePhoto(page); + await expect(composer.getByTestId("composer-media-attachment")).toBeVisible(); + await expect(remove).toHaveCount(1); + await remove.focus(); + await expect(remove).toBeFocused(); + await expect(remove).toHaveCSS("opacity", "1"); + await page.keyboard.press("Enter"); + await expect(composer.getByTestId("composer-media-attachment")).toHaveCount( + 0, + ); +}); + +test("a non-media attachment's remove button is named and keyboard-operable", async ({ + page, +}) => { + // The file-card branch renders its own remove badge, so it needs the same + // accessible name as the image and queued ones. + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await chooseQuarterlyReport(page); + + const composer = page.getByTestId("message-composer"); + await expect(composer).toContainText("quarterly-report.pdf"); + + const remove = composer.getByRole("button", { name: "Remove attachment" }); + await expect(remove).toHaveCount(1); + await remove.focus(); + await expect(remove).toBeFocused(); + await expect(remove).toHaveCSS("opacity", "1"); + await page.keyboard.press("Enter"); + await expect(composer).not.toContainText("quarterly-report.pdf"); +}); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index d9dcffa2e..ed00e8c35 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -7,6 +7,7 @@ import { } from "../helpers/bridge"; const MOCK_VIEWER_PUBKEY = "deadbeef".repeat(8); +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; test.beforeEach(async ({ page }) => { await installMockBridge(page); @@ -67,6 +68,46 @@ async function readCommandPayloadLog(page: import("@playwright/test").Page) { }); } +async function readOutgoingMentionPubkeys( + page: import("@playwright/test").Page, + content: string, +) { + return page.evaluate((expectedContent) => { + const entries = + ( + window as Window & { + __BUZZ_E2E_COMMAND_LOG__?: Array<{ + command: string; + payload: unknown; + }>; + } + ).__BUZZ_E2E_COMMAND_LOG__ ?? []; + + for (const entry of entries) { + if (entry.command !== "plugin:websocket|send") continue; + const data = ( + entry.payload as { message?: { data?: string } } | undefined + )?.message?.data; + if (!data) continue; + + try { + const frame = JSON.parse(data) as [ + string, + { content?: string; tags?: string[][] }, + ]; + if (frame[0] !== "EVENT" || frame[1]?.content !== expectedContent) { + continue; + } + return (frame[1].tags ?? []) + .filter((tag) => tag[0] === "p" && tag[1]) + .map((tag) => tag[1]); + } catch {} + } + + return null; + }, content); +} + function commandCount(commands: string[], command: string) { return commands.filter((entry) => entry === command).length; } @@ -209,7 +250,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m const dropdown = autocomplete(page); await expect(dropdown).toBeVisible(); - await expect(dropdown.getByText("alice")).toHaveCount(0); + await expect(dropdown.getByText("alice")).toBeVisible(); await expect(dropdown.getByText("bob")).toBeVisible(); await expect(dropdown.getByText("Fizz")).toBeVisible(); await expect(dropdown.getByText("charlie")).toBeVisible(); @@ -225,6 +266,7 @@ test("@ trigger prioritizes channel members before runnable personas and other m const suggestions = dropdown.locator("button"); const suggestionText = await suggestions.allInnerTexts(); + const aliceIndex = suggestionText.findIndex((text) => text.includes("alice")); const fizzIndex = suggestionText.findIndex((text) => text.includes("Fizz")); const bobIndex = suggestionText.findIndex((text) => text.includes("bob")); const charlieIndex = suggestionText.findIndex((text) => @@ -233,14 +275,40 @@ test("@ trigger prioritizes channel members before runnable personas and other m const outsiderIndex = suggestionText.findIndex((text) => text.includes("outsider"), ); + expect(aliceIndex).toBeGreaterThanOrEqual(0); expect(fizzIndex).toBeGreaterThanOrEqual(0); expect(bobIndex).toBeGreaterThanOrEqual(0); expect(charlieIndex).toBeGreaterThanOrEqual(0); expect(outsiderIndex).toEqual(-1); + expect(aliceIndex).toBeLessThan(fizzIndex); expect(bobIndex).toBeLessThan(fizzIndex); expect(fizzIndex).toBeLessThan(charlieIndex); }); +test("relay-only shared agents emit an outbound mention tag when selected", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("Ask @alice"); + + const aliceRow = autocomplete(page).locator("button", { hasText: "alice" }); + await expect(aliceRow).toBeVisible(); + await aliceRow.click(); + await page.keyboard.type("please reply"); + + const content = "Ask @alice please reply"; + await expect(input).toHaveText(content); + await page.getByTestId("send-message").click(); + + await expect + .poll(() => readOutgoingMentionPubkeys(page, content)) + .toContain(TEST_IDENTITIES.alice.pubkey); +}); + test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({ page, }) => { @@ -780,7 +848,7 @@ test("other-owned agents without a shared channel are hidden from mentions", asy await expect(input.locator(".mention-chip")).toHaveCount(0); }); -test("own profile-only agents are hidden from channel mentions", async ({ +test("stale channel-member agents absent from managed and relay directories stay hidden", async ({ page, }) => { await installMockBridge(page, { userSearchDelayMs: 1_000 }); @@ -826,7 +894,82 @@ test("managed relay agents are visible in channel mentions regardless of relay p await expect(dropdown.getByText("agent")).toBeVisible(); }); -test("relay-only agents stay hidden from channel mentions even when allowlisted", async ({ +test("relay-only shared agents stay hidden from DM mentions", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-alice-tyler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("alice-tyler"); + + await page.getByTestId("message-input").fill("@alice"); + + await expect(autocomplete(page)).toHaveCount(0); +}); + +test("cached relay-agent suggestions are removed when channel authorization disappears", async ({ + page, +}) => { + await installMockBridge(page, { userSearchDelayMs: 10_000 }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.fill("@alice"); + const aliceSuggestion = autocomplete(page).getByTestId( + `mention-suggestion-${TEST_IDENTITIES.alice.pubkey}`, + ); + await expect(aliceSuggestion).toBeVisible(); + await expect + .poll(async () => + (await readCommandPayloadLog(page)).some( + (entry) => + entry.command === "search_users" && + (entry.payload as { query?: string }).query === "alice", + ), + ) + .toBe(true); + + await page.evaluate(async (channelId) => { + const bridge = window as Window & { + __BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise; + __BUZZ_E2E_MUTATE_CHANNEL__?: (opts: { + channelId: string; + channelType: null; + }) => void; + }; + bridge.__BUZZ_E2E_MUTATE_CHANNEL__?.({ channelId, channelType: null }); + await bridge.__BUZZ_E2E_INVALIDATE_CHANNELS__?.(); + }, GENERAL_CHANNEL_ID); + + await expect(aliceSuggestion).toHaveCount(0); +}); + +test("relay-only shared agents appear in forum mentions", async ({ page }) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["watercooler"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-watercooler").click(); + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await page.getByRole("button", { name: "Start a new post..." }).click(); + + await page.getByTestId("message-input").fill("@quinn"); + + await expect( + page.getByTestId("mention-autocomplete").getByText("quinn"), + ).toBeVisible(); +}); + +test("relay-only allowlisted agents are visible in channel mentions", async ({ page, }) => { await installMockBridge(page, { @@ -836,6 +979,7 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" name: "quinn", respondTo: "allowlist", respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], }, ], }); @@ -846,9 +990,106 @@ test("relay-only agents stay hidden from channel mentions even when allowlisted" const input = page.getByTestId("message-input"); await input.fill("@quinn"); + const dropdown = autocomplete(page); + await expect(dropdown.getByText("quinn")).toBeVisible(); + await expect(dropdown.getByText("agent")).toBeVisible(); +}); + +test("relay-only allowlisted agents stay hidden outside their channel", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["agents"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@quinn"); + await expect(autocomplete(page)).toHaveCount(0); }); +test("relay-only anyone agents are visible when a channel is shared", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "anyone", + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@quinn"); + + await expect(autocomplete(page).getByText("quinn")).toBeVisible(); +}); + +test("relay-only excluded agents stay hidden from channel mentions", async ({ + page, +}) => { + await installMockBridge(page, { + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [TEST_IDENTITIES.outsider.pubkey], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@quinn"); + + await expect(autocomplete(page)).toHaveCount(0); +}); + +test("shared agents wait for initial directory authorization", async ({ + page, +}) => { + await installMockBridge(page, { + agentListDelayMs: 1_000, + relayAgents: [ + { + pubkey: ALLOWLIST_RELAY_AGENT_PUBKEY, + name: "quinn", + respondTo: "allowlist", + respondToAllowlist: [MOCK_VIEWER_PUBKEY], + channelNames: ["general"], + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@quinn"); + + await expect(autocomplete(page)).toHaveCount(0); + await expect(autocomplete(page).getByText("quinn")).toBeVisible({ + timeout: 3_000, + }); +}); + test("mentioning an in-channel stopped managed agent starts it before sending", async ({ page, }) => { diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index f56c9575b..9dce5e759 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -167,23 +167,18 @@ test("automatically shows community join requirements near the community URL", a .toContain('"relayUrl":"wss://policy.example.com"'); }); -test("supports API tokens without cluttering the default join form", async ({ - page, -}) => { +test("joins a community URL without an API token field", async ({ page }) => { await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); await page.goto("/"); await openAddCommunityDialog(page); await page.getByTestId("add-community-join").click(); - await expect(page.getByLabel("API token")).toHaveCount(0); - await expect(page.getByTestId("community-api-token-reveal")).toHaveCount(0); - await page .getByLabel("Community URL or invite link") .fill("token.example.com"); - await page.getByTestId("community-api-token-reveal").click(); - await page.getByLabel("API token").fill("buzz_secret"); + await expect(page.getByLabel("API token")).toHaveCount(0); + await expect(page.getByTestId("community-api-token-reveal")).toHaveCount(0); await page.getByTestId("invite-redeem-submit").click(); await expect @@ -191,20 +186,10 @@ test("supports API tokens without cluttering the default join form", async ({ page.evaluate((key) => { const raw = window.localStorage.getItem(key); if (!raw) return null; - const transaction = JSON.parse(raw) as { - relayUrl?: string; - token?: string; - }; - return { - relayUrl: transaction.relayUrl, - token: transaction.token, - }; + return JSON.parse(raw) as { relayUrl?: string }; }, COMMUNITY_ONBOARDING_STORAGE_KEY), ) - .toEqual({ - relayUrl: "wss://token.example.com", - token: "buzz_secret", - }); + .toMatchObject({ relayUrl: "wss://token.example.com" }); }); test("hides Invites settings on open relays", async ({ page }) => { diff --git a/desktop/tests/e2e/spoiler.spec.ts b/desktop/tests/e2e/spoiler.spec.ts index 738b19a74..4cf4ac671 100644 --- a/desktop/tests/e2e/spoiler.spec.ts +++ b/desktop/tests/e2e/spoiler.spec.ts @@ -102,7 +102,8 @@ test("image attachments can be marked and sent as hidden spoilers", async ({ await expect(composer.getByAltText("Attachment cccc")).toBeVisible(); // Media spoilers are toggled per-attachment from the lightbox. - await composer.getByAltText("Attachment cccc").click(); + await composer.getByTestId("composer-media-attachment").hover(); + await page.getByTestId("composer-attachment-annotate").click(); await page.getByTestId("composer-attachment-spoiler").click(); await page.keyboard.press("Escape"); await expect(composer.locator("[data-composer-media-spoiler]")).toBeVisible(); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 5e90e5a7f..2f56ca660 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -250,6 +250,7 @@ type MockBridgeOptions = { personaSharePublicationStatuses?: Array<"published" | "queued">; teams?: MockTeamSeed[]; relayAgents?: MockRelayAgentSeed[]; + /** Delay both managed and relay agent directory reads. */ agentListDelayMs?: number; createManagedAgentDelayMs?: number; channelTemplates?: ChannelTemplate[]; diff --git a/docs/nips/NIP-PMA.md b/docs/nips/NIP-PMA.md new file mode 100644 index 000000000..82592b1ec --- /dev/null +++ b/docs/nips/NIP-PMA.md @@ -0,0 +1,112 @@ +# NIP-PMA: Private Managed-Agent Aggregate + +`draft` — protocol/codec reservation only. Relays MUST reject this kind until +privacy, transactional CAS, backup/restore, revocation, and capability gates are +deployed. + +## Purpose and kind + +Kind `30179` is an owner-authored, addressable, owner-readable aggregate for one +runnable managed agent. Its coordinate is `(owner pubkey, 30179, agent pubkey)`. +It is the only durable authority after a per-agent migration is independently +verified. Kinds `30175` and `30177` remain public/compatibility projections. + +This reservation does not change current agent authority, storage, startup, +mutation, deletion, catalog, or sharing behavior. + +## Signed outer envelope + +Exactly these two-element tags are permitted: + +- `d = <64 lowercase hex agent pubkey>` exactly once; +- `g = ` exactly once; +- `prev = <64 lowercase hex predecessor event id>` exactly once after + generation 1 and absent at generation 1; +- `state = active|deleted` exactly once. + +Content is bounded NIP-44 v2 ciphertext encrypted owner-to-owner. Event ID and +signature, exact kind/author/tag grammar, canonical curve-valid agent keys, and size +are validated before decrypt. The decrypted payload repeats owner, agent, +generation, predecessor, and state; any mismatch is corruption. + +## Decrypted v1 payload + +Top-level and nested core schemas reject unknown and duplicate JSON member +names. Forward-compatible data is confined to namespaced `extensions` entries; +core semantics never depend on an extension. Projection recovery v1 contains +the complete signed public event; validation verifies its signature and ID, +owner, kind and `d` coordinate, and hashes its exact content bytes against the +binding. This makes reconstruction deterministic rather than an agreement over +an untyped JSON blob. + +An active payload binds exact signed `30175` and `30177` event IDs, SHA-256 of +their exact content bytes, and complete versioned recovery material. It also +contains the preserved agent nsec and an optional NIP-OA attestation, plus +explicitly allowlisted private runnable configuration. When present, the +attestation MUST be a cryptographically valid unconditional (`conditions = ""`) +owner-to-agent authorization: its owner equals the aggregate author and its +agent equals the nsec-derived `d` coordinate. Conditional, malformed, wrong-owner, +or wrong-agent attestations are rejected. The nsec MUST derive the `d` +coordinate. + +All active aggregates require a stable `30175` definition binding. Before a +legacy definition-less agent can be encoded, the migrator MUST deterministically +materialize its definition fields as a non-shared `30175` under the owner, with +a stable collision-safe slug derived from the agent pubkey. Materialization and +read-back verification are prerequisites: failure leaves the agent `LegacyOnly` +and preserves its local record/key unchanged. No client may synthesize a default +or mint a replacement identity to satisfy this schema. + +A deleted payload is minimal: it contains no active body, advances generation +from its predecessor, and includes `deleted_at`. Relay anti-resurrection and +undelete rules are specified by the later transactional CAS contract; generic +NIP-33 LWW is explicitly insufficient. + +## Field authority + +- `30175` definition projection: display name, prompt, runtime/model/provider, + name pool, definition behavior defaults, sharing/provenance, public avatar. +- `30177` instance projection: agent pubkey/name/definition linkage, + parallelism, `respond_to`, and allowlist. +- private portable canonical: nsec, auth tag, env, durable timeout/team fields, + and secret-bearing backend configuration. +- private but device-validated: relay URL, explicit command/args, backend remote + identity, and any explicitly portable path/provider reference. +- local device policy/derived: start-on-launch, auto-restart, effective binary + paths, installed team directory, and catalog-derived commands. +- legacy conversion only: create-time command/model/provider mirrors, + deprecated MCP/turn timeout, source-version drift markers, and relay-mesh + fallback markers where a definition is authoritative. +- transient local only: PID and all last start/stop/exit/error receipts/logs. + +Adding a `ManagedAgentRecord` field must update an exhaustive Desktop +classification/conversion fixture before migration-writing code can merge. +This inert core-only reservation does not yet depend on the Desktop type and +therefore does not claim to provide that compile-time tripwire. + +## Aggregate submission boundary + +Three ordinary Nostr `EVENT` writes cannot atomically commit an aggregate. The +future relay contract accepts independently signed projection candidates plus +the signed private head through one authenticated aggregate submission and one +PostgreSQL transaction. It validates CAS predecessor/generation, signatures, +hashes, recovery material, definition revision, tombstone watermark, and all +coordinates before exposing any candidate. Fan-out begins only after commit. + +Public catalog definitions require an independently verifiable public +CAS/revision head; browsing must never require decrypting kind `30179`. + +## Required deployment order + +1. this inert codec/kind reservation while ingest still rejects `30179`; +2. author-only privacy gates, SQL visibility before `LIMIT`, and verification + that the positive FTS allowlist continues to exclude `30179`; +3. dark CAS schema/transaction; +4. feature-gated aggregate submission; +5. read/repair/export/import and destructive restore drill; +6. tombstone revocation across authentication/ingest/session caches; +7. owner rotation epoch/freeze/receipts/activation; +8. Desktop reader and verified dual-write migration. + +No phase may publish secrets before step 2 or retire local recovery evidence +before the complete migration exit gate passes. diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 48ce85f5e..45289ef91 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -543,14 +543,17 @@ mis-tiered (below): `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS` — resolved by the desktop from the record's `system_prompt` / `idle_timeout_seconds` / `max_turn_duration_seconds` / `parallelism` (each omitted when null, - matching the local spawn's conditional emission). These are **tier-1 by - local fact, not by choice**: the local spawn writes them before the user - env layer (`runtime.rs:716-729,763` vs `:860`) and none is in - `RESERVED_ENV_KEYS`, so a power user's env override beats them today. A - provider that independently mapped the top-level payload copies after - `launch.env` would invert that — the structured field silently defeating - an override that works locally — which is why the provider MUST NOT remap - them (§Entrypoint mapping table). + matching the local spawn's conditional emission). `BUZZ_ACP_AGENTS` is + the **effective** parallelism: `min(record.parallelism, harness_cap)` + where the cap is harness-specific (e.g. OpenClaw is capped at 5). These + are **tier-1 control-plane** keys: `BUZZ_ACP_AGENTS` is in + `RESERVED_ENV_KEYS` (`env_vars.rs`) so the desktop-resolved effective + value cannot be overridden by a definition env var; the others are + tier-1 by local fact (written before the user env layer). A provider + that independently mapped the top-level payload copies after `launch.env` + would invert the precedence for those remaining keys — the structured + field silently defeating an override that works locally — which is why + the provider MUST NOT remap them (§Entrypoint mapping table). `BUZZ_ACP_DEDUP` and `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are **deliberately unset**: the local spawn writes `queue`/`steer` (`runtime.rs:730-731`), and @@ -564,11 +567,17 @@ process to sweep. **Environment precedence (normative) — three tiers, later wins:** -1. **Overridable behavior defaults** — `launch.policy_env`. These keys are - deliberately non-reserved (`env_vars.rs:54-57` says so outright: power - users may bypass the dedicated UI fields), and locally the user env is - written after them (`runtime.rs:860` and its comment). A policy-wins - order here would make remote agents ignore overrides local agents honor. +1. **Overridable behavior defaults** — `launch.policy_env`. Most keys here + are deliberately non-reserved (`env_vars.rs` documents the narrow set + that IS reserved): power users may bypass the dedicated UI fields for + system prompt, model, idle timeout, etc. Locally the user env is written + after them (`runtime.rs:860` and its comment). A policy-wins order here + would make remote agents ignore overrides local agents honor. + **Exception — `BUZZ_ACP_AGENTS`:** this key IS reserved + (`env_vars.rs:RESERVED_ENV_KEYS`) so the desktop-controlled effective + parallelism (applying any per-harness cap) cannot be bypassed by a + user-supplied definition env var. The reserved-key strip removes any + user copy before serialization, so the tier-1 value survives. 2. **User/layered env** — `launch.env`. User `env_vars` need no separate slot: the descriptor's layering already merged them (global < persona < agent), so a provider applies `launch.env` and MUST NOT re-merge the @@ -1081,7 +1090,7 @@ individually: | `launch.args` | `BUZZ_ACP_AGENT_ARGS`, comma-joined | | `launch.env`, `launch.policy_env` | verbatim, at their precedence tiers | | generation token (§K8s Secrets) | `BUZZ_MANAGED_AGENT_START_NONCE` — the lifecycle-frame correlator and the Secret generation are one identity (§Launch data tier 3) | -| `system_prompt`, `idle_timeout_seconds`, `max_turn_duration_seconds`, `parallelism` | **not mapped by the provider** — the desktop resolves these into `launch.policy_env` (`BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS`), because they are tier-1 behavior knobs: locally they are written *before* the user env layer and none is reserved (`runtime.rs:716-729,763` vs `:860`; `env_vars.rs:54-57`), so user env beats them. A provider that mapped the top-level copies after `launch.env` would silently defeat an override that works locally. The top-level fields remain as display/bookkeeping inputs only | +| `system_prompt`, `idle_timeout_seconds`, `max_turn_duration_seconds`, `parallelism` | **not mapped by the provider** — the desktop resolves these into `launch.policy_env` (`BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS`). `BUZZ_ACP_AGENTS` carries the **effective** parallelism (`min(record.parallelism, harness_cap)`), is reserved (`env_vars.rs:RESERVED_ENV_KEYS`), and cannot be overridden by user env. The remaining knobs are tier-1 by local fact (written before user env); a provider that mapped the top-level copies after `launch.env` would silently defeat local overrides. The top-level fields remain as display/bookkeeping inputs only | | `turn_timeout_seconds` | not mapped — deprecated upstream and ignored; the local spawn also does not emit it | | `respond_to` | `BUZZ_ACP_RESPOND_TO` | | `respond_to_allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST`, comma-joined | diff --git a/examples/meadow-core/README.md b/examples/meadow-core/README.md index 416ea1510..3eedd1391 100644 --- a/examples/meadow-core/README.md +++ b/examples/meadow-core/README.md @@ -16,11 +16,15 @@ buzz pack validate ./examples/meadow-core # Inspect resolved config buzz pack inspect ./examples/meadow-core - -# Import into the desktop app -# Use the "Install Pack" button and point to this directory ``` +The desktop app's Import button does not accept this pack directory or a zip of it — it imports +agent/team *snapshots* (`.agent.json`/`.team.json`, exported from agents already running in the +app), not persona-pack source. `buzz pack inspect` above shows the fully-resolved per-agent +config; use it as reference to recreate these agents in the desktop app by hand. Direct +persona-pack runtime integration is not currently implemented. See "Desktop App Import" in +`crates/buzz-persona/PERSONA_PACK_SPEC.md` for the current import paths. + ## Structure ``` diff --git a/migrations/0028_long_reaction_payloads.sql b/migrations/0028_long_reaction_payloads.sql new file mode 100644 index 000000000..7ad01e350 --- /dev/null +++ b/migrations/0028_long_reaction_payloads.sql @@ -0,0 +1,3 @@ +-- A valid 64-character custom emoji shortcode is wrapped as `:shortcode:` in +-- NIP-25 reaction content. Preserve the wrapper in the reaction projection. +ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66); diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index d965f4469..7ccf13bae 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */; }; 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 */; }; 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 */; }; @@ -57,6 +58,7 @@ 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePhotoPicker.swift; sourceTree = ""; }; 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 = ""; }; 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; }; @@ -179,6 +181,7 @@ 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */, 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */, 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */, + 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); @@ -413,6 +416,7 @@ 4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */, 4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */, 4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */, + 4A71C0072F40400100A17E01 /* ConcentricSheetSurface.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 53fa6b996..6ab55c359 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -8,6 +8,7 @@ import UserNotifications private var mediaUploadChannel: FlutterMethodChannel? private var qrScannerChannel: FlutterMethodChannel? private var inlinePhotoPickerSupportChannel: FlutterMethodChannel? + private var concentricSheetSurfaceChannel: FlutterMethodChannel? private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator? override func application( @@ -63,6 +64,30 @@ import UserNotifications ) } + if let concentricSheetRegistrar = engineBridge.pluginRegistry.registrar( + forPlugin: "BuzzConcentricSheetSurface" + ) { + concentricSheetRegistrar.register( + ConcentricSheetSurfaceFactory(), + withId: "buzz/concentric_sheet_surface" + ) + concentricSheetSurfaceChannel = FlutterMethodChannel( + name: "buzz/concentric_sheet_surface", + binaryMessenger: messenger + ) + concentricSheetSurfaceChannel?.setMethodCallHandler { call, result in + guard call.method == "isSupported" else { + result(FlutterMethodNotImplemented) + return + } + if #available(iOS 26.0, *) { + result(true) + } else { + result(false) + } + } + } + let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar( forPlugin: "BuzzNativeAttachmentPopover" ) diff --git a/mobile/ios/Runner/ConcentricSheetSurface.swift b/mobile/ios/Runner/ConcentricSheetSurface.swift new file mode 100644 index 000000000..d4d481d09 --- /dev/null +++ b/mobile/ios/Runner/ConcentricSheetSurface.swift @@ -0,0 +1,54 @@ +import Flutter +import UIKit + +final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory { + func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + FlutterStandardMessageCodec.sharedInstance() + } + + func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + ConcentricSheetSurfacePlatformView(frame: frame, arguments: args) + } +} + +final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView { + private let surfaceView: UIView + + init(frame: CGRect, arguments args: Any?) { + let arguments = args as? [String: Any] + let colorValue = (arguments?["color"] as? NSNumber)?.uint32Value ?? 0xFFFFFFFF + let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24 + + surfaceView = UIView(frame: frame) + surfaceView.isOpaque = true + surfaceView.backgroundColor = Self.color(from: colorValue) + surfaceView.clipsToBounds = true + surfaceView.layer.cornerCurve = .continuous + + if #available(iOS 26.0, *) { + surfaceView.cornerConfiguration = .uniformCorners( + radius: .containerConcentric(minimum: minimumRadius) + ) + } else { + surfaceView.layer.cornerRadius = minimumRadius + } + + super.init() + } + + func view() -> UIView { + surfaceView + } + + private static func color(from value: UInt32) -> UIColor { + 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) + } +} diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index b1dad2a5c..057594dfa 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -50,9 +50,13 @@ class App extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final themeMode = ref.watch(themeProvider); - final accentIndex = ref.watch(accentProvider); - final schemeName = ref.watch(schemeProvider); + final communityTheme = ref.watch(communityThemeProvider); + final themeMode = communityTheme.mode; + final accentIndex = effectiveAccentIndex( + communityTheme.theme, + communityTheme.accent, + ); + final schemeName = communityTheme.theme; final authState = ref.watch(authProvider); final resolved = resolveSchemes(schemeName, themeMode); diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 82a64e375..51a0861b5 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -17,6 +18,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/message_author_meta.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channels_provider.dart'; @@ -59,7 +61,10 @@ EdgeInsets _activityScrollPadding( /// navigation. Row taps deep-link to the represented message (oldest unread /// for grouped conversations) rather than just opening the channel. class ActivityPage extends HookConsumerWidget { - const ActivityPage({super.key}); + const ActivityPage({this.tabReselection, super.key}); + + /// Notifies this page when its already-selected tab is tapped again. + final ValueListenable? tabReselection; @override Widget build(BuildContext context, WidgetRef ref) { @@ -67,9 +72,41 @@ class ActivityPage extends HookConsumerWidget { final channelsAsync = ref.watch(channelsProvider); final filter = useState(InboxFilter.all); final unreadOnly = useState(false); + final scrollController = useScrollController(); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + useEffect(() { + final tabReselection = this.tabReselection; + if (tabReselection == null) return null; + + void scrollToTop() { + if (!scrollController.hasClients) return; + final position = scrollController.position; + if (position.pixels <= position.minScrollExtent + 0.5) return; + if (reducedMotion) { + scrollController.jumpTo(position.minScrollExtent); + return; + } + unawaited( + scrollController.animateTo( + position.minScrollExtent, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), + ); + } + + tabReselection.addListener(scrollToTop); + return () => tabReselection.removeListener(scrollToTop); + }, [tabReselection, scrollController, reducedMotion]); final headerTitleStyle = context.textTheme.titleMedium?.copyWith( fontSize: 22, fontWeight: FontWeight.w600, + color: navigationPrimaryForeground(context), + ); + final topSectionHeight = frostedAppBarHeight( + context, + titleStyle: headerTitleStyle, + bottomHeight: Grid.xxs, ); final readState = ref.watch(readStateProvider); @@ -244,12 +281,18 @@ class ActivityPage extends HookConsumerWidget { ]); } - final Widget body; + late final Widget body; + var bodyRidesOverTopSection = false; if (filter.value == InboxFilter.reminders) { - body = _RemindersList(onOpen: openReminder, onRefresh: refresh); + body = _RemindersList( + scrollController: scrollController, + onOpen: openReminder, + onRefresh: refresh, + ); } else if (filter.value == InboxFilter.drafts) { body = _DraftsList( drafts: drafts, + scrollController: scrollController, channelById: channelById, myPubkey: myPk, onOpen: openDraft, @@ -259,7 +302,7 @@ class ActivityPage extends HookConsumerWidget { } else if (feedAsync.hasError && allItems.isEmpty) { body = _ErrorView(onRetry: refresh); } else if (!hasLoadedOnce.value && allItems.isEmpty) { - body = const _LoadingSkeleton(); + body = _LoadingSkeleton(scrollController: scrollController); } else if (visibleItems.isEmpty) { body = _EmptyFilterState( filter: filter.value, @@ -274,54 +317,73 @@ class ActivityPage extends HookConsumerWidget { ? firstReadIndex : -1; + bodyRidesOverTopSection = true; body = RefreshIndicator( + edgeOffset: topSectionHeight, onRefresh: refresh, - child: ListView.builder( - padding: _activityScrollPadding(context), - itemCount: visibleItems.length, - itemBuilder: (context, index) { - final item = visibleItems[index]; - final channel = item.item.channelId != null - ? channelById[item.item.channelId] - : null; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (index == newBoundaryIndex) const _NewBoundaryDivider(), - _InboxRow( - key: ValueKey(item.id), - item: item, - channel: channel, - currentPubkey: myPk, - isDone: isDone(item), - onTap: () => openItem(item), - onMarkRead: () => markItemRead(item), - onMarkUnread: () => markItemUnread(item), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)), + DecoratedSliver( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(Radii.dialog), ), - ], - ); - }, + ), + sliver: SliverPadding( + padding: _activityScrollPadding(context), + sliver: SliverList.builder( + itemCount: visibleItems.length, + itemBuilder: (context, index) { + final item = visibleItems[index]; + final channel = item.item.channelId != null + ? channelById[item.item.channelId] + : null; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (index == newBoundaryIndex) + const _NewBoundaryDivider(), + _InboxRow( + key: ValueKey(item.id), + item: item, + channel: channel, + currentPubkey: myPk, + isDone: isDone(item), + onTap: () => openItem(item), + onMarkRead: () => markItemRead(item), + onMarkUnread: () => markItemUnread(item), + ), + ], + ); + }, + ), + ), + ), + ], ), ); } return FrostedScaffold( - backgroundColor: Colors.transparent, + backgroundColor: context.colors.surface, appBar: FrostedAppBar( - gradient: context.appColors.topSectionGradient, automaticallyImplyLeading: false, - title: const Text('Activity'), + horizontalInset: Grid.gutter, + showBottomDivider: true, + bottomDividerOpacity: 0.06, + title: Text('Activity', style: headerTitleStyle), titleStyle: headerTitleStyle, actions: [ - _FilterMenuButton( + _ActivityActionsPill( filter: filter.value, dueReminderCount: dueReminderCount, draftCount: drafts.length, - onChanged: (f) => filter.value = f, - ), - _InboxOptionsButton( unreadOnly: unreadOnly.value, unreadCount: unreadVisibleCount, + onFilterChanged: (f) => filter.value = f, onUnreadOnlyChanged: (v) => unreadOnly.value = v, onMarkAllRead: () { for (final item in visibleItems) { @@ -330,17 +392,19 @@ class ActivityPage extends HookConsumerWidget { }, ), ], + bottomHeight: Grid.xxs, + bottom: const SizedBox.expand(), ), body: SafeArea( key: const ValueKey('activity-content-safe-area'), top: false, bottom: false, - child: Padding( - padding: EdgeInsets.only( - top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), - ), - child: body, - ), + child: bodyRidesOverTopSection + ? body + : Padding( + padding: EdgeInsets.only(top: topSectionHeight), + child: body, + ), ), ); } diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 39bb29e7a..08384b69e 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -11,6 +11,59 @@ const _filterLabels = { InboxFilter.drafts: 'Drafts', }; +class _ActivityActionsPill extends StatelessWidget { + final InboxFilter filter; + final int dueReminderCount; + final int draftCount; + final bool unreadOnly; + final int unreadCount; + final ValueChanged onFilterChanged; + final ValueChanged onUnreadOnlyChanged; + final VoidCallback onMarkAllRead; + + const _ActivityActionsPill({ + required this.filter, + required this.dueReminderCount, + required this.draftCount, + required this.unreadOnly, + required this.unreadCount, + required this.onFilterChanged, + required this.onUnreadOnlyChanged, + required this.onMarkAllRead, + }); + + @override + Widget build(BuildContext context) => ClipRRect( + borderRadius: BorderRadius.circular(Radii.full), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.primaryContainer, + borderRadius: BorderRadius.circular(Radii.full), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.quarter), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _FilterMenuButton( + filter: filter, + dueReminderCount: dueReminderCount, + draftCount: draftCount, + onChanged: onFilterChanged, + ), + _InboxOptionsButton( + unreadOnly: unreadOnly, + unreadCount: unreadCount, + onUnreadOnlyChanged: onUnreadOnlyChanged, + onMarkAllRead: onMarkAllRead, + ), + ], + ), + ), + ), + ); +} + /// Compact filter dropdown replacing the old chip rail — mirrors desktop's /// inbox filter menu (`FILTER_OPTIONS`). class _FilterMenuButton extends StatelessWidget { @@ -91,6 +144,7 @@ class _FilterMenuButton extends StatelessWidget { Text( _filterLabels[filter]!, style: context.textTheme.labelLarge?.copyWith( + color: navigationPrimaryForeground(context), fontWeight: FontWeight.w600, ), ), @@ -98,7 +152,7 @@ class _FilterMenuButton extends StatelessWidget { Icon( LucideIcons.chevronDown, size: 16, - color: context.colors.onSurfaceVariant, + color: navigationPrimaryForeground(context), ), if (dueReminderCount > 0 || draftCount > 0) ...[ const SizedBox(width: Grid.quarter), @@ -133,7 +187,7 @@ class _CountBadge extends StatelessWidget { vertical: Grid.quarter, ), decoration: BoxDecoration( - color: context.colors.primary, + color: navigationPrimaryForeground(context), borderRadius: BorderRadius.circular(Grid.xxs), ), child: Text( @@ -168,6 +222,12 @@ class _InboxOptionsButton extends StatelessWidget { builder: (buttonContext) => IconButton( key: const ValueKey('activity-options-menu'), tooltip: 'Activity options', + color: navigationPrimaryForeground(context), + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + constraints: const BoxConstraints.tightFor( + width: Grid.xl, + height: Grid.xl, + ), icon: const Icon(LucideIcons.ellipsis, size: 20), onPressed: () async { final selected = await showAnchoredPopover( diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 398bd16ce..fe7869299 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -340,32 +340,42 @@ class _InboxRow extends HookConsumerWidget { } void _showRowActions(BuildContext context) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - isDone ? LucideIcons.mail : LucideIcons.mailOpen, - size: 20, - ), - title: Text(isDone ? 'Mark unread' : 'Mark as read'), - onTap: () { - Navigator.of(sheetContext).pop(); - isDone ? onMarkUnread() : onMarkRead(); - }, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: IconTheme.merge( + data: const IconThemeData(size: 18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: Icon( + isDone ? LucideIcons.mail : LucideIcons.mailOpen, + ), + title: Text(isDone ? 'Mark unread' : 'Mark as read'), + onTap: () { + Navigator.of(sheetContext).pop(); + isDone ? onMarkUnread() : onMarkRead(); + }, + ), + ListTile( + leading: const Icon(LucideIcons.externalLink), + title: const Text('Open conversation'), + onTap: () { + Navigator.of(sheetContext).pop(); + onTap(); + }, + ), + ], ), - ListTile( - leading: const Icon(LucideIcons.externalLink, size: 20), - title: const Text('Open conversation'), - onTap: () { - Navigator.of(sheetContext).pop(); - onTap(); - }, - ), - ], + ), ), ), ); diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 9df193fe9..7b4be901e 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -3,10 +3,15 @@ part of '../activity_page.dart'; /// Reminders surface for the Reminders filter — due/pending NIP-ER /// reminders that deep-link to their target message. class _RemindersList extends ConsumerWidget { + final ScrollController scrollController; final void Function(Reminder reminder) onOpen; final Future Function() onRefresh; - const _RemindersList({required this.onOpen, required this.onRefresh}); + const _RemindersList({ + required this.scrollController, + required this.onOpen, + required this.onRefresh, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -36,6 +41,7 @@ class _RemindersList extends ConsumerWidget { return RefreshIndicator( onRefresh: onRefresh, child: ListView.builder( + controller: scrollController, padding: _activityScrollPadding(context), itemCount: reminders.length, itemBuilder: (context, index) { @@ -73,6 +79,7 @@ class _RemindersList extends ConsumerWidget { /// text that reopens the target composer. class _DraftsList extends StatelessWidget { final List drafts; + final ScrollController scrollController; final Map channelById; final String? myPubkey; final void Function(ComposeDraft draft) onOpen; @@ -80,6 +87,7 @@ class _DraftsList extends StatelessWidget { const _DraftsList({ required this.drafts, + required this.scrollController, required this.channelById, required this.myPubkey, required this.onOpen, @@ -97,6 +105,7 @@ class _DraftsList extends StatelessWidget { } return ListView.builder( + controller: scrollController, padding: _activityScrollPadding(context), itemCount: drafts.length, itemBuilder: (context, index) { diff --git a/mobile/lib/features/activity/activity_page/status_views.dart b/mobile/lib/features/activity/activity_page/status_views.dart index 442634849..ec7e96903 100644 --- a/mobile/lib/features/activity/activity_page/status_views.dart +++ b/mobile/lib/features/activity/activity_page/status_views.dart @@ -1,11 +1,14 @@ part of '../activity_page.dart'; class _LoadingSkeleton extends StatelessWidget { - const _LoadingSkeleton(); + final ScrollController scrollController; + + const _LoadingSkeleton({required this.scrollController}); @override Widget build(BuildContext context) { return ListView.separated( + controller: scrollController, padding: _activityScrollPadding( context, horizontal: Grid.gutter, diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 3104e9033..29db1f96c 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -2,6 +2,11 @@ import 'package:flutter/foundation.dart'; const Object _sentinel = Object(); +/// Shown when a private-channel add is refused, so a missing Invite action +/// reads as a rule rather than a bug. +const privateChannelAddDeniedMessage = + 'Only channel owners and admins can add people to a private channel.'; + @immutable class Channel { final String id; @@ -77,6 +82,17 @@ class Channel { bool get isForum => channelType == 'forum'; bool get isDm => channelType == 'dm'; bool get isPrivate => visibility == 'private'; + + /// Whether [selfRole] may add *another* identity here, mirroring the relay's + /// kind:9000 authority (`validate_admin_event` + `add_member`): DMs never, + /// open channels always, private channels owners/admins only. An unknown + /// visibility fails closed — the relay is the authority. + bool canAddMembers(String? selfRole) { + if (isDm) return false; + if (visibility == 'open') return true; + return selfRole == 'owner' || selfRole == 'admin'; + } + bool get isArchived => archivedAt != null; String displayLabel({String? currentPubkey}) { diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 42e13be54..4e821ecc0 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -6,6 +9,7 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/sheet_divider.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; @@ -25,7 +29,7 @@ Future showChannelActionsSheet({ required bool isUnread, VoidCallback? onMarkRead, String? sectionId, -}) => showModalBottomSheet( +}) => showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, @@ -103,215 +107,223 @@ class ChannelActionsSheet extends ConsumerWidget { return SafeArea( top: false, - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!channel.isDm) ...[ - _ChannelQuickActionsRow( - isStarred: isStarred, - isUnread: isUnread, - onToggleStar: () { - close(); - final notifier = ref.read(channelStarsProvider.notifier); - isStarred - ? notifier.unstarChannel(channel.id) - : notifier.starChannel(channel.id); - }, - onToggleRead: () { - close(); - final timestamp = dateTimeToUnixSeconds( - channel.lastMessageAt, - ); - if (isUnread) { - onMarkRead?.call(); - if (timestamp != null) { + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!channel.isDm) ...[ + _ChannelQuickActionsRow( + isStarred: isStarred, + isUnread: isUnread, + onToggleStar: () { + close(); + final notifier = ref.read(channelStarsProvider.notifier); + isStarred + ? notifier.unstarChannel(channel.id) + : notifier.starChannel(channel.id); + }, + onToggleRead: () { + close(); + final timestamp = dateTimeToUnixSeconds( + channel.lastMessageAt, + ); + if (isUnread) { + onMarkRead?.call(); + if (timestamp != null) { + ref + .read(readStateProvider.notifier) + .markContextRead( + channel.id, + timestamp, + clearForcedMessages: true, + ); + ref + .read(channelsProvider.notifier) + .clearObservedUnreadCoveredByRead( + channel.id, + timestamp, + ); + } + } else { ref .read(readStateProvider.notifier) - .markContextRead( - channel.id, - timestamp, - clearForcedMessages: true, - ); - ref - .read(channelsProvider.notifier) - .clearObservedUnreadCoveredByRead( - channel.id, - timestamp, - ); + .markContextUnread(channel.id, channelId: channel.id); } - } else { - ref - .read(readStateProvider.notifier) - .markContextUnread(channel.id, channelId: channel.id); - } - }, - ), - const SizedBox(height: Grid.xs), - ], - if (!channel.isDm) - ListTile( - leading: const Icon(LucideIcons.folderInput), - title: const Text('Move to section…'), - onTap: () async { - final pageContext = Navigator.of( - context, - rootNavigator: true, - ).context; - close(); - await _showMoveSectionSheet( - pageContext, - ref, - channel: channel, - sectionId: sectionId, - ); - }, - ), - ListTile( - leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff), - title: Text(isMuted ? 'Unmute channel' : 'Mute channel'), - onTap: () { - close(); - final notifier = ref.read(channelMutesProvider.notifier); - isMuted - ? notifier.unmuteChannel(channel.id) - : notifier.muteChannel(channel.id); - }, - ), - if (!channel.isDm) - ListTile( - leading: const Icon(LucideIcons.settings), - title: const Text('Manage channel'), - onTap: () async { - final shouldClose = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: MediaQuery.sizeOf(context).height * 0.9, - ), - builder: (_) => ManageChannelSheet(channel: channel), - ); - if (shouldClose == true && context.mounted) { - Navigator.of(context).pop(true); - } - }, - ), - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy channel name'), - onTap: () { - close(); - copyToClipboard( - context, - channel.name, - message: 'Channel name copied to clipboard', - ); - }, - ), - ListTile( - leading: const Icon(LucideIcons.hash), - title: const Text('Copy channel ID'), - onTap: () { - close(); - copyToClipboard( - context, - channel.id, - message: 'Channel ID copied to clipboard', - ); - }, - ), - if (!channel.isDm) ...[ - const SheetDivider(), - if (channel.isMember && !channel.isArchived) - _ActionTile( - icon: LucideIcons.logOut, - label: 'Leave channel', - destructive: true, - onTap: () => _confirmAndRun( - context, - ref, - title: 'Leave #${channel.name}?', - body: 'You’ll stop receiving messages from this channel.', - confirmLabel: 'Leave', - action: () => ref - .read(channelActionsProvider) - .leaveChannel(channel.id), - ), + }, ), - if (lifecycleCapabilitiesLoading) - const ListTile( - enabled: false, - leading: BuzzLoadingIndicator( - size: 20, - semanticLabel: 'Loading channel actions', - ), - title: Text('Loading channel actions…'), - ) - else if (lifecycleCapabilitiesUnavailable) - const ListTile( - enabled: false, - leading: Icon(LucideIcons.triangleAlert), - title: Text('Channel actions unavailable'), - ) - else ...[ - if (canArchive) - _ActionTile( - icon: LucideIcons.archive, - label: 'Archive channel', - onTap: () => _confirmAndRun( + const SizedBox(height: Grid.xs), + ], + if (!channel.isDm) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.folderInput), + title: const Text('Move to section…'), + onTap: () async { + final pageContext = Navigator.of( context, + rootNavigator: true, + ).context; + close(); + await _showMoveSectionSheet( + pageContext, ref, - title: 'Archive #${channel.name}?', - body: 'The channel will become read-only.', - confirmLabel: 'Archive', - action: () => ref - .read(channelActionsProvider) - .archiveChannel(channel.id), - ), - ), - if (canUnarchive) + channel: channel, + sectionId: sectionId, + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff), + title: Text(isMuted ? 'Unmute channel' : 'Mute channel'), + onTap: () { + close(); + final notifier = ref.read(channelMutesProvider.notifier); + isMuted + ? notifier.unmuteChannel(channel.id) + : notifier.muteChannel(channel.id); + }, + ), + if (!channel.isDm) + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.settings), + title: const Text('Manage channel'), + onTap: () async { + final shouldClose = await showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + builder: (_) => ManageChannelSheet(channel: channel), + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.copy), + title: const Text('Copy channel name'), + onTap: () { + close(); + copyToClipboard( + context, + channel.name, + message: 'Channel name copied to clipboard', + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.hash), + title: const Text('Copy channel ID'), + onTap: () { + close(); + copyToClipboard( + context, + channel.id, + message: 'Channel ID copied to clipboard', + ); + }, + ), + if (!channel.isDm) ...[ + const SheetDivider(), + if (channel.isMember && !channel.isArchived) _ActionTile( - icon: LucideIcons.archiveRestore, - label: 'Unarchive channel', - onTap: () => _confirmAndRun( - context, - ref, - title: 'Unarchive #${channel.name}?', - body: 'The channel will become active again.', - confirmLabel: 'Unarchive', - action: () => ref - .read(channelActionsProvider) - .unarchiveChannel(channel.id), - ), - ), - if (canDelete) - _ActionTile( - icon: LucideIcons.trash2, - label: 'Delete channel', + icon: LucideIcons.logOut, + label: 'Leave channel', destructive: true, onTap: () => _confirmAndRun( context, ref, - title: 'Delete #${channel.name}?', - body: - 'This permanently deletes the channel and cannot be undone.', - confirmLabel: 'Delete', + title: 'Leave #${channel.name}?', + body: 'You’ll stop receiving messages from this channel.', + confirmLabel: 'Leave', action: () => ref .read(channelActionsProvider) - .deleteChannel(channel.id), + .leaveChannel(channel.id), ), ), + if (lifecycleCapabilitiesLoading) + const ListTile( + enabled: false, + leading: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading channel actions', + ), + title: Text('Loading channel actions…'), + ) + else if (lifecycleCapabilitiesUnavailable) + const ListTile( + enabled: false, + leading: Icon(LucideIcons.triangleAlert), + title: Text('Channel actions unavailable'), + ) + else ...[ + if (canArchive) + _ActionTile( + icon: LucideIcons.archive, + label: 'Archive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Archive #${channel.name}?', + body: 'The channel will become read-only.', + confirmLabel: 'Archive', + action: () => ref + .read(channelActionsProvider) + .archiveChannel(channel.id), + ), + ), + if (canUnarchive) + _ActionTile( + icon: LucideIcons.archiveRestore, + label: 'Unarchive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Unarchive #${channel.name}?', + body: 'The channel will become active again.', + confirmLabel: 'Unarchive', + action: () => ref + .read(channelActionsProvider) + .unarchiveChannel(channel.id), + ), + ), + if (canDelete) + _ActionTile( + icon: LucideIcons.trash2, + label: 'Delete channel', + destructive: true, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Delete #${channel.name}?', + body: + 'This permanently deletes the channel and cannot be undone.', + confirmLabel: 'Delete', + action: () => ref + .read(channelActionsProvider) + .deleteChannel(channel.id), + ), + ), + ], ], ], - ], + ), ), ), ); @@ -333,17 +345,21 @@ class _ChannelQuickActionsRow extends StatelessWidget { @override Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _ChannelQuickAction( - icon: isStarred ? LucideIcons.starOff : LucideIcons.star, - label: isStarred ? 'Unstar' : 'Star', - onTap: onToggleStar, + Expanded( + child: _ChannelQuickAction( + icon: isStarred ? LucideIcons.starOff : LucideIcons.star, + label: isStarred ? 'Unstar' : 'Star', + onTap: onToggleStar, + ), ), - _ChannelQuickAction( - icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, - label: isUnread ? 'Mark Read' : 'Mark Unread', - onTap: onToggleRead, + const SizedBox(width: Grid.twelve), + Expanded( + child: _ChannelQuickAction( + icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, + label: isUnread ? 'Mark Read' : 'Mark Unread', + onTap: onToggleRead, + ), ), ], ); @@ -362,28 +378,35 @@ class _ChannelQuickAction extends StatelessWidget { @override Widget build(BuildContext context) => GestureDetector( - onTap: onTap, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - width: 76, - height: 56, - alignment: Alignment.center, + height: 68 + (Grid.xxs * 2), decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(Radii.dialog), ), - child: Icon(icon, size: 24, color: context.colors.onSurface), - ), - const SizedBox(height: Grid.xxs), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurface, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 22, color: context.colors.onSurface), + const SizedBox(height: Grid.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], ), ), ], @@ -406,12 +429,16 @@ class _ActionTile extends StatelessWidget { @override Widget build(BuildContext context) => ListTile( + contentPadding: EdgeInsets.zero, leading: Icon(icon, color: destructive ? context.colors.error : null), title: Text( label, style: destructive ? TextStyle(color: context.colors.error) : null, ), - onTap: onTap, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, ); } @@ -424,7 +451,7 @@ Future _confirmAndRun( required Future Function() action, }) async { final pageContext = Navigator.of(sheetContext, rootNavigator: true).context; - final confirmed = await showDialog( + final confirmed = await showBuzzDialog( context: pageContext, builder: (dialogContext) => AlertDialog( title: Text(title), @@ -465,69 +492,72 @@ Future _showMoveSectionSheet( }) async { final sections = [...ref.read(channelSectionsProvider).store.sections] ..sort((a, b) => a.order.compareTo(b.order)); - await showModalBottomSheet( + await showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final section in sections) + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final section in sections) + ListTile( + leading: const Icon(LucideIcons.folder), + title: Text(section.name), + trailing: sectionId == section.id + ? Icon( + LucideIcons.check, + color: sheetContext.colors.primary, + ) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .assignChannel(channel.id, section.id); + }, + ), ListTile( - leading: const Icon(LucideIcons.folder), - title: Text(section.name), - trailing: sectionId == section.id - ? Icon( - LucideIcons.check, - color: sheetContext.colors.primary, - ) - : null, - onTap: () { + leading: const Icon(LucideIcons.folderPlus), + title: const Text('New section…'), + onTap: () async { Navigator.of(sheetContext).pop(); - ref - .read(channelSectionsProvider.notifier) - .assignChannel(channel.id, section.id); + final name = await _showSectionNameDialog(context); + if (name == null || name.isEmpty) return; + final notifier = ref.read(channelSectionsProvider.notifier); + notifier.createSection(name); + final created = ref + .read(channelSectionsProvider) + .store + .sections + .where((section) => section.name == name.trim()) + .lastOrNull; + if (created != null) { + notifier.assignChannel(channel.id, created.id); + } }, ), - ListTile( - leading: const Icon(LucideIcons.folderPlus), - title: const Text('New section…'), - onTap: () async { - Navigator.of(sheetContext).pop(); - final name = await _showSectionNameDialog(context); - if (name == null || name.isEmpty) return; - final notifier = ref.read(channelSectionsProvider.notifier); - notifier.createSection(name); - final created = ref - .read(channelSectionsProvider) - .store - .sections - .where((section) => section.name == name.trim()) - .lastOrNull; - if (created != null) { - notifier.assignChannel(channel.id, created.id); - } - }, - ), - if (sectionId != null) - ListTile( - leading: const Icon(LucideIcons.folderMinus), - title: const Text('Remove from section'), - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelSectionsProvider.notifier) - .unassignChannel(channel.id); - }, - ), - ], + if (sectionId != null) + ListTile( + leading: const Icon(LucideIcons.folderMinus), + title: const Text('Remove from section'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .unassignChannel(channel.id); + }, + ), + ], + ), ), ), ), @@ -536,7 +566,7 @@ Future _showMoveSectionSheet( Future _showSectionNameDialog(BuildContext context) async { final controller = TextEditingController(); - final result = await showDialog( + final result = await showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('New Section'), diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d1387c717..bee963982 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -18,6 +18,7 @@ import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/message_author_meta.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/skeleton.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 406d68b4f..fe861bf0b 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -39,7 +39,7 @@ class _MembersButton extends ConsumerWidget { return IconButton( color: context.colors.primary, onPressed: () { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, 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 ca909df72..37082ab96 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -45,7 +45,7 @@ class _MessageList extends HookConsumerWidget { initialMessageId == null && initialThreadRootId == null, ); final isAutoScrolling = useRef(false); - final autoScrollScheduled = useRef(false); + final latestRealignmentQueued = useRef(false); final latestEntryId = entries.isEmpty ? null : entries.last.message.id; final previousLatestEntryId = useRef(null); final didOpenInitialThread = useRef(false); @@ -198,18 +198,6 @@ class _MessageList extends HookConsumerWidget { } } - void scheduleAutoScrollToLatest() { - if (autoScrollScheduled.value || isAutoScrolling.value) return; - autoScrollScheduled.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - autoScrollScheduled.value = false; - if (!context.mounted || !followsLatest.value || hasUserScrolled.value) { - return; - } - scrollToLatest(); - }); - } - bool latestIsAtBoundary() { // In this reversed list, item 0's leading edge is the bottom boundary. // Being merely visible is not enough: a user who has pulled a tall @@ -220,6 +208,29 @@ class _MessageList extends HookConsumerWidget { ); } + void realignLatestAfterLayoutChange() { + if (latestRealignmentQueued.value || + !followsLatest.value || + hasUserScrolled.value) { + return; + } + latestRealignmentQueued.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + latestRealignmentQueued.value = false; + if (!context.mounted || + !itemScrollController.isAttached || + !followsLatest.value || + hasUserScrolled.value || + latestIsAtBoundary()) { + return; + } + // 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. + itemScrollController.jumpTo(index: 0); + }); + } + useEffect(() { void onPositionsChanged() { final positions = itemPositionsListener.itemPositions.value; @@ -232,12 +243,7 @@ class _MessageList extends HookConsumerWidget { } if (nextIsAtLatest) { if (!isAtLatest.value) isAtLatest.value = true; - } else if (followsLatest.value && !hasUserScrolled.value) { - // The viewport can shrink when the composer or keyboard opens. - // Preserve auto-follow until the user scrolls the timeline. - if (!isAtLatest.value) isAtLatest.value = true; - scheduleAutoScrollToLatest(); - } else if (isAtLatest.value) { + } else if (!followsLatest.value && isAtLatest.value) { isAtLatest.value = false; } @@ -261,6 +267,22 @@ class _MessageList extends HookConsumerWidget { ); }, [channelId, entries.length, itemPositionsListener]); + // Composer size changes and keyboard metrics changes arrive in separate + // layout passes. Preserve the latest-message anchor for both, but only + // while the user has not deliberately left the tail. + useEffect(() { + realignLatestAfterLayoutChange(); + return null; + }, [composerBottomInset]); + + useEffect(() { + final observer = _ChannelLatestMetricsObserver( + onMetricsChanged: realignLatestAfterLayoutChange, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [itemScrollController]); + useEffect(() { if (initialThreadRootId == null || didOpenInitialThread.value) { return null; @@ -588,3 +610,12 @@ class _JumpToLatestButton extends StatelessWidget { ); } } + +class _ChannelLatestMetricsObserver with WidgetsBindingObserver { + final VoidCallback onMetricsChanged; + + _ChannelLatestMetricsObserver({required this.onMetricsChanged}); + + @override + void didChangeMetrics() => onMetricsChanged(); +} diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index e22a31684..4e8ba8ab1 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -97,7 +97,10 @@ class _SystemMessageRow extends ConsumerWidget { isArchived: isArchived, ), child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: EdgeInsets.only( + top: usesMessageStyleLayout ? Grid.xs : Grid.xxs, + bottom: usesMessageStyleLayout ? 0 : Grid.xxs, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -341,10 +344,14 @@ class _MessageStyleSystemMessageContent extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _UserAvatar( - profile: userCache[displayPubkey.toLowerCase()], - pubkey: displayPubkey, - size: messageAvatarSize, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet(context, displayPubkey), + child: _UserAvatar( + profile: userCache[displayPubkey.toLowerCase()], + pubkey: displayPubkey, + size: messageAvatarSize, + ), ), const SizedBox(width: messageAvatarContentGap), Expanded( @@ -449,12 +456,23 @@ Widget _systemEventAvatar( height: 20, child: Stack( children: [ - SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet(context, event.actorPubkey!), + child: SmallAvatar( + pubkey: event.actorPubkey!, + userCache: userCache, + ), + ), Positioned( left: 12, - child: SmallAvatar( - pubkey: event.targetPubkey!, - userCache: userCache, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet(context, event.targetPubkey!), + child: SmallAvatar( + pubkey: event.targetPubkey!, + userCache: userCache, + ), ), ), ], @@ -463,7 +481,11 @@ Widget _systemEventAvatar( } if (event.actorPubkey != null) { - return SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet(context, event.actorPubkey!), + child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache), + ); } // Fallback: generic icon when no actor is available. diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 286a931db..7f9615d4a 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -13,6 +13,31 @@ import '../profile/profile_provider.dart'; import 'channel.dart'; import 'channels_provider.dart'; +String _relayErrorMessage(Object error) => + error.toString().replaceFirst('Exception: ', ''); + +/// Raised when one or more kind:9000 adds were rejected, keyed by pubkey. +/// +/// Callers surface [message] to the user — a relay rejection here (e.g. a plain +/// member trying to add someone to a private channel) is a real outcome, not a +/// crash to swallow. +@immutable +class AddMembersException implements Exception { + final Map failures; + + const AddMembersException(this.failures); + + String get message => failures.entries + .map( + (entry) => + '${entry.key.length > 8 ? '${entry.key.substring(0, 8)}…' : entry.key}: ${entry.value}', + ) + .join('; '); + + @override + String toString() => 'AddMembersException($message)'; +} + @immutable class ChannelMember { final String pubkey; @@ -565,21 +590,34 @@ class ChannelActions { if (pubkey.trim().isNotEmpty) pubkey.trim().toLowerCase(), }; _ensureCommunityValid(); + // Per-pubkey failures are collected rather than thrown on the spot: one + // relay rejection must not skip the remaining adds or the invalidation + // below, which would leave the members list stale for the adds that landed. + final failures = {}; for (final pubkey in normalizedPubkeys) { + // Outside the catch: a community switch mid-loop must abort the whole + // add, not be recorded as this pubkey's rejection. _ensureCommunityValid(); - await _signedEventRelay.submit( - kind: 9000, - content: '', - tags: [ - ['h', channelId], - ['p', pubkey], - ['role', normalizedRole], - ], - ); + try { + await _signedEventRelay.submit( + kind: 9000, + content: '', + tags: [ + ['h', channelId], + ['p', pubkey], + ['role', normalizedRole], + ], + ); + } catch (error) { + failures[pubkey] = _relayErrorMessage(error); + } } _ensureCommunityValid(); _ref.invalidate(channelMembersProvider(channelId)); _ref.invalidate(channelBotPubkeysProvider(channelId)); + if (failures.isNotEmpty) { + throw AddMembersException(failures); + } } void _ensureCommunityValid() { diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart index 445ce6a28..e36a514ff 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -53,6 +53,9 @@ class ChannelSectionsManager { Timer? _startupRetryTimer; int _startupRetryAttempt = 0; bool _startupFetchSucceeded = false; + Future? _syncInFlight; + bool _syncAgain = false; + int _subscriptionGeneration = 0; ChannelSectionsManager({ required this.pubkey, @@ -98,12 +101,33 @@ class ChannelSectionsManager { /// sync must eventually land for groups to appear at all, and at the 30s /// delay ceiling a persistent retry is cheap. Do not "fix" this into a /// bounded loop — giving up permanently is the exact bug this replaces. - Future _syncWithRelay() async { + Future _syncWithRelay() { + if (_disposed) return Future.value(); + final inFlight = _syncInFlight; + if (inFlight != null) { + _syncAgain = true; + return inFlight; + } + + final sync = _runSyncWithRelay(); + _syncInFlight = sync; + return sync.whenComplete(() { + _syncInFlight = null; + if (_disposed || !_syncAgain) return; + _syncAgain = false; + unawaited(_syncWithRelay()); + }); + } + + Future _runSyncWithRelay() async { if (!_startupFetchSucceeded) { - _startupFetchSucceeded = await _fetchAndMerge(); + final fetched = await _fetchAndMerge(); + if (_disposed) return; + _startupFetchSucceeded = fetched; } final subscribed = _unsubscribe != null || await _startLiveSubscription(); + if (_disposed) return; if (!_startupFetchSucceeded || !subscribed) { _scheduleStartupRetry(); @@ -146,6 +170,8 @@ class ChannelSectionsManager { void dispose({bool flushPending = true}) { if (_disposed) return; _disposed = true; + _subscriptionGeneration++; + _syncAgain = false; _startupRetryTimer?.cancel(); _startupRetryTimer = null; @@ -270,7 +296,7 @@ class ChannelSectionsManager { /// Returns whether the fetch reached the relay (regardless of whether a /// remote blob exists). - Future _fetchAndMerge() async { + Future _fetchAndMerge({bool allowDisposed = false}) async { if (_relaySession == null) return false; try { final events = await _relaySession.fetchHistory( @@ -283,6 +309,7 @@ class ChannelSectionsManager { limit: 1, ), ); + if (_disposed && !allowDisposed) return false; _mergeEvents(events); _persist(); if (!_disposed) _onChanged(); @@ -296,9 +323,10 @@ class ChannelSectionsManager { /// Returns whether the live subscription was established. Future _startLiveSubscription() async { - if (_relaySession == null) return false; + if (_relaySession == null || _disposed) return false; + final generation = ++_subscriptionGeneration; try { - _unsubscribe = await _relaySession.subscribe( + final unsubscribe = await _relaySession.subscribe( NostrFilter( kinds: const [EventKind.readState], authors: [pubkey], @@ -308,8 +336,13 @@ class ChannelSectionsManager { limit: 1, ), _handleIncomingEvent, - onClosed: _handleSubscriptionClosed, + onClosed: (message) => _handleSubscriptionClosed(generation, message), ); + if (_disposed || generation != _subscriptionGeneration) { + unsubscribe(); + return false; + } + _unsubscribe = unsubscribe; return true; } catch (error) { debugPrint('[ChannelSectionsManager] live subscription failed: $error'); @@ -324,12 +357,13 @@ class ChannelSectionsManager { /// the rate-limit rejection lands later. Without this handler the manager /// would keep a dead subscription and never retry — the exact /// load-correlated cold-start failure this retry exists for. - void _handleSubscriptionClosed(String message) { - if (_disposed) return; + void _handleSubscriptionClosed(int generation, String message) { + if (_disposed || generation != _subscriptionGeneration) return; debugPrint( '[ChannelSectionsManager] live subscription closed by relay: $message', ); _unsubscribe = null; + _subscriptionGeneration++; _scheduleStartupRetry(); } @@ -404,7 +438,7 @@ class ChannelSectionsManager { } // Read-before-write: merge remote state before publishing - await _fetchAndMerge(); + await _fetchAndMerge(allowDisposed: allowDisposed); // No-op suppression: skip if nothing changed if (_isIdenticalToLastPublished()) return; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c00792b96..d5ffb281c 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:math' show max, min, pi; import 'dart:ui'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -19,6 +20,7 @@ import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/skeleton.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; @@ -65,6 +67,14 @@ const double _kChannelLeadingWidth = 22.0; const double _kChannelIconSize = 18.0; const double _kChannelLabelGap = Grid.xxs; const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter; +const double _kSectionSpacingTightening = Grid.half; +const double _kSectionHeaderVerticalPadding = + _kChannelRowVerticalPadding - _kSectionSpacingTightening; +// Section headers include touch targets for their actions, so their visual +// centre sits lower than a channel row's. This keeps an expanded section's +// final row equally spaced from the following divider. +const double _kExpandedSectionTrailingPadding = + 11.0 - _kSectionSpacingTightening; const double _kChannelLabelInset = _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; @@ -74,22 +84,22 @@ const double _kChannelLabelInset = /// sections while the labels stay on [_kChannelLabelInset]. const double _kDmAvatarSize = _kChannelIconSize; -const double _kTopSectionAvatarSize = 32.0; +const double _kTopSectionAvatarSize = 40.0; +const double _kTopSectionBottomPadding = Grid.xxs; -/// The top section's avatars are 32dp circles, which fill their box edge to +/// The top section's avatars are 40dp circles, which fill their box edge to /// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp /// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles /// looking pushed outward, so the bar is pulled in to sit the avatar's centre -/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). Its -/// label gap is derived separately so both labels land on the same 50dp column. +/// near the channel-icon column. const double _kTopSectionInset = Grid.twelve; -const double _kTopSectionLabelGap = - _kChannelLabelInset - _kTopSectionInset - _kTopSectionAvatarSize; const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1); const Curve _kSectionCollapseCurve = Curves.easeInCubic; const double _kSectionCollapsedScaleY = 0.98; +const double _kHeaderFrostScrollDistance = Grid.xxl; +const double _kHeaderFrostMaxBlurSigma = 23.12; class _UnreadChannelState { final Set ids; @@ -145,10 +155,21 @@ _UnreadChannelState _computeUnreadChannelState({ } class ChannelsPage extends HookConsumerWidget { - const ChannelsPage({required this.settingsPageBuilder, super.key}); + const ChannelsPage({ + required this.settingsPageBuilder, + required this.onSettingsTransitionProgress, + this.tabReselection, + super.key, + }); final WidgetBuilder settingsPageBuilder; + /// Reports the Settings route's raw animation progress from 0 to 1. + final ValueChanged onSettingsTransitionProgress; + + /// Notifies this page when its already-selected tab is tapped again. + final ValueListenable? tabReselection; + @override Widget build(BuildContext context, WidgetRef ref) { final channelsAsync = ref.watch(channelsProvider); @@ -157,6 +178,59 @@ class ChannelsPage extends HookConsumerWidget { .watch(profileProvider) .whenData((value) => value?.pubkey) .value; + final headerTitleStyle = context.textTheme.titleMedium?.copyWith( + fontSize: 22, + fontWeight: FontWeight.w600, + color: navigationPrimaryForeground(context), + ); + final topSectionHeight = frostedAppBarHeight( + context, + titleStyle: headerTitleStyle, + bottomHeight: _kTopSectionBottomPadding, + ); + final channelsScrollController = useScrollController(); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final headerFrostProgress = useState(0.0); + useEffect(() { + void updateHeaderTreatment() { + final nextProgress = !channelsScrollController.hasClients + ? 0.0 + : (channelsScrollController.offset / _kHeaderFrostScrollDistance) + .clamp(0.0, 1.0) + .toDouble(); + if ((headerFrostProgress.value - nextProgress).abs() > 0.001) { + headerFrostProgress.value = nextProgress; + } + } + + channelsScrollController.addListener(updateHeaderTreatment); + return () => + channelsScrollController.removeListener(updateHeaderTreatment); + }, [channelsScrollController]); + useEffect(() { + final tabReselection = this.tabReselection; + if (tabReselection == null) return null; + + void scrollToTop() { + if (!channelsScrollController.hasClients) return; + final position = channelsScrollController.position; + if (position.pixels <= position.minScrollExtent + 0.5) return; + if (reducedMotion) { + channelsScrollController.jumpTo(position.minScrollExtent); + return; + } + unawaited( + channelsScrollController.animateTo( + position.minScrollExtent, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ), + ); + } + + tabReselection.addListener(scrollToTop); + return () => tabReselection.removeListener(scrollToTop); + }, [tabReselection, channelsScrollController, reducedMotion]); // Cache the last successfully loaded channels so the UI never flashes // back to a loading state when the provider rebuilds (e.g. reconnect). @@ -223,32 +297,65 @@ class ChannelsPage extends HookConsumerWidget { return timer.cancel; }, [isReconnectingWithContent]); + void openCommunitySwitcher() { + unawaited(HapticFeedback.selectionClick()); + ref.invalidate(communityIconProvider); + showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => const _CommunitySwitcherSheet(), + ); + } + + final topSectionGradient = context.appColors.topSectionGradient; + final usesPinnedGradient = topSectionGradient != null; + return FrostedScaffold( - backgroundColor: Colors.transparent, + backgroundColor: usesPinnedGradient + ? Colors.transparent + : context.colors.surface, + backgroundGradient: topSectionGradient, appBar: FrostedAppBar( horizontalInset: _kTopSectionInset, - // Under a Buzz theme the community + account avatar strip carries the - // branded gradient, the way desktop paints it across the sidebar. Null - // under every other theme, leaving the default frosted fill. - gradient: context.appColors.topSectionGradient, - leading: _CommunityIndicator( - onTap: () { - ref.invalidate(communityIconProvider); - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (_) => const _CommunitySwitcherSheet(), - ); - }, + // Let the full Buzz gradient show at rest. Once the list begins to + // move beneath this row, build up blur over the first 64dp of scroll + // without adding the usual white frosted wash. The Buzz list is + // transparent, so the blurred pixels remain a continuation of the + // pinned gradient instead of turning into a white header. + frosted: !usesPinnedGradient || headerFrostProgress.value > 0, + frostedSurfaceOpacity: usesPinnedGradient ? 0 : 0.5, + frostedBlurSigma: usesPinnedGradient + ? _kHeaderFrostMaxBlurSigma * headerFrostProgress.value + : 20, + showBottomDivider: false, + leading: _CommunityIndicator(onTap: openCommunitySwitcher), + titleStyle: headerTitleStyle, + title: _CommunityHeaderTitle( + style: headerTitleStyle, + onTap: openCommunitySwitcher, ), - title: const SizedBox.shrink(), actions: [ - ProfileAvatar( - onTap: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: settingsPageBuilder)), + SizedBox( + width: Grid.xl, + height: Grid.xl, + child: Center( + child: ProfileAvatar( + size: _kTopSectionAvatarSize, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).push( + _SettingsPageRoute( + builder: settingsPageBuilder, + onTransitionProgress: onSettingsTransitionProgress, + ), + ); + }, + ), + ), ), ], + bottomHeight: _kTopSectionBottomPadding, + bottom: const SizedBox.expand(), ), body: _ChannelsBody( channels: channels, @@ -257,9 +364,96 @@ class ChannelsPage extends HookConsumerWidget { sessionStatus: sessionState.status, showConnectionSkeleton: showConnectionSkeleton.value, currentPubkey: currentPubkey, + topSectionHeight: topSectionHeight, + usesPinnedGradient: usesPinnedGradient, + scrollController: channelsScrollController, onRefresh: () => ref.read(channelsProvider.notifier).refresh(), onSelectChannel: openChannel, ), ); } } + +/// A custom route deliberately avoids [MaterialPageRoute]'s platform exit +/// transition on Home. Settings has a centered scale-and-fade transition, not +/// a lateral page push. +class _SettingsPageRoute extends PageRouteBuilder { + _SettingsPageRoute({ + required WidgetBuilder builder, + required this.onTransitionProgress, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => + builder(context), + transitionsBuilder: _buildSettingsTransition, + opaque: false, + transitionDuration: const Duration(milliseconds: 190), + reverseTransitionDuration: const Duration(milliseconds: 190), + ); + + final ValueChanged onTransitionProgress; + + Animation? _progressAnimation; + + @override + void install() { + super.install(); + _progressAnimation = animation?..addListener(_reportProgress); + _reportProgress(); + } + + void _reportProgress() { + onTransitionProgress(_progressAnimation?.value ?? 0); + } + + @override + void dispose() { + _progressAnimation?.removeListener(_reportProgress); + super.dispose(); + } + + static Widget _buildSettingsTransition( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (MediaQuery.disableAnimationsOf(context)) return child; + + final incoming = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeOutCubic, + ); + return FadeTransition( + key: const ValueKey('settings-transition-opacity'), + opacity: _SettingsOpacityAnimation(incoming), + child: RepaintBoundary( + key: const ValueKey('settings-transition-layer'), + child: ScaleTransition( + scale: Tween(begin: 1.04, end: 1).animate(incoming), + alignment: Alignment.center, + child: child, + ), + ), + ); + } +} + +/// Keeps Settings already composed on entry while retaining a complete exit +/// fade. Reading the parent live also keeps opacity synchronized with scale on +/// the route's first frame. +class _SettingsOpacityAnimation extends Animation + with AnimationWithParentMixin { + _SettingsOpacityAnimation(this.parent); + + @override + final Animation parent; + + @override + double get value { + final progress = parent.value; + return parent.status == AnimationStatus.reverse + ? progress + : 0.8 + (0.2 * progress); + } +} diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 6ce4ce5f8..ad71be1e5 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -7,6 +7,9 @@ class _ChannelsBody extends StatelessWidget { final SessionStatus sessionStatus; final bool showConnectionSkeleton; final String? currentPubkey; + final double topSectionHeight; + final bool usesPinnedGradient; + final ScrollController scrollController; final Future Function() onRefresh; final Future Function(Channel channel) onSelectChannel; @@ -17,13 +20,16 @@ class _ChannelsBody extends StatelessWidget { required this.sessionStatus, required this.showConnectionSkeleton, required this.currentPubkey, + required this.topSectionHeight, + required this.usesPinnedGradient, + required this.scrollController, required this.onRefresh, required this.onSelectChannel, }); @override Widget build(BuildContext context) { - final barHeight = frostedAppBarHeight(context); + final barHeight = topSectionHeight; final loadedChannels = channels; final loading = showConnectionSkeleton || (loadedChannels == null && !showError); @@ -38,13 +44,32 @@ class _ChannelsBody extends StatelessWidget { edgeOffset: barHeight, onRefresh: onRefresh, child: CustomScrollView( + controller: scrollController, + // The transparent gap shows the top section and must not absorb + // taps meant for the community or profile controls beneath it. + hitTestBehavior: HitTestBehavior.deferToChild, slivers: [ SliverToBoxAdapter(child: SizedBox(height: barHeight)), - _SliverChannelsList( - channels: loadedChannels, - currentPubkey: currentPubkey, - onSelectChannel: onSelectChannel, - ), + if (usesPinnedGradient) + _SliverChannelsList( + channels: loadedChannels, + currentPubkey: currentPubkey, + onSelectChannel: onSelectChannel, + ) + else + DecoratedSliver( + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(Radii.dialog), + ), + ), + sliver: _SliverChannelsList( + channels: loadedChannels, + currentPubkey: currentPubkey, + onSelectChannel: onSelectChannel, + ), + ), ], ), ); @@ -253,7 +278,7 @@ class _SliverChannelsList extends HookConsumerWidget { userSections.first.id != section.id, onToggle: () => toggleSection(section.id), onRename: () async { - final name = await showDialog( + final name = await showBuzzDialog( context: context, builder: (_) => _SectionNameDialog( title: 'Rename Section', @@ -268,7 +293,7 @@ class _SliverChannelsList extends HookConsumerWidget { } }, onDelete: () async { - final confirmed = await showDialog( + final confirmed = await showBuzzDialog( context: context, builder: (_) => AlertDialog( title: Text('Delete "${section.name}"?'), diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 5443e6b9d..f7344086e 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -26,6 +26,12 @@ class _ChannelTile extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final contentColor = isMuted + ? navigationSecondaryForeground(context) + : navigationPrimaryForeground( + context, + ).withValues(alpha: isUnread ? 1 : 0.8); + return InkWell( borderRadius: BorderRadius.circular(Radii.md), onTap: onTap, @@ -47,8 +53,9 @@ class _ChannelTile extends ConsumerWidget { ? _DmAvatar(channel: channel, currentPubkey: currentPubkey) : Icon( channelIcon(channel), + key: ValueKey('channel-icon-${channel.id}'), size: _kChannelIconSize, - color: context.colors.onSurface, + color: contentColor, ), ), ), @@ -65,7 +72,7 @@ class _ChannelTile extends ConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: contentListTitleTextStyle.copyWith( - color: context.colors.onSurface, + color: contentColor, fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400, ), ), @@ -81,7 +88,7 @@ class _ChannelTile extends ConsumerWidget { Icon( LucideIcons.bellOff, size: 12, - color: context.colors.onSurfaceVariant, + color: context.colors.onSurface.withValues(alpha: 0.4), ), ], if (!channel.isMember && !channel.isDm) diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 73935f463..19f0efe5d 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -422,7 +422,7 @@ Future _confirmRemoveCommunity( Community community, { required bool closeSheetAfterRemoval, }) async { - final confirmed = await showDialog( + final confirmed = await showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog.adaptive( title: const Text('Remove community?'), @@ -471,37 +471,44 @@ class _CommunityIndicator extends ConsumerWidget { final activeAsync = ref.watch(activeCommunityProvider); final activeCommunity = activeAsync.value; - final name = activeCommunity?.name; return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _CommunityAvatar(name: name, relayUrl: activeCommunity?.relayUrl), - const SizedBox(width: _kTopSectionLabelGap), - if (name != null) - Flexible( - child: Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ) - else - Text( - 'Community', + child: _CommunityAvatar( + name: activeCommunity?.name, + relayUrl: activeCommunity?.relayUrl, + ), + ); + } +} + +class _CommunityHeaderTitle extends ConsumerWidget { + final TextStyle? style; + final VoidCallback onTap; + + const _CommunityHeaderTitle({required this.onTap, this.style}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = ref.watch(activeCommunityProvider).value?.name; + final title = name?.trim(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: SizedBox.expand( + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: Grid.xxs), + child: Text( + title == null || title.isEmpty ? 'Community' : title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w600, - ), + style: style, ), - ], + ), + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index f57514487..adf5cfd17 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -85,7 +85,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { switch (action) { case _QuickAction.createChannel: - final created = await showModalBottomSheet( + final created = await showBuzzModalBottomSheet( context: context, constraints: _quickActionSheetConstraints(context), isScrollControlled: true, @@ -96,7 +96,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { await openChannel(created); } case _QuickAction.newDm: - final opened = await showModalBottomSheet( + final opened = await showBuzzModalBottomSheet( context: context, constraints: _quickActionSheetConstraints(context), isScrollControlled: true, diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index b6d04cdb9..6e17845d7 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -77,6 +77,7 @@ class _CustomChannelSection extends StatelessWidget { onMarkRead: () => onMarkChannelRead(channel), sectionId: section.id, ), + const SizedBox(height: _kExpandedSectionTrailingPadding), ], ), ), @@ -114,7 +115,7 @@ class _CustomSectionHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final sectionColor = context.colors.primary; + final sectionColor = navigationSectionForeground(context); final icon = section.icon; final customEmoji = icon == null ? null @@ -126,9 +127,9 @@ class _CustomSectionHeader extends ConsumerWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.twelve, + _kSectionHeaderVerticalPadding, Grid.gutter, - _kChannelRowVerticalPadding, + _kSectionHeaderVerticalPadding, ), child: Row( children: [ @@ -445,6 +446,7 @@ class _ChannelSection extends StatelessWidget { onMarkRead: null, sectionId: null, ), + const SizedBox(height: _kExpandedSectionTrailingPadding), ], ), ), @@ -495,7 +497,7 @@ class _SectionDivider extends StatelessWidget { thickness: 1, indent: _kChannelSectionInset, endIndent: _kChannelSectionInset, - color: context.colors.outlineVariant.withValues(alpha: 0.72), + color: context.colors.primary.withValues(alpha: 0.15), ), ); } @@ -520,7 +522,7 @@ class _SectionHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final sectionColor = context.colors.primary; + final sectionColor = navigationSectionForeground(context); return GestureDetector( onTap: onToggle, @@ -528,9 +530,9 @@ class _SectionHeader extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.twelve, + _kSectionHeaderVerticalPadding, Grid.gutter, - _kChannelRowVerticalPadding, + _kSectionHeaderVerticalPadding, ), child: Row( children: [ diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 35083610d..2840a6904 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -25,6 +25,7 @@ import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; 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 3cac205ab..99af48d44 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -416,79 +416,37 @@ class ComposeBar extends HookConsumerWidget { for (final entry in mentionMap.value.entries) if (hasMention(text, entry.key)) entry.value, ]; - final pubkeys = LinkedHashSet.from( - selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), - ).toList(); - final nonMemberAgentPubkeys = []; - final nonMemberHumans = []; - if (selectedMentions.isNotEmpty) { - final currentChannel = (await ref.read( - channelsProvider.future, - )).firstWhere((channel) => channel.id == channelId); - if (!currentChannel.isDm) { - final memberPubkeys = (await ref.read( - channelMembersProvider(channelId).future, - )).map((member) => member.pubkey.toLowerCase()).toSet(); - final seenNonMembers = {}; - for (final candidate in selectedMentions) { - final pk = candidate.pubkey.toLowerCase(); - if (memberPubkeys.contains(pk)) continue; - if (!seenNonMembers.add(pk)) continue; - if (candidate.isAgent) { - nonMemberAgentPubkeys.add(pk); - } else { - nonMemberHumans.add(candidate); - } - } - } - } + final outgoing = _OutgoingMentions(selectedMentions); + final scan = await _scanNonMemberMentions( + ref, + channelId: channelId, + selectedMentions: selectedMentions, + currentPubkey: currentPubkey, + ); // Mentioning humans outside the channel prompts "Invite" / "Do // nothing" (send without inviting) — mirrors desktop's // NonMemberMentionDialog. Agents keep the existing silent auto-add. - var mentionPubkeys = pubkeys; - final referenceMentionTags = >[]; - var inviteHumanPubkeys = const []; - if (nonMemberHumans.isNotEmpty) { + if (scan.humans.isNotEmpty) { if (!context.mounted) return; final choice = await _promptNonMemberMention( context, - names: [for (final candidate in nonMemberHumans) candidate.label], + names: [for (final candidate in scan.humans) candidate.label], + canInvite: scan.canAddMembers, ); - switch (choice) { - case null: - return; // Dismissed — keep the draft, send nothing. - case _NonMemberMentionChoice.invite: - inviteHumanPubkeys = [ - for (final candidate in nonMemberHumans) - candidate.pubkey.toLowerCase(), - ]; - case _NonMemberMentionChoice.sendWithoutInviting: - // Strip their p-tags (no channel notification) but keep a - // `mention` reference tag so their name still renders — - // mirrors desktop's mergeOutgoingTagsWithReferenceMentions. - final excluded = { - for (final candidate in nonMemberHumans) - candidate.pubkey.toLowerCase(), - }; - mentionPubkeys = [ - for (final pk in pubkeys) - if (!excluded.contains(pk)) pk, - ]; - referenceMentionTags.addAll([ - for (final pk in excluded) ['mention', pk], - ]); - } + if (choice == null) return; // Dismissed — keep the draft, send nothing. + outgoing.resolveHumanChoice(choice, scan.humans); } final queuedAttachments = List<_PendingAttachment>.of(attachments.value); final channelActions = ref.read(channelActionsProvider); - Future addMentionedNonMembers() => _addMentionedNonMembers( + // An add that was refused doesn't block the message: it is reported and + // the un-added mentions are demoted to reference tags so the send lands. + Future addMentionedNonMembers() => outgoing.addNonMembers( channelActions, - channelId: channelId, - agentPubkeys: nonMemberAgentPubkeys, - humanPubkeys: inviteHumanPubkeys, + scan: scan, + messenger: messenger, ); isSending.value = true; @@ -503,12 +461,19 @@ class ComposeBar extends HookConsumerWidget { ); await onSend( payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], + outgoing.pubkeys, + mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); if (context.mounted) clearComposer(); } on StateError { _reportSendCancelledByCommunitySwitch(messenger); + } catch (error) { + // send() runs unawaited, so a relay rejection or publish timeout + // would otherwise vanish with the composer looking idle. The draft + // is kept (clearComposer never ran) so the user can retry. + messenger?.showSnackBar( + SnackBar(content: Text(_composeSendErrorMessage(error))), + ); } return; } @@ -561,8 +526,8 @@ class ComposeBar extends HookConsumerWidget { if (queueGeneration != uploadGeneration.value) return; await delivery( payload.content, - mentionPubkeys, - mediaTags: [...payload.mediaTags, ...referenceMentionTags], + outgoing.pubkeys, + mediaTags: [...payload.mediaTags, ...outgoing.referenceTags], ); } catch (error) { if (cancellation.isCancelled) return; diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index c630accdf..f8cff238e 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -143,33 +143,48 @@ bool hasMention(String text, String name) { /// cancels the send and keeps the draft. enum _NonMemberMentionChoice { invite, sendWithoutInviting } +/// User-facing text for a failed add or send. +String _composeSendErrorMessage(Object error) { + if (error is AddMembersException) return error.message; + return error.toString().replaceFirst('Exception: ', ''); +} + /// Ask whether to invite mentioned humans who aren't channel members, or /// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`. +/// [canInvite] false (a private channel the sender doesn't own/administer) +/// drops the Invite action — the relay rejects that add, so offering it would +/// only produce an error. Future<_NonMemberMentionChoice?> _promptNonMemberMention( BuildContext context, { required List names, + required bool canInvite, }) { final verb = names.length == 1 ? 'is' : 'are'; - return showDialog<_NonMemberMentionChoice>( + return showBuzzDialog<_NonMemberMentionChoice>( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Mention people outside this channel?'), content: Text( - '${names.join(', ')} $verb not in this channel. Invite them to ' - 'the channel, or send without inviting them.', + canInvite + ? '${names.join(', ')} $verb not in this channel. Invite them to ' + 'the channel, or send without inviting them.' + : '${names.join(', ')} $verb not in this channel. ' + '$privateChannelAddDeniedMessage You can still send without ' + 'inviting them.', ), actions: [ TextButton( onPressed: () => Navigator.of( dialogContext, ).pop(_NonMemberMentionChoice.sendWithoutInviting), - child: const Text('Do nothing'), - ), - TextButton( - onPressed: () => - Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite), - child: const Text('Invite'), + child: Text(canInvite ? 'Do nothing' : 'Send anyway'), ), + if (canInvite) + TextButton( + onPressed: () => + Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite), + child: const Text('Invite'), + ), ], ), ); @@ -232,27 +247,204 @@ void _reportSendCancelledByCommunitySwitch(ScaffoldMessengerState? messenger) { ); } +/// What an add attempt left undone: who is still a non-member, and why. +@immutable +class _NonMemberAddOutcome { + final List notAdded; + final List errors; + + const _NonMemberAddOutcome({required this.notAdded, required this.errors}); + + static const empty = _NonMemberAddOutcome(notAdded: [], errors: []); +} + /// Adds mentioned non-members to the channel before a send. /// /// Agents are added silently with the `bot` role; humans are only passed here /// after they have been explicitly invited from the mention prompt. -Future _addMentionedNonMembers( +/// +/// A rejection is reported, never thrown: the send is fire-and-forget, so an +/// escaping error would drop the message with nothing shown. [StateError] still +/// propagates — a community switch must cancel the whole send. +Future<_NonMemberAddOutcome> _addMentionedNonMembers( ChannelActions channelActions, { required String channelId, required List agentPubkeys, required List humanPubkeys, + required bool canAddMembers, }) async { - if (agentPubkeys.isNotEmpty) { - await channelActions.addMembers( - channelId: channelId, - pubkeys: agentPubkeys, - role: 'bot', + final pending = [ + if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'), + if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'), + ]; + if (pending.isEmpty) return _NonMemberAddOutcome.empty; + + // A plain member of a private channel cannot add anyone: skip the doomed + // kind:9000 rather than trading it for a relay rejection. + if (!canAddMembers) { + return _NonMemberAddOutcome( + notAdded: [for (final (pubkeys, _) in pending) ...pubkeys], + errors: const [privateChannelAddDeniedMessage], ); } - if (humanPubkeys.isNotEmpty) { - await channelActions.addMembers( - channelId: channelId, - pubkeys: humanPubkeys, + + final notAdded = []; + final errors = []; + for (final (pubkeys, role) in pending) { + try { + await channelActions.addMembers( + channelId: channelId, + pubkeys: pubkeys, + role: role, + ); + } on StateError { + rethrow; + } catch (error) { + notAdded.addAll( + error is AddMembersException ? error.failures.keys : pubkeys, + ); + errors.add(_composeSendErrorMessage(error)); + } + } + return _NonMemberAddOutcome(notAdded: notAdded, errors: errors); +} + +/// Mentioned identities that aren't in the channel yet, plus whether the sender +/// is allowed to add them at all. +@immutable +class _NonMemberMentionScan { + final String channelId; + final List agentPubkeys; + final List humans; + final bool canAddMembers; + + const _NonMemberMentionScan({ + required this.channelId, + required this.agentPubkeys, + required this.humans, + required this.canAddMembers, + }); +} + +/// Resolves which mentioned identities are non-members, and whether this +/// identity may add them (see [Channel.canAddMembers]). DMs are skipped: their +/// participant set is fixed at creation. +Future<_NonMemberMentionScan> _scanNonMemberMentions( + WidgetRef ref, { + required String channelId, + required List selectedMentions, + required String? currentPubkey, +}) async { + final none = _NonMemberMentionScan( + channelId: channelId, + agentPubkeys: const [], + humans: const [], + canAddMembers: true, + ); + if (selectedMentions.isEmpty) return none; + + final channel = (await ref.read( + channelsProvider.future, + )).firstWhere((candidate) => candidate.id == channelId); + if (channel.isDm) return none; + + final members = await ref.read(channelMembersProvider(channelId).future); + final memberPubkeys = { + for (final member in members) member.pubkey.toLowerCase(), + }; + String? selfRole; + if (currentPubkey != null) { + final self = currentPubkey.toLowerCase(); + for (final member in members) { + if (member.pubkey.toLowerCase() == self) { + selfRole = member.role; + break; + } + } + } + + final agentPubkeys = []; + final humans = []; + final seen = {}; + for (final candidate in selectedMentions) { + final pubkey = candidate.pubkey.toLowerCase(); + if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue; + if (candidate.isAgent) { + agentPubkeys.add(pubkey); + } else { + humans.add(candidate); + } + } + + return _NonMemberMentionScan( + channelId: channelId, + agentPubkeys: agentPubkeys, + humans: humans, + canAddMembers: channel.canAddMembers(selfRole), + ); +} + +/// The p-tags and `mention` reference tags an outgoing message should carry. +/// +/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so +/// their name still renders without notifying a non-member — mirrors desktop's +/// `mergeOutgoingTagsWithReferenceMentions`. +class _OutgoingMentions { + List pubkeys; + final List> referenceTags = []; + List _invitedHumanPubkeys = const []; + + _OutgoingMentions(List selectedMentions) + : pubkeys = LinkedHashSet.from( + selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()), + ).toList(); + + void demote(Iterable demoted) { + final excluded = {for (final pubkey in demoted) pubkey.toLowerCase()}; + if (excluded.isEmpty) return; + pubkeys = [ + for (final pubkey in pubkeys) + if (!excluded.contains(pubkey)) pubkey, + ]; + referenceTags.addAll([ + for (final pubkey in excluded) ['mention', pubkey], + ]); + } + + /// Applies the mention prompt's outcome: invite them, or send without. + void resolveHumanChoice( + _NonMemberMentionChoice choice, + List humans, + ) { + final humanPubkeys = [ + for (final candidate in humans) candidate.pubkey.toLowerCase(), + ]; + switch (choice) { + case _NonMemberMentionChoice.invite: + _invitedHumanPubkeys = humanPubkeys; + case _NonMemberMentionChoice.sendWithoutInviting: + demote(humanPubkeys); + } + } + + /// Adds the scanned non-members, demoting and reporting whatever didn't land. + Future addNonMembers( + ChannelActions channelActions, { + required _NonMemberMentionScan scan, + required ScaffoldMessengerState? messenger, + }) async { + final outcome = await _addMentionedNonMembers( + channelActions, + channelId: scan.channelId, + agentPubkeys: scan.agentPubkeys, + humanPubkeys: _invitedHumanPubkeys, + canAddMembers: scan.canAddMembers, ); + demote(outcome.notAdded); + if (outcome.errors.isNotEmpty) { + messenger?.showSnackBar( + SnackBar(content: Text(outcome.errors.join(' '))), + ); + } } } diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index e3cd0f8ea..e8e62199d 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -11,6 +11,7 @@ import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_search.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/modal_presentation.dart'; import 'recent_emoji_provider.dart'; part 'emoji_picker/search_field.dart'; @@ -33,7 +34,7 @@ void showEmojiPicker({ required void Function(String emoji) onSelect, VoidCallback? onDismiss, }) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index e1c79164e..24c80daa5 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../profile/user_status.dart'; @@ -50,7 +51,7 @@ class MembersSheet extends HookConsumerWidget { navigator.pop(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!navigator.mounted) return; - showModalBottomSheet( + showBuzzModalBottomSheet( context: navigator.context, isScrollControlled: true, showDragHandle: true, @@ -302,7 +303,7 @@ class _MemberTile extends ConsumerWidget { ? profile!.displayName!.trim() : member.labelFor(currentPubkey)); final canChangeRole = showManagementActions && !member.isBot; - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( @@ -360,7 +361,7 @@ class _MemberTile extends ConsumerWidget { ), onTap: () async { Navigator.of(context).pop(); - final confirmed = await showDialog( + final confirmed = await showBuzzDialog( context: context, builder: (context) => AlertDialog( title: const Text('Remove member'), diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index b2fbc266d..0b5c56886 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -19,6 +19,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/widgets/sheet_divider.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; import 'channel_management_provider.dart'; @@ -47,97 +48,104 @@ void showMessageActions({ bool isMember = false, bool isArchived = false, }) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, + showCloseButton: false, builder: (sheetContext) => SafeArea( - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7, - ), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _QuickReactionRow( - message: message, - sheetContext: sheetContext, - pageContext: context, - pageRef: ref, - ), - const SizedBox(height: Grid.xs), - if (!message.isSystem) ...[ - // Fast actions: respond now, hand off context, defer. - _FastActionsRow( + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.7, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _QuickReactionRow( message: message, - channelId: channelId, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + sheetContext: sheetContext, pageContext: context, + pageRef: ref, ), const SizedBox(height: Grid.xs), - // Triage: come back to this message later. - _MarkReadUnreadTile(message: message, channelId: channelId), - _FollowThreadTile(message: message), - const SheetDivider(), - // Export: take the content out of the conversation. - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - // Copy to clipboard - final data = ClipboardData(text: message.content); - Clipboard.setData(data); - }, - ), - ], - if (canManageMessage) ...[ - if (!message.isSystem) const SheetDivider(), - ListTile( - leading: const Icon(LucideIcons.pencil), - title: const Text('Edit message'), - onTap: () { - Navigator.of(sheetContext).pop(); - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }, - ), - ListTile( - leading: Icon( - LucideIcons.trash2, - color: sheetContext.colors.error, + if (!message.isSystem) ...[ + // Fast actions: respond now, hand off context, defer. + _FastActionsRow( + message: message, + channelId: channelId, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + pageContext: context, ), - title: Text( - 'Delete message', - style: TextStyle(color: sheetContext.colors.error), + const SizedBox(height: Grid.xs), + // Triage: come back to this message later. + _MarkReadUnreadTile(message: message, channelId: channelId), + _FollowThreadTile(message: message), + const SheetDivider(), + // Export: take the content out of the conversation. + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + // Copy to clipboard + final data = ClipboardData(text: message.content); + Clipboard.setData(data); + }, ), - onTap: () { - Navigator.of(sheetContext).pop(); - _confirmDelete( - context: context, - ref: ref, - channelId: channelId, - messageId: message.id, - ); - }, - ), + ], + if (canManageMessage) ...[ + if (!message.isSystem) const SheetDivider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.pencil), + title: const Text('Edit message'), + onTap: () { + Navigator.of(sheetContext).pop(); + _showEditSheet( + context: context, + ref: ref, + message: message, + channelId: channelId, + ); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete message', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + ); + }, + ), + ], ], - ], + ), ), ), ), @@ -156,78 +164,91 @@ void showImageActions({ required bool canManageMessage, VoidCallback? onDeleted, }) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, + isScrollControlled: true, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(LucideIcons.download), - title: const Text('Save image'), - onTap: () { - Navigator.of(sheetContext).pop(); - unawaited(_saveImage(context, ref, imageUrl)); - }, - ), - ListTile( - leading: const Icon(LucideIcons.share2), - title: const Text('Share image'), - onTap: () { - final renderBox = context.findRenderObject() as RenderBox?; - final shareOrigin = renderBox == null - ? null - : renderBox.localToGlobal(Offset.zero) & renderBox.size; - Navigator.of(sheetContext).pop(); - unawaited( - _shareImage(context, ref, imageUrl, shareOrigin: shareOrigin), - ); - }, - ), - ListTile( - leading: const Icon(LucideIcons.link2), - title: const Text('Copy image link'), - onTap: () { - Navigator.of(sheetContext).pop(); - copyToClipboard( - context, - imageUrl, - message: 'Image link copied', - ); - }, - ), - if (canManageMessage) ...[ - const SheetDivider(), + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - leading: Icon( - LucideIcons.trash2, - color: sheetContext.colors.error, - ), - title: Text( - 'Delete message', - style: TextStyle(color: sheetContext.colors.error), - ), + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.download), + title: const Text('Save image'), onTap: () { Navigator.of(sheetContext).pop(); - _confirmDelete( - context: context, - ref: ref, - channelId: channelId, - messageId: message.id, - onDeleted: onDeleted, + unawaited(_saveImage(context, ref, imageUrl)); + }, + ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.share2), + title: const Text('Share image'), + onTap: () { + final renderBox = context.findRenderObject() as RenderBox?; + final shareOrigin = renderBox == null + ? null + : renderBox.localToGlobal(Offset.zero) & renderBox.size; + Navigator.of(sheetContext).pop(); + unawaited( + _shareImage( + context, + ref, + imageUrl, + shareOrigin: shareOrigin, + ), ); }, ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(LucideIcons.link2), + title: const Text('Copy image link'), + onTap: () { + Navigator.of(sheetContext).pop(); + copyToClipboard( + context, + imageUrl, + message: 'Image link copied', + ); + }, + ), + if (canManageMessage) ...[ + const SheetDivider(), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete message', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete( + context: context, + ref: ref, + channelId: channelId, + messageId: message.id, + onDeleted: onDeleted, + ); + }, + ), + ], ], - ], + ), ), ), ), @@ -387,6 +408,7 @@ class _MarkReadUnreadTile extends ConsumerWidget { ); return ListTile( + contentPadding: EdgeInsets.zero, leading: Icon(unread ? LucideIcons.mailCheck : LucideIcons.mailOpen), title: Text(unread ? 'Mark read' : 'Mark unread'), onTap: () { @@ -428,6 +450,7 @@ class _FollowThreadTile extends ConsumerWidget { final following = follows.isFollowing(rootId); return ListTile( + contentPadding: EdgeInsets.zero, leading: Icon(following ? LucideIcons.bellOff : LucideIcons.bellRing), title: Text(following ? 'Unfollow thread' : 'Follow thread'), onTap: () { @@ -533,8 +556,12 @@ class _FastActionsRow extends ConsumerWidget { ]; return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: tiles, + children: [ + for (var index = 0; index < tiles.length; index++) ...[ + Expanded(child: tiles[index]), + if (index < tiles.length - 1) const SizedBox(width: Grid.twelve), + ], + ], ); } } @@ -553,30 +580,37 @@ class _FastActionTile extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTap, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, behavior: HitTestBehavior.opaque, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Pill-shaped icon container with the caption below the fill, - // matching the emoji circles' fill and tap treatment. + // The full-width tiles deliberately contrast with the compact emoji + // reactions immediately above them. Container( - width: 76, - height: 56, - alignment: Alignment.center, + height: 68 + (Grid.xxs * 2), decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(Radii.dialog), ), - child: Icon(icon, size: 24, color: context.colors.onSurface), - ), - const SizedBox(height: Grid.xxs), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurface, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 22, color: context.colors.onSurface), + const SizedBox(height: Grid.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], ), ), ], @@ -636,32 +670,59 @@ class _QuickReactionRow extends ConsumerWidget { pageRef.read(channelActionsProvider).addReaction(message.id, value); } - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - for (final value in emoji) + 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 (final value in emoji) + _QuickReactionCircle( + key: ValueKey('quick-reaction-$value'), + size: circleSize, + onTap: () { + Navigator.of(sheetContext).pop(); + react(value); + }, + child: _QuickReactionGlyph( + value: value, + customByShortcode: customByShortcode, + ), + ), _QuickReactionCircle( + key: const ValueKey('quick-reaction-more'), + size: circleSize, onTap: () { Navigator.of(sheetContext).pop(); - react(value); + showEmojiPicker(context: pageContext, onSelect: react); }, - child: _QuickReactionGlyph( - value: value, - customByShortcode: customByShortcode, + child: Icon( + LucideIcons.plus, + size: 24, + color: context.colors.onSurfaceVariant, ), ), - _QuickReactionCircle( - 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), + ], + ], + ); + }, ); } } @@ -699,16 +760,25 @@ class _QuickReactionGlyph extends StatelessWidget { class _QuickReactionCircle extends StatelessWidget { final VoidCallback onTap; final Widget child; + final double size; - const _QuickReactionCircle({required this.onTap, required this.child}); + const _QuickReactionCircle({ + super.key, + required this.onTap, + required this.child, + required this.size, + }); @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTap, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, child: Container( - width: 52, - height: 52, + width: size, + height: size, alignment: Alignment.center, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, @@ -727,7 +797,7 @@ void _showEditSheet({ required String channelId, }) { final controller = TextEditingController(text: message.content); - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, @@ -794,7 +864,7 @@ void _confirmDelete({ required String messageId, VoidCallback? onDeleted, }) { - showDialog( + showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete message'), diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 8f4281aeb..e2c86b2fb 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -108,6 +108,9 @@ class MessageContent extends HookConsumerWidget { final TextStyle? baseStyle; + /// Alignment applied to rendered markdown text. + final TextAlign? textAlign; + final int? maxLines; /// Render a body that is nothing but emoji at [kEmojiOnlyFontSize], the way @@ -137,6 +140,7 @@ class MessageContent extends HookConsumerWidget { this.onMediaReply, this.onMediaMore, this.baseStyle, + this.textAlign, this.maxLines, this.scaleEmojiOnly = false, this.mediaCarouselLeadingOverflow = 0, @@ -269,6 +273,7 @@ class MessageContent extends HookConsumerWidget { _buildLink(context, ref, linkText, url, linkStyle, style), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), + textAlign: textAlign, maxLines: maxLines, inlineComponents: [ _MentionMd( diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 9d01b5c8e..8a3c589a3 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/emoji/emoji_data_provider.dart'; @@ -316,7 +317,7 @@ void showReactionDetailSheet({ required List reactions, required String initialEmoji, }) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, diff --git a/mobile/lib/features/channels/recent_emoji_provider.dart b/mobile/lib/features/channels/recent_emoji_provider.dart index 088dcd3b2..f024ffcd2 100644 --- a/mobile/lib/features/channels/recent_emoji_provider.dart +++ b/mobile/lib/features/channels/recent_emoji_provider.dart @@ -17,13 +17,14 @@ import '../../shared/theme/theme_provider.dart'; /// leak across an in-place community or account switch. const _recentEmojiPrefsKey = 'buzz.quick-reaction-emojis.v1'; -/// Desktop's `DEFAULT_QUICK_REACTIONS`, used until the user has reacted enough -/// to fill the row. +/// Desktop's `DEFAULT_QUICK_REACTIONS`, plus one mobile-only slot that fits in +/// the compact action sheet, used until the user has reacted enough to fill it. const defaultQuickEmojis = [ '\u{1F44D}', '\u{2764}\u{FE0F}', '\u{1F602}', '\u{1F389}', + '\u{1F525}', ]; /// Desktop's `MAX_STORED_REACTIONS`. @@ -167,7 +168,7 @@ final recentEmojiProvider = List quickReactionEmoji( List entries, { required Set customShortcodes, - int limit = 4, + int limit = 5, }) { final result = []; void add(String emoji) { diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index ddc36a1d4..453c9e8ef 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -7,6 +7,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile_sheet.dart'; @@ -230,44 +231,47 @@ class ForumPostCard extends HookConsumerWidget { currentPubkey != null && post.pubkey.toLowerCase() == currentPubkey!.toLowerCase(); - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - Clipboard.setData(ClipboardData(text: post.content)); - }, - ), - if (isOwn && onDelete != null) + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - leading: Icon( - LucideIcons.trash2, - color: sheetContext.colors.error, - ), - title: Text( - 'Delete post', - style: TextStyle(color: sheetContext.colors.error), - ), + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), onTap: () { Navigator.of(sheetContext).pop(); - _confirmDelete(context); + Clipboard.setData(ClipboardData(text: post.content)); }, ), - ], + if (isOwn && onDelete != null) + ListTile( + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete post', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete(context); + }, + ), + ], + ), ), ), ), @@ -275,7 +279,7 @@ class ForumPostCard extends HookConsumerWidget { } void _confirmDelete(BuildContext context) { - showDialog( + showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete post'), diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index f7859f16f..74303c057 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -12,6 +12,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../channels/compose_bar.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; @@ -113,43 +114,46 @@ class ForumThreadPage extends HookConsumerWidget { WidgetRef ref, ForumThreadResponse thread, ) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - Clipboard.setData(ClipboardData(text: thread.post.content)); - }, - ), - ListTile( - leading: Icon( - LucideIcons.trash2, - color: sheetContext.colors.error, + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), + onTap: () { + Navigator.of(sheetContext).pop(); + Clipboard.setData(ClipboardData(text: thread.post.content)); + }, ), - title: Text( - 'Delete post', - style: TextStyle(color: sheetContext.colors.error), + ListTile( + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete post', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDeletePost(context, ref, thread.post.eventId); + }, ), - onTap: () { - Navigator.of(sheetContext).pop(); - _confirmDeletePost(context, ref, thread.post.eventId); - }, - ), - ], + ], + ), ), ), ), @@ -157,7 +161,7 @@ class ForumThreadPage extends HookConsumerWidget { } void _confirmDeletePost(BuildContext context, WidgetRef ref, String eventId) { - showDialog( + showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete post'), @@ -533,44 +537,47 @@ class _ReplyRow extends ConsumerWidget { currentPubkey != null && reply.pubkey.toLowerCase() == currentPubkey!.toLowerCase(); - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(LucideIcons.copy), - title: const Text('Copy text'), - onTap: () { - Navigator.of(sheetContext).pop(); - Clipboard.setData(ClipboardData(text: reply.content)); - }, - ), - if (isOwn) + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ ListTile( - leading: Icon( - LucideIcons.trash2, - color: sheetContext.colors.error, - ), - title: Text( - 'Delete reply', - style: TextStyle(color: sheetContext.colors.error), - ), + leading: const Icon(LucideIcons.copy), + title: const Text('Copy text'), onTap: () { Navigator.of(sheetContext).pop(); - _confirmDelete(context, ref); + Clipboard.setData(ClipboardData(text: reply.content)); }, ), - ], + if (isOwn) + ListTile( + leading: Icon( + LucideIcons.trash2, + color: sheetContext.colors.error, + ), + title: Text( + 'Delete reply', + style: TextStyle(color: sheetContext.colors.error), + ), + onTap: () { + Navigator.of(sheetContext).pop(); + _confirmDelete(context, ref); + }, + ), + ], + ), ), ), ), @@ -578,7 +585,7 @@ class _ReplyRow extends ConsumerWidget { } void _confirmDelete(BuildContext context, WidgetRef ref) { - showDialog( + showBuzzDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Delete reply'), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 79ccf3199..c691310df 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -36,6 +36,7 @@ class HomePage extends HookConsumerWidget { static const double _fabClearance = _tabBarHeight + _tabBarBottomGap; static const Duration _tabIconWeightDuration = Duration(milliseconds: 120); static const Duration _tabUnreadBadgeDuration = Duration(milliseconds: 220); + static const double _settingsBackgroundScale = 0.97; static const Duration _tabContentTransitionDuration = Duration( milliseconds: 240, ); @@ -71,6 +72,10 @@ class HomePage extends HookConsumerWidget { final tabContentTransitionValue = useAnimation( tabContentTransitionController, ); + final homeReselection = useValueNotifier(0); + final activityReselection = useValueNotifier(0); + final searchReselection = useValueNotifier(0); + final settingsTransitionProgress = useValueNotifier(0.0); final reducedMotion = MediaQuery.of(context).disableAnimations; final tabContentTransitionProgress = reducedMotion ? 1.0 @@ -82,77 +87,141 @@ class HomePage extends HookConsumerWidget { ); final pages = [ - ChannelsPage(settingsPageBuilder: settingsPageBuilder), - const ActivityPage(), - const SearchPage(), - ]; - - return Scaffold( - backgroundColor: Colors.transparent, - // Keep the floating navigation and Home quick actions anchored while the - // keyboard is visible on any tab. - resizeToAvoidBottomInset: false, - extendBody: true, - body: SizedBox.expand( - child: Stack( - fit: StackFit.expand, - children: [ - Positioned.fill(child: ColoredBox(color: context.colors.surface)), - Positioned.fill( - child: MediaQuery( - data: _mediaQueryWithFloatingTabBarClearance( - context, - HomePage._fabClearance, - ), - child: DirectionalTransitionScope( - horizontalOffset: - tabContentTransitionDirection.value * - _tabContentTransitionDistance * - (1 - tabContentTransitionProgress), - opacity: tabContentTransitionProgress, - child: ClipRect( - child: IndexedStack(index: tabIndex.value, children: pages), - ), - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: IgnorePointer( - child: MobileTabFooterBackdrop( - height: mobileTabFooterBackdropHeight(context), - ), - ), - ), - Positioned.fill( - child: ChannelQuickActionsLauncher( - visible: tabIndex.value == 0, - navigationBarHeight: HomePage._tabBarHeight, - navigationBarBottomGap: HomePage._tabBarBottomGap, - navigationBarWidth: navigationBarWidth, - systemBottomInset: systemBottomInset, - rightInset: Grid.sm, - ), - ), - ], - ), - ), - bottomNavigationBar: _FloatingTabBar( - selectedIndex: tabIndex.value, - hasUnreadInbox: hasUnreadInbox, - onDestinationSelected: (i) { - if (i == tabIndex.value) return; - tabContentTransitionDirection.value = i > tabIndex.value ? 1 : -1; - unawaited(HapticFeedback.selectionClick()); - tabIndex.value = i; - if (reducedMotion) { - tabContentTransitionController.value = 1; - } else { - unawaited(tabContentTransitionController.forward(from: 0)); + ChannelsPage( + settingsPageBuilder: settingsPageBuilder, + tabReselection: homeReselection, + onSettingsTransitionProgress: (progress) { + if (settingsTransitionProgress.value != progress) { + settingsTransitionProgress.value = progress; } }, - destinations: _destinations, ), + ActivityPage(tabReselection: activityReselection), + SearchPage(tabReselection: searchReselection), + ]; + + final settingsTransitionGradient = tabIndex.value == 0 + ? context.appColors.topSectionGradient + : null; + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: DecoratedBox( + key: const ValueKey('home-settings-transition-backdrop'), + decoration: BoxDecoration( + color: settingsTransitionGradient == null + ? context.colors.surface + : null, + gradient: settingsTransitionGradient, + ), + ), + ), + ValueListenableBuilder( + valueListenable: settingsTransitionProgress, + child: Scaffold( + backgroundColor: Colors.transparent, + // Keep the floating navigation and Home quick actions anchored while the + // keyboard is visible on any tab. + resizeToAvoidBottomInset: false, + extendBody: true, + body: SizedBox.expand( + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: ColoredBox(color: context.colors.surface), + ), + Positioned.fill( + child: MediaQuery( + data: _mediaQueryWithFloatingTabBarClearance( + context, + HomePage._fabClearance, + ), + child: DirectionalTransitionScope( + horizontalOffset: + tabContentTransitionDirection.value * + _tabContentTransitionDistance * + (1 - tabContentTransitionProgress), + opacity: tabContentTransitionProgress, + child: ClipRect( + child: IndexedStack( + index: tabIndex.value, + children: pages, + ), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + child: MobileTabFooterBackdrop( + height: mobileTabFooterBackdropHeight(context), + tint: context.colors.primaryContainer, + ), + ), + ), + Positioned.fill( + child: ChannelQuickActionsLauncher( + visible: tabIndex.value == 0, + navigationBarHeight: HomePage._tabBarHeight, + navigationBarBottomGap: HomePage._tabBarBottomGap, + navigationBarWidth: navigationBarWidth, + systemBottomInset: systemBottomInset, + rightInset: Grid.sm, + ), + ), + ], + ), + ), + bottomNavigationBar: _FloatingTabBar( + selectedIndex: tabIndex.value, + hasUnreadInbox: hasUnreadInbox, + onDestinationSelected: (i) { + if (i == tabIndex.value) { + switch (i) { + case 0: + homeReselection.value++; + case 1: + activityReselection.value++; + case 2: + searchReselection.value++; + } + return; + } + tabContentTransitionDirection.value = i > tabIndex.value + ? 1 + : -1; + unawaited(HapticFeedback.selectionClick()); + tabIndex.value = i; + if (reducedMotion) { + tabContentTransitionController.value = 1; + } else { + unawaited(tabContentTransitionController.forward(from: 0)); + } + }, + destinations: _destinations, + ), + ), + builder: (context, progress, child) { + final curvedProgress = reducedMotion + ? 0.0 + : Curves.easeOutCubic.transform(progress); + return Opacity( + key: const ValueKey('home-settings-transition-opacity'), + opacity: 1 - curvedProgress, + child: Transform.scale( + key: const ValueKey('home-settings-transition-scale'), + scale: lerpDouble(1, _settingsBackgroundScale, curvedProgress), + alignment: Alignment.center, + child: child, + ), + ); + }, + ), + ], ); } } diff --git a/mobile/lib/features/invites/invite_join_sheet.dart b/mobile/lib/features/invites/invite_join_sheet.dart index a88d38ac1..b0e0cb6f7 100644 --- a/mobile/lib/features/invites/invite_join_sheet.dart +++ b/mobile/lib/features/invites/invite_join_sheet.dart @@ -4,11 +4,12 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../pairing/pairing_page.dart'; import 'invite_join_provider.dart'; Future showInviteJoinSheet(BuildContext context, WidgetRef ref) { - return showModalBottomSheet( + return showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index fb60fae12..087cc909a 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -8,7 +8,7 @@ import 'profile_provider.dart'; import 'user_profile.dart'; /// Matches desktop's sidebar profile card, whose avatar is 32px. -const _avatarSize = 32.0; +const _defaultAvatarSize = 32.0; /// The visible dot is smaller than the notch it sits in, so a ring of /// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a @@ -24,7 +24,15 @@ class ProfileAvatar extends ConsumerWidget { final VoidCallback? onTap; final bool showPresence; - const ProfileAvatar({super.key, this.onTap, this.showPresence = true}); + /// The avatar diameter in logical pixels; defaults to the 32px desktop match. + final double size; + + const ProfileAvatar({ + super.key, + this.onTap, + this.showPresence = true, + this.size = _defaultAvatarSize, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -45,7 +53,7 @@ class ProfileAvatar extends ConsumerWidget { Widget _buildPlaceholder(BuildContext context) { return CircleAvatar( - radius: _avatarSize / 2, + radius: size / 2, backgroundColor: context.colors.primaryContainer, ); } @@ -58,7 +66,7 @@ class ProfileAvatar extends ConsumerWidget { return GestureDetector( onTap: onTap, child: MaskedAvatarBadge( - size: _avatarSize, + size: size, geometry: AvatarBadgeMaskGeometry.presenceDot, avatar: ClipOval( child: ColoredBox( diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 280ce75e4..66fceebb2 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; import 'user_profile.dart'; /// The current user's profile (kind:0 metadata) loaded over the relay @@ -50,13 +51,26 @@ final profileProvider = AsyncNotifierProvider( /// [appLifecycleProvider] to send "away" when backgrounded. class PresenceNotifier extends AsyncNotifier { static const _heartbeatInterval = Duration(seconds: 60); + static const _preferenceKeyPrefix = 'buzz_presence_preference_'; Timer? _heartbeatTimer; + String? _preferencePubkey; + String? _manualPresence; @override Future build() { ref.watch(relaySessionProvider); - ref.watch(profileProvider); + final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); + + if (_preferencePubkey != pubkey) { + _preferencePubkey = pubkey; + final stored = pubkey == null + ? null + : ref + .read(savedPrefsProvider) + .getString('$_preferenceKeyPrefix$pubkey'); + _manualPresence = stored == 'away' || stored == 'offline' ? stored : null; + } final lifecycle = ref.watch(appLifecycleProvider); @@ -65,6 +79,13 @@ class PresenceNotifier extends AsyncNotifier { _heartbeatTimer = null; }); + final manualPresence = _manualPresence; + if (manualPresence != null) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + return _setPresence(manualPresence); + } + if (lifecycle == AppLifecycleState.resumed) { _startHeartbeat(); return _setPresence('online'); @@ -87,6 +108,33 @@ class PresenceNotifier extends AsyncNotifier { }); } + /// Updates the current user's presence preference and publishes it. + /// + /// Online restores automatic lifecycle-driven presence. Away and Offline + /// remain selected until the user chooses another value. + Future setPresence(String status) async { + if (status != 'online' && status != 'away' && status != 'offline') return; + + _manualPresence = status == 'online' ? null : status; + final pubkey = ref.read(myPubkeyProvider)?.toLowerCase(); + if (pubkey != null) { + await ref + .read(savedPrefsProvider) + .setString('$_preferenceKeyPrefix$pubkey', _manualPresence ?? 'auto'); + } + + if (_manualPresence == null && + ref.read(appLifecycleProvider) == AppLifecycleState.resumed) { + _startHeartbeat(); + } else { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + } + + state = AsyncData(status); + await _setPresence(status); + } + /// Publish a kind:20001 presence event. Returns the requested status /// optimistically — failures are silently absorbed and the next heartbeat /// will retry. diff --git a/mobile/lib/features/profile/set_status_sheet.dart b/mobile/lib/features/profile/set_status_sheet.dart index 2e03e4c11..7419548b6 100644 --- a/mobile/lib/features/profile/set_status_sheet.dart +++ b/mobile/lib/features/profile/set_status_sheet.dart @@ -9,6 +9,7 @@ import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/modal_presentation.dart'; import '../channels/emoji_picker.dart'; import 'user_status.dart'; import 'user_status_provider.dart'; @@ -21,7 +22,7 @@ const _emojiGlyphSize = 32.0; const _saveButtonHeight = 52.0; void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index 23816ccaa..d33e0ce2f 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -6,6 +8,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; import 'profile_provider.dart'; import 'set_status_sheet.dart'; @@ -26,12 +29,13 @@ class SettingsProfileHeader extends ConsumerWidget { final profile = ref.watch(profileProvider).asData?.value; final status = ref.watch(userStatusProvider).asData?.value; final hasStatus = status != null && !status.isEmpty; + final presence = ref.watch(presenceProvider).value ?? 'offline'; void openStatusSheet() => showSetStatusSheet(context, currentStatus: status); return Padding( - padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.sm), + padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm), child: Column( children: [ MaskedAvatarBadge( @@ -59,8 +63,8 @@ class SettingsProfileHeader extends ConsumerWidget { style: context.textTheme.titleMedium, textAlign: TextAlign.center, ), - // No placeholder copy — the badge is the affordance, so this line - // appears only once there is an actual status to show. + // Keep the status text visible even when no emoji is set. NIP-38 + // permits text-only statuses, which the avatar badge cannot represent. if (hasStatus) GestureDetector( onTap: openStatusSheet, @@ -82,12 +86,154 @@ class SettingsProfileHeader extends ConsumerWidget { ), ), ), + _PresencePill( + presence: presence, + onSelected: (nextPresence) => unawaited( + ref.read(presenceProvider.notifier).setPresence(nextPresence), + ), + ), ], ), ); } } +class _PresencePill extends StatelessWidget { + const _PresencePill({required this.presence, required this.onSelected}); + + final String presence; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final effectivePresence = switch (presence) { + 'online' || 'away' => presence, + _ => 'offline', + }; + final (backgroundColor, foregroundColor) = switch (effectivePresence) { + 'online' => ( + context.appColors.success.withValues(alpha: 0.15), + context.appColors.success, + ), + 'away' => ( + context.appColors.warning.withValues(alpha: 0.15), + context.appColors.warning, + ), + _ => ( + context.colors.onSurfaceVariant.withValues(alpha: 0.15), + context.colors.onSurfaceVariant, + ), + }; + final label = _presenceLabel(effectivePresence); + + return Builder( + builder: (buttonContext) => Semantics( + button: true, + label: 'Presence: $label', + child: SizedBox( + key: const ValueKey('settings-presence-target'), + height: Grid.xl, + child: Material( + color: Colors.transparent, + child: InkWell( + key: const ValueKey('settings-presence-menu'), + borderRadius: BorderRadius.circular(Radii.full), + onTap: () async { + final selected = await showAnchoredPopover( + context: buttonContext, + width: 176, + alignment: AnchoredPopoverAlignment.center, + offset: const Offset(0, Grid.half), + menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), + surfaceKey: const ValueKey('settings-presence-popover'), + items: [ + for (final option in const ['online', 'away', 'offline']) + PopupMenuItem( + key: ValueKey('settings-presence-$option'), + value: option, + height: Grid.xl, + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + ), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _presenceColor(context, option), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + _presenceLabel(option), + style: filterChipTextStyle.copyWith( + color: context.colors.onSurface, + fontWeight: option == effectivePresence + ? FontWeight.w500 + : FontWeight.w400, + ), + ), + ), + if (option == effectivePresence) + Icon( + LucideIcons.check, + size: 16, + color: context.colors.primary, + ), + ], + ), + ), + ], + ); + if (buttonContext.mounted && selected != null) { + onSelected(selected); + } + }, + child: Center( + child: Material( + key: const ValueKey('settings-presence-pill'), + color: backgroundColor, + borderRadius: BorderRadius.circular(Radii.full), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Text( + label, + key: const ValueKey('settings-presence-label'), + style: filterChipTextStyle.copyWith( + color: foregroundColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +String _presenceLabel(String presence) => switch (presence) { + 'online' => 'Online', + 'away' => 'Away', + _ => 'Offline', +}; + +Color _presenceColor(BuildContext context, String presence) => + switch (presence) { + 'online' => context.appColors.success, + 'away' => context.appColors.warning, + _ => context.colors.outline, + }; + /// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask /// geometry rather than being set here. class _StatusBadge extends StatelessWidget { diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index bce99dc84..9700c69ba 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -1,28 +1,40 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; -import '../../shared/widgets/avatar_image.dart'; import '../../shared/utils/string_utils.dart'; +import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; +import '../channels/message_content.dart'; import 'presence_cache_provider.dart'; import 'user_cache_provider.dart'; import 'user_status_cache_provider.dart'; -import '../channels/message_content.dart'; /// Show a user profile bottom sheet for the given [pubkey]. void showUserProfileSheet(BuildContext context, String pubkey) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, + showDragHandle: false, builder: (_) => UserProfileSheet(pubkey: pubkey), - ); + ).then((channel) { + if (channel == null || !context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + }); } class UserProfileSheet extends HookConsumerWidget { @@ -66,6 +78,8 @@ class UserProfileSheet extends HookConsumerWidget { ref.read(userCacheProvider.notifier).preload([pk]); return null; }, [pk]); + final copied = useState(false); + final isOpeningDirectMessage = useState(false); final displayName = profile?.displayName; final avatarUrl = profile?.avatarUrl; @@ -73,181 +87,282 @@ class UserProfileSheet extends HookConsumerWidget { final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); - final presenceColor = switch (presence) { + Future copyPublicKey() async { + await Clipboard.setData(ClipboardData(text: pubkey)); + if (!context.mounted) return; + copied.value = true; + _showProfileCopyToast(context); + unawaited( + Future.delayed(const Duration(seconds: 2), () { + if (context.mounted) copied.value = false; + }), + ); + } + + Future openDirectMessage() async { + if (isOpeningDirectMessage.value) return; + isOpeningDirectMessage.value = true; + try { + final channel = await ref + .read(channelActionsProvider) + .openDm(pubkeys: [pk]); + if (!context.mounted) return; + Navigator.of(context).pop(channel); + } catch (_) { + // Silently fail — user tapped but DM open failed. + if (context.mounted) isOpeningDirectMessage.value = false; + } + } + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Center the presence chip on the avatar's lower edge. + Padding( + padding: const EdgeInsets.only(bottom: Grid.xl / 2), + child: Stack( + clipBehavior: Clip.none, + children: [ + Align( + alignment: Alignment.center, + child: FractionallySizedBox( + widthFactor: 0.5, + child: _ProfileAvatar( + avatarUrl: avatarUrl, + initial: initial, + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: -(Grid.xl / 2), + child: _ProfilePresenceChip(presence: presence), + ), + ], + ), + ), + const SizedBox(height: Grid.half), + + // Display name — centered, large + Center( + child: Text( + displayName ?? shortPubkey(pubkey), + style: context.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + // Match Settings: status is quiet, centered copy directly + // below the profile name rather than a separate information row. + if (userStatus != null && !userStatus.isEmpty) + SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.only( + top: Grid.xxs, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xxs, + ), + child: MessageContent( + content: userStatus.text.isNotEmpty + ? '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}' + : userStatus.emoji, + baseStyle: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 2, + ), + ), + ), + + // NIP-05 handle — centered, secondary + if (nip05 != null && nip05.isNotEmpty) ...[ + const SizedBox(height: Grid.half), + Center( + child: Text( + nip05, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + + const SizedBox(height: Grid.xs + Grid.half), + + Row( + children: [ + if (pk != currentPubkey) ...[ + Expanded( + child: _ProfileActionTile( + icon: isOpeningDirectMessage.value + ? null + : LucideIcons.messageSquare, + label: isOpeningDirectMessage.value + ? 'Opening…' + : 'Message', + isLoading: isOpeningDirectMessage.value, + onTap: openDirectMessage, + ), + ), + const SizedBox(width: Grid.twelve), + ], + Expanded( + child: _ProfileActionTile( + icon: copied.value + ? LucideIcons.check + : LucideIcons.key, + label: copied.value ? 'Copied' : 'Copy public key', + onTap: copyPublicKey, + ), + ), + ], + ), + const SizedBox(height: Grid.xs), + + // About / bio section + if (about.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + Divider( + color: context.colors.outlineVariant.withValues( + alpha: 0.3, + ), + ), + const SizedBox(height: Grid.xxs), + Text( + 'About', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Grid.half), + Text( + about, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Shows clipboard feedback above the bottom-sheet route. A regular SnackBar +/// belongs to the page Scaffold, which sits behind the modal sheet. +void _showProfileCopyToast(BuildContext context) { + final overlay = Overlay.of(context, rootOverlay: true); + final colors = context.colors; + final textStyle = context.textTheme.bodyMedium?.copyWith( + color: colors.onInverseSurface, + ); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (overlayContext) => Positioned( + left: Grid.gutter, + right: Grid.gutter, + bottom: MediaQuery.viewPaddingOf(overlayContext).bottom + Grid.xs, + child: Material( + color: colors.inverseSurface, + elevation: 6, + borderRadius: BorderRadius.circular(Radii.lg), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.twelve, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.check, size: 18, color: colors.onInverseSurface), + const SizedBox(width: Grid.xxs), + Text('Public key copied', style: textStyle), + ], + ), + ), + ), + ), + ); + overlay.insert(entry); + Future.delayed(const Duration(seconds: 2), () { + if (entry.mounted) entry.remove(); + }); +} + +/// The read-only version of the presence control in Settings. A viewed profile +/// reflects its owner's availability but does not allow another user to change +/// it. +class _ProfilePresenceChip extends StatelessWidget { + const _ProfilePresenceChip({required this.presence}); + + final String presence; + + @override + Widget build(BuildContext context) { + final effectivePresence = switch (presence) { + 'online' || 'away' => presence, + _ => 'offline', + }; + final backgroundColor = switch (effectivePresence) { 'online' => context.appColors.success, 'away' => context.appColors.warning, - _ => context.colors.outline, + _ => context.colors.onSurfaceVariant, }; - final presenceLabel = switch (presence) { + final label = switch (effectivePresence) { 'online' => 'Online', 'away' => 'Away', _ => 'Offline', }; - return SizedBox( - width: double.infinity, - child: Padding( - padding: EdgeInsets.fromLTRB( - Grid.sm, - 0, - Grid.sm, - MediaQuery.viewInsetsOf(context).bottom, - ), - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height * 0.7, - ), - child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Avatar — near full-width - _ProfileAvatar(avatarUrl: avatarUrl, initial: initial), - const SizedBox(height: Grid.xs), - - // Display name — centered, large - Center( - child: Text( - displayName ?? shortPubkey(pubkey), - style: context.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), + return Semantics( + label: 'Presence: $label', + child: SizedBox( + height: Grid.xl, + child: Center( + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(Radii.full), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Text( + label, + style: filterChipTextStyle.copyWith( + color: context.colors.onPrimary, + fontWeight: FontWeight.w500, ), - - // NIP-05 handle — centered, secondary - if (nip05 != null && nip05.isNotEmpty) ...[ - const SizedBox(height: Grid.half), - Center( - child: Text( - nip05, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - - const SizedBox(height: Grid.xs), - - // Presence row — filled dot, not an outline icon - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.half + 2), - child: Row( - children: [ - SizedBox( - width: 24, - child: Center( - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: presenceColor, - shape: BoxShape.circle, - ), - ), - ), - ), - const SizedBox(width: Grid.xxs), - Text( - presenceLabel, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurface, - ), - ), - ], - ), - ), - - if (userStatus != null && !userStatus.isEmpty) - _InfoRowContent( - icon: LucideIcons.messageCircle, - child: MessageContent( - content: - '${userStatus.emoji.isNotEmpty ? '${userStatus.emoji} ' : ''}${userStatus.text}', - ), - ), - - _InfoRow( - icon: LucideIcons.key, - text: shortPubkey(pubkey), - textStyle: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontFamily: 'monospace', - ), - onTap: () async { - await copyToClipboard( - context, - pubkey, - message: 'Public key copied', - ); - }, - ), - - // About / bio section - if (about.isNotEmpty) ...[ - const SizedBox(height: Grid.xxs), - Divider( - color: context.colors.outlineVariant.withValues(alpha: 0.3), - ), - const SizedBox(height: Grid.xxs), - Text( - 'About', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Grid.half), - Text( - about, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - - const SizedBox(height: Grid.xs), - - // Action button — Message (hidden on own profile) - if (pk != currentPubkey) ...[ - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () async { - Navigator.of(context).pop(); - try { - final channel = await ref - .read(channelActionsProvider) - .openDm(pubkeys: [pk]); - if (!context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ChannelDetailPage(channel: channel), - ), - ); - } catch (_) { - // Silently fail — user tapped but DM open failed. - } - }, - icon: const Icon(LucideIcons.messageSquare, size: 18), - label: const Text('Message'), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: Grid.twelve, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.lg), - ), - ), - ), - ), - const SizedBox(height: Grid.xxs), - ], - ], + ), ), ), ), @@ -256,83 +371,57 @@ class UserProfileSheet extends HookConsumerWidget { } } -/// A row displaying an icon + text, used for profile info items. - -class _InfoRowContent extends StatelessWidget { - final IconData icon; - final Widget child; - - const _InfoRowContent({required this.icon, required this.child}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.quarter), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 16, color: context.colors.onSurfaceVariant), - const SizedBox(width: Grid.xxs), - Expanded(child: child), - ], - ), - ); - } -} - -class _InfoRow extends StatelessWidget { - final IconData icon; - final String text; - final TextStyle? textStyle; - final VoidCallback? onTap; - - const _InfoRow({ +class _ProfileActionTile extends StatelessWidget { + const _ProfileActionTile({ required this.icon, - required this.text, - this.textStyle, - this.onTap, + required this.label, + required this.onTap, + this.isLoading = false, }); + final IconData? icon; + final String label; + final VoidCallback onTap; + final bool isLoading; + @override - Widget build(BuildContext context) { - final child = Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.half + 2), - child: Row( + Widget build(BuildContext context) => GestureDetector( + onTap: isLoading + ? null + : () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, + behavior: HitTestBehavior.opaque, + child: Container( + width: double.infinity, + height: 68 + (Grid.xxs * 2), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - SizedBox( - width: 24, - child: Icon(icon, size: 16, color: context.colors.onSurfaceVariant), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - text, - style: - textStyle ?? - context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurface, - ), + if (isLoading) + BuzzLoadingIndicator( + size: 22, + color: context.colors.onSurface, + semanticLabel: 'Opening direct message', + ) + else + Icon(icon, size: 22, color: context.colors.onSurface), + const SizedBox(height: Grid.xxs), + Text( + label, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, ), ), - if (onTap != null) - Icon( - LucideIcons.copy, - size: 14, - color: context.colors.onSurfaceVariant, - ), ], ), - ); - - if (onTap != null) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap, - child: child, - ); - } - return child; - } + ), + ); } class _ProfileAvatar extends StatelessWidget { @@ -345,8 +434,7 @@ class _ProfileAvatar extends StatelessWidget { Widget build(BuildContext context) { return AspectRatio( aspectRatio: 1, - child: ClipRRect( - borderRadius: BorderRadius.circular(20), + child: ClipOval( child: AvatarImageContent( imageUrl: avatarUrl, fallback: _AvatarFallback(initial: initial), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 65fc12dfd..b97d10f55 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -26,13 +27,42 @@ import '../profile/user_profile.dart'; import 'recent_searches_provider.dart'; import 'search_provider.dart'; +part 'search_page/motion_field.dart'; + enum _SearchFilter { all, messages, channels, people } const _searchFieldMinHeight = 36.0; +const _searchIdleFieldHeight = 45.0; +const _searchIdleTextSize = 15.0; const _searchFieldVerticalPadding = Grid.xxs; -const _searchFieldHint = 'Search messages, channels, people\u2026'; -const _searchCancelEnterDuration = Duration(milliseconds: 160); -const _searchCancelExitDuration = Duration(milliseconds: 120); +const _searchFieldMoveDuration = Duration(milliseconds: 160); +const _searchTitleReturnDuration = Duration(milliseconds: 80); +const _searchCancelEnterDuration = Duration(milliseconds: 80); +const _searchCancelExitDuration = Duration(milliseconds: 60); +const _searchIdleFieldTopInset = Grid.half; +const _searchActiveFieldTopOffset = 42.0; +const _searchBottomOverlap = + _searchActiveFieldTopOffset + _searchIdleFieldTopInset; +const _searchFilterChipVerticalPadding = Grid.xxs; +const _searchFilterBarVerticalPadding = Grid.xxs; +const _searchHeaderFiltersMinHeight = Grid.xl; +const _searchActiveFieldRightInsetMin = 72.0; + +/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset. +double _searchActiveFieldRightInset(BuildContext context) { + final textPainter = TextPainter( + text: TextSpan( + text: 'Cancel', + style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500), + ), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout(); + final cancelWidth = textPainter.width + Grid.half * 2 + Grid.twelve; + return cancelWidth > _searchActiveFieldRightInsetMin + ? cancelWidth + : _searchActiveFieldRightInsetMin; +} double _searchFieldHeight(BuildContext context) { const style = searchInputTextStyle; @@ -46,8 +76,35 @@ double _searchFieldHeight(BuildContext context) { : _searchFieldMinHeight; } +double _idleSearchFieldHeight(BuildContext context) { + final scaledFontSize = MediaQuery.textScalerOf( + context, + ).scale(_searchIdleTextSize); + final contentHeight = + scaledFontSize * (20 / _searchIdleTextSize) + + _searchFieldVerticalPadding * 2; + return contentHeight > _searchIdleFieldHeight + ? contentHeight + : _searchIdleFieldHeight; +} + +double _searchHeaderFiltersHeight(BuildContext context) { + const style = filterChipTextStyle; + final scaledLabelHeight = + MediaQuery.textScalerOf(context).scale(style.fontSize ?? 15) * + (style.height ?? 1); + final chipHeight = scaledLabelHeight + _searchFilterChipVerticalPadding * 2; + final contentHeight = chipHeight + _searchFilterBarVerticalPadding * 2; + return contentHeight > _searchHeaderFiltersMinHeight + ? contentHeight + : _searchHeaderFiltersMinHeight; +} + class SearchPage extends HookConsumerWidget { - const SearchPage({super.key}); + const SearchPage({this.tabReselection, super.key}); + + /// Notifies this page when its already-selected tab is tapped again. + final ValueListenable? tabReselection; @override Widget build(BuildContext context, WidgetRef ref) { @@ -59,202 +116,306 @@ class SearchPage extends HookConsumerWidget { final activeFilter = useState(_SearchFilter.all); final textController = useTextEditingController(); final focusNode = useFocusNode(); - final isSearchFocused = useListenableSelector( - focusNode, - () => focusNode.hasFocus, - ); + final isSearchEditing = useState(false); + final showSearchTitle = useState(true); + final isTabActivationInFlight = useRef(false); final reduceMotion = MediaQuery.disableAnimationsOf(context); - final isBuzzTheme = context.appColors.topSectionGradient != null; - final buzzSearchColor = context.theme.brightness == Brightness.dark - ? Colors.white - : Colors.black; - final searchSurfaceColor = isBuzzTheme - ? buzzSearchColor.withValues(alpha: 0.04) - : context.colors.surfaceContainerHighest; - final searchMutedColor = isBuzzTheme - ? buzzSearchColor.withValues(alpha: 0.4) - : context.colors.onSurfaceVariant; + final searchSurfaceColor = navigationSearchSurface(context); + final searchPrimaryColor = navigationPrimaryForeground(context); + final searchPlaceholderColor = navigationSecondaryForeground(context); final headerTitleStyle = context.textTheme.titleMedium?.copyWith( fontSize: 22, fontWeight: FontWeight.w600, ); - final searchFieldHeight = _searchFieldHeight(context); - final searchControlHeight = searchFieldHeight > Grid.xl - ? searchFieldHeight + final compactSearchFieldHeight = _searchFieldHeight(context); + final idleSearchFieldHeight = _idleSearchFieldHeight(context); + final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context); + final searchActiveFieldRightInset = _searchActiveFieldRightInset(context); + // Cancel remains an accessible target without giving the text action a + // visual button treatment. + final searchControlHeight = compactSearchFieldHeight > Grid.xl + ? compactSearchFieldHeight : Grid.xl; - final searchHeaderBottomHeight = searchControlHeight + Grid.twelve; + final searchHeaderBottomHeight = isSearchEditing.value + ? _searchIdleFieldTopInset + + compactSearchFieldHeight + + searchHeaderFiltersHeight + : idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs; + final topSectionHeight = frostedAppBarHeight( + context, + titleStyle: headerTitleStyle, + bottomHeight: searchHeaderBottomHeight, + ); + + void activateSearch() { + showSearchTitle.value = false; + isSearchEditing.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted && isSearchEditing.value) { + focusNode.requestFocus(); + } + }); + } + + void deactivateSearch() { + if (!isSearchEditing.value) return; + // The field is painted above the title while it returns to its idle + // position. Start this fade now so the title is already there beneath it + // and is revealed by the field's motion instead of arriving afterward. + showSearchTitle.value = true; + isSearchEditing.value = false; + } + + useEffect(() { + final tabReselection = this.tabReselection; + if (tabReselection == null) return null; + + void reactivateSearch() { + // The same tab gesture can report focus loss after this callback. Keep + // that notification from starting a competing return animation while + // the normal field activation path restores focus. + isTabActivationInFlight.value = true; + activateSearch(); + WidgetsBinding.instance.addPostFrameCallback((_) { + isTabActivationInFlight.value = false; + }); + } + + tabReselection.addListener(reactivateSearch); + return () => tabReselection.removeListener(reactivateSearch); + }, [tabReselection, focusNode]); + + useEffect(() { + void resetIdlePromptWhenFocusLeaves() { + if (!focusNode.hasFocus && !isTabActivationInFlight.value) { + deactivateSearch(); + } + } + + focusNode.addListener(resetIdlePromptWhenFocusLeaves); + return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves); + }, [focusNode]); void runRecentSearch(String query) { textController.value = TextEditingValue( text: query, selection: TextSelection.collapsed(offset: query.length), ); - focusNode.requestFocus(); + activateSearch(); ref.read(recentSearchesProvider.notifier).record(query); ref.read(searchProvider.notifier).search(query); } return FrostedScaffold( - backgroundColor: Colors.transparent, + backgroundColor: context.colors.surface, // Keep the empty state centered in the page rather than the portion left // above the keyboard. resizeToAvoidBottomInset: false, appBar: FrostedAppBar( automaticallyImplyLeading: false, - gradient: context.appColors.topSectionGradient, - title: const Text('Search'), + horizontalInset: Grid.twelve, + showBottomDivider: true, + bottomDividerOpacity: 0.06, titleStyle: headerTitleStyle, - bottomHeight: searchHeaderBottomHeight, - bottom: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.twelve, + // Keep this mounted through the search-field morph so it can fade in + // beneath the returning field rather than popping in afterward. + title: IgnorePointer( + child: AnimatedOpacity( + key: const Key('search-header-title-opacity'), + duration: reduceMotion ? Duration.zero : _searchTitleReturnDuration, + curve: Curves.easeOutCubic, + opacity: showSearchTitle.value ? 1 : 0, + child: Text( + 'Search', + key: const ValueKey('search-header-title'), + style: headerTitleStyle, + ), ), - child: Row( - children: [ - Expanded( - child: Container( - key: const Key('search-field-container'), - height: searchFieldHeight, - padding: const EdgeInsets.symmetric(horizontal: Grid.half), - decoration: BoxDecoration( - color: searchSurfaceColor, - borderRadius: BorderRadius.circular(Radii.lg), - ), - child: TextField( - key: const Key('search-field'), - controller: textController, - focusNode: focusNode, - decoration: InputDecoration( - hintText: isSearchFocused ? null : _searchFieldHint, - hintStyle: searchInputTextStyle.copyWith( - color: searchMutedColor, - ), - prefixIcon: Icon( - LucideIcons.search, - size: 16, - color: searchMutedColor, - ), - prefixIconConstraints: const BoxConstraints(minWidth: 32), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - vertical: _searchFieldVerticalPadding, - ), - ), - style: searchInputTextStyle.copyWith( - color: context.colors.onSurface, - ), - textInputAction: TextInputAction.search, - onChanged: (value) => - ref.read(searchProvider.notifier).search(value), - onSubmitted: (value) { - final query = value.trim(); - if (query.isEmpty) return; - ref.read(recentSearchesProvider.notifier).record(query); - }, + ), + actions: [ + AnimatedSwitcher( + duration: reduceMotion ? Duration.zero : _searchCancelEnterDuration, + reverseDuration: reduceMotion + ? Duration.zero + : _searchCancelExitDuration, + transitionBuilder: (child, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return SizeTransition( + sizeFactor: curvedAnimation, + axis: Axis.horizontal, + axisAlignment: 1, + child: FadeTransition( + opacity: curvedAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.35, 0), + end: Offset.zero, + ).animate(curvedAnimation), + child: child, ), ), + ); + }, + child: isSearchEditing.value + ? Semantics( + key: const Key('search-cancel'), + button: true, + label: 'Cancel search', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + textController.clear(); + ref.read(searchProvider.notifier).clear(); + deactivateSearch(); + focusNode.unfocus(); + }, + child: SizedBox( + height: searchControlHeight, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half, + ), + child: Center( + child: Text( + 'Cancel', + style: filterChipTextStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ), + ) + : const SizedBox.shrink(key: ValueKey('search-cancel-hidden')), + ), + ], + bottomHeight: searchHeaderBottomHeight, + bottomOverlap: _searchBottomOverlap, + bottom: Stack( + clipBehavior: Clip.none, + children: [ + AnimatedPositioned( + duration: reduceMotion ? Duration.zero : _searchFieldMoveDuration, + curve: Curves.easeInOutCubic, + left: Grid.gutter, + right: isSearchEditing.value + ? searchActiveFieldRightInset + : Grid.gutter, + top: isSearchEditing.value + ? _searchIdleFieldTopInset + : _searchBottomOverlap + _searchIdleFieldTopInset, + height: isSearchEditing.value + ? compactSearchFieldHeight + : idleSearchFieldHeight, + // Do not key this subtree by the visual state: replacing the + // TextField immediately after its first tap can detach its + // native input connection before the keyboard is shown. + child: SizedBox( + key: const Key('search-field-container'), + child: _SearchMotionField( + controller: textController, + focusNode: focusNode, + iconColor: searchPrimaryColor, + inputColor: searchPrimaryColor, + placeholderColor: searchPlaceholderColor, + surfaceColor: searchSurfaceColor, + isSearchEditing: isSearchEditing.value, + reduceMotion: reduceMotion, + motionDuration: _searchFieldMoveDuration, + onTap: activateSearch, + onChanged: (value) => + ref.read(searchProvider.notifier).search(value), + onSubmitted: (value) { + final query = value.trim(); + if (query.isEmpty) return; + ref.read(recentSearchesProvider.notifier).record(query); + }, + ), ), - AnimatedSwitcher( + ), + Positioned.fill( + child: AnimatedSwitcher( duration: reduceMotion ? Duration.zero : _searchCancelEnterDuration, - reverseDuration: reduceMotion - ? Duration.zero - : _searchCancelExitDuration, - transitionBuilder: (child, animation) { - final curvedAnimation = CurvedAnimation( - parent: animation, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - return SizeTransition( - sizeFactor: curvedAnimation, - axis: Axis.horizontal, - axisAlignment: 1, - child: FadeTransition( - opacity: curvedAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.35, 0), - end: Offset.zero, - ).animate(curvedAnimation), - child: child, - ), - ), - ); - }, - child: isSearchFocused - ? Padding( - key: const ValueKey('search-cancel-visible'), - padding: const EdgeInsets.only(left: Grid.xxs), - child: TextButton( - key: const Key('search-cancel'), - onPressed: () { - textController.clear(); - ref.read(searchProvider.notifier).clear(); - focusNode.unfocus(); - }, - style: TextButton.styleFrom( - foregroundColor: context.colors.primary, - minimumSize: Size(0, searchControlHeight), - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: Grid.xxs, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text( - 'Cancel', - style: filterChipTextStyle.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w500, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: isSearchEditing.value + ? Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only(top: _searchBottomOverlap), + child: SizedBox( + key: const ValueKey('search-header-filters'), + height: searchHeaderFiltersHeight, + child: FilterChipBar<_SearchFilter>( + expandItems: true, + visualDensity: const VisualDensity( + horizontal: -2, + ), + chipVerticalPadding: + _searchFilterChipVerticalPadding, + barVerticalPadding: + _searchFilterBarVerticalPadding, + selected: activeFilter.value, + onSelected: (f) => activeFilter.value = f, + items: [ + for (final f in _SearchFilter.values) + FilterChipItem(id: f, label: f.label), + ], ), ), ), ) : const SizedBox.shrink( - key: ValueKey('search-cancel-hidden'), + key: ValueKey('search-header-filters-hidden'), ), ), - ], - ), + ), + ], ), - actions: const [], ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox( - height: frostedAppBarHeight( - context, - bottomHeight: searchHeaderBottomHeight, - titleStyle: headerTitleStyle, - ), - ), - FilterChipBar<_SearchFilter>( - expandItems: true, - visualDensity: const VisualDensity(horizontal: -2), - chipVerticalPadding: Grid.xxs, - barVerticalPadding: Grid.twelve, - selected: activeFilter.value, - onSelected: (f) => activeFilter.value = f, - items: [ - for (final f in _SearchFilter.values) - FilterChipItem(id: f, label: f.label), - ], - ), + SizedBox(height: topSectionHeight), Expanded( - child: _SearchBody( - state: searchState, - filter: activeFilter.value, - currentPubkey: currentPubkey, - onRecentSearchSelected: runRecentSearch, + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(Radii.dialog), + ), + child: ColoredBox( + color: context.colors.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: _SearchBody( + state: searchState, + filter: activeFilter.value, + currentPubkey: currentPubkey, + onRecentSearchSelected: runRecentSearch, + ), + ), + ], + ), + ), ), ), ], diff --git a/mobile/lib/features/search/search_page/motion_field.dart b/mobile/lib/features/search/search_page/motion_field.dart new file mode 100644 index 000000000..96a23849a --- /dev/null +++ b/mobile/lib/features/search/search_page/motion_field.dart @@ -0,0 +1,113 @@ +part of '../search_page.dart'; + +const _searchIdleIconSize = 26.0; +const _searchCompactIconSize = 18.0; +const _searchFieldHint = 'Search messages, channels, and people'; +const _searchIdleIconInset = Grid.xxs; +const _searchIdleTextInset = + _searchIdleIconInset + _searchIdleIconSize + Grid.xxs; +const _searchCompactTextInset = + _searchIdleIconInset + _searchCompactIconSize + Grid.xxs; + +class _SearchMotionField extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final Color iconColor; + final Color inputColor; + final Color placeholderColor; + final Color surfaceColor; + final bool isSearchEditing; + final bool reduceMotion; + final Duration motionDuration; + final VoidCallback onTap; + final ValueChanged onChanged; + final ValueChanged onSubmitted; + + const _SearchMotionField({ + required this.controller, + required this.focusNode, + required this.iconColor, + required this.inputColor, + required this.placeholderColor, + required this.surfaceColor, + required this.isSearchEditing, + required this.reduceMotion, + required this.motionDuration, + required this.onTap, + required this.onChanged, + required this.onSubmitted, + }); + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: surfaceColor, + borderRadius: BorderRadius.circular(Radii.lg), + ), + child: Stack( + children: [ + Positioned.fill( + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: double.infinity, + child: TextField( + key: const Key('search-field'), + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + hintText: isSearchEditing ? null : _searchFieldHint, + hintStyle: searchInputTextStyle.copyWith( + color: placeholderColor, + fontSize: _searchIdleTextSize, + height: 20 / _searchIdleTextSize, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.only( + left: isSearchEditing + ? _searchCompactTextInset + : _searchIdleTextInset, + right: Grid.xxs, + top: isSearchEditing ? _searchFieldVerticalPadding : 0, + bottom: isSearchEditing ? _searchFieldVerticalPadding : 0, + ), + ), + style: searchInputTextStyle.copyWith(color: inputColor), + textAlignVertical: TextAlignVertical.center, + textAlign: TextAlign.start, + textInputAction: TextInputAction.search, + onTap: onTap, + onChanged: onChanged, + onSubmitted: onSubmitted, + ), + ), + ), + ), + IgnorePointer( + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: _searchIdleIconInset), + child: AnimatedScale( + duration: reduceMotion ? Duration.zero : motionDuration, + curve: Curves.easeInOutCubic, + scale: isSearchEditing + ? _searchCompactIconSize / _searchIdleIconSize + : 1, + child: Icon( + LucideIcons.search, + key: const Key('search-moving-icon'), + size: _searchIdleIconSize, + color: iconColor, + ), + ), + ), + ), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/settings/accent_picker_page.dart b/mobile/lib/features/settings/accent_picker_page.dart index 6f174df74..88965e251 100644 --- a/mobile/lib/features/settings/accent_picker_page.dart +++ b/mobile/lib/features/settings/accent_picker_page.dart @@ -15,7 +15,9 @@ class AccentPickerPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selected = ref.watch(accentProvider); + final selected = + accentIndexForWireValue(ref.watch(communityThemeProvider).accent) ?? + defaultAccentIndex; final colorScheme = context.colors; return FrostedScaffold( @@ -31,7 +33,8 @@ class AccentPickerPage extends ConsumerWidget { color: accentColorForScheme(colorScheme, i), label: accentColors[i].name, selected: selected == i, - onTap: () => ref.read(accentProvider.notifier).setAccent(i), + onTap: () => + ref.read(communityThemeProvider.notifier).setAccent(i), ), ], ), diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index f089f6391..d53f39a67 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + 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'; @@ -13,6 +16,7 @@ import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/widgets/modal_presentation.dart'; import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; @@ -28,17 +32,38 @@ class SettingsPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform()); final packageInfo = useFuture(packageInfoFuture); + final topSectionHeight = frostedAppBarHeight( + context, + bottomHeight: Grid.xxs, + ); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Settings')), + backgroundColor: context.colors.surface, + appBar: FrostedAppBar( + automaticallyImplyLeading: false, + horizontalInset: Grid.gutter, + showBottomDivider: false, + leading: SizedBox( + width: Grid.xl, + height: Grid.xl, + child: IconButton( + tooltip: 'Close settings', + onPressed: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).pop(); + }, + color: navigationPrimaryForeground(context), + icon: const Icon(LucideIcons.x), + ), + ), + bottomHeight: Grid.xxs, + bottom: const SizedBox.expand(), + ), body: Column( children: [ Expanded( child: ListView( - padding: EdgeInsets.only( - top: frostedAppBarHeight(context), - bottom: Grid.xs, - ), + padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs), children: [ profileHeader, const _AppearanceSection(), diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart index 26c9d20fe..917900318 100644 --- a/mobile/lib/features/settings/settings_page/appearance_section.dart +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -15,12 +15,16 @@ class _AppearanceSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = ref.watch(themeProvider); - final schemeName = ref.watch(schemeProvider); - final accentIndex = ref.watch(accentProvider); + final preference = ref.watch(communityThemeProvider); + final mode = preference.mode; + final schemeName = preference.theme; + final accentIndex = effectiveAccentIndex( + preference.theme, + preference.accent, + ); return AppListCard( - label: 'Style', + label: 'Style · This community', children: [ AppListRow( icon: LucideIcons.sunMoon, @@ -38,23 +42,24 @@ class _AppearanceSection extends ConsumerWidget { MaterialPageRoute(builder: (_) => const ThemePickerPage()), ), ), - AppListRow( - icon: LucideIcons.droplet, - title: 'Accent color', - // The swatch *is* the value — naming the color as well would say the - // same thing twice, so it takes the chevron's place. - trailing: _AccentSwatch(accentIndex: accentIndex), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AccentPickerPage()), + if (!isBuzzTheme(effectiveTheme(schemeName, mode)?.name ?? schemeName)) + AppListRow( + icon: LucideIcons.droplet, + title: 'Accent color', + // The swatch *is* the value — naming the color as well would say the + // same thing twice, so it takes the chevron's place. + trailing: _AccentSwatch(accentIndex: accentIndex), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AccentPickerPage()), + ), ), - ), ], ); } } void _showAppearanceModeSheet(BuildContext context) { - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (_) => const _AppearanceModeSheet(), @@ -68,7 +73,7 @@ class _AppearanceModeSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = ref.watch(themeProvider); + final mode = ref.watch(communityThemeProvider).mode; return SafeArea( child: Column( @@ -96,15 +101,7 @@ class _AppearanceModeSheet extends ConsumerWidget { ) : null, onTap: () { - final schemeName = ref.read(schemeProvider); - final compatibleScheme = schemeForAppearanceMode( - schemeName, - option.mode, - ); - if (compatibleScheme != schemeName) { - ref.read(schemeProvider.notifier).setScheme(compatibleScheme); - } - ref.read(themeProvider.notifier).setMode(option.mode); + ref.read(communityThemeProvider.notifier).setMode(option.mode); Navigator.of(context).pop(); }, ), diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart index f34e88f26..19da784a6 100644 --- a/mobile/lib/features/settings/settings_page/connection_section.dart +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -73,7 +73,7 @@ class _IdentityRow extends StatelessWidget { } void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) { - showDialog( + showBuzzDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Remove Community'), diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index aba657914..bd2c8fae3 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -18,8 +18,9 @@ class ThemePickerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = ref.watch(themeProvider); - final selectedScheme = ref.watch(schemeProvider); + final preference = ref.watch(communityThemeProvider); + final mode = preference.mode; + final selectedScheme = preference.theme; final searchQuery = useState(''); final searchController = useTextEditingController(); final scrollController = useScrollController(); @@ -112,8 +113,8 @@ class ThemePickerPage extends HookConsumerWidget { label: labelFor(theme), selected: isSelected(theme), onTap: () => ref - .read(schemeProvider.notifier) - .setScheme(theme.name), + .read(communityThemeProvider.notifier) + .setTheme(theme.name), ); }, ), diff --git a/mobile/lib/shared/reminders/remind_me_later_sheet.dart b/mobile/lib/shared/reminders/remind_me_later_sheet.dart index 8d02a5760..a2fb7c59e 100644 --- a/mobile/lib/shared/reminders/remind_me_later_sheet.dart +++ b/mobile/lib/shared/reminders/remind_me_later_sheet.dart @@ -4,6 +4,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/theme.dart'; import '../widgets/sheet_divider.dart'; +import '../widgets/modal_presentation.dart'; import 'reminder_service.dart'; import 'reminder_time_presets.dart'; @@ -37,56 +38,59 @@ void showRemindMeLaterSheet({ } } - showModalBottomSheet( + showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only( - left: Grid.half, - bottom: Grid.xxs, + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: Grid.half, + bottom: Grid.xxs, + ), + child: Text( + 'Remind me about this message', + style: Theme.of(sheetContext).textTheme.titleSmall, + ), ), - child: Text( - 'Remind me about this message', - style: Theme.of(sheetContext).textTheme.titleSmall, - ), - ), - for (final preset in reminderTimePresets) + for (final preset in reminderTimePresets) + ListTile( + leading: const Icon(LucideIcons.clock), + title: Text(preset.label), + onTap: () { + Navigator.of(sheetContext).pop(); + submit(preset.getTimestamp()); + }, + ), + const SheetDivider(), ListTile( - leading: const Icon(LucideIcons.clock), - title: Text(preset.label), - onTap: () { - Navigator.of(sheetContext).pop(); - submit(preset.getTimestamp()); + leading: const Icon(LucideIcons.calendarClock), + title: const Text('Pick a date & time'), + onTap: () async { + final navigator = Navigator.of(sheetContext); + final timestamp = await _pickCustomDateTime(context); + // Cancelled or not-in-the-future: keep the preset sheet + // open so retrying doesn't mean long-pressing again. + if (timestamp == null) return; + if (navigator.mounted) navigator.pop(); + await submit(timestamp); }, ), - const SheetDivider(), - ListTile( - leading: const Icon(LucideIcons.calendarClock), - title: const Text('Pick a date & time'), - onTap: () async { - final navigator = Navigator.of(sheetContext); - final timestamp = await _pickCustomDateTime(context); - // Cancelled or not-in-the-future: keep the preset sheet - // open so retrying doesn't mean long-pressing again. - if (timestamp == null) return; - if (navigator.mounted) navigator.pop(); - await submit(timestamp); - }, - ), - ], + ], + ), ), ), ), @@ -98,17 +102,20 @@ void showRemindMeLaterSheet({ /// timestamp in seconds, or null when cancelled or not in the future. Future _pickCustomDateTime(BuildContext context) async { final now = DateTime.now(); - final date = await showDatePicker( + final date = await showBuzzDialog( context: context, - initialDate: now, - firstDate: DateTime(now.year, now.month, now.day), - lastDate: now.add(const Duration(days: 365 * 2)), + builder: (_) => DatePickerDialog( + initialDate: now, + firstDate: DateTime(now.year, now.month, now.day), + lastDate: now.add(const Duration(days: 365 * 2)), + ), ); if (date == null || !context.mounted) return null; - final time = await showTimePicker( + final time = await showBuzzDialog( context: context, - initialTime: const TimeOfDay(hour: 9, minute: 0), + builder: (_) => + const TimePickerDialog(initialTime: TimeOfDay(hour: 9, minute: 0)), ); if (time == null) return null; diff --git a/mobile/lib/shared/theme/accent_colors.dart b/mobile/lib/shared/theme/accent_colors.dart index d54925b71..0688a3575 100644 --- a/mobile/lib/shared/theme/accent_colors.dart +++ b/mobile/lib/shared/theme/accent_colors.dart @@ -6,41 +6,78 @@ class AccentColor { final Color light; final Color dark; final bool useThemeForegroundInDark; + final String wireValue; const AccentColor({ required this.name, required this.light, required this.dark, this.useThemeForegroundInDark = false, + required this.wireValue, }); } const accentColors = [ - AccentColor(name: 'Blue', light: Color(0xFF3B82F6), dark: Color(0xFF60A5FA)), - AccentColor(name: 'Cyan', light: Color(0xFF06B6D4), dark: Color(0xFF22D3EE)), - AccentColor(name: 'Green', light: Color(0xFF22C55E), dark: Color(0xFF4ADE80)), + AccentColor( + name: 'Neutral', + light: Color(0xFF000000), + dark: Color(0xFFE1E4E8), + wireValue: 'neutral', + useThemeForegroundInDark: true, + ), + AccentColor( + name: 'Blue', + light: Color(0xFF3B82F6), + dark: Color(0xFF60A5FA), + wireValue: '#3b82f6', + ), + AccentColor( + name: 'Cyan', + light: Color(0xFF06B6D4), + dark: Color(0xFF22D3EE), + wireValue: '#06b6d4', + ), + AccentColor( + name: 'Green', + light: Color(0xFF22C55E), + dark: Color(0xFF4ADE80), + wireValue: '#22c55e', + ), AccentColor( name: 'Orange', light: Color(0xFFF97316), dark: Color(0xFFFB923C), + wireValue: '#f97316', + ), + AccentColor( + name: 'Red', + light: Color(0xFFEF4444), + dark: Color(0xFFF87171), + wireValue: '#ef4444', + ), + AccentColor( + name: 'Pink', + light: Color(0xFFEC4899), + dark: Color(0xFFF472B6), + wireValue: '#ec4899', + ), + AccentColor( + name: 'Lilac', + light: Color(0xFFC0A2F1), + dark: Color(0xFFC0A2F1), + wireValue: '#c0a2f1', ), - AccentColor(name: 'Red', light: Color(0xFFEF4444), dark: Color(0xFFF87171)), - AccentColor(name: 'Pink', light: Color(0xFFEC4899), dark: Color(0xFFF472B6)), AccentColor( name: 'Purple', light: Color(0xFFA855F7), dark: Color(0xFFC084FC), + wireValue: '#a855f7', ), AccentColor( name: 'Indigo', light: Color(0xFF6366F1), dark: Color(0xFF818CF8), - ), - AccentColor( - name: 'Black', - light: Color(0xFF000000), - dark: Color(0xFFFFFFFF), - useThemeForegroundInDark: true, + wireValue: '#6366f1', ), ]; @@ -59,7 +96,26 @@ Color accentColorForScheme(ColorScheme scheme, int accentIndex) { /// /// Keep this at the end of [accentColors] so existing saved accent indexes keep /// pointing at the same colors. -const defaultAccentIndex = 8; +const neutralAccentIndex = 0; +const defaultAccentIndex = neutralAccentIndex; /// Legacy default: Catppuccin Mauve/the base theme primary. const legacyDefaultAccentIndex = -1; + +int? accentIndexForWireValue(String value) { + final index = accentColors.indexWhere((accent) => accent.wireValue == value); + return index < 0 ? null : index; +} + +String legacyAccentWireValue(int? index) { + // Legacy mobile indexes 0...7 match desktop Blue...Indigo except Lilac, + // which did not exist. Black (8) has no desktop wire value, so migrate it + // deterministically to Neutral rather than publishing a mobile-only value. + if (index == 8) return 'neutral'; + if (index != null && index >= 0 && index <= 5) { + return accentColors[index + 1].wireValue; + } + if (index == 6) return '#a855f7'; + if (index == 7) return '#6366f1'; + return '#3b82f6'; +} diff --git a/mobile/lib/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart index 1214b9779..416b9815e 100644 --- a/mobile/lib/shared/theme/buzz_theme.dart +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'accent_colors.dart'; +import 'app_colors.dart'; + /// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for /// every base color; the one thing that sets it apart is a branded gradient /// painted across the app's top section. Mirrors desktop, where the same @@ -17,6 +20,57 @@ const buzzDarkThemeName = 'buzz-dark'; bool isBuzzTheme(String themeName) => themeName == buzzThemeName || themeName == buzzDarkThemeName; +/// Whether the current widget tree is using the first-party Buzz treatment. +bool isBuzzThemeContext(BuildContext context) => + Theme.of(context).extension()?.topSectionGradient != null; + +/// Primary foreground for the mobile top navigation. +/// +/// Every theme uses its own [ColorScheme.onSurface]. Buzz is the exception: +/// its desktop-matching top gradient needs a neutral black or white foreground +/// rather than the accent-derived color scheme foreground. +Color navigationPrimaryForeground(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (!isBuzzThemeContext(context)) return scheme.onSurface; + return scheme.brightness == Brightness.dark ? Colors.white : Colors.black; +} + +/// Secondary label and placeholder foreground for the mobile top navigation. +Color navigationSecondaryForeground(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant; + return navigationPrimaryForeground(context).withValues(alpha: 0.4); +} + +/// Channel-section label and icon foreground for the mobile side navigation. +/// +/// Section labels need more hierarchy than a placeholder. Buzz therefore uses +/// a stronger neutral over its gradient, while all other themes preserve their +/// established secondary foreground token. +Color navigationSectionForeground(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant; + return navigationPrimaryForeground(context).withValues(alpha: 0.8); +} + +/// Search-field surface for the mobile top navigation. +Color navigationSearchSurface(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + if (!isBuzzThemeContext(context)) return scheme.surfaceContainerHighest; + return navigationPrimaryForeground(context).withValues(alpha: 0.04); +} + +/// A low-contrast navigation divider derived from the active theme foreground. +Color navigationDivider(BuildContext context, double opacity) => + navigationPrimaryForeground(context).withValues(alpha: opacity); + +/// Buzz renders with its fixed neutral foreground while preserving the stored +/// wire accent so the user's choice returns on another theme. +int effectiveAccentIndex(String themeName, String storedAccent) { + if (isBuzzTheme(themeName)) return neutralAccentIndex; + return accentIndexForWireValue(storedAccent) ?? defaultAccentIndex; +} + /// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties. const _lightTop = Color(0xFFE6E6B6); const _lightBottom = Color(0xFFC4D0DA); diff --git a/mobile/lib/shared/theme/community_theme_preference.dart b/mobile/lib/shared/theme/community_theme_preference.dart new file mode 100644 index 000000000..216f6a8d3 --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'accent_colors.dart'; +import 'theme_catalog.dart'; +import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode; + +const communityThemeDTag = 'community-theme'; +const defaultCommunityTheme = CommunityThemePreference( + theme: 'buzz', + accent: '#3b82f6', + followSystem: true, +); + +class CommunityThemePreference { + final int version; + final String theme; + final String accent; + final bool followSystem; + + const CommunityThemePreference({ + this.version = 1, + required this.theme, + required this.accent, + required this.followSystem, + }); + + factory CommunityThemePreference.fromJson(Map json) { + if (json['version'] != 1 || + json['theme'] is! String || + findTheme(json['theme'] as String) == null || + json['accent'] is! String || + accentIndexForWireValue(json['accent'] as String) == null || + json['followSystem'] is! bool) { + throw const FormatException('Invalid community theme preference'); + } + return CommunityThemePreference( + theme: json['theme'] as String, + accent: json['accent'] as String, + followSystem: json['followSystem'] as bool, + ); + } + + Map toJson() => { + 'version': version, + 'theme': theme, + 'accent': accent, + 'followSystem': followSystem, + }; + + ThemeMode get mode { + if (followSystem) return ThemeMode.system; + return findTheme(theme)?.isDark == true ? ThemeMode.dark : ThemeMode.light; + } + + @override + bool operator ==(Object other) => + other is CommunityThemePreference && + theme == other.theme && + accent == other.accent && + followSystem == other.followSystem; + + @override + int get hashCode => Object.hash(theme, accent, followSystem); +} + +class CommunityThemeStorage { + static const _prefix = 'buzz-community-theme.v1'; + static const _outboxPrefix = 'buzz-community-theme-outbox.v1'; + static const _migrationPrefix = 'buzz-community-theme-migrated.v1'; + static const _legacyModeKey = 'buzz_theme_mode'; + static const _legacyAccentKey = 'buzz_accent_color'; + static const _legacySchemeKey = 'buzz_color_scheme'; + + final SharedPreferences prefs; + + const CommunityThemeStorage(this.prefs); + + String key(String pubkey, String relayUrl) => + '$_prefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + + String outboxKey(String pubkey, String relayUrl) => + '$_outboxPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + + CommunityThemePreference? _readKey(String storageKey) { + try { + final raw = prefs.getString(storageKey); + if (raw == null) return null; + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return CommunityThemePreference.fromJson(decoded); + } catch (_) { + return null; + } + } + + CommunityThemePreference? read(String pubkey, String relayUrl) => + _readKey(key(pubkey, relayUrl)); + + CommunityThemePreference? readOutbox(String pubkey, String relayUrl) => + _readKey(outboxKey(pubkey, relayUrl)); + + Future write( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) => prefs.setString(key(pubkey, relayUrl), jsonEncode(preference.toJson())); + + Future writeOutbox( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) => prefs.setString( + outboxKey(pubkey, relayUrl), + jsonEncode(preference.toJson()), + ); + + Future clearOutbox( + String pubkey, + String relayUrl, + CommunityThemePreference acknowledged, + ) async { + if (readOutbox(pubkey, relayUrl) == acknowledged) { + await prefs.remove(outboxKey(pubkey, relayUrl)); + } + } + + bool hasMigrated(String pubkey) => + prefs.getBool('$_migrationPrefix:$pubkey') == true; + + Future markMigrated(String pubkey) => + prefs.setBool('$_migrationPrefix:$pubkey', true); + + Future writeLegacy(CommunityThemePreference preference) async { + await prefs.setString(_legacyModeKey, preference.mode.name); + await prefs.setString(_legacySchemeKey, preference.theme); + await prefs.setInt( + _legacyAccentKey, + accentIndexForWireValue(preference.accent) ?? defaultAccentIndex, + ); + } + + CommunityThemePreference legacyPreference() { + final modeName = prefs.getString(_legacyModeKey); + final mode = + ThemeMode.values.where((value) => value.name == modeName).firstOrNull ?? + ThemeMode.system; + final storedTheme = prefs.getString(_legacySchemeKey); + final theme = findTheme(storedTheme ?? 'buzz')?.name ?? 'buzz'; + final legacyAccent = prefs.getInt(_legacyAccentKey); + final resolvedTheme = switch (mode) { + ThemeMode.system => schemeForAppearanceMode(theme, mode) ?? theme, + ThemeMode.light || + ThemeMode.dark => effectiveTheme(theme, mode)?.name ?? theme, + }; + return CommunityThemePreference( + theme: resolvedTheme, + accent: legacyAccentWireValue(legacyAccent), + followSystem: mode == ThemeMode.system, + ); + } +} + +String normalizeCommunityRelayUrl(String relayUrl) => + relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase(); diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart new file mode 100644 index 000000000..4d66de884 --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/relay.dart'; +import 'accent_colors.dart'; +import 'community_theme_preference.dart'; +import 'community_theme_sync.dart'; +import 'theme_provider.dart'; + +class CommunityThemeNotifier extends Notifier { + CommunityThemeSyncManager? _manager; + late CommunityThemeStorage _storage; + String? _pubkey; + String? _relayUrl; + Future _persistenceQueue = Future.value(); + int _localRevision = 0; + CommunityThemePreference? _scopedLocalPreference; + String? _scopedLocalPubkey; + String? _scopedLocalRelayUrl; + + @override + CommunityThemePreference build() { + _manager?.dispose(); + _manager = null; + + _storage = ref.watch(communityThemeStorageProvider); + final config = ref.watch(relayConfigProvider); + final session = ref.watch(relaySessionProvider); + final pubkey = pubkeyFromNsec(config.nsec); + _pubkey = pubkey; + _relayUrl = config.baseUrl; + + if (pubkey == null || config.nsec == null) { + final legacy = _storage.legacyPreference(); + unawaited(_storage.writeLegacy(legacy)); + return legacy; + } + + final cached = _storage.read(pubkey, config.baseUrl); + final dirty = _storage.readOutbox(pubkey, config.baseUrl); + final inMemoryLocal = + _scopedLocalPubkey == pubkey && _scopedLocalRelayUrl == config.baseUrl + ? _scopedLocalPreference + : null; + if (inMemoryLocal == null) { + _scopedLocalPreference = null; + _scopedLocalPubkey = pubkey; + _scopedLocalRelayUrl = config.baseUrl; + } + final fallback = _storage.hasMigrated(pubkey) + ? defaultCommunityTheme + : _storage.legacyPreference(); + final initial = inMemoryLocal ?? dirty ?? cached ?? fallback; + + if (session.status == SessionStatus.connected) { + late final CommunityThemeSyncManager manager; + manager = CommunityThemeSyncManager( + pubkey: pubkey, + relaySession: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec!, + ), + crypto: _crypto(config.nsec!, pubkey), + onRemote: (remote) => _applyRemote(manager, remote), + onPublished: (preference) { + if (_scopedLocalPubkey == pubkey && + _scopedLocalRelayUrl == config.baseUrl && + _scopedLocalPreference == preference) { + _scopedLocalPreference = null; + } + unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference)); + }, + ); + _manager = manager; + final pending = inMemoryLocal ?? dirty; + if (pending != null) manager.publish(pending); + Future.microtask(() async { + final result = await manager.initialize(); + if (_manager != manager) return; + if (result.status == CommunityThemeRemoteStatus.absent) { + final seedRevision = _localRevision; + await _enqueuePersistence(() async { + if (_manager != manager || _localRevision != seedRevision) return; + final currentDirty = _storage.readOutbox(pubkey, config.baseUrl); + final seed = + currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state; + if (!await _storage.write(pubkey, config.baseUrl, seed)) return; + if (_manager != manager || _localRevision != seedRevision) return; + if (!await _storage.writeOutbox(pubkey, config.baseUrl, seed)) { + return; + } + if (_manager == manager && _localRevision == seedRevision) { + manager.publish(seed); + } + }); + } + if (result.status == CommunityThemeRemoteStatus.valid || + result.status == CommunityThemeRemoteStatus.absent) { + await _storage.markMigrated(pubkey); + } + }); + ref.onDispose(manager.dispose); + } + return initial; + } + + void setMode(ThemeMode mode) { + var theme = state.theme; + if (mode == ThemeMode.system) { + theme = schemeForAppearanceMode(theme, mode) ?? theme; + } else { + final effective = effectiveTheme(theme, mode); + if (effective != null) theme = effective.name; + } + _save( + CommunityThemePreference( + theme: theme, + accent: state.accent, + followSystem: mode == ThemeMode.system, + ), + ); + } + + void setTheme(String? theme) { + _save( + CommunityThemePreference( + theme: theme ?? defaultSchemeName, + accent: state.accent, + followSystem: state.followSystem, + ), + ); + } + + void setAccent(int index) { + if (index < 0 || index >= accentColors.length) return; + _save( + CommunityThemePreference( + theme: state.theme, + accent: accentColors[index].wireValue, + followSystem: state.followSystem, + ), + ); + } + + void _save(CommunityThemePreference preference) { + if (preference == state) return; + state = preference; + _localRevision++; + final pubkey = _pubkey; + final relayUrl = _relayUrl; + if (pubkey == null || relayUrl == null) { + unawaited(_storage.writeLegacy(preference)); + return; + } + _scopedLocalPreference = preference; + _scopedLocalPubkey = pubkey; + _scopedLocalRelayUrl = relayUrl; + _manager?.stage(preference); + unawaited( + _enqueuePersistence( + () => _persistAndPublish(pubkey, relayUrl, preference), + ), + ); + } + + Future _enqueuePersistence(Future Function() operation) { + final result = _persistenceQueue.then((_) => operation()); + _persistenceQueue = result.catchError((Object _) {}); + return result; + } + + Future _persistAndPublish( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + if (!await _storage.write(pubkey, relayUrl, preference)) return; + if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return; + if (_pubkey == pubkey && _relayUrl == relayUrl) { + final manager = _manager; + if (manager != null) { + manager.stage(preference); + manager.publishStaged(preference); + } + } + } + + void _applyRemote( + CommunityThemeSyncManager manager, + RemoteCommunityTheme remote, + ) { + if (_manager != manager) return; + final pubkey = _pubkey; + final relayUrl = _relayUrl; + if (pubkey != null && + relayUrl != null && + _storage.readOutbox(pubkey, relayUrl) != null) { + final dirty = _storage.readOutbox(pubkey, relayUrl)!; + manager.publish(dirty); + return; + } + state = remote.preference; + if (pubkey != null && relayUrl != null) { + unawaited(_storage.write(pubkey, relayUrl, remote.preference)); + } + } +} + +CommunityThemeCrypto _crypto(String nsec, String pubkey) { + final privateHex = nostr.Nip19.decode(payload: nsec).data; + final key = getConversationKey(privateHex, pubkey); + return CommunityThemeCrypto( + encrypt: (plaintext) => nip44Encrypt(key, plaintext), + decrypt: (ciphertext) => nip44Decrypt(key, ciphertext), + ); +} + +final communityThemeStorageProvider = Provider( + (ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)), +); + +final communityThemeProvider = + NotifierProvider( + CommunityThemeNotifier.new, + ); diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart new file mode 100644 index 000000000..fdbe5ae3a --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../relay/relay.dart'; +import 'community_theme_preference.dart'; + +class CommunityThemeCrypto { + final String Function(String) encrypt; + final String Function(String) decrypt; + + const CommunityThemeCrypto({required this.encrypt, required this.decrypt}); +} + +enum CommunityThemeRemoteStatus { valid, absent, invalid, unavailable } + +class RemoteCommunityTheme { + final CommunityThemePreference preference; + final int createdAt; + final String eventId; + + const RemoteCommunityTheme({ + required this.preference, + required this.createdAt, + required this.eventId, + }); +} + +class CommunityThemeRemoteResult { + final CommunityThemeRemoteStatus status; + final RemoteCommunityTheme? remote; + + const CommunityThemeRemoteResult(this.status, [this.remote]); +} + +class CommunityThemeSyncManager { + final String pubkey; + final RelaySessionNotifier relaySession; + final SignedEventRelay signedEventRelay; + final CommunityThemeCrypto crypto; + final Duration debounce; + final Duration publishRetryBase; + final Duration publishRetryMax; + final Duration subscriptionRetryBase; + final void Function(RemoteCommunityTheme) onRemote; + final void Function(CommunityThemePreference) onPublished; + + Timer? _publishTimer; + Timer? _subscriptionRetryTimer; + void Function()? _unsubscribe; + CommunityThemePreference? _pending; + CommunityThemePreference? _lastPublished; + int _lastCreatedAt = 0; + String _lastEventId = ''; + RemoteCommunityTheme? _lastRemote; + int _subscriptionEpoch = 0; + int _subscriptionRetryAttempt = 0; + int _publishRetryAttempt = 0; + bool _publishInFlight = false; + bool _publishRequestedWhileInFlight = false; + bool _disposed = false; + + CommunityThemeSyncManager({ + required this.pubkey, + required this.relaySession, + required this.signedEventRelay, + required this.crypto, + required this.onRemote, + this.onPublished = _ignorePublished, + this.debounce = const Duration(seconds: 2), + this.publishRetryBase = const Duration(seconds: 1), + this.publishRetryMax = const Duration(seconds: 30), + this.subscriptionRetryBase = const Duration(seconds: 1), + }); + + CommunityThemePreference? get pending => _pending; + + Future fetchRemote() async { + try { + final events = await relaySession.fetchHistory(_themeFilter(limit: 1)); + if (events.isEmpty) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.absent, + ); + } + final event = events.reduce(_newerEvent); + final remote = _decode(event); + return remote == null + ? const CommunityThemeRemoteResult(CommunityThemeRemoteStatus.invalid) + : CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.valid, + remote, + ); + } catch (_) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } + } + + Future initialize() async { + final subscribed = await _startLiveSubscription(); + if (_disposed) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } + final result = await fetchRemote(); + if (_disposed) return result; + if (result.status == CommunityThemeRemoteStatus.valid) { + _accept(result.remote!); + } + final remote = _lastRemote; + if (remote != null) { + return CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.valid, + remote, + ); + } + if (!subscribed && result.status == CommunityThemeRemoteStatus.absent) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } + return result; + } + + Future _startLiveSubscription() async { + if (_disposed) return false; + final epoch = ++_subscriptionEpoch; + try { + final unsubscribe = await relaySession.subscribe( + _themeFilter(limit: 0), + (event) { + if (_disposed || epoch != _subscriptionEpoch) return; + final remote = _decode(event); + if (remote != null) _accept(remote); + }, + onClosed: (message) => _handleSubscriptionClosed(epoch, message), + ); + if (_disposed || epoch != _subscriptionEpoch) { + unsubscribe(); + return false; + } + _unsubscribe = unsubscribe; + _subscriptionRetryAttempt = 0; + return true; + } catch (error) { + if (!_disposed && epoch == _subscriptionEpoch) { + debugPrint('[CommunityThemeSync] live subscription failed: $error'); + _scheduleSubscriptionRetry(); + } + return false; + } + } + + void _handleSubscriptionClosed(int epoch, String message) { + if (_disposed || epoch != _subscriptionEpoch) return; + debugPrint('[CommunityThemeSync] live subscription closed: $message'); + _unsubscribe = null; + _scheduleSubscriptionRetry(); + } + + void _scheduleSubscriptionRetry() { + if (_disposed || _subscriptionRetryTimer != null) return; + final multiplier = 1 << min(_subscriptionRetryAttempt, 5); + _subscriptionRetryAttempt++; + _subscriptionRetryTimer = Timer(subscriptionRetryBase * multiplier, () { + _subscriptionRetryTimer = null; + unawaited(_recoverLiveSubscription()); + }); + } + + Future _recoverLiveSubscription() async { + if (_disposed) return; + if (!await _startLiveSubscription()) return; + + // A relay CLOSED removes the retained subscription from RelaySession, so + // reconnect replay cannot recover it. Query the replacement coordinate + // after re-subscribing to close the gap while this stream was silent. + final result = await fetchRemote(); + if (_disposed) return; + if (result.status == CommunityThemeRemoteStatus.valid) { + _accept(result.remote!); + } + } + + NostrFilter _themeFilter({required int limit}) => NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': [communityThemeDTag], + }, + limit: limit, + ); + + void stage(CommunityThemePreference preference) { + if (_disposed) return; + _pending = preference; + _publishRetryAttempt = 0; + _publishTimer?.cancel(); + _publishTimer = null; + } + + void publish(CommunityThemePreference preference) { + stage(preference); + publishStaged(preference); + } + + void publishStaged(CommunityThemePreference preference) { + if (_disposed || _pending != preference) return; + _schedulePublish(debounce); + } + + void _schedulePublish(Duration delay) { + if (_disposed) return; + _publishTimer?.cancel(); + _publishTimer = Timer(delay, () { + _publishTimer = null; + unawaited(flush()); + }); + } + + void cancelPending() { + _publishTimer?.cancel(); + _publishTimer = null; + _pending = null; + } + + Future flush() async { + if (_publishInFlight) { + _publishTimer?.cancel(); + _publishTimer = null; + _publishRequestedWhileInFlight = true; + return; + } + final preference = _pending; + if (_disposed || preference == null) return; + if (preference == _lastPublished) { + _pending = null; + onPublished(preference); + return; + } + _publishInFlight = true; + try { + final content = crypto.encrypt(jsonEncode(preference.toJson())); + if (_disposed) return; + final createdAt = max( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + _lastCreatedAt + 1, + ); + NostrEvent? signed; + await signedEventRelay.submit( + kind: EventKind.readState, + content: content, + tags: const [ + ['d', communityThemeDTag], + ['t', communityThemeDTag], + ], + createdAt: createdAt, + onSigned: (event) => signed = event, + ); + if (_disposed) return; + final published = signed; + if (published == null) { + throw StateError('Signed event coordinate unavailable'); + } + final publishedCoordinateIsStale = + _lastCreatedAt > published.createdAt || + (_lastCreatedAt == published.createdAt && + _lastEventId.isNotEmpty && + _lastEventId.compareTo(published.id) < 0); + if (publishedCoordinateIsStale) { + _lastPublished = null; + _publishRetryAttempt = 0; + if (_pending == preference) _schedulePublish(Duration.zero); + return; + } + _lastCreatedAt = published.createdAt; + _lastEventId = published.id; + _lastPublished = preference; + _publishRetryAttempt = 0; + if (_pending == preference) _pending = null; + onPublished(preference); + } catch (error) { + debugPrint('[CommunityThemeSync] publish failed: $error'); + if (_disposed || _pending != preference) return; + final multiplier = 1 << min(_publishRetryAttempt, 30); + _publishRetryAttempt++; + final retryMs = min( + publishRetryBase.inMilliseconds * multiplier, + publishRetryMax.inMilliseconds, + ); + _schedulePublish(Duration(milliseconds: retryMs)); + } finally { + _publishInFlight = false; + if (!_disposed && + _pending != null && + (_publishRequestedWhileInFlight || _pending != preference) && + _publishTimer == null) { + _publishRequestedWhileInFlight = false; + _schedulePublish(Duration.zero); + } else { + _publishRequestedWhileInFlight = false; + } + } + } + + RemoteCommunityTheme? _decode(NostrEvent event) { + if (event.pubkey != pubkey || + event.getTagValue('d') != communityThemeDTag) { + return null; + } + try { + final decoded = jsonDecode(crypto.decrypt(event.content)); + if (decoded is! Map) return null; + return RemoteCommunityTheme( + preference: CommunityThemePreference.fromJson(decoded), + createdAt: event.createdAt, + eventId: event.id, + ); + } catch (_) { + return null; + } + } + + void _accept(RemoteCommunityTheme remote) { + if (remote.createdAt < _lastCreatedAt || + (remote.createdAt == _lastCreatedAt && + _lastEventId.isNotEmpty && + remote.eventId.compareTo(_lastEventId) >= 0)) { + return; + } + _lastCreatedAt = remote.createdAt; + _lastEventId = remote.eventId; + _lastRemote = remote; + if (_pending != null) { + _lastPublished = null; + return; + } + _lastPublished = null; + onRemote(remote); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _subscriptionEpoch++; + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = null; + cancelPending(); + _unsubscribe?.call(); + _unsubscribe = null; + } +} + +void _ignorePublished(CommunityThemePreference _) {} + +NostrEvent _newerEvent(NostrEvent left, NostrEvent right) { + if (right.createdAt != left.createdAt) { + return right.createdAt > left.createdAt ? right : left; + } + return right.id.compareTo(left.id) < 0 ? right : left; +} diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 862534b4f..0424e3b41 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -4,6 +4,9 @@ export 'app_colors.dart'; export 'app_theme.dart'; export 'buzz_theme.dart'; export 'color_scheme.dart'; +export 'community_theme_preference.dart'; +export 'community_theme_provider.dart'; +export 'community_theme_sync.dart'; export 'grid.dart'; export 'message_typography.dart'; export 'theme_catalog.dart'; diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 7188e021f..94e729868 100644 --- a/mobile/lib/shared/widgets/anchored_popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -32,6 +32,9 @@ enum AnchoredPopoverAlignment { /// Aligns the popover's leading edge with the trigger's leading edge. start, + /// Centers the popover horizontally on the trigger. + center, + /// Aligns the popover's trailing edge with the trigger's trailing edge. end, } @@ -160,6 +163,7 @@ class _AnchoredPopoverRoute extends PopupRoute { ).animate(curvedAnimation); final transformOrigin = switch (alignment) { AnchoredPopoverAlignment.start => Alignment.topLeft, + AnchoredPopoverAlignment.center => Alignment.topCenter, AnchoredPopoverAlignment.end => Alignment.topRight, }; @@ -234,6 +238,11 @@ class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate { final anchorBottom = size.height - position.bottom; final desiredX = switch (alignment) { AnchoredPopoverAlignment.start => position.left + offset.dx, + AnchoredPopoverAlignment.center => + position.left + + (size.width - position.left - position.right - childSize.width) / + 2 + + offset.dx, AnchoredPopoverAlignment.end => size.width - position.right - childSize.width + offset.dx, }; diff --git a/mobile/lib/shared/widgets/concentric_sheet_surface.dart b/mobile/lib/shared/widgets/concentric_sheet_surface.dart new file mode 100644 index 000000000..12e7e002f --- /dev/null +++ b/mobile/lib/shared/widgets/concentric_sheet_surface.dart @@ -0,0 +1,91 @@ +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 '../theme/theme.dart'; + +/// An iOS-native sheet surface that adopts the system's concentric corners on +/// iOS 26 and newer. Other platforms keep the normal Flutter shape. +class ConcentricSheetSurface extends HookWidget { + const ConcentricSheetSurface({ + required this.child, + required this.enabled, + this.color, + super.key, + }); + + final Widget child; + final bool enabled; + final Color? color; + + static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface'); + + Future _checkNativeSurfaceSupport() async { + try { + final supported = await _surfaceChannel.invokeMethod('isSupported'); + return supported == true; + } on MissingPluginException { + // The registrar is unavailable, so retain the Flutter surface. + return false; + } on PlatformException { + // The native surface is optional; retain the Flutter surface on failure. + return false; + } + } + + @override + Widget build(BuildContext context) { + final shouldCheckNativeSurface = + enabled && defaultTargetPlatform == TargetPlatform.iOS; + final supportFuture = useMemoized( + () => shouldCheckNativeSurface + ? _checkNativeSurfaceSupport() + : Future.value(false), + [shouldCheckNativeSurface], + ); + final nativeSurfaceSupported = useFuture(supportFuture).data ?? false; + + if (!shouldCheckNativeSurface) { + return child; + } + + final surfaceColor = color ?? context.colors.surface; + + return Padding( + padding: const EdgeInsets.only( + left: Grid.xxs, + right: Grid.xxs, + bottom: Grid.xxs, + ), + child: Stack( + children: [ + if (nativeSurfaceSupported) + Positioned.fill( + child: ExcludeSemantics( + child: UiKitView( + viewType: 'buzz/concentric_sheet_surface', + hitTestBehavior: PlatformViewHitTestBehavior.transparent, + creationParams: { + 'color': surfaceColor.toARGB32(), + 'minimumRadius': Radii.dialog, + }, + creationParamsCodec: const StandardMessageCodec(), + ), + ), + ) + else + Positioned.fill( + child: Material( + color: surfaceColor, + borderRadius: BorderRadius.circular(Radii.dialog), + clipBehavior: Clip.antiAlias, + ), + ), + child, + ], + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 8e8a8b1ce..2a2993f33 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -36,6 +36,22 @@ double _barContentHeight( : _kBarContentMinHeight; } +/// Height for a compact title rail below the app bar's action row. +/// +/// The rail normally stays at 40dp, but grows with an accessible title rather +/// than clipping text at larger system text sizes. +double frostedAppBarLowerTitleHeight( + BuildContext context, { + TextStyle? titleStyle, +}) { + final style = _effectiveTitleStyle(context, titleStyle); + final scaledFontSize = MediaQuery.textScalerOf( + context, + ).scale(style.fontSize ?? 20); + final titleHeight = scaledFontSize * (style.height ?? 1); + return titleHeight > 40 ? titleHeight : 40; +} + /// Returns the total height of the [FrostedAppBar] including safe area padding. /// /// Use this to add top spacing to body content so it starts below the bar. @@ -82,6 +98,11 @@ class FrostedAppBar extends StatelessWidget { /// Height reserved for [bottom]. final double bottomHeight; + /// Extends [bottom] upward into the title row without moving the app bar's + /// outer bounds. This keeps overlapping controls inside the app bar's hit + /// test region as well as its paint region. + final double bottomOverlap; + /// Widgets displayed on the trailing (right) side. final List actions; @@ -96,6 +117,24 @@ class FrostedAppBar extends StatelessWidget { /// top section — see [buzzTopSectionGradient]. final Gradient? gradient; + /// Whether to apply the translucent blur treatment behind the app bar. + /// + /// A page can leave its painted backdrop exposed at rest, then turn this on + /// when scrolling moves content beneath the controls. + final bool frosted; + + /// Opacity of the frosted surface above the blurred backdrop. + final double frostedSurfaceOpacity; + + /// Blur strength of the frosted backdrop. + final double frostedBlurSigma; + + /// Whether to draw a divider below the app bar. + final bool showBottomDivider; + + /// Opacity of the divider below the app bar. + final double bottomDividerOpacity; + const FrostedAppBar({ super.key, this.leading, @@ -105,11 +144,21 @@ class FrostedAppBar extends StatelessWidget { this.titleContentHeight = 0, this.bottom, this.bottomHeight = 0, + this.bottomOverlap = 0, this.actions = const [], this.horizontalInset = Grid.quarter, this.iconColor, this.gradient, - }) : assert(bottom == null || bottomHeight > 0); + this.frosted = true, + this.frostedSurfaceOpacity = 0.5, + this.frostedBlurSigma = 20, + this.showBottomDivider = true, + this.bottomDividerOpacity = 0.15, + }) : assert(bottom == null || bottomHeight > 0), + assert(bottomOverlap >= 0), + assert(bottom != null || bottomOverlap == 0), + assert(frostedBlurSigma >= 0), + assert(bottomDividerOpacity >= 0 && bottomDividerOpacity <= 1); @override Widget build(BuildContext context) { @@ -137,86 +186,112 @@ class FrostedAppBar extends StatelessWidget { ) : null); - return Positioned( - top: 0, - left: 0, - right: 0, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - key: const ValueKey('frosted-app-bar-background'), - padding: EdgeInsets.only(top: topPadding), - decoration: BoxDecoration( - // A gradient and a color cannot both paint, so the gradient - // replaces the frosted surface fill when one is supplied. - color: gradient == null - ? context.colors.surface.withValues(alpha: 0.5) - : null, - gradient: gradient, - border: Border( - bottom: BorderSide( - color: context.colors.outlineVariant.withValues(alpha: 0.3), - width: _kBottomBorderWidth, - ), - ), - ), - child: DirectionalTransitionMotion( - transformKey: const ValueKey( - 'frosted-app-bar-content-transition-transform', - ), - opacityKey: const ValueKey( - 'frosted-app-bar-content-transition-opacity', - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: barContentHeight, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: horizontalInset, - ), - child: IconTheme.merge( - data: IconThemeData(color: iconColor), - child: Row( - children: [ - ?effectiveLeading, - if (title != null) - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: effectiveLeading != null - ? 0 - : Grid.gutter - Grid.quarter, - right: actions.isEmpty - ? Grid.gutter - Grid.quarter - : 0, - ), - child: DefaultTextStyle.merge( - style: effectiveTitleStyle, - overflow: TextOverflow.ellipsis, - maxLines: 1, - child: title!, - ), - ), - ) - else - const Spacer(), - ...actions, - ], - ), - ), + final titleRow = SizedBox( + height: barContentHeight, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: horizontalInset), + child: IconTheme.merge( + data: IconThemeData(color: iconColor), + child: Row( + children: [ + ?effectiveLeading, + if (title != null) + Expanded( + child: Padding( + padding: EdgeInsets.only( + left: effectiveLeading != null + ? 0 + : horizontalInset < Grid.gutter + ? Grid.gutter - horizontalInset + : 0, + right: actions.isEmpty + ? horizontalInset < Grid.gutter + ? Grid.gutter - horizontalInset + : 0 + : 0, + ), + child: DefaultTextStyle.merge( + style: effectiveTitleStyle, + overflow: TextOverflow.ellipsis, + maxLines: 1, + child: title!, ), ), - if (bottom != null) - SizedBox(height: bottomHeight, child: bottom), - ], - ), - ), + ) + else + const Spacer(), + ...actions, + ], ), ), ), ); + final contentBody = bottom != null && bottomOverlap > 0 + ? SizedBox( + height: barContentHeight + bottomHeight, + child: Stack( + children: [ + Positioned(top: 0, left: 0, right: 0, child: titleRow), + Positioned( + top: barContentHeight - bottomOverlap, + left: 0, + right: 0, + height: bottomHeight + bottomOverlap, + child: bottom!, + ), + ], + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + titleRow, + if (bottom != null) SizedBox(height: bottomHeight, child: bottom), + ], + ); + + final content = DirectionalTransitionMotion( + transformKey: const ValueKey( + 'frosted-app-bar-content-transition-transform', + ), + opacityKey: const ValueKey('frosted-app-bar-content-transition-opacity'), + child: contentBody, + ); + + final background = Container( + key: const ValueKey('frosted-app-bar-background'), + padding: EdgeInsets.only(top: topPadding), + decoration: BoxDecoration( + color: !frosted + ? Colors.transparent + : gradient == null + ? context.colors.surface.withValues(alpha: frostedSurfaceOpacity) + : null, + gradient: gradient, + border: showBottomDivider + ? Border( + bottom: BorderSide( + color: navigationDivider(context, bottomDividerOpacity), + width: _kBottomBorderWidth, + ), + ) + : null, + ), + child: content, + ); + + final child = ClipRect( + child: frosted + ? BackdropFilter( + filter: ImageFilter.blur( + sigmaX: frostedBlurSigma, + sigmaY: frostedBlurSigma, + ), + child: background, + ) + : background, + ); + + return Positioned(top: 0, left: 0, right: 0, child: child); } } diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 9772fd024..7243d6627 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -26,6 +26,9 @@ class FrostedScaffold extends StatelessWidget { /// surface behind this page. final Color? backgroundColor; + /// A fixed gradient painted behind the app bar and scrolling body. + final Gradient? backgroundGradient; + const FrostedScaffold({ super.key, required this.appBar, @@ -33,6 +36,7 @@ class FrostedScaffold extends StatelessWidget { this.floatingActionButton, this.resizeToAvoidBottomInset, this.backgroundColor, + this.backgroundGradient, }); @override @@ -41,20 +45,41 @@ class FrostedScaffold extends StatelessWidget { backgroundColor: backgroundColor, resizeToAvoidBottomInset: resizeToAvoidBottomInset, floatingActionButton: floatingActionButton, - body: Stack( - children: [ - DirectionalTransitionMotion( - transformKey: const ValueKey( - 'frosted-scaffold-body-transition-transform', - ), - opacityKey: const ValueKey( - 'frosted-scaffold-body-transition-opacity', - ), - child: body, - ), - appBar, - ], - ), + body: Stack(children: _stackChildren()), ); } + + List _stackChildren() { + final backdrop = backgroundGradient == null + ? const [] + : [ + Positioned.fill( + child: _PinnedGradientBackground(gradient: backgroundGradient!), + ), + ]; + final bodyMotion = DirectionalTransitionMotion( + transformKey: const ValueKey( + 'frosted-scaffold-body-transition-transform', + ), + opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'), + child: body, + ); + // The bar must be painted after the scrollable sheet: [BackdropFilter] + // only samples pixels that were already painted behind it. This is the + // same composition as channel navigation, so top-level headers blur their + // content rather than only the fixed gradient. + return [...backdrop, bodyMotion, appBar]; + } +} + +class _PinnedGradientBackground extends StatelessWidget { + final Gradient gradient; + + const _PinnedGradientBackground({required this.gradient}); + + @override + Widget build(BuildContext context) => DecoratedBox( + key: const ValueKey('frosted-scaffold-pinned-gradient'), + decoration: BoxDecoration(gradient: gradient), + ); } diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index d972b8184..36e0ce573 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -8,13 +8,8 @@ const mobileTabBarHeight = 56.0; /// Gap between the floating mobile tab bar and the bottom safe area. const mobileTabBarBottomGap = Grid.twelve; -/// Returns the shared footer backdrop height, including the logical safe area. -double mobileTabFooterBackdropHeight(BuildContext context) => - mobileTabBarHeight + - mobileTabBarBottomGap + - MediaQuery.paddingOf(context).bottom + - Grid.xl + - Grid.gutter; +/// Fixed visual height of the shared footer fade behind the floating tab bar. +double mobileTabFooterBackdropHeight(BuildContext _) => 180; /// Builds the shared transparent-to-surface footer fade. /// @@ -22,11 +17,14 @@ double mobileTabFooterBackdropHeight(BuildContext context) => /// the channel composer can paint the exact same fade behind their own content. LinearGradient mobileTabFooterBackdropGradient( BuildContext context, { - List stops = const [0, 0.5, 1], - List opacities = const [0, 0.75, 1], + List stops = const [0, 0.18, 0.38, 0.6, 0.8, 1], + List opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1], + Color? tint, + double tintBlend = 0, }) { assert(stops.length == opacities.length); - final surface = context.colors.surface; + assert(tintBlend >= 0 && tintBlend <= 1); + final surface = Color.lerp(context.colors.surface, tint, tintBlend)!; return LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -48,15 +46,24 @@ class MobileTabFooterBackdrop extends StatelessWidget { /// Surface-color alpha values paired with [stops]. final List opacities; + /// Optional color blended into the surface before opacity is applied. + final Color? tint; + + /// Amount of [tint] mixed into the surface, from 0 to 1. + final double tintBlend; + /// Creates a footer backdrop with the required [height]. /// /// Override [stops] and [opacities] together to customize the gradient. const MobileTabFooterBackdrop({ super.key, required this.height, - this.stops = const [0, 0.5, 1], - this.opacities = const [0, 0.75, 1], - }) : assert(stops.length == opacities.length); + this.stops = const [0, 0.18, 0.38, 0.6, 0.8, 1], + this.opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1], + this.tint, + this.tintBlend = 0, + }) : assert(stops.length == opacities.length), + assert(tintBlend >= 0 && tintBlend <= 1); @override Widget build(BuildContext context) { @@ -69,6 +76,8 @@ class MobileTabFooterBackdrop extends StatelessWidget { context, stops: stops, opacities: opacities, + tint: tint, + tintBlend: tintBlend, ), ), ), diff --git a/mobile/lib/shared/widgets/modal_presentation.dart b/mobile/lib/shared/widgets/modal_presentation.dart new file mode 100644 index 000000000..3793774a2 --- /dev/null +++ b/mobile/lib/shared/widgets/modal_presentation.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; +import 'concentric_sheet_surface.dart'; + +/// Shared motion for occasional modal UI. +/// +/// The strong ease-out makes entrances respond immediately, while the shorter +/// exit keeps dismissals from feeling sluggish. +const buzzModalAnimationStyle = AnimationStyle( + curve: Cubic(0.23, 1, 0.32, 1), + duration: Duration(milliseconds: 280), + reverseCurve: Cubic(0.77, 0, 0.175, 1), + reverseDuration: Duration(milliseconds: 220), +); + +/// Shows a bottom sheet with Buzz's shared motion and sheet chrome. +/// +/// Sheets include the shared close control by default. On iOS, the surface +/// uses native concentric corners when available and paints a requested drag +/// handle inside that inset surface; other platforms retain Flutter's handle. +Future showBuzzModalBottomSheet({ + required BuildContext context, + required WidgetBuilder builder, + Color? backgroundColor, + String? barrierLabel, + double? elevation, + ShapeBorder? shape, + Clip? clipBehavior, + BoxConstraints? constraints, + Color? barrierColor, + bool isScrollControlled = false, + double scrollControlDisabledMaxHeightRatio = 9.0 / 16.0, + bool useRootNavigator = false, + bool isDismissible = true, + bool enableDrag = true, + bool? showDragHandle, + bool showCloseButton = true, + bool useSafeArea = false, + RouteSettings? routeSettings, + AnimationController? transitionAnimationController, + Offset? anchorPoint, + AnimationStyle? sheetAnimationStyle, + bool? requestFocus, +}) { + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + final theme = Theme.of(context); + final surfaceColor = + backgroundColor ?? + theme.bottomSheetTheme.modalBackgroundColor ?? + theme.bottomSheetTheme.backgroundColor ?? + context.colors.surface; + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + return showModalBottomSheet( + context: context, + builder: (sheetContext) => ConcentricSheetSurface( + enabled: isIos, + color: surfaceColor, + child: _SheetContent( + showCloseButton: showCloseButton, + showDragHandle: isIos && showDragHandle == true, + child: builder(sheetContext), + ), + ), + backgroundColor: isIos ? Colors.transparent : backgroundColor, + barrierLabel: barrierLabel, + elevation: elevation, + shape: shape, + clipBehavior: clipBehavior, + constraints: constraints, + barrierColor: barrierColor, + isScrollControlled: isScrollControlled, + scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio, + useRootNavigator: useRootNavigator, + isDismissible: isDismissible, + enableDrag: enableDrag, + // The iOS route is transparent so its stock handle sits outside the inset + // concentric surface. Paint it inside the surface above instead. + showDragHandle: isIos ? false : showDragHandle, + useSafeArea: useSafeArea, + routeSettings: routeSettings, + transitionAnimationController: transitionAnimationController, + anchorPoint: anchorPoint, + sheetAnimationStyle: reduceMotion + ? AnimationStyle.noAnimation + : (sheetAnimationStyle ?? buzzModalAnimationStyle), + requestFocus: requestFocus, + ); +} + +class _SheetContent extends StatelessWidget { + const _SheetContent({ + required this.child, + required this.showCloseButton, + required this.showDragHandle, + }); + + final Widget child; + final bool showCloseButton; + final bool showDragHandle; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showCloseButton) + Padding( + padding: const EdgeInsets.only( + top: Grid.xxs, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xs, + ), + child: SizedBox( + height: 56, + child: Stack( + alignment: Alignment.topCenter, + children: [ + if (showDragHandle) const _SheetDragHandle(), + Align( + alignment: Alignment.bottomRight, + child: SizedBox.square( + dimension: 44, + child: IconButton( + tooltip: 'Close sheet', + onPressed: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).pop(); + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: + context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + icon: const Icon(LucideIcons.x, size: 22), + ), + ), + ), + ], + ), + ), + ) + else if (showDragHandle) + const Padding( + padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs), + child: _SheetDragHandle(), + ), + Flexible(child: child), + ], + ); + } +} + +class _SheetDragHandle extends StatelessWidget { + const _SheetDragHandle(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Drag handle', + child: Container( + key: const ValueKey('buzz-sheet-drag-handle'), + width: 32, + height: 4, + decoration: BoxDecoration( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(Radii.full), + ), + ), + ); + } +} + +/// Shows a dialog with Buzz's shared motion, respecting reduced-motion settings. +Future showBuzzDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, + TraversalEdgeBehavior? traversalEdgeBehavior, + bool fullscreenDialog = false, + bool? requestFocus, + AnimationStyle? animationStyle, +}) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + return showDialog( + context: context, + builder: builder, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + useSafeArea: useSafeArea, + useRootNavigator: useRootNavigator, + routeSettings: routeSettings, + anchorPoint: anchorPoint, + traversalEdgeBehavior: traversalEdgeBehavior, + fullscreenDialog: fullscreenDialog, + requestFocus: requestFocus, + animationStyle: reduceMotion + ? AnimationStyle.noAnimation + : (animationStyle ?? buzzModalAnimationStyle), + ); +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 7e5aa0f4d..8619a513d 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:buzz/features/activity/activity_page.dart'; import 'package:buzz/features/activity/activity_provider.dart'; import 'package:buzz/features/activity/feed_item.dart'; @@ -115,6 +116,7 @@ void main() { List? channels, TextScaler? textScaler, EdgeInsets mediaPadding = EdgeInsets.zero, + ValueListenable? tabReselection, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -143,7 +145,7 @@ void main() { ).copyWith(textScaler: textScaler, padding: mediaPadding), child: child!, ), - home: const ActivityPage(), + home: ActivityPage(tabReselection: tabReselection), ), ); } @@ -181,11 +183,43 @@ void main() { await tester.pumpWidget(await buildTestable()); await tester.pumpAndSettle(); - final appBar = tester.widget(find.byType(FrostedAppBar)); + final appBar = tester.widget( + find.byType(FrostedAppBar).last, + ); expect(appBar.automaticallyImplyLeading, isFalse); + expect(appBar.gradient, isNull); + expect(appBar.frosted, isTrue); + expect(appBar.showBottomDivider, isTrue); + expect(appBar.bottomHeight, Grid.xxs); expect(find.byTooltip('Back'), findsNothing); }); + testWidgets('sizes the Activity app bar for its custom title style', ( + tester, + ) async { + await tester.pumpWidget( + await buildTestable(textScaler: const TextScaler.linear(2)), + ); + await tester.pumpAndSettle(); + + final appBar = tester.widget( + find.byType(FrostedAppBar).last, + ); + final titleStyle = appBar.titleStyle!; + expect(titleStyle.fontSize, 22); + expect( + tester.getSize(find.byType(ClipRect).last).height, + closeTo( + frostedAppBarHeight( + tester.element(find.byType(FrostedAppBar).last), + titleStyle: titleStyle, + bottomHeight: Grid.xxs, + ), + 0.01, + ), + ); + }); + testWidgets('keeps footer clearance inside the scrollable content', ( tester, ) async { @@ -200,8 +234,48 @@ void main() { expect(safeArea.top, isFalse); expect(safeArea.bottom, isFalse); - final list = tester.widget(find.byType(ListView)); - expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96)); + final padding = tester.widget( + find.descendant( + of: find.byType(CustomScrollView), + matching: find.byType(SliverPadding), + ), + ); + expect(padding.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96)); + }); + + testWidgets('scrolls Activity to the top when its tab is selected again', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 180); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tabReselection = ValueNotifier(0); + addTearDown(tabReselection.dispose); + await tester.pumpWidget( + await buildTestable(tabReselection: tabReselection), + ); + await tester.pumpAndSettle(); + + final scrollable = tester.state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + tabReselection.value++; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 130)); + + expect( + scrollable.position.pixels, + lessThan(scrollable.position.maxScrollExtent), + ); + await tester.pumpAndSettle(); + expect(scrollable.position.pixels, scrollable.position.minScrollExtent); }); testWidgets('shows error view with retry button', (tester) async { @@ -285,7 +359,13 @@ void main() { await tester.tap(find.descendant(of: surface, matching: find.text('All'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('activity-options-menu'))); + final optionsTrigger = find.byKey(const ValueKey('activity-options-menu')); + expect( + tester.getSize(optionsTrigger), + const Size(Grid.xl, Grid.xl), + reason: 'Activity options must retain a 48dp touch target.', + ); + await tester.tap(optionsTrigger); await tester.pump(); final optionsSurface = find.byKey( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 912233aba..00b9f8122 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -27,6 +28,7 @@ import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/features/profile/user_profile_sheet.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -1465,6 +1467,16 @@ void main() { ); await tester.pumpAndSettle(); + // History requests are deliberately scheduled after the current frame. + // Advance the test clock until the capped chain has settled. + for ( + var frame = 0; + frame < 8 && messagesNotifier.fetchOlderCalls < 4; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(messagesNotifier.fetchOlderCalls, 4); final unreadButton = find.byKey( const ValueKey('channel-jump-to-oldest-unread'), @@ -1758,6 +1770,143 @@ void main() { ); }); + testWidgets( + 'keeps the followed tail anchored through composer and keyboard resize', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); + + final messages = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: 'msg$i', + pubkey: i.isEven ? 'alice' : 'bob', + content: 'Message $i', + createdAt: 1000 + i * 1000, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final latestMessage = find.byKey( + const ValueKey('channel-message-group-msg19'), + ); + final composerDock = find.byKey( + const ValueKey('channel-composer-dock'), + ); + final compactDockHeight = tester.getSize(composerDock).height; + + expect(latestMessage, findsOneWidget); + expect( + tester.getBottomLeft(latestMessage).dy, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + + expect( + tester.getSize(composerDock).height, + greaterThan(compactDockHeight), + ); + expect( + tester.getBottomLeft(latestMessage).dy, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect(latestMessage, findsOneWidget); + expect( + tester.getBottomLeft(latestMessage).dy, + lessThanOrEqualTo(tester.getTopLeft(composerDock).dy + 1), + ); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsNothing, + ); + }, + ); + + testWidgets( + 'does not realign a user-detached timeline on keyboard resize', + (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); + + final messages = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'msg$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + ]; + + 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')); + await tester.drag(messageList, const Offset(0, 300)); + await tester.pumpAndSettle(); + + expect(findRichText('Message 39'), findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect(findRichText('Message 39'), findsNothing); + expect( + find.byKey(const ValueKey('channel-jump-to-latest')), + findsOneWidget, + ); + final positions = tester + .widget(messageList) + .itemPositionsNotifier! + .itemPositions + .value; + expect( + positions.any( + (position) => + position.index == 0 && position.itemLeadingEdge.abs() < 0.01, + ), + isFalse, + ); + }, + ); + testWidgets('can jump back to latest after a non-drag user scroll', ( tester, ) async { @@ -2254,6 +2403,82 @@ void main() { ); }); + testWidgets( + 'keeps membership and huddle rows evenly spaced with authored messages', + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'message-alice', + pubkey: 'alice', + content: 'First message', + createdAt: 1000, + ), + _textMsg( + id: 'message-bob', + pubkey: 'bob', + content: 'Second message', + createdAt: 1010, + ), + _systemMsg( + id: 'membership-carol', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1020, + ), + _huddleMsg( + id: 'huddle-dave', + kind: EventKind.huddleStarted, + pubkey: 'dave', + createdAt: 1030, + ), + _textMsg( + id: 'message-erin', + pubkey: 'erin', + content: 'Third message', + createdAt: 1040, + ), + ], + users: { + for (final name in ['alice', 'bob', 'carol', 'dave', 'erin']) + name: UserProfile(pubkey: name, displayName: name), + }, + ), + ); + await tester.pumpAndSettle(); + + Rect avatarRect(String rowKey) => tester.getRect( + find + .descendant( + of: find.byKey(ValueKey(rowKey)), + matching: find.byType(CircleAvatar), + ) + .first, + ); + + final avatars = [ + avatarRect('message-row-message-alice'), + avatarRect('message-row-message-bob'), + avatarRect('system-message-row-membership-carol'), + avatarRect('system-message-row-huddle-dave'), + avatarRect('message-row-message-erin'), + ]; + final authoredMessageGap = avatars[1].top - avatars[0].bottom; + + for (var index = 2; index < avatars.length; index++) { + expect( + avatars[index].top - avatars[index - 1].bottom, + closeTo(authoredMessageGap, 1), + reason: 'row $index should use the authored-message gap', + ); + } + }, + ); + testWidgets('renders member_joined (self-join) system event', ( tester, ) async { @@ -2280,6 +2505,118 @@ void main() { ); }); + testWidgets('opens a profile sheet from a membership system avatar', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'sys-membership-avatar', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'bob', + }, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(find.text('Copy public key'), findsOneWidget); + expect(find.text('alice'), findsNothing); + expect(find.byType(UserProfileSheet), findsOneWidget); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (_) async => null); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + await tester.ensureVisible(find.text('Copy public key')); + await tester.pumpAndSettle(); + final copyAction = find + .ancestor( + of: find.text('Copy public key'), + matching: find.byType(GestureDetector), + ) + .last; + tester.widget(copyAction).onTap!(); + await tester.pump(); + await tester.pump(); + expect(find.text('Public key copied'), findsOneWidget); + + await tester.tap(find.byTooltip('Close sheet')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 2)); + + expect(tester.takeException(), isNull); + }); + + testWidgets('opens a profile sheet from a huddle system avatar', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'sys-huddle-avatar', + kind: EventKind.huddleStarted, + pubkey: 'alice', + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(find.text('Copy public key'), findsOneWidget); + expect(find.byType(UserProfileSheet), findsOneWidget); + }); + + testWidgets('opens a profile sheet from a generic system avatar', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'sys-removed-avatar', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'bob', + }, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar).first); + await tester.pumpAndSettle(); + + expect(find.text('Copy public key'), findsOneWidget); + expect(find.byType(UserProfileSheet), findsOneWidget); + }); + testWidgets('renders member_joined (added by other) system event', ( tester, ) async { diff --git a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart index 5b36ed6a4..16d1936d3 100644 --- a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart +++ b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; @@ -199,6 +200,46 @@ void main() { expect(relay.subscribeCalls, 1, reason: 'no re-subscribe after dispose'); }); + test( + 'dispose while subscribe is pending closes the late subscription', + () async { + await setUpEnv(); + final relay = _DelayedSubscribeRelaySession(); + final manager = buildManager(relaySession: relay); + + final initializing = manager.initialize(); + await relay.subscribeStarted.future; + manager.dispose(flushPending: false); + relay.completeSubscribe(); + await initializing; + + expect(relay.activeListeners, 0); + expect(relay.unsubscribeCalls, 1); + }, + ); + + test( + 'a retry request during an in-flight sync does not overlap subscribe', + () async { + await setUpEnv(); + final relay = _DelayedSubscribeRelaySession(); + final manager = buildManager(relaySession: relay); + + final initializing = manager.initialize(); + await relay.subscribeStarted.future; + relay.closePendingSubscription('rate-limited: quota exceeded'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(relay.subscribeCalls, 1); + relay.completeSubscribe(); + await initializing; + await _waitUntil(() => relay.subscribeCalls == 2); + expect(relay.maxConcurrentSubscribes, 1); + expect(relay.activeListeners, 1); + manager.dispose(flushPending: false); + }, + ); + test('backoff resets after full recovery so later failures start from the ' 'base delay', () async { await setUpEnv(); @@ -242,6 +283,60 @@ Future _waitUntil( } } +class _DelayedSubscribeRelaySession extends RelaySessionNotifier { + final subscribeStarted = Completer(); + Completer? _pendingSubscribe; + void Function(String)? _pendingOnClosed; + int subscribeCalls = 0; + int concurrentSubscribes = 0; + int maxConcurrentSubscribes = 0; + int activeListeners = 0; + int unsubscribeCalls = 0; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) { + subscribeCalls++; + concurrentSubscribes++; + if (concurrentSubscribes > maxConcurrentSubscribes) { + maxConcurrentSubscribes = concurrentSubscribes; + } + if (!subscribeStarted.isCompleted) subscribeStarted.complete(); + _pendingOnClosed = onClosed; + if (subscribeCalls > 1) { + concurrentSubscribes--; + activeListeners++; + return Future.value(_unsubscribe); + } + _pendingSubscribe = Completer(); + return _pendingSubscribe!.future.whenComplete(() { + concurrentSubscribes--; + }); + } + + void closePendingSubscription(String message) => + _pendingOnClosed?.call(message); + + void completeSubscribe() { + activeListeners++; + _pendingSubscribe!.complete(_unsubscribe); + } + + void _unsubscribe() { + activeListeners--; + unsubscribeCalls++; + } +} + /// Rejects the first [failuresBeforeSuccess] fetch and subscribe calls with /// the relay's rate-limit error, then succeeds — the exact failure mode seen /// on Android cold start where the channel-list REQ burst exhausts the diff --git a/mobile/test/features/channels/channel_test.dart b/mobile/test/features/channels/channel_test.dart index 9f3676086..9673eda28 100644 --- a/mobile/test/features/channels/channel_test.dart +++ b/mobile/test/features/channels/channel_test.dart @@ -195,4 +195,50 @@ void main() { expect(updated.archivedAt, newDate); }); }); + + group('Channel.canAddMembers', () { + Channel make({required String channelType, required String visibility}) => + Channel( + id: '1', + name: 'c', + channelType: channelType, + visibility: visibility, + description: '', + createdBy: 'x', + createdAt: DateTime(2025), + memberCount: 2, + ); + + test('open channels accept adds from anyone', () { + final channel = make(channelType: 'stream', visibility: 'open'); + expect(channel.canAddMembers(null), isTrue); + expect(channel.canAddMembers('member'), isTrue); + }); + + test('private channels accept adds only from owners/admins', () { + final channel = make(channelType: 'stream', visibility: 'private'); + expect(channel.canAddMembers('owner'), isTrue); + expect(channel.canAddMembers('admin'), isTrue); + expect(channel.canAddMembers('member'), isFalse); + expect(channel.canAddMembers('bot'), isFalse); + expect(channel.canAddMembers(null), isFalse); + }); + + test('DMs never accept adds', () { + expect( + make(channelType: 'dm', visibility: 'open').canAddMembers('owner'), + isFalse, + ); + expect( + make(channelType: 'dm', visibility: 'private').canAddMembers('owner'), + isFalse, + ); + }); + + test('unknown visibility fails closed for non-elevated callers', () { + final channel = make(channelType: 'stream', visibility: 'mystery'); + expect(channel.canAddMembers('member'), isFalse); + expect(channel.canAddMembers('owner'), isTrue); + }); + }); } diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 77dc65fbc..f925724b5 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:math'; +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:hooks_riverpod/misc.dart'; @@ -22,6 +24,8 @@ import 'package:buzz/shared/community/community_icon_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; void main() { @@ -34,6 +38,9 @@ void main() { Map communityIcons = const {}, ValueChanged? onCommunityIconLoad, TextScaler textScaler = TextScaler.noScaling, + Gradient? topSectionGradient, + ValueChanged? onSettingsTransitionProgress, + ValueListenable? tabReselection, }) { return ProviderScope( overrides: [ @@ -50,7 +57,7 @@ void main() { ...overrides, ], child: MaterialApp( - theme: AppTheme.light(), + theme: AppTheme.light(topSectionGradient: topSectionGradient), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, @@ -60,10 +67,15 @@ void main() { ), child: child!, ), - home: const Stack( + home: Stack( children: [ - ChannelsPage(settingsPageBuilder: _buildSettingsPage), - Positioned.fill( + ChannelsPage( + settingsPageBuilder: _buildSettingsPage, + onSettingsTransitionProgress: + onSettingsTransitionProgress ?? (_) {}, + tabReselection: tabReselection, + ), + const Positioned.fill( child: ChannelQuickActionsLauncher( visible: true, navigationBarHeight: 60, @@ -161,12 +173,65 @@ void main() { final text = tester.widget(find.text(label)); expect(text.style?.fontSize, contentListTitleTextStyle.fontSize); expect(text.style?.height, contentListTitleTextStyle.height); + expect( + text.style?.color, + Theme.of( + tester.element(find.text(label)), + ).colorScheme.onSurface.withValues(alpha: 0.8), + ); } + final channelIcon = tester.widget( + find.byKey(const ValueKey('channel-icon-1')), + ); + expect( + channelIcon.color, + Theme.of( + tester.element(find.byKey(const ValueKey('channel-icon-1'))), + ).colorScheme.onSurface.withValues(alpha: 0.8), + ); final sectionTitle = tester.widget(find.text('Channels')); expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize); expect(sectionTitle.style?.fontWeight, FontWeight.w600); }); + testWidgets('sizes the community header for accessible text', (tester) async { + await tester.pumpWidget( + buildTestable( + textScaler: const TextScaler.linear(2), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final appBar = tester.widget( + find.byType(FrostedAppBar).last, + ); + final titleStyle = appBar.titleStyle!; + expect(titleStyle.fontSize, 22); + expect( + tester + .getSize( + find.descendant( + of: find.byType(FrostedAppBar).last, + matching: find.byType(ClipRect), + ), + ) + .height, + closeTo( + frostedAppBarHeight( + tester.element(find.byType(FrostedAppBar).last), + titleStyle: titleStyle, + bottomHeight: appBar.bottomHeight, + ) - + 1, + 0.01, + ), + ); + expect(tester.takeException(), isNull); + }); + testWidgets('keeps the last channel above the floating tab bar', ( tester, ) async { @@ -190,6 +255,149 @@ void main() { expect((padding.padding as EdgeInsets).bottom, footerClearance); }); + testWidgets('balances an expanded section around its following divider', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final lastChannel = tester.getRect(find.text('general')); + final divider = tester.getRect(find.byType(Divider).last); + final nextSectionHeader = tester.getRect(find.text('DMs')); + + expect( + divider.top - lastChannel.bottom, + closeTo(nextSectionHeader.top - divider.bottom, 0.01), + ); + }); + + testWidgets('keeps the Buzz background fixed behind the channels list', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + topSectionGradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.yellow, Colors.blue], + ), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')), + findsOneWidget, + ); + expect(find.byType(DecoratedSliver), findsNothing); + final gradientBackground = tester.widget( + find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')), + ); + final gradient = + (gradientBackground.decoration as BoxDecoration).gradient + as LinearGradient; + expect(gradient.end, Alignment.bottomCenter); + + final appBar = tester.widget( + find.byType(FrostedAppBar).last, + ); + expect(appBar.frosted, isFalse); + expect(appBar.frostedSurfaceOpacity, 0); + expect(appBar.frostedBlurSigma, 0); + expect(appBar.showBottomDivider, isFalse); + expect(appBar.bottomHeight, Grid.xxs); + }); + + testWidgets('builds Home header frost progressively while scrolling', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 160); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + buildTestable( + topSectionGradient: const LinearGradient( + colors: [Colors.yellow, Colors.blue], + ), + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final scrollable = tester.state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ); + expect(scrollable.position.maxScrollExtent, greaterThanOrEqualTo(Grid.xxl)); + + scrollable.position.jumpTo(Grid.xl / 2); + await tester.pump(); + var appBar = tester.widget(find.byType(FrostedAppBar).last); + expect(appBar.frosted, isTrue); + expect(appBar.frostedSurfaceOpacity, 0); + expect(appBar.frostedBlurSigma, closeTo(8.67, 0.001)); + + scrollable.position.jumpTo(Grid.xxl); + await tester.pump(); + appBar = tester.widget(find.byType(FrostedAppBar).last); + expect(appBar.frostedSurfaceOpacity, 0); + expect(appBar.frostedBlurSigma, 23.12); + }); + + testWidgets('scrolls Home to the top when its tab is selected again', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 160); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final tabReselection = ValueNotifier(0); + addTearDown(tabReselection.dispose); + await tester.pumpWidget( + buildTestable( + tabReselection: tabReselection, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final scrollable = tester.state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ); + scrollable.position.jumpTo(scrollable.position.maxScrollExtent); + tabReselection.value++; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 130)); + + expect( + scrollable.position.pixels, + lessThan(scrollable.position.maxScrollExtent), + ); + await tester.pumpAndSettle(); + expect(scrollable.position.pixels, scrollable.position.minScrollExtent); + }); + testWidgets('truncates long custom section names beside the menu', ( tester, ) async { @@ -322,7 +530,9 @@ void main() { final topLabelX = tester.getTopLeft(find.text('Community')).dx; final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx; final rowLabelX = tester.getTopLeft(find.text('general')).dx; - expect(topLabelX, sectionLabelX); + // The community title shares the leading row with its avatar. Channel + // labels stay aligned below it. + expect(topLabelX, Grid.twelve + 40 + Grid.xxs); expect(sectionLabelX, rowLabelX); relaySession.setReconnecting(); @@ -344,6 +554,32 @@ void main() { expect(skeletonSectionLabelX, sectionLabelX); }); + testWidgets('matches the community and profile avatar circle sizes', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final appBar = find.byType(FrostedAppBar).last; + final communityAvatar = find.descendant( + of: appBar, + matching: find.byType(AvatarImage), + ); + final profileAvatar = find.descendant( + of: appBar, + matching: find.byType(MaskedAvatarBadge), + ); + + expect(tester.getSize(communityAvatar), const Size.square(40)); + expect(tester.getSize(profileAvatar), const Size.square(40)); + }); + testWidgets('reveals channel content from same-slot reconnect skeletons', ( tester, ) async { @@ -451,10 +687,128 @@ void main() { ); await tester.pumpAndSettle(); + expect(find.byType(Hero), findsNothing); await tester.tap(find.byType(ProfileAvatar)); await tester.pumpAndSettle(); expect(find.text('Injected settings'), findsOneWidget); + final route = ModalRoute.of(tester.element(find.text('Injected settings'))); + expect(route, isNot(isA>())); + expect(route?.opaque, isFalse); + }); + + testWidgets('reports Settings progress in both directions', (tester) async { + final progress = []; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + onSettingsTransitionProgress: progress.add, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pumpAndSettle(); + expect(progress.any((value) => value > 0 && value < 1), isTrue); + expect(progress.last, 1); + + final reverseStart = progress.length; + Navigator.of(tester.element(find.text('Injected settings'))).pop(); + await tester.pumpAndSettle(); + expect( + progress.skip(reverseStart).any((value) => value > 0 && value < 1), + isTrue, + ); + expect(progress.last, 0); + }); + + testWidgets('paints Settings content with its surface from the first frame', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pump(); + + final transition = find.byKey( + const ValueKey('settings-transition-opacity'), + skipOffstage: false, + ); + expect(transition, findsOneWidget); + expect( + find.descendant( + of: transition, + matching: find.byKey( + const ValueKey('settings-transition-layer'), + skipOffstage: false, + ), + ), + findsOneWidget, + ); + expect(tester.widget(transition).opacity.value, 0.8); + + await tester.pump(const Duration(milliseconds: 95)); + expect( + tester.widget(transition).opacity.value, + inExclusiveRange(0.8, 1), + ); + await tester.pumpAndSettle(); + expect(tester.widget(transition).opacity.value, 1); + + Navigator.of(tester.element(find.text('Injected settings'))).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 95)); + expect( + tester.widget(transition).opacity.value, + inExclusiveRange(0, 1), + reason: 'The complete Settings layer still fades out on exit.', + ); + }); + + testWidgets('gives feedback for the profile and community controls', ( + tester, + ) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call); + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pumpAndSettle(); + expect(hapticCalls.single.arguments, 'HapticFeedbackType.lightImpact'); + + Navigator.of(tester.element(find.text('Injected settings'))).pop(); + await tester.pumpAndSettle(); + final communityAvatar = find.descendant( + of: find.byType(FrostedAppBar).last, + matching: find.byType(AvatarImage), + ); + await tester.tap(communityAvatar); + await tester.pump(); + expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick'); }); testWidgets('community switcher separates selection from edit removal', ( @@ -1360,6 +1714,10 @@ void main() { tester.widget(find.text('general')).style?.fontWeight, FontWeight.w700, ); + expect( + tester.widget(find.text('general')).style?.color, + Theme.of(tester.element(find.text('general'))).colorScheme.onSurface, + ); readState.markContextRead('1', 20); await tester.pump(); diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 11bf30682..d58555f45 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -3138,6 +3138,81 @@ void main() { expect(publishedEvents.where((event) => event['kind'] == 9000), isEmpty); }); + testWidgets( + 'skips the agent add in a private channel when not owner/admin', + (tester) async { + final agentPubkey = 'a' * 64; + final signer = nostr.Keys.generate(); + final publishedEvents = >[]; + var didSend = false; + List sentMentionPubkeys = const []; + List> sentMediaTags = const >[]; + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + // Plain member of a private channel: the relay rejects any add, so + // the composer must not attempt one — and must still send. + members: [ + ChannelMember( + pubkey: signer.public, + role: 'member', + joinedAt: DateTime(2024), + ), + ], + relayAgents: [_testAgent(agentPubkey)], + channels: [ + _makeCurrentChannel(visibility: 'private'), + _makeSharedMemberChannel(), + ], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + didSend = true; + sentMentionPubkeys = mentionPubkeys; + sentMediaTags = mediaTags; + }, + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + final session = container.read(relaySessionProvider.notifier); + final socket = _RecordingRelaySocket( + publishedEvents, + session.debugHandleSocketMessageForTest, + ); + session.debugAttachSocketForTest(socket); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); + await tester.pumpAndSettle(); + + expect(didSend, isTrue); + expect( + publishedEvents.where((event) => event['kind'] == 9000), + isEmpty, + ); + // The un-added agent is demoted from p-tag to a reference mention. + expect(sentMentionPubkeys, isEmpty); + expect( + sentMediaTags, + contains(orderedEquals(['mention', agentPubkey])), + ); + expect(find.text(privateChannelAddDeniedMessage), findsOneWidget); + }, + ); + testWidgets('adds a sanitized animated PNG attachment', (tester) async { final keychain = nostr.Keys.generate(); final nsec = keychain.nsec; @@ -3489,12 +3564,15 @@ List<({String text, TextStyle style})> _flattenStyledTextSpans( return result; } -Channel _makeCurrentChannel({String channelType = 'stream'}) { +Channel _makeCurrentChannel({ + String channelType = 'stream', + String visibility = 'open', +}) { return Channel( id: 'channel-1', name: 'current', channelType: channelType, - visibility: 'open', + visibility: visibility, description: '', createdBy: 'pubkey123', createdAt: DateTime(2024), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 1fc56f731..bf8086da4 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -409,8 +409,8 @@ void main() { final row = quickReactionEmoji(entries, customShortcodes: const {}); expect(row.first, '\u{1F525}'); - expect(row, hasLength(4)); - expect(row, containsAll(defaultQuickEmojis.take(3))); + expect(row, hasLength(5)); + expect(row, containsAll(defaultQuickEmojis.take(4))); }); test('drops custom emoji no longer in the palette', () { diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index c95df3446..1c665efcb 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -196,6 +196,22 @@ void main() { expect(find.text('Remind me'), findsNothing); expect(find.text('Edit message'), findsNothing); expect(find.text('Delete message'), findsNothing); + expect(find.byTooltip('Close sheet'), findsNothing); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'quick-reaction-', + ), + ), + findsNWidgets(6), + ); + expect( + tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))), + const Size.square(52), + ); }); testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions ' @@ -227,6 +243,47 @@ void main() { expect(find.text('Follow thread'), findsNothing); expect(find.text('Reply'), findsNothing); expect(find.text('Remind me'), findsNothing); + expect(find.byTooltip('Close sheet'), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'quick-reaction-', + ), + ), + findsNWidgets(6), + ); + }); + + testWidgets('keeps six reaction targets within a narrow phone', ( + tester, + ) async { + tester.view.physicalSize = const Size(375, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final prefs = await _mockPrefs(); + + await _pumpSheet(tester, message: _message(), prefs: prefs); + + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'quick-reaction-', + ), + ), + findsNWidgets(6), + ); + expect( + tester + .getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))) + .width, + inInclusiveRange(44, 52), + ); + expect(tester.takeException(), isNull); }); testWidgets('shows Edit/Delete only with manage rights', (tester) async { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 7c84a7471..0d904960c 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -155,6 +156,24 @@ bool _spanHasStyle( void main() { group('MessageContent', () { + testWidgets('forwards text alignment to markdown rendering', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Centered status', + textAlign: TextAlign.center, + ), + ), + ); + + expect( + tester.widget(find.byType(GptMarkdown)).textAlign, + TextAlign.center, + ); + }); + testWidgets('opens local file links through an authenticated download', ( tester, ) async { diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index f22c25856..3a54332c9 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -11,13 +11,14 @@ void main() { Future buildHome({ int unreadInboxCount = 0, bool disableAnimations = false, + Gradient? topSectionGradient, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); return ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], child: MaterialApp( - theme: AppTheme.light(), + theme: AppTheme.light(topSectionGradient: topSectionGradient), builder: (context, child) => MediaQuery( data: MediaQuery.of( context, @@ -70,6 +71,46 @@ void main() { ); }); + testWidgets('keeps the Buzz backdrop behind the scalable Home screen', ( + tester, + ) async { + const gradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.yellow, Colors.blue], + ); + await tester.pumpWidget(await buildHome(topSectionGradient: gradient)); + await tester.pump(); + + final backdrop = find.byKey( + const ValueKey('home-settings-transition-backdrop'), + ); + final decoration = + tester.widget(backdrop).decoration as BoxDecoration; + expect(decoration.gradient, gradient); + expect( + find.byKey(const ValueKey('home-settings-transition-scale')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('home-settings-transition-scale')), + ) + .transform + .getMaxScaleOnAxis(), + 1, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('home-settings-transition-opacity')), + ) + .opacity, + 1, + ); + }); + testWidgets('gives selection haptics only when the tab changes', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart new file mode 100644 index 000000000..9130153ed --- /dev/null +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -0,0 +1,90 @@ +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test( + 'manual presence persists until Online restores automatic mode', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + var container = _buildContainer(prefs); + + expect( + await container + .read(presenceProvider.future) + .timeout( + const Duration(seconds: 2), + onTimeout: () => + throw StateError('initial presence did not resolve'), + ), + 'online', + ); + await container + .read(presenceProvider.notifier) + .setPresence('away') + .timeout( + const Duration(seconds: 2), + onTimeout: () => throw StateError('setting Away did not resolve'), + ); + expect(container.read(presenceProvider).value, 'away'); + expect(prefs.getString('buzz_presence_preference_aabb'), 'away'); + + container.dispose(); + container = _buildContainer(prefs); + addTearDown(container.dispose); + expect( + await container + .read(presenceProvider.future) + .timeout( + const Duration(seconds: 2), + onTimeout: () => + throw StateError('stored presence did not resolve'), + ), + 'away', + ); + + await container + .read(presenceProvider.notifier) + .setPresence('online') + .timeout( + const Duration(seconds: 2), + onTimeout: () => throw StateError('setting Online did not resolve'), + ); + expect(container.read(presenceProvider).value, 'online'); + expect(prefs.getString('buzz_presence_preference_aabb'), 'auto'); + }, + ); +} + +ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + myPubkeyProvider.overrideWithValue('aabb'), + profileProvider.overrideWith(_FakeProfileNotifier.new), + relaySessionProvider.overrideWith(_DisconnectedRelaySession.new), + appLifecycleProvider.overrideWith(_ResumedLifecycle.new), + ], +); + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _DisconnectedRelaySession extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); +} + +class _ResumedLifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index dbf27b5c8..a3c7b3dd9 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -4,7 +4,9 @@ import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -19,6 +21,7 @@ void main() { WidgetHelpers.testable( overrides: [ profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), userStatusProvider.overrideWith( () => _FakeUserStatusNotifier( const UserStatus( @@ -35,6 +38,7 @@ void main() { ); await tester.pumpAndSettle(); + expect(find.byType(Hero), findsNothing); final badge = find.byType(MaskedAvatarBadge); expect( find.descendant(of: badge, matching: find.text(missingShortcode)), @@ -45,6 +49,99 @@ void main() { findsOneWidget, ); }); + + testWidgets( + 'keeps text-only status visible beside a changeable presence pill', + (tester) async { + final presenceNotifier = _FakePresenceNotifier('away'); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => presenceNotifier), + userStatusProvider.overrideWith( + () => _FakeUserStatusNotifier( + const UserStatus(text: 'Focusing', emoji: '', updatedAt: 1), + ), + ), + customEmojiListProvider.overrideWithValue(const []), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Focusing'), findsOneWidget); + await tester.tap(find.text('Focusing')); + await tester.pumpAndSettle(); + expect(find.text('Set a status'), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller?.text, + 'Focusing', + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('settings-presence-label')), + findsOneWidget, + ); + expect(find.text('Away'), findsOneWidget); + expect( + tester + .widget(find.byKey(const ValueKey('settings-presence-label'))) + .style + ?.fontSize, + filterChipTextStyle.fontSize, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('settings-presence-target'))) + .height, + 48, + ); + expect( + tester + .getSize(find.byKey(const ValueKey('settings-presence-pill'))) + .height, + greaterThanOrEqualTo(31), + ); + + final presenceTarget = find.byKey( + const ValueKey('settings-presence-target'), + ); + final targetRect = tester.getRect(presenceTarget); + await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1)); + await tester.pump(); + + final scale = tester.widget( + find.byKey(const ValueKey('activity-popover-scale')), + ); + expect(scale.alignment, Alignment.topCenter); + expect( + find.byKey(const ValueKey('settings-presence-popover')), + findsOneWidget, + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('settings-presence-online')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('settings-presence-away')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('settings-presence-offline')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('settings-presence-offline'))); + await tester.pumpAndSettle(); + expect(presenceNotifier.selected, ['offline']); + }, + ); } class _FakeProfileNotifier extends ProfileNotifier { @@ -61,3 +158,18 @@ class _FakeUserStatusNotifier extends UserStatusNotifier { @override Future build() async => _status; } + +class _FakePresenceNotifier extends PresenceNotifier { + _FakePresenceNotifier(this._presence); + + final String _presence; + final List selected = []; + + @override + Future build() async => _presence; + + @override + Future setPresence(String status) async { + selected.add(status); + } +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index c3db833fc..55645313c 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -18,6 +19,110 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('reselecting Search uses the field activation path', ( + tester, + ) async { + final tabReselection = ValueNotifier(0); + addTearDown(tabReselection.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: SearchPage(tabReselection: tabReselection), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('search-cancel')), findsNothing); + tabReselection.value++; + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('search-cancel')), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).focusNode?.hasFocus, + isTrue, + ); + + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + // Reproduce the real tab-tap ordering where the destination callback can + // run immediately before the same pointer gesture dismisses the field. + tabReselection.value++; + focusNode.unfocus(); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('search-cancel')), findsOneWidget); + expect(focusNode.hasFocus, isTrue); + expect( + tester + .widget( + find.byKey(const Key('search-header-title-opacity')), + ) + .opacity, + 0, + reason: 'The tab gesture must not paint a close-and-reopen flicker.', + ); + }); + + testWidgets('uses the shared frosted navigation surface', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final appBar = tester.widget(find.byType(FrostedAppBar)); + expect(appBar.gradient, isNull); + expect(appBar.frosted, isTrue); + expect(appBar.showBottomDivider, isTrue); + expect(appBar.bottomDividerOpacity, 0.06); + expect(appBar.bottomHeight, 57); + expect(appBar.leading, isNull); + expect(find.text('Search'), findsOneWidget); + final promptText = find.descendant( + of: find.byKey(const Key('search-field-container')), + matching: find.byType(Text), + ); + expect( + tester.getRect(promptText).left, + closeTo( + tester.getRect(find.byKey(const Key('search-moving-icon'))).right + + Grid.xxs, + 0.01, + ), + ); + expect( + tester.getRect(promptText).center.dy, + closeTo( + tester + .getRect(find.byKey(const Key('search-field-container'))) + .center + .dy, + 0.5, + ), + ); + }); + testWidgets('empty state preserves large accessible text scaling', ( tester, ) async { @@ -49,8 +154,30 @@ void main() { ); await tester.pumpAndSettle(); + final appBar = tester.widget(find.byType(FrostedAppBar)); + final titleStyle = appBar.titleStyle!; + expect(titleStyle.fontSize, 22); + expect( + tester + .getSize( + find + .descendant( + of: find.byType(FrostedAppBar), + matching: find.byType(ClipRect), + ) + .first, + ) + .height, + closeTo( + frostedAppBarHeight( + tester.element(find.byType(FrostedAppBar)), + titleStyle: titleStyle, + bottomHeight: appBar.bottomHeight, + ), + 0.01, + ), + ); final emptyState = find.byKey(const Key('search-empty-state')); - final message = find.text('Search messages, channels, and people'); final searchField = find.byKey(const Key('search-field-container')); final searchFieldContext = tester.element(searchField); final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!; @@ -66,12 +193,68 @@ void main() { tester.getSize(searchField).height, greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2), ); - final input = tester.widget( - find.byKey(const Key('search-field')), + final prompt = tester.widget( + find.descendant( + of: find.byKey(const Key('search-field-container')), + matching: find.byType(Text), + ), + ); + expect(prompt.style?.fontSize, 15); + expect(prompt.maxLines, 1); + expect(prompt.overflow, TextOverflow.ellipsis); + expect( + tester + .getSize(find.descendant(of: emptyState, matching: find.byType(Text))) + .height, + greaterThan(32), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('search filters grow with accessible text', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: const TextScaler.linear(2)), + child: const SearchPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('search-field'))); + await tester.pumpAndSettle(); + + final filters = find.byKey(const Key('search-header-filters')); + final activeField = find.byKey(const Key('search-field-container')); + final cancel = find.byKey(const Key('search-cancel')); + expect(filters, findsOneWidget); + expect(cancel, findsOneWidget); + expect( + tester.getRect(activeField).right, + lessThanOrEqualTo(tester.getRect(cancel).left), + reason: 'Scaled Cancel must not overlap the active search field.', + ); + expect(tester.getSize(filters).height, greaterThan(Grid.xl)); + expect( + tester.getSize(filters).height, + greaterThanOrEqualTo( + tester.getSize(find.text('Messages')).height + Grid.xs * 2, + ), ); - expect(input.style?.fontSize, searchInputTextStyle.fontSize); - expect(input.style?.height, searchInputTextStyle.height); - expect(tester.getSize(message).height, greaterThan(32)); expect(tester.takeException(), isNull); }); @@ -93,20 +276,13 @@ void main() { await tester.pumpAndSettle(); final searchField = find.byKey(const Key('search-field')); + final editingField = find.byType(TextField); final searchFieldContainer = find.byKey( const Key('search-field-container'), ); final unfocusedWidth = tester.getSize(searchFieldContainer).width; + final unfocusedTop = tester.getRect(searchFieldContainer).top; expect(find.byKey(const Key('search-cancel')), findsNothing); - expect( - tester.widget(searchField).decoration?.hintText, - 'Search messages, channels, people\u2026', - ); - expect( - tester.widget(searchField).textInputAction, - TextInputAction.search, - ); - await tester.tap(searchField); await tester.pump(); @@ -117,7 +293,14 @@ void main() { greaterThanOrEqualTo(Grid.xl), reason: 'Cancel must keep a 48dp touch target.', ); - expect(tester.widget(searchField).decoration?.hintText, isNull); + final input = tester.widget(editingField); + expect(input.decoration?.hintText, isNull); + expect(input.textInputAction, TextInputAction.search); + expect( + input.focusNode?.hasFocus, + isTrue, + reason: 'Tapping the idle search field opens the native keyboard.', + ); final enteringSlide = tester.widget( find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, ); @@ -125,28 +308,58 @@ void main() { await tester.pump(const Duration(milliseconds: 160)); final focusedWidth = tester.getSize(searchFieldContainer).width; + final focusedRect = tester.getRect(searchFieldContainer); expect(focusedWidth, lessThan(unfocusedWidth)); + expect( + focusedRect.top, + lessThan(unfocusedTop), + reason: 'The active field translates upward into the title row.', + ); + expect(find.byKey(const Key('search-header-filters')), findsOneWidget); final settledSlide = tester.widget( find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first, ); expect(settledSlide.position.value, Offset.zero); + final movingIcon = find.byKey(const Key('search-moving-icon')); + final iconScale = tester.widget( + find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)), + ); + final movingField = tester.widget( + find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)), + ); + expect(iconScale.scale, lessThan(1)); + expect(movingField.top, Grid.half); + final appBarRect = tester.getRect(find.byType(FrostedAppBar)); + expect( + appBarRect.contains(focusedRect.center), + isTrue, + reason: 'The translated field remains inside the app bar hit-test box.', + ); - await tester.enterText(searchField, 'design'); + await tester.enterText(editingField, 'design'); await tester.tap(cancel); await tester.pump(); - await tester.pump(const Duration(milliseconds: 60)); - final exitingWidth = tester.getSize(searchFieldContainer).width; - expect(exitingWidth, greaterThan(focusedWidth)); - expect(exitingWidth, lessThan(unfocusedWidth)); + + final titleOpacity = tester.widget( + find.byKey(const Key('search-header-title-opacity')), + ); + expect( + titleOpacity.opacity, + 1, + reason: + 'The title fades beneath the returning field instead of appearing after it.', + ); + await tester.pump(const Duration(milliseconds: 159)); + expect( + tester + .widget( + find.byKey(const Key('search-header-title-opacity')), + ) + .opacity, + 1, + ); await tester.pumpAndSettle(); - final input = tester.widget(searchField); - expect(input.controller?.text, isEmpty); - expect(input.focusNode?.hasFocus, isFalse); - expect( - input.decoration?.hintText, - 'Search messages, channels, people\u2026', - ); expect(find.byKey(const Key('search-cancel')), findsNothing); expect( tester.getSize(searchFieldContainer).width, @@ -154,6 +367,42 @@ void main() { ); }); + testWidgets('keeps the search prompt calm until it is focused', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith( + () => _FakeSearchNotifier(const SearchState.initial()), + ), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final searchField = find.byKey(const Key('search-field')); + final searchFieldContainer = find.byKey( + const Key('search-field-container'), + ); + expect(find.text('Messages'), findsNothing); + expect(tester.getSize(searchFieldContainer).height, greaterThan(36)); + + await tester.tap(searchField); + await tester.pumpAndSettle(); + + expect(find.text('Messages'), findsOneWidget); + expect( + tester.getSize(searchFieldContainer).height, + greaterThanOrEqualTo(36), + ); + }); + testWidgets('only submitted queries are added to recent searches', ( tester, ) async { @@ -243,7 +492,13 @@ void main() { await tester.tap(find.byKey(const Key('clear-recent-searches'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recent-searches-list')), findsNothing); - expect(find.text('Search messages, channels, and people'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const Key('search-empty-state')), + matching: find.text('Search messages, channels, and people'), + ), + findsOneWidget, + ); }); testWidgets('keeps recent searches scrollable above the keyboard', ( diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 6f8591f28..010db98ba 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/settings/accent_picker_page.dart'; import 'package:buzz/features/settings/theme_picker_page.dart'; +import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -167,6 +168,34 @@ void main() { }); }); + group('Buzz accent behavior', () { + testWidgets('settings hides accent navigation for Buzz', (tester) async { + await _pumpPicker( + tester, + const SettingsPage(profileHeader: SizedBox.shrink()), + prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4}, + ); + + expect(find.text('Accent color'), findsNothing); + }); + + testWidgets('settings restores accent navigation away from Buzz', ( + tester, + ) async { + await _pumpPicker( + tester, + const SettingsPage(profileHeader: SizedBox.shrink()), + prefs: { + 'buzz_theme_mode': 'light', + 'buzz_color_scheme': 'github-light', + 'buzz_accent_color': 4, + }, + ); + + expect(find.text('Accent color'), findsOneWidget); + }); + }); + group('AccentPickerPage', () { testWidgets('lists every accent and checks the stored one', (tester) async { await _pumpPicker( diff --git a/mobile/test/shared/crypto/nip44_interop_test.dart b/mobile/test/shared/crypto/nip44_interop_test.dart new file mode 100644 index 000000000..be4e7d87b --- /dev/null +++ b/mobile/test/shared/crypto/nip44_interop_test.dart @@ -0,0 +1,21 @@ +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('decrypts a desktop nostr-rs NIP-44 v2 self-encrypted payload', () { + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + const publicKey = + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const desktopCiphertext = + 'Au0C/BZ3gT83RnPFiPYGr70BuEyKDlZrk1nEJUDZbkoNgpSjE7JUKRb3VRbegcQYUvNT2Qayf3DkfuSb1M6l70IDpsQ25y8xwDA+uEreyRxDdZ5tQF+C9iB3Qr0vinFQpbR9f0SIvUahwAzyHBMdZ1butlCHi9aqv0C1/w1MWMWeoGaPm4XtkhJSPawCGMuFVw1Z8r64bxMSI6EThc4HtR9p4Q=='; + + expect( + nip44Decrypt( + getConversationKey(privateKey, publicKey), + desktopCiphertext, + ), + '{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}', + ); + }); +} diff --git a/mobile/test/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart index 613fec146..51b5ad1f3 100644 --- a/mobile/test/shared/theme/buzz_theme_test.dart +++ b/mobile/test/shared/theme/buzz_theme_test.dart @@ -40,6 +40,24 @@ void main() { expect(themeSelectionLabel(buzzDarkThemeName, ThemeMode.system), 'Buzz'); }); + test('forces neutral rendering without changing the stored accent', () { + const storedAccent = '#ef4444'; + + expect( + effectiveAccentIndex(buzzThemeName, storedAccent), + neutralAccentIndex, + ); + expect( + effectiveAccentIndex(buzzDarkThemeName, storedAccent), + neutralAccentIndex, + ); + expect( + effectiveAccentIndex('github-light', storedAccent), + accentIndexForWireValue(storedAccent), + ); + expect(storedAccent, '#ef4444'); + }); + test('resolve across brightnesses like any other pair', () { final resolved = resolveSchemes(buzzThemeName, ThemeMode.system); expect(resolved.forcedMode, isNull); @@ -183,6 +201,59 @@ void main() { expect(decoration.gradient, isNull); expect(decoration.color, isNotNull); }); + + testWidgets('Buzz section labels use 80% neutral foreground', ( + tester, + ) async { + await tester.pumpWidget( + harness( + AppTheme.light( + topSectionGradient: buzzTopSectionGradient( + buzzThemeName, + Brightness.light, + ), + ), + ), + ); + + final context = tester.element(find.text('Home')); + expect( + navigationSectionForeground(context), + Colors.black.withValues(alpha: 0.8), + ); + }); + + testWidgets('navigation roles inherit non-Buzz theme tokens', ( + tester, + ) async { + const primaryForeground = Color(0xFF123456); + const secondaryForeground = Color(0xFF789ABC); + const searchSurface = Color(0xFFDEF012); + final theme = ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple).copyWith( + onSurface: primaryForeground, + onSurfaceVariant: secondaryForeground, + surfaceContainerHighest: searchSurface, + ), + ); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Scaffold(body: SizedBox()), + ), + ); + + final context = tester.element(find.byType(SizedBox)); + expect(navigationPrimaryForeground(context), primaryForeground); + expect(navigationSecondaryForeground(context), secondaryForeground); + expect(navigationSectionForeground(context), secondaryForeground); + expect(navigationSearchSurface(context), searchSurface); + expect( + navigationDivider(context, 0.15), + primaryForeground.withValues(alpha: 0.15), + ); + }); }); group('isBuzzTheme', () { diff --git a/mobile/test/shared/theme/community_theme_preference_test.dart b/mobile/test/shared/theme/community_theme_preference_test.dart new file mode 100644 index 000000000..ce6a2afae --- /dev/null +++ b/mobile/test/shared/theme/community_theme_preference_test.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('desktop v1 payload round-trips exactly', () { + final preference = CommunityThemePreference.fromJson({ + 'version': 1, + 'theme': 'github-dark', + 'accent': '#c0a2f1', + 'followSystem': false, + }); + + expect(preference.mode, ThemeMode.dark); + expect( + jsonEncode(preference.toJson()), + '{"version":1,"theme":"github-dark","accent":"#c0a2f1","followSystem":false}', + ); + }); + + test('rejects unknown themes, accents, and future versions', () { + for (final payload in [ + { + 'version': 2, + 'theme': 'buzz', + 'accent': '#3b82f6', + 'followSystem': true, + }, + { + 'version': 1, + 'theme': 'unknown', + 'accent': '#3b82f6', + 'followSystem': true, + }, + { + 'version': 1, + 'theme': 'buzz', + 'accent': '#000000', + 'followSystem': true, + }, + ]) { + expect( + () => CommunityThemePreference.fromJson(payload), + throwsFormatException, + ); + } + }); + + test('storage is scoped by pubkey and normalized relay URL', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = CommunityThemeStorage(prefs); + const a = CommunityThemePreference( + theme: 'buzz', + accent: '#3b82f6', + followSystem: true, + ); + const b = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + + await storage.write('pk', 'WSS://Relay.Example///', a); + await storage.write('pk', 'wss://other.example', b); + + expect(storage.read('pk', 'wss://relay.example'), a); + expect(storage.read('pk', 'wss://other.example/'), b); + expect(storage.read('other-pk', 'wss://relay.example'), isNull); + }); + + test('dirty outbox survives restart and clears only exact ack', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = CommunityThemeStorage(prefs); + const pending = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + const newer = CommunityThemePreference( + theme: 'houston', + accent: '#a855f7', + followSystem: false, + ); + + await storage.writeOutbox('pk', 'wss://relay.example', pending); + expect( + CommunityThemeStorage(prefs).readOutbox('pk', 'wss://relay.example'), + pending, + ); + await storage.writeOutbox('pk', 'wss://relay.example', newer); + await storage.clearOutbox('pk', 'wss://relay.example', pending); + expect(storage.readOutbox('pk', 'wss://relay.example'), newer); + await storage.clearOutbox('pk', 'wss://relay.example', newer); + expect(storage.readOutbox('pk', 'wss://relay.example'), isNull); + }); + + test('legacy accent indexes migrate without inventing wire values', () async { + SharedPreferences.setMockInitialValues({ + 'buzz_theme_mode': 'dark', + 'buzz_color_scheme': 'dracula', + 'buzz_accent_color': 8, + }); + final prefs = await SharedPreferences.getInstance(); + final preference = CommunityThemeStorage(prefs).legacyPreference(); + + expect( + preference, + const CommunityThemePreference( + theme: 'dracula', + accent: 'neutral', + followSystem: false, + ), + ); + expect(preference.mode, ThemeMode.dark); + expect(legacyAccentWireValue(6), '#a855f7'); + expect(legacyAccentWireValue(7), '#6366f1'); + }); +} diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart new file mode 100644 index 000000000..099ddd2f7 --- /dev/null +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -0,0 +1,311 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('delayed absence seeds the intervening local edit', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final history = Completer>(); + final session = _ThemeRelaySession( + keys.nsec, + keys.public, + historyFuture: history.future, + ); + final storage = CommunityThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true); + + container.read(communityThemeProvider.notifier).setTheme('dracula'); + history.complete([]); + await _waitUntil(() => session.published != null); + + expect(container.read(communityThemeProvider).theme, 'dracula'); + expect( + storage.read(keys.public, 'https://relay.example')?.theme, + 'dracula', + ); + }); + + test('edit during delayed absence seed wins durable state', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final history = Completer>(); + final session = _ThemeRelaySession( + keys.nsec, + keys.public, + historyFuture: history.future, + ); + final storage = _DelayedThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true); + + history.complete([]); + await storage.cacheWriteStarted.future; + container.read(communityThemeProvider.notifier).setTheme('dracula'); + storage.allowCacheWrite.complete(); + storage.allowOutboxWrite.complete(); + await _waitUntil(() => session.published != null); + + expect(container.read(communityThemeProvider).theme, 'dracula'); + expect( + storage.read(keys.public, 'https://relay.example')?.theme, + 'dracula', + ); + final privateHex = nostr.Nip19.decode(payload: keys.nsec).data; + final key = getConversationKey(privateHex, keys.public); + expect( + jsonDecode(nip44Decrypt(key, session.published!.content))['theme'], + 'dracula', + ); + }); + + test( + 'local edit stays authoritative before persistence through exact ack', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final session = _ThemeRelaySession(keys.nsec, keys.public); + final storage = _DelayedThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + + final subscription = container.listen( + communityThemeProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await session.subscribed.future; + + final notifier = container.read(communityThemeProvider.notifier); + notifier.setTheme('dracula'); + const local = CommunityThemePreference( + theme: 'dracula', + accent: '#3b82f6', + followSystem: true, + ); + expect(container.read(communityThemeProvider), local); + + session.emit(session.remoteEvent(theme: 'houston', id: 'remote-z')); + expect(container.read(communityThemeProvider), local); + + storage.allowCacheWrite.complete(); + await storage.outboxWriteStarted.future; + session.emit(session.remoteEvent(theme: 'solarized', id: 'remote-a')); + expect(container.read(communityThemeProvider), local); + + storage.allowOutboxWrite.complete(); + await _waitUntil(() => session.published != null); + expect(container.read(communityThemeProvider), local); + + session.emit(session.published!); + await _pumpEventQueue(); + expect(container.read(communityThemeProvider), local); + expect(storage.readOutbox(keys.public, 'https://relay.example'), isNull); + }, + ); + + test( + 'provider rebuild preserves delayed local edit and publishes on replacement manager', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final session = _ThemeRelaySession(keys.nsec, keys.public); + final storage = _DelayedThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + communityThemeProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await session.subscribed.future; + + container.read(communityThemeProvider.notifier).setTheme('dracula'); + expect(container.read(communityThemeProvider).theme, 'dracula'); + await storage.cacheWriteStarted.future; + + session.setStatus(SessionStatus.reconnecting); + await _pumpEventQueue(); + expect(container.read(communityThemeProvider).theme, 'dracula'); + session.setStatus(SessionStatus.connected); + await _waitUntil(() => session.subscribeCalls == 2); + expect(container.read(communityThemeProvider).theme, 'dracula'); + + storage.allowCacheWrite.complete(); + storage.allowOutboxWrite.complete(); + await _waitUntil(() => session.published != null); + + final privateHex = nostr.Nip19.decode(payload: keys.nsec).data; + final key = getConversationKey(privateHex, keys.public); + expect( + jsonDecode(nip44Decrypt(key, session.published!.content))['theme'], + 'dracula', + ); + expect(container.read(communityThemeProvider).theme, 'dracula'); + }, + ); +} + +class _DelayedThemeStorage extends CommunityThemeStorage { + _DelayedThemeStorage(super.prefs); + + final allowCacheWrite = Completer(); + final allowOutboxWrite = Completer(); + final cacheWriteStarted = Completer(); + final outboxWriteStarted = Completer(); + + @override + Future write( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + if (!cacheWriteStarted.isCompleted) cacheWriteStarted.complete(); + await allowCacheWrite.future; + return super.write(pubkey, relayUrl, preference); + } + + @override + Future writeOutbox( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + if (!outboxWriteStarted.isCompleted) outboxWriteStarted.complete(); + await allowOutboxWrite.future; + return super.writeOutbox(pubkey, relayUrl, preference); + } +} + +class _RelayConfig extends RelayConfigNotifier { + _RelayConfig(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _ThemeRelaySession extends RelaySessionNotifier { + _ThemeRelaySession(this.nsec, this.pubkey, {this.historyFuture}); + + final String nsec; + final String pubkey; + final Future>? historyFuture; + final subscribed = Completer(); + int subscribeCalls = 0; + void Function(NostrEvent)? _listener; + NostrEvent? published; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => historyFuture ?? [remoteEvent(theme: 'buzz', id: 'initial')]; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + subscribeCalls++; + _listener = onEvent; + if (!subscribed.isCompleted) subscribed.complete(); + return () => _listener = null; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published = event; + return event; + } + + void emit(NostrEvent event) => _listener?.call(event); + + void setStatus(SessionStatus status) { + state = SessionState(status: status); + } + + NostrEvent remoteEvent({required String theme, required String id}) { + final privateHex = nostr.Nip19.decode(payload: nsec).data; + final key = getConversationKey(privateHex, pubkey); + final preference = CommunityThemePreference( + theme: theme, + accent: '#3b82f6', + followSystem: true, + ); + return NostrEvent( + id: id, + pubkey: pubkey, + createdAt: 1, + kind: 30078, + tags: const [ + ['d', communityThemeDTag], + ['t', communityThemeDTag], + ], + content: nip44Encrypt(key, jsonEncode(preference.toJson())), + sig: 'sig', + ); + } +} + +Future _pumpEventQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +Future _waitUntil(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) fail('condition not met'); + await Future.delayed(const Duration(milliseconds: 5)); + } +} diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart new file mode 100644 index 000000000..fa65f0029 --- /dev/null +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -0,0 +1,546 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const local = CommunityThemePreference( + theme: 'buzz', + accent: '#3b82f6', + followSystem: true, + ); + + test('confirmed absence seeds exact NIP-78 coordinate', () async { + final session = _FakeSession(); + final relay = _FakeSignedRelay(); + final manager = _manager(session, relay); + + final result = await manager.initialize(); + expect(result.status, CommunityThemeRemoteStatus.absent); + manager.publish(local); + await manager.flush(); + + expect(relay.submissions, hasLength(1)); + expect(relay.submissions.single.kind, 30078); + expect( + relay.submissions.single.tags, + containsAll(>[ + ['d', 'community-theme'], + ['t', 'community-theme'], + ]), + ); + expect(jsonDecode(relay.submissions.single.content), local.toJson()); + }); + + test( + 'live replacement closes the history-to-subscription absence gap', + () async { + const replacement = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + final applied = []; + late final _FakeSession session; + session = _FakeSession( + onFetchHistory: () { + session.emit( + _event( + id: 'replacement', + createdAt: 100, + content: jsonEncode(replacement.toJson()), + ), + ); + }, + ); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (remote) => applied.add(remote.preference), + ); + + final result = await manager.initialize(); + + expect(session.subscribeCalls, 1); + expect(result.status, CommunityThemeRemoteStatus.valid); + expect(result.remote?.preference, replacement); + expect(applied, [replacement]); + }, + ); + + test('invalid and unavailable records never seed', () async { + for (final session in [ + _FakeSession(history: [_event(content: '{bad json')]), + _FakeSession(error: StateError('offline')), + ]) { + final relay = _FakeSignedRelay(); + final result = await _manager(session, relay).initialize(); + expect( + result.status, + anyOf( + CommunityThemeRemoteStatus.invalid, + CommunityThemeRemoteStatus.unavailable, + ), + ); + expect(relay.submissions, isEmpty); + } + }); + + test( + 'newest valid event wins with deterministic same-second ordering', + () async { + final applied = []; + final session = _FakeSession( + history: [ + _event( + id: 'z', + createdAt: 50, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + _event(id: 'a', createdAt: 50, content: jsonEncode(local.toJson())), + ], + ); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (r) => applied.add(r.preference), + ); + + await manager.initialize(); + expect(applied.single.theme, 'buzz'); + + session.emit( + _event( + id: 'z', + createdAt: 50, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + ); + expect(applied, hasLength(1)); + }, + ); + + test('remote hydration never cancels a newer pending local write', () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(); + manager.cancelPending(); + manager.publish( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ), + ); + + session.emit( + _event(id: 'remote', createdAt: 100, content: jsonEncode(local.toJson())), + ); + await manager.flush(); + expect(relay.submissions, hasLength(1)); + expect(jsonDecode(relay.submissions.single.content)['theme'], 'dracula'); + + manager.publish(local); + manager.dispose(); + await manager.flush(); + expect(relay.submissions, hasLength(1)); + }); + + test( + 'relay CLOSED resubscribes then catches up latest replacement event', + () async { + final applied = []; + final session = _FakeSession(); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (remote) => applied.add(remote.preference), + ); + await manager.initialize(); + manager.cancelPending(); + expect(session.subscribeCalls, 1); + + const replacement = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + session.history = [ + _event( + id: 'replacement', + createdAt: 100, + content: jsonEncode(replacement.toJson()), + ), + ]; + session.closeLiveSubscription('rate-limited: quota exceeded'); + + await _waitUntil(() => session.subscribeCalls == 2 && applied.isNotEmpty); + expect(applied.single, replacement); + expect(session.activeListeners, 1); + manager.dispose(); + }, + ); + + test('relay CLOSED after dispose never resubscribes', () async { + final session = _FakeSession(); + final manager = _manager(session, _FakeSignedRelay()); + await manager.initialize(); + final close = session.latestClosedCallback; + + manager.dispose(); + close?.call('late close'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(session.subscribeCalls, 1); + }); + + test('publish failure retries and acknowledges exact preference', () async { + final relay = _FakeSignedRelay(failuresRemaining: 1); + final acknowledgements = []; + final manager = _manager( + _FakeSession(), + relay, + onPublished: acknowledgements.add, + ); + manager.publish(local); + await manager.flush(); + expect(manager.pending, local); + + await _waitUntil(() => acknowledgements.length == 1); + expect(relay.attempts, 2); + expect(manager.pending, isNull); + expect(acknowledgements, [local]); + }); + + test('serializes in-flight publish before latest edit', () async { + final firstSubmission = Completer(); + final relay = _FakeSignedRelay(firstSubmissionGate: firstSubmission.future); + final manager = _manager(_FakeSession(), relay); + const latest = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + + manager.publish(local); + final firstFlush = manager.flush(); + await _waitUntil(() => relay.attempts == 1); + manager.publish(latest); + await manager.flush(); + expect(relay.attempts, 1); + + firstSubmission.complete(); + await firstFlush; + await _waitUntil(() => relay.attempts == 2); + + expect(relay.submittedEvents, hasLength(2)); + expect( + relay.submittedEvents[1].createdAt, + greaterThan(relay.submittedEvents[0].createdAt), + ); + expect(jsonDecode(relay.submissions[1].content)['theme'], 'dracula'); + expect(manager.pending, isNull); + }); + + test( + 'republishes above remote observed while publish is in flight', + () async { + final firstSubmission = Completer(); + final relay = _FakeSignedRelay( + firstSubmissionGate: firstSubmission.future, + ); + final session = _FakeSession(); + final acknowledgements = []; + final manager = _manager( + session, + relay, + onPublished: acknowledgements.add, + ); + await manager.initialize(); + manager.publish(local); + final firstFlush = manager.flush(); + await _waitUntil(() => relay.attempts == 1); + + session.emit( + _event( + id: 'remote-winner', + createdAt: 2000000000, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + ); + firstSubmission.complete(); + await firstFlush; + + expect(acknowledgements, isEmpty); + expect(manager.pending, local); + await _waitUntil(() => relay.attempts == 2); + expect(relay.submittedEvents[1].createdAt, 2000000001); + expect(acknowledgements, [local]); + expect(manager.pending, isNull); + }, + ); + + test('remote coordinate advances pending local publish timestamp', () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(); + manager.publish(local); + + session.emit( + _event( + id: 'remote', + createdAt: 2000000000, + content: jsonEncode(local.toJson()), + ), + ); + await manager.flush(); + + expect(relay.submittedEvents.single.createdAt, 2000000001); + }); + + test( + 'remote replacement invalidates A to B to A no-op suppression', + () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(); + manager.publish(local); + await manager.flush(); + + const remotePreference = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + session.emit( + _event( + id: 'remote', + createdAt: relay.submittedEvents.single.createdAt + 1, + content: jsonEncode(remotePreference.toJson()), + ), + ); + manager.publish(local); + await manager.flush(); + + expect(relay.submissions, hasLength(2)); + expect(manager.pending, isNull); + }, + ); + + test( + 'published coordinate rejects delayed same-second initialization result', + () async { + const stale = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + final history = Completer>(); + final session = _FakeSession(historyFuture: history.future); + final relay = _FakeSignedRelay(eventId: 'published-a'); + final applied = []; + final manager = _manager( + session, + relay, + onRemote: (remote) => applied.add(remote.preference), + ); + + final initializing = manager.initialize(); + manager.publish(local); + await manager.flush(); + final createdAt = relay.submittedEvents.single.createdAt; + history.complete([ + _event( + id: 'published-z', + createdAt: createdAt, + content: jsonEncode(stale.toJson()), + ), + ]); + await initializing; + + expect(applied, isEmpty); + expect(manager.pending, isNull); + }, + ); +} + +CommunityThemeSyncManager _manager( + _FakeSession session, + _FakeSignedRelay relay, { + void Function(RemoteCommunityTheme)? onRemote, + void Function(CommunityThemePreference)? onPublished, +}) => CommunityThemeSyncManager( + pubkey: 'pk', + relaySession: session, + signedEventRelay: relay, + crypto: const CommunityThemeCrypto(encrypt: _identity, decrypt: _identity), + debounce: const Duration(days: 1), + publishRetryBase: const Duration(milliseconds: 1), + publishRetryMax: const Duration(milliseconds: 4), + subscriptionRetryBase: const Duration(milliseconds: 1), + onRemote: onRemote ?? (_) {}, + onPublished: onPublished ?? (_) {}, +); + +String _identity(String value) => value; + +NostrEvent _event({ + String id = 'event', + int createdAt = 1, + required String content, +}) => NostrEvent( + id: id, + pubkey: 'pk', + createdAt: createdAt, + kind: 30078, + tags: const [ + ['d', 'community-theme'], + ], + content: content, + sig: 'sig', +); + +class _FakeSession extends RelaySessionNotifier { + _FakeSession({ + List history = const [], + this.historyFuture, + this.error, + this.onFetchHistory, + }) : history = List.of(history); + List history; + final Future>? historyFuture; + final Object? error; + final void Function()? onFetchHistory; + int subscribeCalls = 0; + final List _listeners = []; + final List _closedCallbacks = []; + + int get activeListeners => _listeners.length; + void Function(String)? get latestClosedCallback => + _closedCallbacks.isEmpty ? null : _closedCallbacks.last; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (error != null) throw error!; + onFetchHistory?.call(); + if (historyFuture != null) return historyFuture!; + return history; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async { + subscribeCalls++; + _listeners.add(onEvent); + _closedCallbacks.add(onClosed ?? (_) {}); + return () { + final index = _listeners.indexOf(onEvent); + if (index < 0) return; + _listeners.removeAt(index); + _closedCallbacks.removeAt(index); + }; + } + + void emit(NostrEvent event) { + for (final listener in List.of(_listeners)) { + listener(event); + } + } + + void closeLiveSubscription(String message) { + if (_listeners.isEmpty) return; + _listeners.removeAt(0); + _closedCallbacks.removeAt(0)(message); + } +} + +Future _waitUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 2), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('condition not met within $timeout'); + } + await Future.delayed(const Duration(milliseconds: 1)); + } +} + +class _Submission { + final int kind; + final String content; + final List> tags; + const _Submission(this.kind, this.content, this.tags); +} + +class _FakeSignedRelay implements SignedEventRelay { + _FakeSignedRelay({ + this.failuresRemaining = 0, + this.eventId = 'event', + this.firstSubmissionGate, + }); + int failuresRemaining; + final String eventId; + final Future? firstSubmissionGate; + int attempts = 0; + final submissions = <_Submission>[]; + final submittedEvents = []; + @override + String? get pubkey => 'pk'; + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent)? onSigned, + }) async { + attempts++; + if (attempts == 1 && firstSubmissionGate != null) { + await firstSubmissionGate; + } + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('publish failed'); + } + submissions.add(_Submission(kind, content, tags)); + final event = _event( + id: eventId, + content: content, + createdAt: createdAt ?? 0, + ); + onSigned?.call(event); + submittedEvents.add(event); + return event; + } +} diff --git a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart index e13d17a65..5d6882fdd 100644 --- a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart +++ b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart @@ -19,27 +19,25 @@ void main() { ), ); - expect(gradient?.stops, [0, 0.5, 1]); + expect(gradient?.stops, [0, 0.18, 0.38, 0.6, 0.8, 1]); expect(gradient?.colors.first.a, 0); - expect(gradient?.colors[1].a, 0.75); + expect(gradient?.colors[1].a, closeTo(0.03, 0.01)); + expect(gradient?.colors[3].a, closeTo(0.34, 0.01)); expect(gradient?.colors.last.a, 1); }); - testWidgets('uses the logical bottom safe-area inset', (tester) async { + testWidgets('uses a fixed 180px footer backdrop height', (tester) async { double? height; await tester.pumpWidget( - MediaQuery( - data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)), - child: Builder( - builder: (context) { - height = mobileTabFooterBackdropHeight(context); - return const SizedBox(); - }, - ), + Builder( + builder: (context) { + height = mobileTabFooterBackdropHeight(context); + return const SizedBox(); + }, ), ); - expect(height, 170); + expect(height, 180); }); } diff --git a/mobile/test/shared/widgets/modal_presentation_test.dart b/mobile/test/shared/widgets/modal_presentation_test.dart new file mode 100644 index 000000000..068187b54 --- /dev/null +++ b/mobile/test/shared/widgets/modal_presentation_test.dart @@ -0,0 +1,226 @@ +import 'package:buzz/shared/widgets/concentric_sheet_surface.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/modal_presentation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets( + 'keeps an opaque Flutter surface when iOS native support is unavailable', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const ConcentricSheetSurface( + enabled: true, + color: Colors.red, + child: SizedBox(height: 80, child: Text('Sheet body')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byWidgetPredicate( + (widget) => widget is Material && widget.color == Colors.red, + ), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'replaces the Flutter fallback when native support is available', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + surfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const ConcentricSheetSurface( + enabled: true, + color: Colors.red, + child: SizedBox(height: 80, child: Text('Sheet body')), + ), + ), + ); + await tester.pump(); + + expect(find.byType(UiKitView), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => widget is Material && widget.color == Colors.red, + ), + findsNothing, + ); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + surfaceChannel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets( + 'non-iOS sheets use Flutter drag handle and shared close control', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => const Text('Sheet body'), + ), + child: const Text('Open sheet'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + + final closeButton = find.byTooltip('Close sheet'); + expect(closeButton, findsOneWidget); + expect(tester.getSize(closeButton), const Size.square(44)); + final closeGutter = find.ancestor( + of: closeButton, + matching: find.byWidgetPredicate( + (widget) => + widget is Padding && + widget.padding == + const EdgeInsets.only( + top: Grid.xxs, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xs, + ), + ), + ); + expect(closeGutter, findsOneWidget); + final gutterRect = tester.getRect(closeGutter); + final closeRect = tester.getRect(closeButton); + expect(closeRect.top - gutterRect.top, Grid.gutter); + expect(gutterRect.right - closeRect.right, Grid.gutter); + expect( + tester.widget(find.byType(BottomSheet)).showDragHandle, + isTrue, + ); + expect( + find.byKey(const ValueKey('buzz-sheet-drag-handle')), + findsNothing, + ); + expect(find.text('Sheet body'), findsOneWidget); + + await tester.tap(closeButton); + await tester.pumpAndSettle(); + + expect(find.text('Sheet body'), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }, + ); + + testWidgets('iOS paints the drag handle inside the concentric surface', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => const Text('Sheet body'), + ), + child: const Text('Open sheet'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(BottomSheet)).showDragHandle, + isFalse, + ); + final internalHandle = find.byKey( + const ValueKey('buzz-sheet-drag-handle'), + ); + expect(internalHandle, findsOneWidget); + expect(tester.getSize(internalHandle), const Size(32, 4)); + expect(find.bySemanticsLabel('Drag handle'), findsOneWidget); + expect(find.byTooltip('Close sheet'), findsOneWidget); + expect(find.text('Sheet body'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('iOS compact sheets can omit X but retain the inside handle', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showBuzzModalBottomSheet( + context: context, + showDragHandle: true, + showCloseButton: false, + builder: (_) => const Text('Compact sheet body'), + ), + child: const Text('Open compact sheet'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open compact sheet')); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('buzz-sheet-drag-handle')), + findsOneWidget, + ); + expect(find.byTooltip('Close sheet'), findsNothing); + expect(find.text('Compact sheet body'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/schema/schema.sql b/schema/schema.sql index 9f3449b06..4dac29176 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -539,7 +539,7 @@ CREATE TABLE reactions ( event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, pubkey BYTEA NOT NULL, - emoji VARCHAR(64) NOT NULL, + emoji VARCHAR(66) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), removed_at TIMESTAMPTZ, reaction_event_id BYTEA, diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index d6518b26b..ce9ceaab9 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -5,15 +5,17 @@ from __future__ import annotations import argparse import json +import os import re import subprocess import sys from pathlib import Path -ROOT = Path(__file__).resolve().parent.parent +ROOT = Path(os.environ.get("DESKTOP_RELEASE_ROOT", Path(__file__).resolve().parent.parent)) CHANGELOG = ROOT / "CHANGELOG.md" METADATA = ROOT / ".release" / "desktop-candidate.json" SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +STABLE_TAG = re.compile(r"desktop-v([0-9]+)\.([0-9]+)\.([0-9]+)$") DESKTOP_PATHS = ( "desktop/", "crates/buzz-core/", @@ -54,30 +56,94 @@ def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[d return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()] -def stable_tags(base_sha: str) -> list[tuple[int, str, str]]: - tags: list[tuple[int, str, str]] = [] - for tag in git("tag", "--merged", base_sha, "--list").splitlines(): - if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag): +def gh_json(endpoint: str) -> object: + try: + return json.loads(subprocess.check_output( + ["gh", "api", endpoint], cwd=ROOT, text=True + )) + except (subprocess.CalledProcessError, json.JSONDecodeError) as error: + raise SystemExit(f"cannot verify prior desktop release via GitHub: {error}") from error + + +def stable_tags() -> list[tuple[tuple[int, int, int], str, str]]: + tags: list[tuple[tuple[int, int, int], str, str]] = [] + aliases: dict[tuple[int, int, int], list[tuple[str, str]]] = {} + for tag in git("tag", "--list", "desktop-v*").splitlines(): + match = STABLE_TAG.fullmatch(tag) + if not match: continue + version = tuple(map(int, match.groups())) sha = git("rev-list", "-n", "1", tag) - distance = int(git("rev-list", "--count", f"{sha}..{base_sha}")) - tags.append((distance, tag, sha)) + aliases.setdefault(version, []).append((tag, sha)) + for version, refs in aliases.items(): + if len(refs) != 1: + detail = ", ".join(f"{tag}@{sha}" for tag, sha in refs) + raise SystemExit(f"ambiguous desktop release version {version}: {detail}") + tag, sha = refs[0] + tags.append((version, tag, sha)) return tags -def previous_tag(base_sha: str) -> str: - tags = stable_tags(base_sha) - if not tags: - return "" - min_distance = min(item[0] for item in tags) - nearest = [item for item in tags if item[0] == min_distance] - commits = {item[2] for item in nearest} - if len(commits) != 1: - detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest) - raise SystemExit(f"ambiguous previous desktop release tags: {detail}") - # During migration, prefer the namespaced tag when aliases share a commit. - nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1])) - return nearest[0][1] +def previous_release( + version: str, repo: str, *, allow_target_sha: str | None = None +) -> dict[str, str] | None: + target = tuple(map(int, version.split("-", 1)[0].split("."))) + target_tag = f"desktop-v{version}" + target_refs = git("tag", "--list", target_tag).splitlines() + target_ref = ( + (target_tag, git("rev-list", "-n", "1", target_tag)) + if target_refs + else None + ) + target_collision = target_ref is not None and ( + allow_target_sha is None or target_ref[1] != allow_target_sha + ) + tags = stable_tags() + newer = [item for item in tags if item[0] > target] + equal = [item for item in tags if item[0] == target] + allowed_stable_retry = ( + "-" not in version + and len(equal) == 1 + and allow_target_sha is not None + and equal[0][1] == target_tag + and equal[0][2] == allow_target_sha + ) + if target_collision or newer or (equal and not allowed_stable_retry): + blocked = [item[1] for item in newer + equal] + if target_collision and target_tag not in blocked: + blocked.append(target_tag) + detail = ", ".join(blocked) + raise SystemExit(f"desktop release version must increase beyond existing tags: {detail}") + eligible = [item for item in tags if item[0] < target] + if not eligible: + return None + prior_version, tag, candidate_sha = max(eligible) + try: + metadata = json.loads(git("show", f"{tag}:.release/desktop-candidate.json")) + except (subprocess.CalledProcessError, json.JSONDecodeError) as error: + raise SystemExit(f"prior release {tag} has invalid candidate metadata") from error + expected = { + "version": ".".join(map(str, prior_version)), + "tag": tag, + } + if any(metadata.get(key) != value for key, value in expected.items()): + raise SystemExit(f"prior release {tag} metadata does not match its tag") + base_sha = metadata.get("base_sha") + if not isinstance(base_sha, str) or not re.fullmatch(r"[0-9a-f]{40}", base_sha): + raise SystemExit(f"prior release {tag} has invalid base_sha") + pulls = gh_json(f"repos/{repo}/commits/{candidate_sha}/pulls") + matches = [pr for pr in pulls if pr.get("merged_at") and ( + pr.get("head", {}).get("sha") == candidate_sha + or pr.get("merge_commit_sha") == candidate_sha + )] + if len(matches) != 1 or not matches[0].get("merge_commit_sha"): + raise SystemExit(f"prior release {tag} does not identify exactly one merged release PR") + return { + "tag": tag, + "candidate_sha": candidate_sha, + "base_sha": base_sha, + "merge_sha": matches[0]["merge_commit_sha"], + } def bullet(commit: dict[str, str], repo: str) -> str: @@ -91,24 +157,27 @@ def bullet(commit: dict[str, str], repo: str) -> str: return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))" -def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: - # With no prior desktop tag, account for the repository's root commit too. - # A ``root..base`` range silently drops that first commit. - range_spec = f"{previous}..{base_sha}" if previous else base_sha - all_commits = commit_list(range_spec) - relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} +def expected(base_sha: str, previous_base: str, previous_merge: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + # Immutable candidate tags may live on side history after squash merge. The + # prior candidate metadata is the ledger boundary; exclude only its known + # squash commit so unrelated commits around that merge remain accounted for. + range_spec = f"{previous_base}..{base_sha}" if previous_base else base_sha + all_commits = [c for c in commit_list(range_spec) if c["sha"] != previous_merge] + relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} - {previous_merge} relevant = [c for c in all_commits if c["sha"] in relevant_shas] other = [c for c in all_commits if c["sha"] not in relevant_shas] return relevant, other -def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]: - relevant, other = expected(base_sha, previous) +def render(version: str, base_sha: str, previous: dict[str, str] | None, repo: str) -> tuple[str, list[str]]: + relevant, other = expected( + base_sha, previous["base_sha"] if previous else "", previous["merge_sha"] if previous else "" + ) lines = [f"## v{version}", "", "### Desktop and shared changes", ""] lines += [bullet(c, repo) for c in relevant] or ["- None"] lines += ["", "### Other repository changes", ""] lines += [bullet(c, repo) for c in other] or ["- None"] - compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0] + compare_start = previous["tag"] if previous else git("rev-list", "--max-parents=0", base_sha).splitlines()[0] lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"] return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other] @@ -117,8 +186,8 @@ def generate(args: argparse.Namespace) -> None: if not SEMVER.fullmatch(args.version): raise SystemExit(f"invalid semver: {args.version}") base_sha = git("rev-parse", args.base) - previous = previous_tag(base_sha) repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git") + previous = previous_release(args.version, repo) block, commits = render(args.version, base_sha, previous, repo) old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n" if not old.startswith("# Changelog"): @@ -127,10 +196,12 @@ def generate(args: argparse.Namespace) -> None: CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}") METADATA.parent.mkdir(parents=True, exist_ok=True) METADATA.write_text(json.dumps({ - "schema": 1, + "schema": 2, "version": args.version, "base_sha": base_sha, - "previous_tag": previous or None, + "previous_tag": previous["tag"] if previous else None, + "previous_base_sha": previous["base_sha"] if previous else None, + "previous_merge_sha": previous["merge_sha"] if previous else None, "tag": f"desktop-v{args.version}", "commit_count": len(commits), }, indent=2) + "\n") @@ -157,14 +228,16 @@ def validate(args: argparse.Namespace) -> None: if missing: detail.append(f"missing required files: {', '.join(sorted(missing))}") raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") - previous = data["previous_tag"] or "" - actual_previous = previous_tag(data["base_sha"]) - if previous != actual_previous: - raise SystemExit( - f"recorded previous tag {previous or ''} does not match " - f"nearest release tag {actual_previous or ''}" - ) repo = args.repo or "block/buzz" + previous = previous_release(version, repo, allow_target_sha=candidate) + recorded_previous = { + "tag": data.get("previous_tag"), + "base_sha": data.get("previous_base_sha"), + "merge_sha": data.get("previous_merge_sha"), + } if data.get("previous_tag") else None + expected_previous = {key: previous[key] for key in ("tag", "base_sha", "merge_sha")} if previous else None + if recorded_previous != expected_previous: + raise SystemExit("recorded previous release ledger does not match immutable prior release") expected_block, shas = render(version, data["base_sha"], previous, repo) text = CHANGELOG.read_text() blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text) diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index fb586005f..3626b31a9 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -71,9 +71,9 @@ cat >"$body" <"$tmp/bin/gh" <<'GH' -#!/usr/bin/env bash -set -euo pipefail -printf '%q ' "$@" >>"$GH_CALLS" -printf '\n' >>"$GH_CALLS" - -[[ "${1:-}" == api ]] || { echo "expected gh api" >&2; exit 91; } -if [[ "${2:-}" == graphql ]]; then - expected_query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' - [[ "$#" -eq 12 && "$3" == -f && "$4" == "query=$expected_query" && - "$5" == -F && "$6" == owner=block && - "$7" == -F && "$8" == repo=buzz && - "$9" == -F && "${10}" == number=123 && - "${11}" == --jq && "${12}" == '.data.repository.pullRequest' ]] || { - echo "GraphQL call does not match the deployed query contract" >&2; exit 92; - } - if [[ -n "${REVIEW_DECISION:-}" ]]; then printf '%s\n' "$REVIEW_DECISION"; else printf '%s\n' '{"reviewDecision":"APPROVED"}'; fi -elif [[ "$#" -eq 4 && "$2" == --paginate && "$3" == --slurp && "$4" == "repos/block/buzz/pulls/123/reviews?per_page=100&page=1" ]]; then - [[ "${GH_FAIL_REVIEWS:-false}" != true ]] || { echo "simulated reviews API failure" >&2; exit 94; } - if [[ -n "${REVIEWS:-}" ]]; then printf '%s\n' "$REVIEWS"; else printf '%s\n' '[[],[{"state":"APPROVED","commit_id":"head","author_association":"MEMBER"}]]'; fi -else - echo "unexpected or malformed gh call: $*" >&2 - exit 95 -fi -GH -chmod +x "$tmp/bin/gh" - -run_authorization() { - (cd "$repo_root" && PATH="$tmp/bin:$PATH" GH_CALLS="$tmp/calls" GH_TOKEN=test \ - GITHUB_REPOSITORY=block/buzz PR_NUMBER=123 PR_HEAD_SHA=head \ - REVIEW_DECISION="${REVIEW_DECISION-}" REVIEWS="${REVIEWS-}" GH_FAIL_REVIEWS="${GH_FAIL_REVIEWS-false}" \ - scripts/verify-desktop-release-authorization.sh) -} - -: >"$tmp/calls" -run_authorization -! grep -Fq 'rule-suites' "$tmp/calls" - -for invalid in \ - '[[{"state":"APPROVED","commit_id":"stale","author_association":"MEMBER"}]]' \ - '[[{"state":"APPROVED","commit_id":"head","author_association":"NONE"}]]' \ - '[[{"state":"CHANGES_REQUESTED","commit_id":"head","author_association":"MEMBER"}]]'; do - : >"$tmp/calls" - if REVIEWS="$invalid" run_authorization >/dev/null 2>&1; then - echo "invalid approval was accepted: $invalid" >&2 - exit 1 - fi -done - -: >"$tmp/calls" -if REVIEW_DECISION='{"reviewDecision":"CHANGES_REQUESTED"}' run_authorization >/dev/null 2>&1; then - echo "changes-requested review decision was accepted" >&2 - exit 1 -fi - -: >"$tmp/calls" -if GH_FAIL_REVIEWS=true run_authorization >/dev/null 2>&1; then - echo "reviews API failure was ignored" >&2 - exit 1 -fi - -echo "desktop release authorization passed" diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index f36a1d690..c63a157c0 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -5,7 +5,6 @@ repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT cp "$repo_root/scripts/desktop_release.py" "$tmp/desktop_release.py" - git -C "$tmp" init -q git -C "$tmp" config user.name test git -C "$tmp" config user.email test@example.com @@ -14,54 +13,165 @@ mv "$tmp/desktop_release.py" "$tmp/scripts/desktop_release.py" printf '{"version":"1.0.0"}\n' > "$tmp/desktop/package.json" printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json" printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml" -echo '# Changelog' > "$tmp/CHANGELOG.md" -echo first > "$tmp/desktop/feature" +printf '# Changelog\n' > "$tmp/CHANGELOG.md" +echo root > "$tmp/ROOT.md" git -C "$tmp" add . -git -C "$tmp" commit -qm 'feat: first desktop change' -git -C "$tmp" -c tag.gpgSign=false tag v1.0.0 -echo second >> "$tmp/desktop/feature" -git -C "$tmp" commit -qam 'fix: desktop fix' -echo policy > "$tmp/POLICY.md" +git -C "$tmp" commit -qm 'feat: root content' +prior_base=$(git -C "$tmp" rev-parse HEAD) + +# The prior immutable candidate lives on side history after its squash merge. +git -C "$tmp" checkout -qb prior-candidate +echo prior > "$tmp/desktop/feature" +cat > "$tmp/.release/desktop-candidate.json" < "$tmp/POLICY.md" git -C "$tmp" add POLICY.md -git -C "$tmp" commit -qm 'docs: repository policy' +git -C "$tmp" commit -qm 'chore(release): unrelated hostile subject' +unrelated_before=$(git -C "$tmp" rev-parse HEAD) +echo squash > "$tmp/PRIOR_RELEASE.md" +git -C "$tmp" add PRIOR_RELEASE.md +git -C "$tmp" commit -qm 'edited prior release subject' +prior_merge=$(git -C "$tmp" rev-parse HEAD) +echo after-squash >> "$tmp/desktop/feature" +git -C "$tmp" add desktop/feature +git -C "$tmp" commit -qm 'fix: desktop fix after prior release' +unrelated_after=$(git -C "$tmp" rev-parse HEAD) base=$(git -C "$tmp" rev-parse HEAD) + +mock_bin=$(mktemp -d) +cat > "$mock_bin/gh" <msg <<'EOF' -chore(release): release Buzz Desktop version 1.0.1 + git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -m 'chore(release): release Buzz Desktop version 1.0.1' -m 'Co-authored-by: Test Automation ' + PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + grep -Fq "$unrelated_before" CHANGELOG.md + grep -Fq "$unrelated_after" CHANGELOG.md + ! grep -Fq "$prior_merge" CHANGELOG.md + jq -e --arg base "$prior_base" --arg merge "$prior_merge" \ + '.schema == 2 and .previous_tag == "desktop-v1.0.0" and .previous_base_sha == $base and .previous_merge_sha == $merge' \ + .release/desktop-candidate.json >/dev/null -Co-authored-by: Test Automation -EOF - git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -F msg - rm msg - scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz - grep -Fq '### Other repository changes' CHANGELOG.md - grep -Fq "$(git rev-parse HEAD~1)" CHANGELOG.md - grep -Fq "$(git rev-parse HEAD~2)" CHANGELOG.md - - # Metadata cannot lie about the prior release boundary. cp .release/desktop-candidate.json metadata.json - python3 - <<'PY' -import json -p='.release/desktop-candidate.json'; d=json.load(open(p)); d['previous_tag']=None; open(p,'w').write(json.dumps(d)+'\n') -PY - if scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then - echo "validator accepted a forged previous release tag" >&2 - exit 1 + jq '.previous_merge_sha = "0000000000000000000000000000000000000000"' metadata.json > .release/desktop-candidate.json + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a forged previous release ledger" >&2; exit 1 fi mv metadata.json .release/desktop-candidate.json + + # Post-merge verification may be retried after this candidate's immutable tag + # already exists. Accept only the exact candidate SHA; an equal-version tag + # anywhere else remains a collision. + candidate=$(git rev-parse HEAD) + git -c tag.gpgSign=false tag desktop-v1.0.1 "$candidate" + PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + git -c tag.gpgSign=false tag -f desktop-v1.0.1 "$base" >/dev/null + if PATH="$mock_bin:$PATH" scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted an equal-version tag at the wrong SHA" >&2; exit 1 + fi + git tag -d desktop-v1.0.1 >/dev/null + + # Prerelease tags are not prior-release ledgers, but the exact target tag is + # still a collision boundary: same-SHA retry passes; wrong-SHA reuse fails. + git -c tag.gpgSign=false tag desktop-v1.0.1-beta "$candidate" + PATH="$mock_bin:$PATH" python3 - <<'PY' +import importlib.util +import pathlib + +spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +candidate = module.git("rev-parse", "HEAD") +module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) +PY + git -c tag.gpgSign=false tag -f desktop-v1.0.1-beta "$base" >/dev/null + if PATH="$mock_bin:$PATH" python3 - <<'PY' +import importlib.util +import pathlib + +spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +candidate = module.git("rev-parse", "HEAD") +module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) +PY + then + echo "validator accepted a prerelease target tag at the wrong SHA" >&2; exit 1 + fi + git tag -d desktop-v1.0.1-beta >/dev/null + + # A stable tag with the same numeric tuple is a different tag and cannot + # authorize a prerelease retry, even when it points at the candidate. + git -c tag.gpgSign=false tag desktop-v1.0.1 "$candidate" + if PATH="$mock_bin:$PATH" python3 - <<'PY' +import importlib.util +import pathlib + +spec = importlib.util.spec_from_file_location("desktop_release", pathlib.Path("scripts/desktop_release.py")) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +candidate = module.git("rev-parse", "HEAD") +module.previous_release("1.0.1-beta", "block/buzz", allow_target_sha=candidate) +PY + then + echo "validator accepted a mismatched stable tag for a prerelease target" >&2; exit 1 + fi + git tag -d desktop-v1.0.1 >/dev/null ) -# An initial release must account for the root commit, not silently omit it. +# Equal and decreasing versions are rejected before any GitHub lookup. +for invalid_version in 1.0.0 0.9.9; do + if (cd "$tmp" && PATH="/usr/bin:/bin" scripts/desktop_release.py generate "$invalid_version" --base "$base" --repo block/buzz) >/dev/null 2>&1; then + echo "generator accepted non-increasing version $invalid_version" >&2; exit 1 + fi +done + +# A production-style schema-1 tag points at its squash commit on main. It must +# still resolve as the prior ledger during migration to head-tagged releases. +migration=$(mktemp -d) +git clone -q "$tmp" "$migration" +git -C "$migration" config user.name test +git -C "$migration" config user.email test@example.com +git -C "$migration" checkout -q "$prior_base" +GIT_EDITOR=true git -C "$migration" cherry-pick "$prior_candidate" >/dev/null +production_tag=$(git -C "$migration" rev-parse HEAD) +git -C "$migration" -c tag.gpgSign=false tag -f desktop-v1.0.0 "$production_tag" >/dev/null +echo migration >> "$migration/desktop/feature" +git -C "$migration" add desktop/feature +git -C "$migration" commit -qm 'fix: migration change' +migration_base=$(git -C "$migration" rev-parse HEAD) +cat > "$mock_bin/gh" </dev/null +rm -rf "$migration" + +# An initial release still accounts for the root commit without calling GitHub. initial=$(mktemp -d) cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py" git -C "$initial" init -q diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 8bfd6798e..a42f437ef 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -64,69 +64,85 @@ grep -q 'permission-contents: write' "$auto_tag" grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" grep -Fq 'git/refs' "$auto_tag" grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag" -grep -Fq 'target_sha=${{ github.event.pull_request.merge_commit_sha }}' "$auto_tag" +grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag" grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" -grep -Fq 'current \`main\`' "$repo_root/scripts/prepare-desktop-release.sh" +candidate_workflow="$repo_root/.github/workflows/desktop-release-candidate.yml" +grep -Eq '^ pull-requests: read$' "$candidate_workflow" || { + echo "desktop candidate token cannot read pull requests for prior-release lookup" >&2 + exit 1 +} +grep -Fq 'GH_TOKEN: ${{ github.token }}' "$candidate_workflow" || { + echo "desktop candidate validation has no GitHub token for prior-release lookup" >&2 + exit 1 +} +grep -Fq 'reviewed candidate' "$repo_root/scripts/prepare-desktop-release.sh" if grep -Fq 'current `main`' "$repo_root/scripts/prepare-desktop-release.sh"; then echo "desktop release PR body contains executable command substitution" >&2 exit 1 fi -"$repo_root/scripts/test-desktop-release-authorization.sh" -if rg -q 'rule-suites|desktop-release-bypass-authorized|MERGED_BY' \ - "$repo_root/scripts/verify-desktop-release-merge.sh" \ - "$repo_root/scripts/verify-desktop-release-authorization.sh" \ - "$auto_tag"; then - echo "desktop auto-tag still depends on unavailable rule-suite authorization" >&2 - exit 1 -fi - -review_filter="$repo_root/scripts/review-decision-approved.jq" -for fixture in \ - '{"reviewDecision":"CHANGES_REQUESTED"}' \ - '{"reviewDecision":"REVIEW_REQUIRED"}' \ - '{"reviewDecision":null}' \ - '{}'; do - if jq -e -f "$review_filter" <<<"$fixture" >/dev/null; then - echo "review-decision filter accepted non-approved fixture: $fixture" >&2 - exit 1 - fi -done -jq -e -f "$review_filter" >/dev/null <<'JSON' || { -{"reviewDecision":"APPROVED"} -JSON - echo "review-decision filter rejected approved GraphQL response" >&2 - exit 1 -} required_check_filter="$repo_root/scripts/required-check-succeeded.jq" check_fixture() { - local expected="$1" conclusion="$2" status="${3:-completed}" - local payload - payload=$(jq -n --arg status "$status" --arg conclusion "$conclusion" '{check_runs: [{name: "Web", status: $status, conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z"}]}') - if jq -e --arg name Web -f "$required_check_filter" <<<"[$payload]" >/dev/null; then - actual=pass - else - actual=fail - fi - [[ "$actual" == "$expected" ]] || { - echo "required-check filter: expected $conclusion/$status to $expected" >&2 - exit 1 - } + local expected="$1" conclusion="$2" app="${3:-15368}" completed="${4:-2026-01-01T00:00:00Z}" + local payload actual + # Production-shaped REST check run: notably, there is no created_at field. + payload=$(jq -n --arg conclusion "$conclusion" --argjson app "$app" --arg completed "$completed" \ + '{check_runs: [{id: 100, check_suite: {id: 10}, name: "Web", app: {id: $app}, status: "completed", conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z", completed_at: $completed}]}') + if jq -e --arg name Web --argjson integration_id 15368 \ + --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" <<<"[$payload]" >/dev/null; then actual=pass; else actual=fail; fi + [[ "$actual" == "$expected" ]] || { echo "required-check fixture expected $expected, got $actual" >&2; exit 1; } } check_fixture pass success check_fixture pass skipped check_fixture pass neutral check_fixture fail failure -check_fixture fail success in_progress -# A newer failure must not be hidden by an older successful run of the same check. -jq -e --arg name Web -f "$required_check_filter" >/dev/null <<'JSON' && { +check_fixture fail success 999 +check_fixture fail success 15368 2026-01-03T00:00:00Z + +# filter=latest may still return multiple same-name runs from distinct workflows. +# Highest immutable run ID is authoritative and must not reveal stale green. +jq -e --arg name Web --argjson integration_id 15368 --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" >/dev/null <<'JSON' && { [{"check_runs":[ - {"name":"Web","status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z"}, - {"name":"Web","status":"completed","conclusion":"failure","started_at":"2026-01-02T00:00:00Z"} + {"id":100,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T01:00:00Z"}, + {"id":101,"check_suite":{"id":11},"name":"Web","app":{"id":15368},"status":"in_progress","conclusion":null,"started_at":"2026-01-01T23:59:00Z","completed_at":null} ]}] JSON - echo "required-check filter accepted a stale pass over a newer failure" >&2 - exit 1 + echo "required-check filter hid the highest-ID pending attempt" >&2; exit 1; } +# A post-merge rerun is indistinguishable from other latest attempts and fails closed. +jq -e --arg name Web --argjson integration_id 15368 --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" >/dev/null <<'JSON' && { +[{"check_runs":[ + {"id":100,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T01:00:00Z"}, + {"id":101,"check_suite":{"id":10},"name":"Web","app":{"id":15368},"status":"completed","conclusion":"failure","started_at":"2026-01-02T00:01:00Z","completed_at":"2026-01-02T00:10:00Z"} +]}] +JSON + echo "required-check filter accepted stale success after post-merge rerun" >&2; exit 1; +} +# DCO alone may complete just after merge, inside its explicit five-minute bound. +dco_fixture() { + local expected="$1" completed="$2" actual + if jq -e --arg name "DCO Check" --argjson integration_id 1455659 --arg merged_at 2026-01-02T00:00:00Z \ + -f "$required_check_filter" >/dev/null <&2; exit 1; } +} +dco_fixture pass 2026-01-02T00:04:59Z +dco_fixture fail 2026-01-02T00:05:01Z + +# The verifier must request production endpoint semantics and pin helpers before checkout. +verify_merge="$repo_root/scripts/verify-desktop-release-merge.sh" +grep -Fq 'check-runs?filter=latest&per_page=100' "$verify_merge" +grep -Fq 'git fetch origin main --no-tags' "$verify_merge" +grep -Fq 'git merge-base --is-ancestor "$candidate_parents" origin/main' "$verify_merge" +grep -Fq 'git show "$candidate_parents:scripts/desktop_release.py"' "$verify_merge" +grep -Fq 'git show "$candidate_parents:scripts/required-check-succeeded.jq"' "$verify_merge" +grep -Fq 'DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py"' "$verify_merge" +grep -Fq -- '-f "$verifier_dir/required-check-succeeded.jq"' "$verify_merge" + release_workflow="$repo_root/.github/workflows/release.yml" [[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || { echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1; diff --git a/scripts/verify-desktop-release-authorization.sh b/scripts/verify-desktop-release-authorization.sh deleted file mode 100755 index b18cf6cad..000000000 --- a/scripts/verify-desktop-release-authorization.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -: "${PR_HEAD_SHA:?}" -: "${PR_NUMBER:?}" -: "${GITHUB_REPOSITORY:?}" -: "${GH_TOKEN:?}" - -review="$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest')" -reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100&page=1")" -valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[][] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" -if ! jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || [[ "$valid_approvals" -eq 0 ]]; then - echo "release lacks an exact-head approval" >&2 - exit 1 -fi diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh index a9fbb48d0..57b4e54af 100755 --- a/scripts/verify-desktop-release-merge.sh +++ b/scripts/verify-desktop-release-merge.sh @@ -3,25 +3,30 @@ set -euo pipefail : "${PR_HEAD_SHA:?}" : "${MERGE_SHA:?}" +: "${MERGED_AT:?}" : "${VERSION:?}" : "${PR_NUMBER:?}" : "${GH_TOKEN:?}" +# Keep this list aligned with the main ruleset. Producer IDs prevent a check +# with a copied display name from authorizing a release. Every current required +# gate is a check run; add explicit legacy-status verification before introducing +# any required context that reports only through the commit-status API. required_checks=( - "Desktop E2E Integration" - "Desktop" - "Rust Lint" - "Security" - "Unit Tests" - "Windows Rust (x86_64-pc-windows-msvc)" - "Mobile" - "Web" - "Backend Integration (relay e2e)" - "Desktop E2E Relay" - "Relay E2E" - "Desktop Build (macOS)" - "DCO Check" - "Desktop Release Candidate" + "Desktop E2E Integration:15368" + "Desktop:15368" + "Rust Lint:15368" + "Security:15368" + "Unit Tests:15368" + "Windows Rust (x86_64-pc-windows-msvc):15368" + "Mobile:15368" + "Web:15368" + "Backend Integration (relay e2e):15368" + "Desktop E2E Relay:15368" + "Relay E2E:15368" + "Desktop Build (macOS):15368" + "DCO Check:1455659" + "Desktop Release Candidate:15368" ) expected_branch="version-bump/$VERSION" @@ -29,33 +34,53 @@ expected_branch="version-bump/$VERSION" [[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; } [[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; } -git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags -mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') -[[ "${#parents[@]}" -eq 1 ]] || { echo "desktop release was not squash merged" >&2; exit 1; } -base_sha="$(git show "$PR_HEAD_SHA:.release/desktop-candidate.json" | jq -r .base_sha)" -[[ "${parents[0]}" == "$base_sha" ]] || { echo "squash parent is not the frozen candidate base" >&2; exit 1; } -[[ "$(git show -s --format=%T "$MERGE_SHA")" == "$(git show -s --format=%T "$PR_HEAD_SHA")" ]] || { - echo "squash tree differs from the validated candidate" >&2 +# The API identity must match the closed event. Branch names are mutable and are +# never used to resolve the artifact. +pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")" +jq -e \ + --arg head "$PR_HEAD_SHA" --arg head_ref "$PR_HEAD_REF" --arg head_repo "$PR_HEAD_REPO" \ + --arg base "$PR_BASE_REF" --arg merge "$MERGE_SHA" --arg merged_at "$MERGED_AT" \ + '.merged == true and .head.sha == $head and .head.ref == $head_ref and + .head.repo.full_name == $head_repo and .base.ref == $base and + .merge_commit_sha == $merge and .merged_at == $merged_at' <<<"$pr" >/dev/null || { + echo "pull request API identity does not match the closed merge event" >&2 exit 1 } -git merge-base --is-ancestor "$MERGE_SHA" origin/main || { echo "squash commit is not reachable from current main" >&2; exit 1; } + +# Pin trusted verifier code from the candidate's frozen base, not from the +# candidate or its squash. A release PR cannot alter the code that validates it. +git fetch origin main --no-tags +git fetch origin "$PR_HEAD_SHA" --no-tags +candidate_parents="$(git show -s --format=%P "$PR_HEAD_SHA")" +[[ "$candidate_parents" =~ ^[0-9a-f]{40}$ ]] || { + echo "desktop candidate must have exactly one parent before validation" >&2 + exit 1 +} +git merge-base --is-ancestor "$candidate_parents" origin/main || { + echo "desktop candidate base is not protected main history" >&2 + exit 1 +} +verifier_dir="$(mktemp -d)" +trap 'rm -rf "$verifier_dir"' EXIT +git show "$candidate_parents:scripts/desktop_release.py" > "$verifier_dir/desktop_release.py" +git show "$candidate_parents:scripts/required-check-succeeded.jq" > "$verifier_dir/required-check-succeeded.jq" git checkout --detach "$PR_HEAD_SHA" -scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" +DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py" \ + validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" -scripts/verify-desktop-release-authorization.sh - -checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" -for required in "${required_checks[@]}"; do - jq -e --arg name "$required" -f scripts/required-check-succeeded.jq <<<"$checks" >/dev/null || { - echo "required check is missing or unsuccessful: $required" >&2 +# `filter=latest` is deliberate: GitHub exposes no per-rerun creation time. A +# post-merge rerun replaces the visible attempt and fails closed below. +checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?filter=latest&per_page=100")" +for entry in "${required_checks[@]}"; do + required="${entry%:*}" + integration_id="${entry##*:}" + jq -e --arg name "$required" --argjson integration_id "$integration_id" \ + --arg merged_at "$MERGED_AT" \ + -f "$verifier_dir/required-check-succeeded.jq" <<<"$checks" >/dev/null || { + echo "trusted required check was not successful at merge: $required" >&2 exit 1 } done -status="$(gh api "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/status")" -jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || { - echo "candidate has a failing or pending combined commit status" >&2 - exit 1 -} -echo "verified desktop candidate $PR_HEAD_SHA at squash $MERGE_SHA" +echo "verified immutable desktop candidate $PR_HEAD_SHA authorized by merged PR $PR_NUMBER"