diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 3a090b3ebd..3db5c6baaa 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 eddebea685..61ccc800af 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 23dacea2ce..53d5805561 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 900e5a9475..66a106bdeb 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 700d5e8dcf..93109fa94d 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 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 permission_request_with_no_options_is_cancelled() { + let response = + permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response"); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + } - 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")); + /// 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_by_kind() { + let options = + options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#); + + let response = + permission_denial_response(&serde_json::json!(1), &options).expect("denial response"); + + 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 35aaec188d..d959685846 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 811253e4ac..0c4e5f158c 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 ddc0330d9f..8430307d9c 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 ff87a33a1a..b3dfdd3aa6 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -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 @@ -249,10 +277,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,6 +303,22 @@ 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; + } + 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 @@ -1075,6 +1119,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/llm.rs b/crates/buzz-agent/src/llm.rs index 220d99f9a4..99e74c6d67 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1706,6 +1706,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,9 +1869,14 @@ 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() { @@ -2117,6 +2127,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)); } @@ -6217,6 +6230,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 e386421981..fce705d69d 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -388,6 +388,10 @@ pub enum AgentError { Llm(String), LlmAuth(String), LlmModelNotFound(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 +403,7 @@ 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::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 5b660da48c..1b7f346162 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 ef6f9d2d80..4253ef329c 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 &'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 5508c95cad..9d15fccfc8 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-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index d3118d8a76..53e3f59463 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 fcd0d70728..c711538284 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3216,6 +3216,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 660a55fef3..98f8a9aa84 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-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..5b9a50b5b1 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 7aaa3d1702..e142221169 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/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index fafcb2589d..cd6010b64f 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.)" ) } diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index 4e02b7bd68..2cb2068d51 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 e4afe7fea4..0abdad82fa 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/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 8829edab39..51297b915b 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 0000000000..0af22459f4 --- /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 0000000000..b01c6f5216 --- /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 c6349546a2..2659a9c1d4 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/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 79dab559c4..625dc17360 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 a20e750186..bd3d9d2ef7 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 c6f1bfa6c1..0e884dd247 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 0000000000..14ba5ec4e8 --- /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/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 0c73b75339..cd52b1bebf 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -14,10 +14,13 @@ import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomple import { coalesceAgentAutocompleteCandidates, coalesceAutocompleteCandidatesByKey, + filterCachedAgentSuggestions, getMentionableAgentPubkeys, getSharedChannelIds, - isAgentIdentityInManagedList, + isAgentIdentityInAllowedList, + isAgentMentionChannelType, shouldHideAgentFromMentions, + uniqueAutocompleteLabels, } from "@/features/agents/lib/agentAutocompleteEligibility"; import { useInfiniteUserSearchQuery, @@ -93,7 +96,6 @@ export function useMentions( const mentionMapRef = React.useRef>(new Map()); const personaMentionMapRef = React.useRef>(new Map()); const previousSuggestionsRef = React.useRef([]); - void options?.channelType; const mentionSearchQuery = mentionQuery?.trim() ?? ""; const canSearchGlobalPeople = mentionSearchQuery.length > 0; const identityQuery = useIdentityQuery(); @@ -192,10 +194,16 @@ export function useMentions( () => getSharedChannelIds(channelsQuery.data), [channelsQuery.data], ); + const mentionChannelId = isAgentMentionChannelType(options?.channelType) + ? channelId + : null; const mentionableAgentPubkeys = React.useMemo( () => getMentionableAgentPubkeys({ currentPubkey, + eligibilityScope: mentionChannelId + ? { type: "channel", channelId: mentionChannelId } + : { type: "managed-only" }, managedAgentPubkeys, relayAgents: relayAgentsQuery.data, sharedChannelIds, @@ -203,6 +211,7 @@ export function useMentions( [ currentPubkey, managedAgentPubkeys, + mentionChannelId, relayAgentsQuery.data, sharedChannelIds, ], @@ -246,7 +255,7 @@ export function useMentions( if (isArchivedDiscovery(pubkey)) { return; } - if (!isAgentIdentityInManagedList(candidate, managedAgentPubkeys)) { + if (!isAgentIdentityInAllowedList(candidate, mentionableAgentPubkeys)) { return; } if ( @@ -420,7 +429,6 @@ export function useMentions( managedAgentNamesByPubkey, managedAgentPersonaIds, managedAgentPersonaIdsByPubkey, - managedAgentPubkeys, managedAgentsQuery.data, memberPubkeys, members, @@ -457,26 +465,10 @@ export function useMentions( enabled: ownerPubkeys.length > 0, }); - const searchableNames = React.useMemo(() => { - const names: string[] = []; - const seen = new Set(); - - for (const candidate of mentionCandidatesWithTeams) { - for (const name of [ - candidate.displayName, - candidate.personaName, - candidate.secondaryLabel, - ]) { - const trimmed = name?.trim(); - if (trimmed && !seen.has(trimmed.toLowerCase())) { - names.push(trimmed); - seen.add(trimmed.toLowerCase()); - } - } - } - - return names; - }, [mentionCandidatesWithTeams]); + const searchableNames = React.useMemo( + () => uniqueAutocompleteLabels(mentionCandidatesWithTeams), + [mentionCandidatesWithTeams], + ); const highlightNames = React.useMemo(() => { const names: string[] = []; @@ -580,11 +572,19 @@ export function useMentions( } if (userSearchQuery.isFetching) { - return previousSuggestionsRef.current; + return filterCachedAgentSuggestions( + previousSuggestionsRef.current, + mentionCandidatesWithTeams, + ); } return []; - }, [matchingSuggestions, mentionQuery, userSearchQuery.isFetching]); + }, [ + matchingSuggestions, + mentionCandidatesWithTeams, + mentionQuery, + userSearchQuery.isFetching, + ]); React.useEffect(() => { if (mentionQuery === null) { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6f79daa606..58601f452f 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -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 20f50924fb..c72686a642 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 6ba9f69050..647ad4cbe8 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 c9ea05e428..494ef124d2 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/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 52245da577..55048a638d 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/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0bf65c16da..ac9d5318e5 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/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index d9dcffa2e4..ed00e8c355 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/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 5e90e5a7f9..2f56ca6609 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 0000000000..82592b1eca --- /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/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 82a64e3751..dbae58d687 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'; @@ -59,7 +60,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 +71,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 +280,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 +301,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 +316,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 +391,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 39bb29e7a3..08384b69e1 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/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 9df193fe96..7b4be901e1 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 442634849c..ec7e969034 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 3104e90335..29db1f96c5 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_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 286a931db1..7f9615d4ab 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 445ce6a28f..e36a514ff6 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 c00792b966..16b1c246bc 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'; @@ -65,6 +66,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 +83,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 +154,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 +177,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 +296,65 @@ class ChannelsPage extends HookConsumerWidget { return timer.cancel; }, [isReconnectingWithContent]); + void openCommunitySwitcher() { + unawaited(HapticFeedback.selectionClick()); + ref.invalidate(communityIconProvider); + showModalBottomSheet( + 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 +363,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 6ce4ce5f8f..6b071e7382 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, + ), + ), ], ), ); diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 5443e6b9d0..f7344086e5 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 73935f4631..4c8b26ca4b 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -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/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index b6d04cdb9b..6e17845d74 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/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 3cac205abc..99af48d44b 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 c630accdff..65d236d1ad 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -143,11 +143,21 @@ 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>( @@ -155,21 +165,26 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention( 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/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 79ccf31999..c691310df5 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(), + ChannelsPage( + settingsPageBuilder: settingsPageBuilder, + tabReselection: homeReselection, + onSettingsTransitionProgress: (progress) { + if (settingsTransitionProgress.value != progress) { + settingsTransitionProgress.value = progress; + } + }, + ), + ActivityPage(tabReselection: activityReselection), + SearchPage(tabReselection: searchReselection), ]; - 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), + 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, + ), + ), + ], ), ), - Align( - alignment: Alignment.bottomCenter, - child: IgnorePointer( - child: MobileTabFooterBackdrop( - height: mobileTabFooterBackdropHeight(context), - ), - ), + 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, ), - Positioned.fill( - child: ChannelQuickActionsLauncher( - visible: tabIndex.value == 0, - navigationBarHeight: HomePage._tabBarHeight, - navigationBarBottomGap: HomePage._tabBarBottomGap, - navigationBarWidth: navigationBarWidth, - systemBottomInset: systemBottomInset, - rightInset: Grid.sm, + ), + 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, ), - ), - ], + ); + }, ), - ), - 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)); - } - }, - destinations: _destinations, - ), + ], ); } } diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index fb60fae125..087cc909aa 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 280ce75e4f..66fceebb27 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/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index 23816ccaa9..d33e0ce2f7 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/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 65fc12dfd9..b97d10f55f 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), + ), + 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: 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, + ), + ); + }, + 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, + ), + ), + ), + ), ), ), - 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); - }, - ), + ) + : 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 0000000000..96a23849a1 --- /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/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index f089f63915..b86a28b88d 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'; @@ -28,17 +31,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/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart index 1214b97790..92cec8ef34 100644 --- a/mobile/lib/shared/theme/buzz_theme.dart +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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 +19,50 @@ 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); + /// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties. const _lightTop = Color(0xFFE6E6B6); const _lightBottom = Color(0xFFC4D0DA); diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 7188e021f9..94e7298682 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/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 8e8a8b1ce1..2a2993f330 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 9772fd0246..7243d66276 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', + body: Stack(children: _stackChildren()), + ); + } + + List _stackChildren() { + final backdrop = backgroundGradient == null + ? const [] + : [ + Positioned.fill( + child: _PinnedGradientBackground(gradient: backgroundGradient!), ), - child: body, - ), - appBar, - ], + ]; + 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 d972b8184f..36e0ce573e 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/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 7e5aa0f4dd..8619a513d9 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_sections/channel_sections_manager_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart index 5b36ed6a4d..16d1936d39 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 9f36760861..9673eda28e 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 77dc65fbc5..f925724b54 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 11bf306825..d58555f45a 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/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index f22c258564..3a54332c98 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 0000000000..9130153edb --- /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 dbf27b5c81..a3c7b3dd9a 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 c3db833fc1..55645313c9 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)); - await tester.pumpAndSettle(); - final input = tester.widget(searchField); - expect(input.controller?.text, isEmpty); - expect(input.focusNode?.hasFocus, isFalse); + 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( - input.decoration?.hintText, - 'Search messages, channels, people\u2026', + tester + .widget( + find.byKey(const Key('search-header-title-opacity')), + ) + .opacity, + 1, ); + await tester.pumpAndSettle(); + 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/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart index 613fec1467..bc9992be90 100644 --- a/mobile/test/shared/theme/buzz_theme_test.dart +++ b/mobile/test/shared/theme/buzz_theme_test.dart @@ -183,6 +183,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/widgets/mobile_tab_footer_backdrop_test.dart b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart index e13d17a65e..5d6882fddf 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/scripts/desktop_release.py b/scripts/desktop_release.py index d6518b26b1..ce9ceaab93 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -5,15 +5,17 @@ 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 fb586005fb..3626b31a9a 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 f36a1d690a..c63a157c00 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: 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/desktop/feature" -git -C "$tmp" commit -qam 'fix: desktop fix' -echo policy > "$tmp/POLICY.md" +git -C "$tmp" commit -qm 'chore(release): release Buzz Desktop version 1.0.0' +prior_candidate=$(git -C "$tmp" rev-parse HEAD) +git -C "$tmp" -c tag.gpgSign=false tag desktop-v1.0.0 + +git -C "$tmp" checkout -q - +echo before-squash > "$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 - -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 + 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 - # 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 8bfd6798ee..a42f437efd 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 b18cf6cade..0000000000 --- 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 a9fbb48d09..57b4e54af1 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; } -git checkout --detach "$PR_HEAD_SHA" -scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" +# 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" -scripts/verify-desktop-release-authorization.sh +git checkout --detach "$PR_HEAD_SHA" +DESKTOP_RELEASE_ROOT="$PWD" python3 "$verifier_dir/desktop_release.py" \ + validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" -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"