From 6dbc94651201154ceb88c1b54653bf6e8ca77518 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 5 Aug 2026 09:51:15 -0400 Subject: [PATCH 01/11] fix(desktop): make missing-command error actionable for released builds (#4802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing error for missing ACP harness commands has been pointing released-build users to run `cargo build --release --workspace` and read TESTING.md β€” both dead ends for anyone not building from source. Updated message acknowledges that antivirus software can quarantine bundled binaries and provides practical remediation steps. Preserves pointer to TESTING.md for source builds. Fixes issue context from #4491. Signed-off-by: Will Pfleger Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 --- desktop/src-tauri/src/managed_agents/discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.)" ) } From dc17965c792fc9f9d4d9e70028980821d5d89c71 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 5 Aug 2026 07:55:26 -0700 Subject: [PATCH 02/11] fix(mobile): serialize channel sections sync (#3165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### What changed? Serializes channel-section relay synchronization so a late relay `CLOSED` cannot overlap an in-flight retry and install duplicate subscriptions. Pending subscription results are invalidated and immediately closed when the manager is disposed or superseded. ### Why? The startup retry added in #3004 could race with a late `CLOSED` or manager disposal, leaking an untracked live subscription. This keeps retry recovery single-flight and makes the lifecycle boundary explicit. ### How is it tested? Build and run. Added tests: - [`ChannelSectionsManager`](https://github.com/block/buzz/tree/main/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart) interleaving coverage for in-flight retry serialization and disposal during subscription setup *πŸ€– This PR was authored with a Buzz agent.* Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh --- .../channel_sections_manager.dart | 52 ++++++++-- .../channel_sections_manager_test.dart | 95 +++++++++++++++++++ 2 files changed, 138 insertions(+), 9 deletions(-) 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/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 From 067c085f37d9dcb2f598b0e2a6b6653903364783 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 09:34:02 -0600 Subject: [PATCH 03/11] Define private managed agent wire protocol (#4593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - reserve kind `30179` for owner-private managed-agent aggregates - define the fail-closed owner-self NIP-44 v2 envelope and versioned payload codec - bind runnable identity/configuration to complete signed `30175`/`30177` recovery projections - validate NIP-OA ownerβ†’agent attestations and reject self-attestation - document NIP-PMA authority, migration prerequisites, privacy, and deployment order - keep generic relay ingest closed until private storage and atomic aggregate CAS exist ## Safety boundary This is the inert protocol/codec slice only. It does not publish secrets, change agent authority, migrate local records, or enable kind `30179` ingestion. The relay regression test proves generic EVENT ingest still rejects the kind. The finalized migration plan adds later prerequisites for relay-private storage/CAS, runtime lease/fencing, Desktop cutover, and harness authentication. Those belong in staged follow-up PRs rather than expanding this inert foundation. ## Validation At commit `67f0ea4ebb8d3ccba3a3eb9374e89a7178913f74`: - `cargo test -p buzz-core` β€” 246 unit + 2 doc tests passed - `cargo test -p buzz-relay private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists` β€” passed - push hooks: Rust tests and desktop checks passed (`2145` desktop tests passed, `14` ignored) - `cargo fmt --all -- --check` - `git diff --check` ## Review Princess Donut cleared security/data integrity with no remaining high/medium findings. Mongo cleared migration compatibility and wire grammar. The later runtime lease/fencing protocol was also adversarially cleared as a plan; implementation slices still require independent evidence before activation. Deterministic plaintext/signed-projection/auth-tag interoperability vectors remain a valuable follow-up, not an S0 merge gate; random NIP-44 ciphertext is intentionally not snapshotted. --------- Signed-off-by: Wes Co-authored-by: Carl --- crates/buzz-core/src/kind.rs | 17 +- crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/private_managed_agent.rs | 1134 +++++++++++++++++ crates/buzz-relay/src/handlers/ingest.rs | 12 + docs/nips/NIP-PMA.md | 112 ++ 5 files changed, 1276 insertions(+), 1 deletion(-) create mode 100644 crates/buzz-core/src/private_managed_agent.rs create mode 100644 docs/nips/NIP-PMA.md diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b1be7c5038..3c6f1d5913 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -108,6 +108,15 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// dedicated push lease tables. pub const KIND_PUSH_LEASE: u32 = 30350; +/// NIP-PMA: owner-encrypted private managed-agent aggregate. +/// +/// Addressed by `(owner pubkey, kind, agent pubkey)`. The signed outer tags +/// expose only the agent coordinate, CAS generation/predecessor, and active/deleted +/// state required for relay enforcement. Content is NIP-44 v2 encrypted from +/// the owner's key to itself and contains the runnable identity/configuration +/// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. +pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -117,7 +126,11 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// /// Currently a tiny linear set. If this grows past ~4 kinds, convert to a /// compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_PUSH_LEASE]; +pub const AUTHOR_ONLY_KINDS: &[u32] = &[ + KIND_EVENT_REMINDER, + KIND_PUSH_LEASE, + KIND_PRIVATE_MANAGED_AGENT, +]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's @@ -643,6 +656,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, + KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -843,6 +857,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PRIVATE_MANAGED_AGENT)); // 30179 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..7424915c83 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -32,6 +32,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// NIP-PMA owner-encrypted private managed-agent wire codec. +pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; /// Tenant identity β€” the server-resolved community key carried on scoped paths. diff --git a/crates/buzz-core/src/private_managed_agent.rs b/crates/buzz-core/src/private_managed_agent.rs new file mode 100644 index 0000000000..180dd6fa0c --- /dev/null +++ b/crates/buzz-core/src/private_managed_agent.rs @@ -0,0 +1,1134 @@ +//! NIP-PMA private managed-agent wire codec. +//! +//! This module defines and validates the inert wire format only. Relays must +//! not accept [`KIND_PRIVATE_MANAGED_AGENT`](crate::kind::KIND_PRIVATE_MANAGED_AGENT) +//! until the dedicated privacy and aggregate-CAS transactions are deployed. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::str::FromStr; + +use nostr::nips::nip44::{self, Version}; +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::Message; +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, SECP256K1}; +use serde::de::{DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT}; + +/// Wire-format discriminator for decrypted private managed-agent payloads. +pub const FORMAT: &str = "buzz-private-managed-agent"; +/// Current decrypted payload schema version. +pub const VERSION: u32 = 1; +/// NIP-44 v2 plaintext limit. +pub const MAX_PLAINTEXT_BYTES: usize = 65_535; +/// Maximum plausible NIP-44 v2 ciphertext length. +pub const MAX_CIPHERTEXT_BYTES: usize = 87_472; +/// Largest integer represented exactly by interoperable JSON implementations. +pub const MAX_SAFE_GENERATION: u64 = (1_u64 << 53) - 1; +/// Maximum number of environment variables in one private payload. +pub const MAX_ENV_VARS: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable key. +pub const MAX_ENV_KEY_BYTES: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable value. +pub const MAX_ENV_VALUE_BYTES: usize = 16_384; +/// Maximum number of explicit agent arguments. +pub const MAX_AGENT_ARGS: usize = 256; +/// Maximum UTF-8 bytes in one argument. +pub const MAX_AGENT_ARG_BYTES: usize = 8_192; +/// Maximum serialized bytes accepted for an extension/recovery/config value. +pub const MAX_VALUE_BYTES: usize = 32_768; + +/// Errors returned by the private managed-agent codec. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + /// The signed outer event is malformed or does not match the expected owner. + #[error("invalid private managed-agent envelope: {0}")] + InvalidEnvelope(String), + /// The ciphertext could not be authenticated/decrypted. Deliberately redacted. + #[error("private managed-agent payload could not be decrypted")] + Decrypt, + /// The decrypted JSON is malformed, ambiguous, or semantically invalid. + #[error("invalid private managed-agent payload: {0}")] + InvalidPayload(String), + /// Encryption failed. + #[error("private managed-agent encryption failed")] + Encrypt, + /// Event signing failed. + #[error("private managed-agent signing failed")] + Sign, +} + +/// Authoritative lifecycle state repeated in the outer tags and ciphertext. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum State { + /// Runnable aggregate. + Active, + /// Anti-resurrection tombstone. + Deleted, +} + +impl State { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } +} + +/// Versioned signed-event recovery material for a bound public projection. +/// +/// Retaining the complete signed event makes reconstruction unambiguous: its +/// signature, ID, author, kind, coordinate, and exact content bytes can all be +/// checked without trusting replaceable-event history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionRecoveryV1 { + /// Recovery schema version. Version 1 stores one complete signed event. + pub version: u32, + /// Exact signed public projection event. + pub signed_event: Event, +} + +/// Complete definition projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefinitionBinding { + /// CAS-managed definition revision pinned by this aggregate. + pub revision: u64, + /// Exact signed kind:30175 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Complete kind:30177 projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstanceBinding { + /// Exact signed kind:30177 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Secret agent identity material. It never appears in public projections. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateIdentity { + /// Agent private key in nsec form. + pub private_key_nsec: String, + /// Optional NIP-OA owner attestation JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_tag: Option, +} + +impl fmt::Debug for PrivateIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateIdentity") + .field("private_key_nsec", &"") + .field("auth_tag", &self.auth_tag.as_ref().map(|_| "")) + .finish() + } +} + +/// Portable private runnable configuration. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateConfig { + /// Explicit kind:30175 coordinate, when definition-backed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_coordinate: Option, + /// Intended relay endpoint; validated again on each device before use. + pub relay_url: String, + /// Explicit harness override; never launched without local validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_command_override: Option, + /// Explicit harness arguments; validated again on each device. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_args: Vec, + /// Idle timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout_seconds: Option, + /// Absolute turn timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turn_duration_seconds: Option, + /// Secret environment overrides. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, + /// Versioned backend configuration. Device/provider validation is required. + pub backend: Value, + /// Durable remote backend identity; ownership/existence is device-validated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_agent_id: Option, + /// Portable team linkage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_id: Option, + /// Portable identity within a team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_name_in_team: Option, + /// Versioned provider/definition relay-mesh marker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay_mesh: Option, +} + +impl fmt::Debug for PrivateConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateConfig") + .field("contents", &"") + .finish() + } +} + +/// Fields present only when [`Payload::state`] is [`State::Active`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivePayload { + /// Exact definition projection binding. + pub definition: DefinitionBinding, + /// Exact public instance projection binding. + pub instance_projection: InstanceBinding, + /// Secret identity material. + pub identity: PrivateIdentity, + /// Private portable/device-validated configuration. + pub config: PrivateConfig, +} + +/// Decrypted private managed-agent payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + /// Always [`FORMAT`]. + pub format: String, + /// Always [`VERSION`]. + pub version: u32, + /// Agent pubkey and event `d` coordinate. + pub agent_pubkey: String, + /// Owner pubkey and signed event author. + pub owner_pubkey: String, + /// Monotonic CAS generation. + pub generation: u64, + /// Exact predecessor event ID; absent only for generation one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_event_id: Option, + /// Lifecycle state, repeated in the outer `state` tag. + pub state: State, + /// RFC3339 bookkeeping timestamp; never used for conflict resolution. + pub updated_at: String, + /// Required for active records and forbidden for tombstones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, + /// Required for tombstones and forbidden for active records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + /// Forward-compatible namespaced data. Core semantics must never depend on it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +/// Validated public metadata from a private managed-agent event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + /// Agent pubkey from `d`. + pub agent_pubkey: PublicKey, + /// Owner pubkey from the signed event author. + pub owner_pubkey: PublicKey, + /// CAS generation from `g`. + pub generation: u64, + /// CAS predecessor from `prev`. + pub previous_event_id: Option, + /// Lifecycle state from `state`. + pub state: State, +} + +/// Compute the lowercase SHA-256 binding for exact projection content bytes. +pub fn content_sha256(content: &[u8]) -> String { + hex::encode(Sha256::digest(content)) +} + +/// Validate a signed outer envelope before any decryption. +pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result { + if event.kind.as_u16() as u32 != KIND_PRIVATE_MANAGED_AGENT { + return Err(Error::InvalidEnvelope("wrong kind".into())); + } + if &event.pubkey != expected_owner { + return Err(Error::InvalidEnvelope( + "author is not expected owner".into(), + )); + } + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidEnvelope( + "invalid event id or signature".into(), + )); + } + if event.content.is_empty() || event.content.len() > MAX_CIPHERTEXT_BYTES { + return Err(Error::InvalidEnvelope("invalid ciphertext length".into())); + } + + let mut d = None; + let mut g = None; + let mut prev = None; + let mut state = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() != 2 { + return Err(Error::InvalidEnvelope( + "every tag must have exactly one value".into(), + )); + } + let slot = match parts[0].as_str() { + "d" => &mut d, + "g" => &mut g, + "prev" => &mut prev, + "state" => &mut state, + name => return Err(Error::InvalidEnvelope(format!("unexpected tag: {name}"))), + }; + if slot.replace(parts[1].clone()).is_some() { + return Err(Error::InvalidEnvelope(format!( + "duplicate {} tag", + parts[0] + ))); + } + } + + let agent_pubkey = parse_canonical_pubkey( + "d", + d.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing d tag".into()))?, + )?; + let owner_pubkey = *expected_owner; + let generation = parse_generation( + g.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing g tag".into()))?, + )?; + let previous_event_id = match prev { + Some(value) => Some(parse_event_id("prev", &value)?), + None => None, + }; + if (generation == 1) != previous_event_id.is_none() { + return Err(Error::InvalidEnvelope( + "prev must be absent exactly at generation 1".into(), + )); + } + let state = match state.as_deref() { + Some("active") => State::Active, + Some("deleted") => State::Deleted, + Some(_) => return Err(Error::InvalidEnvelope("invalid state tag".into())), + None => return Err(Error::InvalidEnvelope("missing state tag".into())), + }; + Ok(Envelope { + agent_pubkey, + owner_pubkey, + generation, + previous_event_id, + state, + }) +} + +/// Encrypt and sign an inert private managed-agent event candidate. +pub fn build_event(owner_keys: &Keys, payload: &Payload, created_at: u64) -> Result { + validate_payload(payload)?; + if payload.owner_pubkey != owner_keys.public_key().to_hex() { + return Err(Error::InvalidPayload( + "owner_pubkey does not match signing key".into(), + )); + } + let plaintext = serde_json::to_vec(payload).map_err(|_| Error::Encrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::InvalidPayload( + "plaintext exceeds NIP-44 limit".into(), + )); + } + let plaintext = std::str::from_utf8(&plaintext).map_err(|_| Error::Encrypt)?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + plaintext, + Version::V2, + ) + .map_err(|_| Error::Encrypt)?; + let mut tags = vec![ + parse_tag(["d", payload.agent_pubkey.as_str()])?, + parse_tag(["g", payload.generation.to_string().as_str()])?, + parse_tag(["state", payload.state.as_str()])?, + ]; + if let Some(previous) = payload.previous_event_id.as_deref() { + tags.push(parse_tag(["prev", previous])?); + } + EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(owner_keys) + .map_err(|_| Error::Sign) +} + +/// Validate, owner-self decrypt, strictly parse, and cross-check a payload. +pub fn validate_and_decrypt( + event: &Event, + owner_keys: &Keys, +) -> Result<(Envelope, Payload), Error> { + let envelope = validate_envelope(event, &owner_keys.public_key())?; + let plaintext = nip44::decrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + &event.content, + ) + .map_err(|_| Error::Decrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::Decrypt); + } + let value = parse_strict_json(plaintext.as_bytes())?; + let payload: Payload = + serde_json::from_value(value).map_err(|e| Error::InvalidPayload(format!("schema: {e}")))?; + validate_payload(&payload)?; + if payload.agent_pubkey != envelope.agent_pubkey.to_hex() + || payload.owner_pubkey != envelope.owner_pubkey.to_hex() + || payload.generation != envelope.generation + || payload.state != envelope.state + || payload.previous_event_id.as_deref() + != envelope + .previous_event_id + .as_ref() + .map(EventId::to_hex) + .as_deref() + { + return Err(Error::InvalidPayload( + "outer/inner metadata mismatch".into(), + )); + } + Ok((envelope, payload)) +} + +/// Validate decrypted payload semantics independently of encryption. +pub fn validate_payload(payload: &Payload) -> Result<(), Error> { + if payload.format != FORMAT || payload.version != VERSION { + return Err(Error::InvalidPayload( + "unsupported format or version".into(), + )); + } + let agent = parse_canonical_pubkey("agent_pubkey", &payload.agent_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + validate_generation_and_prev(payload.generation, payload.previous_event_id.as_deref())?; + parse_rfc3339("updated_at", &payload.updated_at)?; + for (key, value) in &payload.extensions { + if key.is_empty() || key.len() > 128 || !key.contains(':') { + return Err(Error::InvalidPayload( + "extension keys must be non-empty namespaced strings <= 128 bytes".into(), + )); + } + validate_value_size("extension", value)?; + } + match payload.state { + State::Active => { + if payload.deleted_at.is_some() { + return Err(Error::InvalidPayload( + "active payload must not contain deleted_at".into(), + )); + } + let active = payload.active.as_ref().ok_or_else(|| { + Error::InvalidPayload("active payload missing active body".into()) + })?; + validate_active(active, &agent, &payload.owner_pubkey)?; + } + State::Deleted => { + if payload.active.is_some() { + return Err(Error::InvalidPayload( + "deleted payload must not contain active body".into(), + )); + } + parse_rfc3339( + "deleted_at", + payload.deleted_at.as_deref().ok_or_else(|| { + Error::InvalidPayload("deleted payload missing deleted_at".into()) + })?, + )?; + } + } + Ok(()) +} + +fn validate_active( + active: &ActivePayload, + agent: &PublicKey, + owner_pubkey: &str, +) -> Result<(), Error> { + if active.definition.revision == 0 || active.definition.revision > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload("invalid definition revision".into())); + } + let definition_d = + parse_definition_coordinate(active.config.definition_coordinate.as_deref(), owner_pubkey)?; + validate_binding( + "definition", + KIND_PERSONA, + owner_pubkey, + Some(&definition_d), + &active.definition.event_id, + &active.definition.content_sha256, + &active.definition.recovery, + )?; + validate_binding( + "instance_projection", + KIND_MANAGED_AGENT, + owner_pubkey, + Some(&agent.to_hex()), + &active.instance_projection.event_id, + &active.instance_projection.content_sha256, + &active.instance_projection.recovery, + )?; + let agent_keys = Keys::parse(active.identity.private_key_nsec.trim()) + .map_err(|_| Error::InvalidPayload("invalid agent nsec".into()))?; + if agent_keys.public_key() != *agent { + return Err(Error::InvalidPayload( + "agent nsec does not derive agent_pubkey".into(), + )); + } + if let Some(auth_tag) = &active.identity.auth_tag { + validate_auth_tag(auth_tag, owner_pubkey, agent)?; + } + let config = &active.config; + if config.relay_url.is_empty() || config.relay_url.len() > 4096 { + return Err(Error::InvalidPayload("invalid relay_url length".into())); + } + if config.agent_args.len() > MAX_AGENT_ARGS + || config + .agent_args + .iter() + .any(|arg| arg.len() > MAX_AGENT_ARG_BYTES) + { + return Err(Error::InvalidPayload("agent_args exceed limits".into())); + } + if config.env_vars.len() > MAX_ENV_VARS + || config.env_vars.iter().any(|(k, v)| { + k.is_empty() || k.len() > MAX_ENV_KEY_BYTES || v.len() > MAX_ENV_VALUE_BYTES + }) + { + return Err(Error::InvalidPayload("env_vars exceed limits".into())); + } + validate_value_size("backend", &config.backend)?; + if let Some(mesh) = &config.relay_mesh { + validate_value_size("relay_mesh", mesh)?; + } + Ok(()) +} + +fn validate_auth_tag(auth_tag: &str, expected_owner: &str, agent: &PublicKey) -> Result<(), Error> { + if auth_tag.is_empty() || auth_tag.len() > 4096 { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let parts: Vec = serde_json::from_str(auth_tag) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if parts.len() != 4 || parts[0] != "auth" || parts[1] != expected_owner || !parts[2].is_empty() + { + return Err(Error::InvalidPayload( + "auth_tag must be an unconditional attestation for this owner".into(), + )); + } + parse_canonical_pubkey("auth_tag owner", &parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if agent.to_hex() == expected_owner { + return Err(Error::InvalidPayload( + "auth_tag must attest a distinct agent key".into(), + )); + } + if parts[3].len() != 128 + || !parts[3] + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let signature = Signature::from_str(&parts[3]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let preimage = format!("nostr:agent-auth:{}:", agent.to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let message = Message::from_digest(digest.into()); + let owner = PublicKey::from_hex(&parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let owner = owner + .xonly() + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + SECP256K1 + .verify_schnorr(&signature, &message, &owner) + .map_err(|_| Error::InvalidPayload("invalid auth_tag signature".into())) +} + +fn parse_definition_coordinate( + coordinate: Option<&str>, + owner_pubkey: &str, +) -> Result { + let coordinate = coordinate.ok_or_else(|| { + Error::InvalidPayload("active payload missing definition_coordinate".into()) + })?; + let mut parts = coordinate.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let d = parts.next(); + if kind != Some("30175") || owner != Some(owner_pubkey) || d.is_none_or(str::is_empty) { + return Err(Error::InvalidPayload( + "definition_coordinate must be 30175::".into(), + )); + } + Ok(d.unwrap().to_owned()) +} + +fn validate_binding( + label: &str, + expected_kind: u32, + owner_pubkey: &str, + expected_d: Option<&str>, + event_id: &str, + hash: &str, + recovery: &ProjectionRecoveryV1, +) -> Result<(), Error> { + parse_event_id(label, event_id).map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_lower_hex_32(&format!("{label}.content_sha256"), hash) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + if recovery.version != 1 { + return Err(Error::InvalidPayload(format!( + "unsupported {label} recovery version" + ))); + } + let event = &recovery.signed_event; + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidPayload(format!( + "invalid {label} recovery event" + ))); + } + if event.id.to_hex() != event_id + || event.kind.as_u16() as u32 != expected_kind + || event.pubkey.to_hex() != owner_pubkey + || content_sha256(event.content.as_bytes()) != hash + { + return Err(Error::InvalidPayload(format!( + "{label} recovery does not match binding" + ))); + } + let d_tags: Vec<_> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")).then_some(parts) + }) + .collect(); + if d_tags.len() != 1 || d_tags[0].len() != 2 || d_tags[0][1].is_empty() { + return Err(Error::InvalidPayload(format!( + "{label} recovery must have exactly one non-empty d tag" + ))); + } + if expected_d.is_some_and(|expected| d_tags[0][1] != expected) { + return Err(Error::InvalidPayload(format!( + "{label} recovery has wrong coordinate" + ))); + } + validate_value_size( + label, + &serde_json::to_value(recovery) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))?, + ) +} + +fn validate_generation_and_prev(generation: u64, previous: Option<&str>) -> Result<(), Error> { + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload( + "generation must be a positive safe integer".into(), + )); + } + if (generation == 1) != previous.is_none() { + return Err(Error::InvalidPayload( + "previous_event_id must be absent exactly at generation 1".into(), + )); + } + if let Some(value) = previous { + parse_event_id("previous_event_id", value) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + } + Ok(()) +} + +fn validate_value_size(label: &str, value: &Value) -> Result<(), Error> { + let len = serde_json::to_vec(value) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))? + .len(); + if len > MAX_VALUE_BYTES { + return Err(Error::InvalidPayload(format!("{label} exceeds size limit"))); + } + Ok(()) +} + +fn parse_rfc3339(label: &str, value: &str) -> Result<(), Error> { + chrono::DateTime::parse_from_rfc3339(value) + .map(|_| ()) + .map_err(|_| Error::InvalidPayload(format!("{label} must be RFC3339"))) +} + +fn parse_generation(value: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|b| b.is_ascii_digit()) + { + return Err(Error::InvalidEnvelope("g must be canonical decimal".into())); + } + let generation = value + .parse::() + .map_err(|_| Error::InvalidEnvelope("invalid g tag".into()))?; + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidEnvelope( + "g must be a positive safe integer".into(), + )); + } + Ok(generation) +} + +fn parse_canonical_pubkey(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + let key = PublicKey::from_hex(value) + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label}")))?; + key.xonly() + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label} curve point")))?; + Ok(key) +} + +fn parse_event_id(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + EventId::from_hex(value).map_err(|_| Error::InvalidEnvelope(format!("invalid {label}"))) +} + +fn parse_lower_hex_32(label: &str, value: &str) -> Result<(), Error> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidEnvelope(format!( + "{label} must be 64 lowercase hex chars" + ))); + } + Ok(()) +} + +fn parse_tag(parts: [&str; N]) -> Result { + Tag::parse(parts).map_err(|_| Error::InvalidEnvelope("failed to build tag".into())) +} + +fn parse_strict_json(bytes: &[u8]) -> Result { + struct StrictValue; + impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + fn deserialize>(self, d: D) -> Result { + d.deserialize_any(self) + } + } + impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("valid JSON with unique object keys") + } + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite float")) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(self) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(value) = seq.next_element_seed(StrictValue)? { + out.push(value); + } + Ok(Value::Array(out)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut seen = HashSet::new(); + let mut out = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(serde::de::Error::custom(format!("duplicate key: {key}"))); + } + out.insert(key, map.next_value_seed(StrictValue)?); + } + Ok(Value::Object(out)) + } + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue + .deserialize(&mut deserializer) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + deserializer + .end() + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::ToBech32; + + fn auth_tag(owner: &Keys, agent: &Keys) -> String { + let preimage = format!("nostr:agent-auth:{}:", agent.public_key().to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let signature = owner.sign_schnorr(&Message::from_digest(digest.into())); + serde_json::json!([ + "auth", + owner.public_key().to_hex(), + "", + signature.to_string() + ]) + .to_string() + } + + fn payload(owner: &Keys, agent: &Keys) -> Payload { + let definition_event = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + let instance_event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "instance") + .tags(vec![Tag::parse([ + "d", + agent.public_key().to_hex().as_str(), + ]) + .unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: agent.public_key().to_hex(), + owner_pubkey: owner.public_key().to_hex(), + generation: 1, + previous_event_id: None, + state: State::Active, + updated_at: "2026-08-03T18:00:00Z".into(), + active: Some(ActivePayload { + definition: DefinitionBinding { + revision: 1, + event_id: definition_event.id.to_hex(), + content_sha256: content_sha256(definition_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: definition_event, + }, + }, + instance_projection: InstanceBinding { + event_id: instance_event.id.to_hex(), + content_sha256: content_sha256(instance_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: instance_event, + }, + }, + identity: PrivateIdentity { + private_key_nsec: agent.secret_key().to_bech32().unwrap(), + auth_tag: None, + }, + config: PrivateConfig { + definition_coordinate: Some(format!( + "30175:{}:test-agent", + owner.public_key().to_hex() + )), + relay_url: "wss://relay.example".into(), + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: Some(300), + max_turn_duration_seconds: None, + env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]), + backend: serde_json::json!({"type": "local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + }, + }), + deleted_at: None, + extensions: BTreeMap::new(), + } + } + + #[test] + fn owner_self_round_trip_binds_outer_and_inner() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let expected = payload(&owner, &agent); + let event = build_event(&owner, &expected, 1_785_780_000).unwrap(); + let (envelope, actual) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(actual, expected); + assert_eq!(envelope.agent_pubkey, agent.public_key()); + assert_eq!(envelope.owner_pubkey, owner.public_key()); + assert_eq!(envelope.generation, 1); + assert_eq!(envelope.state, State::Active); + } + + #[test] + fn debug_output_redacts_private_material() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let private_key_nsec = candidate + .active + .as_ref() + .unwrap() + .identity + .private_key_nsec + .clone(); + let active = candidate.active.as_mut().unwrap(); + active.identity.auth_tag = Some("secret-auth-tag".into()); + active.config.backend = serde_json::json!({"token": "secret-backend-token"}); + + let debug = format!("{candidate:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(&private_key_nsec)); + assert!(!debug.contains("secret-auth-tag")); + assert!(!debug.contains("not-public")); + assert!(!debug.contains("secret-backend-token")); + } + + #[test] + fn wrong_owner_and_tampering_fail_closed() { + let owner = Keys::generate(); + let event = + build_event(&owner, &payload(&owner, &Keys::generate()), 1_785_780_000).unwrap(); + let stranger = Keys::generate(); + assert!(matches!( + validate_and_decrypt(&event, &stranger), + Err(Error::InvalidEnvelope(_)) + )); + + let mut tampered = event; + tampered.content.push('A'); + assert!(matches!( + validate_and_decrypt(&tampered, &owner), + Err(Error::InvalidEnvelope(_)) + )); + } + + #[test] + fn duplicate_and_unknown_json_fields_are_rejected() { + let duplicate = br#"{"format":"a","format":"b"}"#; + assert!(matches!( + parse_strict_json(duplicate), + Err(Error::InvalidPayload(message)) if message.contains("duplicate key") + )); + + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("surprise".into(), Value::Bool(true)); + let err = serde_json::from_value::(value).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + candidate.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &agent)); + validate_payload(&candidate).unwrap(); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&Keys::generate(), &agent)); + assert!(validate_payload(&candidate).is_err()); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&owner, &Keys::generate())); + assert!(validate_payload(&candidate).is_err()); + + let mut self_attested = payload(&owner, &owner); + self_attested.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &owner)); + assert!(matches!( + validate_payload(&self_attested), + Err(Error::InvalidPayload(message)) if message.contains("distinct agent key") + )); + + let valid = auth_tag(&owner, &agent); + let mut parts: Vec = serde_json::from_str(&valid).unwrap(); + parts[2] = "kind=9".into(); + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(serde_json::to_string(&parts).unwrap()); + assert!(validate_payload(&candidate).is_err()); + } + + #[test] + fn active_identity_must_derive_coordinate() { + let owner = Keys::generate(); + let mut candidate = payload(&owner, &Keys::generate()); + candidate.active.as_mut().unwrap().identity.private_key_nsec = + Keys::generate().secret_key().to_bech32().unwrap(); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not derive") + )); + } + + #[test] + fn tombstone_requires_successor_shape() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut deleted = payload(&owner, &agent); + deleted.generation = 2; + deleted.previous_event_id = Some("33".repeat(32)); + deleted.state = State::Deleted; + deleted.active = None; + deleted.deleted_at = Some("2026-08-03T18:01:00Z".into()); + validate_payload(&deleted).unwrap(); + + deleted.previous_event_id = None; + assert!(validate_payload(&deleted).is_err()); + } + + #[test] + fn outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let body = payload(&owner, &agent); + let ciphertext = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + serde_json::to_string(&body).unwrap(), + Version::V2, + ) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(vec![ + Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), + Tag::parse(["g", "01"]).unwrap(), + Tag::parse(["state", "active"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(matches!( + validate_envelope(&event, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") + )); + } + + #[test] + fn projection_recovery_must_match_binding_and_coordinate() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let active = candidate.active.as_mut().unwrap(); + active.instance_projection.content_sha256 = content_sha256(b"wrong"); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .config + .definition_coordinate = + Some(format!("30175:{}:wrong-slug", owner.public_key().to_hex())); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate") + )); + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .version = 2; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .signed_event + .content + .push('!'); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event") + )); + + let mut candidate = payload(&owner, &agent); + let wrong_kind = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = wrong_kind.id.to_hex(); + definition.content_sha256 = content_sha256(wrong_kind.content.as_bytes()); + definition.recovery.signed_event = wrong_kind; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = missing_d.id.to_hex(); + definition.content_sha256 = content_sha256(missing_d.content.as_bytes()); + definition.recovery.signed_event = missing_d; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("exactly one non-empty d tag") + )); + } + + #[test] + fn projection_hash_fixture_is_stable() { + assert_eq!( + content_sha256(b"buzz-private-managed-agent-v1"), + "c3ca1603249c95343fc1766ba58d075d6bdf0e57b375bef38738729b2022cc80" + ); + } +} diff --git a/crates/buzz-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/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. From 8a7eb8d3d71cd6ecc988963b36ff5777715f33ef Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:51:05 -0400 Subject: [PATCH 04/11] fix(agent): recover from unsupported image input instead of poisoning the turn (#4896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `buzz-dev-mcp` advertises `view_image` to every agent regardless of whether the session's model accepts images. When a text-only model (e.g. DeepSeek V4 Flash) takes the bait, the image lands in session history and every subsequent LLM request 404s with `No endpoints found that support image input`. The error was classified as `LlmModelNotFound` and propagated fatally out of the turn loop β€” history stays poisoned, buzz-acp retries the batch with exponential backoff, and the session burns its entire clock doing no work. In a recent trial run, **all 57 trials that called `view_image` on a text-only model died this way; none recovered.** ## Fix Capability-gating the advertised tool isn't reliable β€” there is no image-capability metadata at the agent layer across providers. Instead, recover at the turn loop: - **Typed error**: new `AgentError::UnsupportedImageInput`, classified narrowly on the exact provider phrase `No endpoints found that support image input` on both the generic 404 path and OpenRouter's 404 path. Unknown-model 404s and OpenRouter parameter-routing 404s keep their existing classifications. No deterministic retry. - **In-turn recovery**: on this error, `RunCtx::run` strips every image block from history β€” keeping the tool result (and therefore tool-call/result pairing) intact β€” marks the result `is_error`, appends actionable model-facing guidance ("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…"), and continues the same turn. Base64 never replays again. - **Loop guard**: recovery only fires when at least one image was removed; if the provider says "image" and history has none, the error propagates as before. ## Tests - Unit: phrase classification (typed, not retried; unknown-model 404 unaffected), idempotent image-to-error history mutation preserving call IDs and text. - End-to-end (`fake_llm.rs` + `fake_mcp.rs`): tool call β†’ MCP image result β†’ 404 unsupported-image β†’ same-turn recovery. Captured requests prove round 2 carried the image, round 3 replays no image, carries the guidance text, preserves pairing, and ends `end_turn`. - Loop guard: typed unsupported-image error with **no** image in history fails after exactly one provider request instead of spinning β€” mutation-testing showed deleting the `removed == 0` guard survived the suite, and `max_rounds` defaults to unlimited in production, so this branch needed direct coverage. Verified at `a210305019b33d5f56677b4c82bab79e4ac52d24`: `cargo test -p buzz-agent` (full package, 381 unit + all integration suites) green; `clippy --all-targets -D warnings` green; `fmt --check` green; pre-push hooks (rust-tests, desktop-tauri-checks, branch-skew) green. **Scope of the classification guarantee**: the classifier runs in the shared `post()` (which Anthropic and OpenAI paths route through) and in `openrouter_post()` β€” i.e., every 404 path in `llm.rs`. It only runs on 404 responses; providers that reject images with a different status (e.g. a 400) are out of scope for this PR β€” see the review-comment discussion for why broadening the phrase list alone would not cover them. Authored by Wren, loop-guard test by Sami, reviewed by Eva. --------- Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Sami Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: Sami --- crates/buzz-agent/src/agent.rs | 89 ++++++++++- crates/buzz-agent/src/llm.rs | 45 +++++- crates/buzz-agent/src/types.rs | 5 + crates/buzz-agent/tests/bin/fake_mcp.rs | 11 +- crates/buzz-agent/tests/fake_llm.rs | 198 +++++++++++++++++++++++- 5 files changed, 335 insertions(+), 13 deletions(-) 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 Date: Wed, 5 Aug 2026 12:32:41 -0500 Subject: [PATCH 05/11] fix(git): revoke access for banned relay members (#4608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change rechecks the durable community ban in the shared Git HTTP authentication path for advertise, fetch, and push requests. A banned member is denied even if repository-channel membership still exists, and restriction lookup errors fail closed. The additional database lookup happens on every Git HTTP request so access revocation does not depend on stale session state. The check also cascades to the NIP-OA owner. Git accepts NIP-OA attestations on the NIP-98 token, so an agent key can act for its owner β€” without the cascade, a banned human would keep clone and push access through any agent key. This mirrors the NIP-42 gate in `handlers::auth`: either principal's ban denies the request. The check runs inside the `GitAuth` extractor, so all three Git routes inherit it. ## Testing - `git diff --check origin/main...codex/security-ban-revokes-git` - Rebased onto `origin/main` at `5c98932` - `cargo test -p buzz-relay --lib sec005_read_gate_tests`: 8 passed, 7 ignored (Postgres) - `cargo clippy -p buzz-relay --all-targets -- -D warnings` and `cargo fmt --check`: clean Pure tests cover the decision table (agent ban, inherited owner ban, no attestation). Postgres-gated tests cover the wiring: the real ban row, a live `compute_auth_tag` attestation, and the 503 fail-closed path. **Not yet verified:** the three Postgres-gated tests compile and skip but have not been run β€” no local Postgres, and CI does not run `--ignored`. They need `cargo test -p buzz-relay --lib sec005_read_gate_tests -- --ignored` against a migrated dev database. Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom Signed-off-by: Eli Foster Co-authored-by: Eli Foster Co-authored-by: Claude Opus 5 --- crates/buzz-relay/src/api/git/transport.rs | 280 +++++++++++++++++++++ 1 file changed, 280 insertions(+) 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"); + } } From 885bed35eee3f933c48d333c8979fdbc038e98b9 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 5 Aug 2026 12:35:40 -0500 Subject: [PATCH 06/11] fix(workflow): bind trigger author to the signed event (#4607) This change derives `trigger_author` exclusively from the signed event pubkey. Actor tags remain available as event data but cannot override the identity used by author-sensitive workflow conditions. This removes the impersonation path without changing workflow definitions or requiring stored-data migration. ## Testing - `bin/cargo test -p buzz-workflow` at `78819df`: 154 passed, 2 Postgres-dependent tests ignored - `git diff --check origin/main...codex/security-workflow-trigger-author` Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` Signed-off-by: Jordan Mecom --- crates/buzz-workflow/src/lib.rs | 34 +++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) 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(); From ad538bfb1e6bfebcb03afaf4dd4d22323e7e62bd Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 5 Aug 2026 12:38:51 -0500 Subject: [PATCH 07/11] fix(acp): reject unattended permission requests (#4609) This change removes the ACP permission-bypass mode, defaults managed sessions to `dontAsk`, and answers permission requests with `reject_once` or cancellation in both ACP read loops. Unattended operations that require interactive approval now fail closed instead of being silently authorized. Explicit non-interactive modes that do not bypass a permission request remain available. Both layers have to change together: `apply_permission_mode` treats an unsupported mode and a failed `set_config_option` as non-fatal by design, so a request can still reach the harness even in a non-interactive mode. Removing `bypassPermissions` from the enum rather than only changing the default means the mode cannot be restored by configuration alone. The scope of the guarantee is that `buzz-acp` never grants approval. An agent that pre-authorizes tools in its own configuration (for example Claude Code's `settings.json`) still runs them without asking, which is outside this harness. ## Testing - `env -u BUZZ_ACP_LAZY_POOL bin/cargo test -p buzz-acp` at `16fff4d`: 671 library tests and 9 integration tests passed - `cargo clippy -p buzz-acp --all-targets -- -D warnings` and `cargo fmt -p buzz-acp -- --check`: clean - `git diff --check origin/main...codex/security-acp-shell-auto-approval` The permission tests previously re-implemented the `reject_once` lookup in the test body instead of calling the code under test, so they would have passed unchanged if the harness went back to selecting `allow_once`. They could not call it directly, because `handle_permission_request` is a method on `AcpClient`, which owns a live `Child` and its stdio pipes. The choice is now a free function, `permission_denial_response`, and the tests exercise it: `reject_once` preferred over offered allow options, the cancelled fallback when no `reject_once` exists, an empty option list, and a `reject_once` missing its `optionId`. The cancelled fallback had no coverage before despite being the fail-closed backstop. ## Operator notes - `BUZZ_ACP_PERMISSION_MODE=bypassPermissions` no longer parses, so a process configured with it fails to start rather than silently downgrading. - Desktop managed agents do not set a permission mode, so they inherit `dontAsk`. The desktop has no permission prompt, so operations needing approval now fail with no in-app way to approve them. Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom Signed-off-by: Eli Foster Co-authored-by: Eli Foster Co-authored-by: Claude Opus 5 --- crates/buzz-acp/src/acp.rs | 193 ++++++++++++++++++++-------------- crates/buzz-acp/src/config.rs | 51 ++++----- crates/buzz-acp/src/lib.rs | 4 +- crates/buzz-acp/src/pool.rs | 16 +-- 4 files changed, 148 insertions(+), 116 deletions(-) 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(_) => { From efe1893dd372cfb92ed2e8a3ada2ed7b62c9477a Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Wed, 5 Aug 2026 12:47:00 -0500 Subject: [PATCH 08/11] fix(channels): restrict private-channel invitations (#4612) This change requires an active owner or administrator for third-party additions to private channels. The relay validator and transactional database authority enforce the same rule, including removed-member reactivation and role-change paths. Idempotent self-target behavior remains available, while ordinary members can no longer extend private-channel access to another identity. ## Testing - `git diff --check origin/main...codex/security-private-channel-invite-authority` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom Signed-off-by: Eli Foster Co-authored-by: Eli Foster --- VISION.md | 2 +- crates/buzz-db/src/channel.rs | 14 +- .../buzz-relay/src/handlers/side_effects.rs | 38 +-- crates/buzz-test-client/tests/e2e_relay.rs | 78 +++++- desktop/src/features/channels/hooks.ts | 28 +++ .../lib/channelMemberAdmission.test.mjs | 79 ++++++ .../channels/lib/channelMemberAdmission.ts | 36 +++ .../features/channels/ui/MembersSidebar.tsx | 27 ++- .../messages/lib/dmThreadAgentMentionError.ts | 57 +++++ .../features/messages/ui/MessageComposer.tsx | 10 +- .../messages/ui/NonMemberMentionDialog.tsx | 29 ++- .../messages/ui/useMentionSendFlow.ts | 86 ++++--- desktop/tests/e2e/channels.spec.ts | 45 +++- mobile/lib/features/channels/channel.dart | 16 ++ .../channels/channel_management_provider.dart | 56 ++++- .../compose_bar/compose_bar_widget.dart | 91 +++---- .../channels/compose_bar/helpers.dart | 228 ++++++++++++++++-- .../test/features/channels/channel_test.dart | 46 ++++ .../features/channels/compose_bar_test.dart | 82 ++++++- 19 files changed, 858 insertions(+), 190 deletions(-) create mode 100644 desktop/src/features/channels/lib/channelMemberAdmission.test.mjs create mode 100644 desktop/src/features/channels/lib/channelMemberAdmission.ts create mode 100644 desktop/src/features/messages/lib/dmThreadAgentMentionError.ts 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-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/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/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..5e9e6740c3 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -14,6 +14,10 @@ import { 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, @@ -240,9 +244,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: @@ -723,6 +736,14 @@ export function MembersSidebar({ value={searchQuery} /> + {showPrivateAddDeniedNotice ? ( +

+ {PRIVATE_CHANNEL_ADD_DENIED_MESSAGE} +

+ ) : null}
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/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/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0bf65c16da..858f6ac3b9 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -3750,7 +3750,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 +3764,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/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/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/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/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), From 4674750b7ee1b3a6b299b79d69f11a8ec5128f26 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:00:14 -0600 Subject: [PATCH 09/11] fix(release): tag immutable desktop candidates (#4811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Redesign the permanent Desktop release flow so unrelated merges to `main` cannot invalidate an already reviewed, green release candidate. - Tag the immutable, API-confirmed release PR head instead of its later squash commit. - Treat the merged PRβ€”including an authorized owner/admin bypassβ€”as publication authorization, while requiring trusted check evidence that was complete at merge time. - Make tag creation idempotent and collision-safe: an existing tag succeeds only at the exact candidate SHA, and create races refetch before accepting equality. - Replace ancestry-based previous-release discovery with a validated metadata ledger for side-history candidate tags. - Compute the next release from the prior frozen base to the new frozen base, excluding only the prior release squash SHA so unrelated commits remain in the changelog. - Preserve schema-1 production-tag migration and reject malformed metadata or equal/decreasing versions. - Update operator documentation for the normal squash-merge workflow. This is the reusable release process for `0.5.6` onward, not the retired one-shot `0.5.5` recovery path. ### Invariants covered - Candidate creation β†’ unrelated `main` merge β†’ authorized squash merge β†’ immutable candidate tag. - Trusted producer IDs and merge-time completion timestamps; DCO's bounded post-merge exception remains isolated. - Missing/spoofed checks, tampered candidates, ambiguous PR associations, conflicting tags, and equal/decreasing versions fail closed. - Same-SHA retries succeed; different-SHA collisions fail. - Legacy schema-1 tag-on-main migration and schema-2 side-history accounting both preserve the correct next-release changelog. ### Related issue N/A β€” follows the Desktop release failures in #4788 and #4800 and the recovery revert in #4808. ### Testing At clean commit `6a91fbed8147a48cf174997de0c3e4cb2fb26474`: - `scripts/test-desktop-release-candidate.sh` - `scripts/test-release-ref-contract.sh` Both focused suites passed with HEAD unchanged. Princess Donut cleared the security/provenance surface, including the hostile merge-time timestamp cases. Mongo cleared the side-history ledger, migration, version-order, documentation, and contract-test surface. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../auto-tag-on-release-pr-merge.yml | 17 +- .../workflows/desktop-release-candidate.yml | 2 + RELEASING.md | 46 ++--- scripts/desktop_release.py | 151 +++++++++++---- scripts/prepare-desktop-release.sh | 4 +- scripts/required-check-succeeded.jq | 19 +- scripts/review-decision-approved.jq | 1 - scripts/test-desktop-release-authorization.sh | 69 ------- scripts/test-desktop-release-candidate.sh | 174 ++++++++++++++---- scripts/test-release-ref-contract.sh | 110 ++++++----- .../verify-desktop-release-authorization.sh | 15 -- scripts/verify-desktop-release-merge.sh | 95 ++++++---- 12 files changed, 434 insertions(+), 269 deletions(-) delete mode 100644 scripts/review-decision-approved.jq delete mode 100755 scripts/test-desktop-release-authorization.sh delete mode 100755 scripts/verify-desktop-release-authorization.sh 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/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" From ff0b7982f1af694056acbb0e2f8c40faefc39c45 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 5 Aug 2026 19:01:20 +0100 Subject: [PATCH 10/11] Polish mobile top navigation (#4778) ## Summary - Polish mobile Home, Activity, Search, and Settings navigation chrome. - Add progressive Buzz gradients/frost, aligned theme colors, dividers, typography, and section spacing. - Refine Search and Settings motion, including automatic keyboard focus on search activation. C78FA3CE-F2B3-45F2-B9F5-7EA7500778CC 5C058A73-1879-476A-881C-531ACC256D84 3F50ADB7-9BDA-4A8D-A81E-20560C3B9EA6 35ECE741-01F3-4B79-80C5-1DDD447121A7 ## Validation - `flutter analyze` - Focused Home, Activity, Channels, Search, theme, and footer widget tests - Full pre-push checks, including mobile tests, desktop checks, and Tauri checks - On-device iPhone review during the visual polish pass --------- Signed-off-by: kenny lopez Signed-off-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez Co-authored-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> --- .../lib/features/activity/activity_page.dart | 145 ++++-- .../activity_page/header_actions.dart | 64 ++- .../activity/activity_page/lists.dart | 11 +- .../activity/activity_page/status_views.dart | 5 +- .../lib/features/channels/channels_page.dart | 245 ++++++++- .../features/channels/channels_page/body.dart | 37 +- .../channels/channels_page/channel_tile.dart | 13 +- .../channels/channels_page/community.dart | 55 +- .../channels/channels_page/sections.dart | 16 +- mobile/lib/features/home/home_page.dart | 195 +++++--- .../lib/features/profile/profile_avatar.dart | 16 +- .../features/profile/profile_provider.dart | 50 +- .../profile/settings_profile_header.dart | 152 +++++- mobile/lib/features/search/search_page.dart | 471 ++++++++++++------ .../search/search_page/motion_field.dart | 113 +++++ .../lib/features/settings/settings_page.dart | 34 +- mobile/lib/shared/theme/buzz_theme.dart | 46 ++ .../shared/widgets/anchored_popover_menu.dart | 9 + .../lib/shared/widgets/frosted_app_bar.dart | 227 ++++++--- .../lib/shared/widgets/frosted_scaffold.dart | 49 +- .../widgets/mobile_tab_footer_backdrop.dart | 35 +- .../features/activity/activity_page_test.dart | 90 +++- .../features/channels/channels_page_test.dart | 368 +++++++++++++- mobile/test/features/home/home_page_test.dart | 43 +- .../profile/profile_provider_test.dart | 90 ++++ .../profile/settings_profile_header_test.dart | 112 +++++ .../features/search/search_page_test.dart | 311 ++++++++++-- mobile/test/shared/theme/buzz_theme_test.dart | 53 ++ .../mobile_tab_footer_backdrop_test.dart | 22 +- 29 files changed, 2583 insertions(+), 494 deletions(-) create mode 100644 mobile/lib/features/search/search_page/motion_field.dart create mode 100644 mobile/test/features/profile/profile_provider_test.dart 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/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/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/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/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); }); } From 014562c063eae6ab1b7c6e3d20f2be3024c5f3a8 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 5 Aug 2026 12:15:11 -0600 Subject: [PATCH 11/11] fix(desktop): allow shared agent mentions (#4913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - admit relay-discovered agents to autocomplete when their response policy authorizes the viewer - require authorization in the exact active stream/forum channel for mentions, while keeping community-wide discovery for member invitation - fail closed for relay-only agents in DMs and unresolved composer contexts - re-authorize cached autocomplete rows after policy/channel changes so stale agent suggestions cannot leak back in - preserve managed-agent behavior and explicitly reject stale agent-marked channel members absent from both live directories ## Validation - `pnpm --dir desktop test` β€” 4,288 passed - `pnpm --dir desktop typecheck` - `pnpm --dir desktop check` - `pnpm --dir desktop build:e2e` - focused Playwright mention matrix β€” 12 passed - focused Playwright member-invitation matrix β€” 2 passed - pre-push hooks after rebase to current `origin/main` β€” desktop check and 4,288 tests passed - independent correctness/privacy re-review cleared with no remaining blocker ## Related competing PRs This supersedes or overlaps #2333, #3056, #4242, #4137, #2314, #4058, and #2605. This version adds exact-channel authorization, fail-closed DM/context handling, cached-row reauthorization, forum coverage, outbound mention-tag coverage, explicit stale-member coverage, and add-member discovery coverage. Signed-off-by: Wes Co-authored-by: Carl --- .../lib/agentAutocompleteEligibility.test.mjs | 111 +++++++- .../lib/agentAutocompleteEligibility.ts | 85 +++++- .../features/channels/ui/MembersSidebar.tsx | 21 +- .../src/features/forum/ui/ForumComposer.tsx | 3 +- .../features/forum/ui/ForumComposer.types.ts | 4 +- .../features/forum/ui/ForumThreadPanel.tsx | 1 + desktop/src/features/forum/ui/ForumView.tsx | 1 + .../src/features/messages/lib/useMentions.ts | 52 ++-- .../messages/ui/useNewMessageRecipients.ts | 1 + .../projects/ui/ProjectsAgentPromptPage.tsx | 1 + desktop/tests/e2e/channels.spec.ts | 44 ++++ desktop/tests/e2e/mentions.spec.ts | 247 +++++++++++++++++- desktop/tests/helpers/bridge.ts | 1 + 13 files changed, 524 insertions(+), 48 deletions(-) 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/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 5e9e6740c3..2659a9c1d4 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -5,11 +5,14 @@ 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"; @@ -159,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 }) => { @@ -284,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); @@ -295,7 +306,7 @@ export function MembersSidebar({ )) || memberPubkeys.has(pubkey) || isArchivedDiscovery(pubkey) || - !isAgentIdentityInManagedList(candidate, managedAgentPubkeys) + !isAgentIdentityInAllowedList(candidate, allowedAgentPubkeys) ) { return; } @@ -374,6 +385,7 @@ export function MembersSidebar({ }); }, [ canAddMembers, + channelsQuery.data, isArchivedDiscovery, currentPubkey, managedAgentsQuery.data, @@ -386,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, 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/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/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 858f6ac3b9..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, }) => { 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[];