From 822c5ab231bc253d809d2d13da4b381f723dcd25 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 25 Aug 2026 08:18:22 +0100 Subject: [PATCH 01/24] Hide Huddles in mobile agent DMs (#6676) ## Summary - hide the mobile Huddle action in one-to-one agent DMs - preserve Huddles for human DMs and group DMs ## Testing - just mobile-check - flutter test (1,662 tests) --------- Signed-off-by: kenny lopez Signed-off-by: Princess Donut Co-authored-by: Princess Donut --- .../channels/channel_detail_page.dart | 247 +++- .../channels/channel_management_provider.dart | 6 +- .../mentions/agent_identity_provider.dart | 66 +- .../shared/profile/user_cache_provider.dart | 92 +- mobile/lib/shared/relay/relay_session.dart | 107 +- .../lib/shared/relay/relay_session_auth.dart | 33 + .../lib/shared/relay/relay_session_types.dart | 25 + .../channels/channel_detail_page_test.dart | 1115 ++++++++++++++++- .../features/channels/reaction_row_test.dart | 2 +- .../agent_identity_provider_test.dart | 130 +- .../profile/user_cache_provider_test.dart | 226 ++++ .../test/shared/relay/relay_session_test.dart | 55 + 12 files changed, 1994 insertions(+), 110 deletions(-) create mode 100644 mobile/lib/shared/relay/relay_session_auth.dart create mode 100644 mobile/lib/shared/relay/relay_session_types.dart create mode 100644 mobile/test/shared/profile/user_cache_provider_test.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b195dc9eb8..a5493de11b 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -107,17 +107,99 @@ Future _loadDeepLinkEvents( } /// Fetch channel members and preload their profiles into the user cache. -Future _preloadMembers(WidgetRef ref, String channelId) async { +/// One-to-one DMs additionally refresh participant profiles for identity gates. +/// Returns whether identity resolution completed successfully. +Future _preloadMembers( + WidgetRef ref, + String channelId, + List participantPubkeys, { + required bool refreshDmParticipants, +}) async { // Capture references before async gap to avoid using disposed ref. final notifier = ref.read(userCacheProvider.notifier); try { final members = await ref.read(channelMembersProvider(channelId).future); - final pubkeys = members.map((m) => m.pubkey).toList(); - if (pubkeys.isNotEmpty) { - notifier.preload(pubkeys); + await notifier.preload(members.map((member) => member.pubkey).toList()); + if (refreshDmParticipants) { + return notifier.refresh(participantPubkeys); } + return true; } catch (_) { - // Non-fatal — mentions will just fall back to cache from messages. + // Identity remains unresolved, so agent-only actions stay hidden. + return false; + } +} + +Future _subscribeToDmIdentityUpdates( + WidgetRef ref, + List participantPubkeys, { + required ValueChanged onReadyChanged, + required ValueChanged> onAgentPubkeysChanged, + required VoidCallback onFailure, +}) async { + final session = ref.read(relaySessionProvider.notifier); + var subscriptionStatus = RelaySubscriptionStatus.retrying; + var directLookupComplete = false; + final agentPubkeys = {}; + + void publishAgentPubkeys() { + onAgentPubkeysChanged(Set.unmodifiable(agentPubkeys)); + } + + void handleEvent(NostrEvent event) { + if (event.kind == 0) { + try { + ref.read(userCacheProvider.notifier).cacheProfileEvent(event); + } catch (error) { + debugPrint('[DmIdentity] invalid live profile: $error'); + onFailure(); + } + } else if (event.kind == 10100) { + agentPubkeys.add(event.pubkey.toLowerCase()); + publishAgentPubkeys(); + ref.invalidate(agentDirectoryProvider); + ref.invalidate(agentOwnersProvider); + } + } + + final unsubscribe = await session.subscribeWithStatus( + NostrFilter( + kinds: const [0, 10100], + authors: participantPubkeys, + limit: 100, + ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000 - 5), + handleEvent, + onClosed: (_) => onFailure(), + onStatusChanged: (status) { + subscriptionStatus = status; + if (status == RelaySubscriptionStatus.retrying) { + onReadyChanged(false); + } else if (directLookupComplete) { + onReadyChanged(true); + } + }, + ); + + try { + final profiles = await session.fetchHistory( + NostrFilter( + kinds: const [10100], + authors: participantPubkeys, + limit: participantPubkeys.length, + ), + ); + for (final profile in profiles) { + if (profile.kind == 10100) { + agentPubkeys.add(profile.pubkey.toLowerCase()); + } + } + publishAgentPubkeys(); + directLookupComplete = true; + onReadyChanged(subscriptionStatus == RelaySubscriptionStatus.ready); + return unsubscribe; + } catch (_) { + unsubscribe(); + rethrow; } } @@ -146,6 +228,16 @@ int? _channelReadTimestamp({ return dateTimeToUnixSeconds(channel.lastMessageAt); } +bool _isOneToOneAgentDm(Channel channel, Set agentPubkeys) { + final participants = channel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet(); + return channel.isDm && + participants.length == 2 && + participants.any(agentPubkeys.contains); +} + /// Controls how a hydrated initial thread is added to the navigation stack. enum InitialThreadRouteBehavior { /// Keep the channel route beneath the thread. @@ -256,10 +348,147 @@ class ChannelDetailPage extends HookConsumerWidget { channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; + final participantCount = resolvedChannel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .length; + final isOneToOneDm = resolvedChannel.isDm && participantCount == 2; + final memberProfilesPreload = useMemoized( + () => _preloadMembers( + ref, + resolvedChannel.id, + resolvedChannel.participantPubkeys, + refreshDmParticipants: isOneToOneDm, + ), + [ + resolvedChannel.id, + sessionStatus, + isOneToOneDm, + Object.hashAll(resolvedChannel.participantPubkeys), + ], + ); + final memberProfilesPreloadState = useFuture(memberProfilesPreload); final showsComposer = !resolvedChannel.isForum && resolvedChannel.isMember && !resolvedChannel.isArchived; + final profileOwnedAgentPubkeys = []; + for (final participantPubkey in resolvedChannel.participantPubkeys) { + final normalized = participantPubkey.trim().toLowerCase(); + final isProfileOwnedAgent = ref.watch( + userCacheProvider.select( + (cache) => cache[normalized]?.ownerPubkey != null, + ), + ); + if (isProfileOwnedAgent) profileOwnedAgentPubkeys.add(normalized); + } + final agentDirectoryState = ref.watch(agentDirectoryProvider); + final agentOwnersState = ref.watch(agentOwnersProvider); + final channelMembershipUpdateState = isOneToOneDm + ? ref.watch(channelMembershipUpdateProvider(resolvedChannel.id)) + : const ChannelMembershipUpdateState(isReady: true); + final channelBotPubkeysState = ref.watch( + channelBotPubkeysProvider(resolvedChannel.id), + ); + final identitySubscriptionPubkeys = isOneToOneDm + ? (resolvedChannel.participantPubkeys + .map((pubkey) => pubkey.trim().toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .toList() + ..sort()) + : const []; + final identitySubscriptionKey = Object.hashAll(identitySubscriptionPubkeys); + final identitySubscriptionReady = useValueNotifier(false, [ + sessionStatus, + resolvedChannel.id, + identitySubscriptionKey, + ]); + final directlyResolvedAgentPubkeys = useValueNotifier({}, [ + sessionStatus, + resolvedChannel.id, + identitySubscriptionKey, + ]); + final isIdentitySubscriptionReady = useValueListenable( + identitySubscriptionReady, + ); + final directAgentPubkeys = useValueListenable(directlyResolvedAgentPubkeys); + final agentPubkeys = agentPubkeysWithChannelBots( + knownAgentPubkeys: agentPubkeysWithProfileOwners( + knownAgentPubkeys: { + ...ref.watch(knownAgentPubkeysProvider), + ...directAgentPubkeys, + }, + profileOwnedAgentPubkeys: profileOwnedAgentPubkeys, + ), + channelBotPubkeys: + channelBotPubkeysState.asData?.value ?? const {}, + ); + useEffect(() { + if (sessionStatus != SessionStatus.connected || + identitySubscriptionPubkeys.isEmpty) { + return null; + } + var disposed = false; + var subscriptionFailed = false; + void markFailed() { + subscriptionFailed = true; + if (!disposed) identitySubscriptionReady.value = false; + } + + void Function()? unsubscribe; + Future.microtask(() async { + try { + final cleanup = await _subscribeToDmIdentityUpdates( + ref, + identitySubscriptionPubkeys, + onReadyChanged: (isReady) { + if (!disposed && !subscriptionFailed) { + identitySubscriptionReady.value = isReady; + } + }, + onAgentPubkeysChanged: (pubkeys) { + if (!disposed) directlyResolvedAgentPubkeys.value = pubkeys; + }, + onFailure: markFailed, + ); + if (disposed) { + cleanup(); + } else { + unsubscribe = cleanup; + } + } catch (error) { + if (!disposed) { + debugPrint('[DmIdentity] live subscription failed: $error'); + markFailed(); + } + } + }); + return () { + disposed = true; + unsubscribe?.call(); + }; + }, [sessionStatus, resolvedChannel.id, identitySubscriptionKey]); + final isAgentIdentityUnresolved = + isOneToOneDm && + (sessionStatus != SessionStatus.connected || + !isIdentitySubscriptionReady || + agentDirectoryState.isLoading || + agentDirectoryState.hasError || + agentOwnersState.isLoading || + agentOwnersState.hasError || + !channelMembershipUpdateState.isReady || + channelMembershipUpdateState.error != null || + channelBotPubkeysState.isLoading || + channelBotPubkeysState.hasError || + memberProfilesPreloadState.connectionState != + ConnectionState.done || + memberProfilesPreloadState.data != true); + final showsHuddleAction = + showsComposer && + !isAgentIdentityUnresolved && + !_isOneToOneAgentDm(resolvedChannel, agentPubkeys); final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, ); @@ -301,12 +530,6 @@ class ChannelDetailPage extends HookConsumerWidget { return session.registerVisibleChannel(channel.id); }, [channel.id]); - // Preload channel member profiles so @mentions resolve correctly. - useEffect(() { - _preloadMembers(ref, channel.id); - return null; - }, [channel.id]); - useEffect( () { if (channel.isForum) return null; @@ -394,7 +617,7 @@ class ChannelDetailPage extends HookConsumerWidget { ), actions: resolvedChannel.isDm ? [ - if (showsComposer) + if (showsHuddleAction) _HuddleButton( channel: resolvedChannel, events: [ diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 9e021ab517..119f5dce7b 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -488,7 +488,11 @@ final channelDetailsProvider = FutureProvider.family(( /// Channel members from kind:39002 NIP-29 members event. final channelMembersProvider = FutureProvider.autoDispose .family, String>((ref, channelId) async { - ref.watch(channelMembershipUpdateProvider(channelId)); + ref.watch( + channelMembershipUpdateProvider( + channelId, + ).select((update) => update.version), + ); final relayBaseUrl = ref.watch(relayConfigProvider).baseUrl; final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase(); final snapshotCache = ref.read(_channelMembersSnapshotCacheProvider); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index ea6a2ee50f..35c063d13d 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -161,10 +161,24 @@ Map mentionNamesWithDirectoryLabels({ String _agentFallbackLabel(String pubkey) => pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; +/// Readiness and refresh state for a channel's live membership subscription. +class ChannelMembershipUpdateState { + final int version; + final bool isReady; + final Object? error; + + const ChannelMembershipUpdateState({ + this.version = 0, + this.isReady = false, + this.error, + }); +} + /// Keeps the role feed alive for consumers that render mentions outside the /// channel timeline, such as search results. A membership change refreshes the /// shared bot-role lookup below, regardless of which surface owns the channel. -class _ChannelBotRoleSubscription extends Notifier { +class _ChannelBotRoleSubscription + extends Notifier { final String channelId; void Function()? _unsubscribe; int _subscriptionVersion = 0; @@ -172,7 +186,7 @@ class _ChannelBotRoleSubscription extends Notifier { _ChannelBotRoleSubscription(this.channelId); @override - int build() { + ChannelMembershipUpdateState build() { final sessionState = ref.watch(relaySessionProvider); final subscriptionVersion = ++_subscriptionVersion; _clearSubscription(); @@ -181,34 +195,64 @@ class _ChannelBotRoleSubscription extends Notifier { _clearSubscription(); }); - if (sessionState.status != SessionStatus.connected) return 0; + if (sessionState.status != SessionStatus.connected) { + return const ChannelMembershipUpdateState(); + } Future.microtask(() => _subscribe(channelId, subscriptionVersion)); - return 0; + return const ChannelMembershipUpdateState(); } Future _subscribe(String channelId, int subscriptionVersion) async { final session = ref.read(relaySessionProvider.notifier); + var subscriptionStatus = RelaySubscriptionStatus.retrying; try { - final unsubscribe = await session.subscribe( + final unsubscribe = await session.subscribeWithStatus( NostrFilter( kinds: const [39002], tags: { - '#h': [channelId], + '#d': [channelId], }, ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), (_) { if (_isCurrent(subscriptionVersion)) { - state++; + state = ChannelMembershipUpdateState( + version: state.version + 1, + isReady: state.isReady, + ); } }, + onClosed: (message) { + if (_isCurrent(subscriptionVersion)) { + state = ChannelMembershipUpdateState( + version: state.version, + error: Exception(message), + ); + } + }, + onStatusChanged: (status) { + subscriptionStatus = status; + if (!_isCurrent(subscriptionVersion)) return; + state = ChannelMembershipUpdateState( + version: state.version, + isReady: status == RelaySubscriptionStatus.ready, + ); + }, ); if (!_isCurrent(subscriptionVersion)) { unsubscribe(); return; } _unsubscribe = unsubscribe; + state = ChannelMembershipUpdateState( + version: state.version, + isReady: subscriptionStatus == RelaySubscriptionStatus.ready, + ); } catch (error) { if (_isCurrent(subscriptionVersion)) { + state = ChannelMembershipUpdateState( + version: state.version, + error: error, + ); debugPrint( '[ChannelBotRoleSubscription] failed for $channelId: $error', ); @@ -229,14 +273,18 @@ class _ChannelBotRoleSubscription extends Notifier { /// changes. Channel-member and agent-role views share this source so remote /// membership updates refresh both snapshots together. final channelMembershipUpdateProvider = NotifierProvider.autoDispose - .family<_ChannelBotRoleSubscription, int, String>( + .family<_ChannelBotRoleSubscription, ChannelMembershipUpdateState, String>( _ChannelBotRoleSubscription.new, ); /// Bot pubkeys currently assigned a channel bot role. final channelBotPubkeysProvider = FutureProvider.autoDispose .family, String>((ref, channelId) async { - ref.watch(channelMembershipUpdateProvider(channelId)); + ref.watch( + channelMembershipUpdateProvider( + channelId, + ).select((update) => update.version), + ); final sessionState = ref.watch(relaySessionProvider); if (sessionState.status != SessionStatus.connected) return const {}; final session = ref.read(relaySessionProvider.notifier); diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index fd8d3fafcd..d11db73b1c 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -12,14 +12,19 @@ import 'user_profile.dart'; /// kind:0 batch query (NIP-01 `authors` filter) every 50ms. class UserCacheNotifier extends Notifier> { final Set _pending = {}; + final Map _profileEventOrders = {}; Timer? _batchTimer; + Completer? _batchCompleter; @override Map build() { ref.watch(relayConfigProvider); + _profileEventOrders.clear(); ref.onDispose(() { _batchTimer?.cancel(); _batchTimer = null; + _batchCompleter?.complete(false); + _batchCompleter = null; }); return {}; } @@ -39,14 +44,52 @@ class UserCacheNotifier extends Notifier> { } /// Preload profiles for a list of pubkeys (e.g. channel members). - void preload(List pubkeys) { - final uncached = pubkeys + /// Returns whether the batch completed successfully. + Future preload(List pubkeys) { + final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet(); + final alreadyPending = normalized.any(_pending.contains); + final uncached = normalized .map((pk) => pk.toLowerCase()) .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) .toList(); - if (uncached.isEmpty) return; + if (uncached.isEmpty && !alreadyPending) return Future.value(true); _pending.addAll(uncached); + final completer = _batchCompleter ??= Completer(); _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + return completer.future; + } + + /// Force-refresh profiles for identity-sensitive gates. + /// + /// Unlike [preload], this fetches cached pubkeys too so stale human profiles + /// cannot be trusted after a verified agent-owner profile was published. + Future refresh(List pubkeys) async { + final normalized = pubkeys + .map((pubkey) => pubkey.toLowerCase()) + .where((pubkey) => pubkey.isNotEmpty) + .toSet() + .toList(); + if (normalized.isEmpty) return true; + try { + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch(normalized), + ); + final updated = Map.from(state); + final updatedOrders = Map.from( + _profileEventOrders, + ); + for (final event in events) { + _cacheProfileEvent(event, updated, updatedOrders); + } + _profileEventOrders + ..clear() + ..addAll(updatedOrders); + state = updated; + return true; + } catch (_) { + return false; + } } /// Applies a live kind:0 profile event to the cache. @@ -55,13 +98,14 @@ class UserCacheNotifier extends Notifier> { /// to update names and avatars without discarding the rest of the cache. void cacheProfileEvent(NostrEvent event) { if (event.kind != 0) return; - final profile = _profileFromEvent(event); - state = {...state, profile.pubkey: profile}; + final updated = Map.from(state); + if (_cacheProfileEvent(event, updated)) state = updated; } void _scheduleFetch(String pubkey) { if (state.containsKey(pubkey) || _pending.contains(pubkey)) return; _pending.add(pubkey); + _batchCompleter ??= Completer(); _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); } @@ -71,7 +115,10 @@ class UserCacheNotifier extends Notifier> { final pubkeys = _pending.toList(); _pending.clear(); + final completer = _batchCompleter; + _batchCompleter = null; + var succeeded = false; try { final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory( @@ -79,17 +126,46 @@ class UserCacheNotifier extends Notifier> { ); final updated = Map.from(state); + final updatedOrders = Map.from( + _profileEventOrders, + ); for (final event in events) { - final profile = _profileFromEvent(event); - updated[profile.pubkey] = profile; + _cacheProfileEvent(event, updated, updatedOrders); } + _profileEventOrders + ..clear() + ..addAll(updatedOrders); state = updated; + succeeded = true; } catch (_) { - // Silently fail — we'll just show pubkeys. + // Silently fail — non-gating callers will just show pubkeys. + } finally { + completer?.complete(succeeded); } } + bool _cacheProfileEvent( + NostrEvent event, + Map profiles, [ + Map? orders, + ]) { + if (event.kind != 0) return false; + final eventOrders = orders ?? _profileEventOrders; + final pubkey = event.pubkey.toLowerCase(); + final current = eventOrders[pubkey]; + final isNewer = + current == null || + event.createdAt > current.createdAt || + (event.createdAt == current.createdAt && + event.id.compareTo(current.eventId) < 0); + if (!isNewer) return false; + + profiles[pubkey] = _profileFromEvent(event); + eventOrders[pubkey] = (createdAt: event.createdAt, eventId: event.id); + return true; + } + UserProfile _profileFromEvent(NostrEvent event) { final data = ProfileData.fromEvent(event); final pubkey = data.pubkey.toLowerCase(); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 77e10e66ed..b8d13ee34a 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -17,17 +17,12 @@ import 'relay_closed_policy.dart'; import 'relay_http_query_client.dart'; import 'relay_provider.dart'; import 'relay_rate_limit_gate.dart'; +import 'relay_session_types.dart'; import 'relay_socket.dart'; -enum SessionStatus { disconnected, connecting, connected, reconnecting } +export 'relay_session_types.dart'; -@immutable -class SessionState { - final SessionStatus status; - final int reconnectAttempt; - - const SessionState({required this.status, this.reconnectAttempt = 0}); -} +part 'relay_session_auth.dart'; class _HistorySubscription { final List events = []; @@ -41,6 +36,7 @@ class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent; final void Function(String message)? onClosed; + final void Function(RelaySubscriptionStatus status)? onStatusChanged; Completer? readyCompleter; int? lastSeenCreatedAt; int closedRetryAttempt = 0; @@ -50,6 +46,7 @@ class _LiveSubscription { required this.filter, required this.onEvent, this.onClosed, + this.onStatusChanged, this.readyCompleter, }); } @@ -57,7 +54,6 @@ class _LiveSubscription { class _ClosedRetry { final _LiveSubscription subscription; final int generation; - _ClosedRetry({required this.subscription, required this.generation}); } @@ -75,16 +71,6 @@ class _BufferedEvent { _BufferedEvent(this.subId, this.event); } -/// Manages websocket subscriptions, batching, reconnection, and pending events. -typedef RelaySocketFactory = - RelaySocket Function({ - required String wsUrl, - required String? nsec, - required void Function(List message) onMessage, - required void Function() onConnected, - required void Function(Object? error) onDisconnected, - }); - class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, @@ -257,13 +243,38 @@ class RelaySessionNotifier extends Notifier { return completer.future; } - /// Subscribe to live events matching [filter]. Returns an unsubscribe - /// function. Live subscriptions survive reconnects — they are replayed with - /// `since: lastSeenCreatedAt - 5s` on reconnect. Future subscribe( NostrFilter filter, void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, + }) => _subscribe(filter, onEvent, onClosed: onClosed); + + /// Subscribe to a live stream and observe its recovery lifecycle. + /// + /// The returned future completes after initial EOSE (or the existing + /// fallback timeout) and yields a cleanup callback. [onStatusChanged] emits + /// [RelaySubscriptionStatus.ready] at each EOSE after buffered replay events + /// have been delivered, and [RelaySubscriptionStatus.retrying] immediately + /// when a retryable or rate-limited CLOSED begins backoff. Terminal CLOSED + /// invokes [onClosed] and removes the subscription instead of retrying it. + /// Calling the cleanup callback cancels pending retries and sends CLOSE. + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) => _subscribe( + filter, + onEvent, + onClosed: onClosed, + onStatusChanged: onStatusChanged, + ); + + Future _subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + void Function(RelaySubscriptionStatus status)? onStatusChanged, }) async { if (_disposed) throw StateError('Relay session is disposed'); final subId = _nextSubId('l'); @@ -273,12 +284,12 @@ class RelaySessionNotifier extends Notifier { filter: filter, onEvent: onEvent, onClosed: onClosed, + onStatusChanged: onStatusChanged, readyCompleter: readyCompleter, ); _sendReq(subId, filter); - // Wait for EOSE or a short fallback timeout. try { await readyCompleter.future.timeout( const Duration(milliseconds: 500), @@ -297,7 +308,6 @@ class RelaySessionNotifier extends Notifier { return () => _unsubscribe(subId); } - /// Publish an event and wait for the relay's OK confirmation. Future publish( NostrEvent event, { Duration timeout = const Duration(seconds: 8), @@ -324,8 +334,6 @@ class RelaySessionNotifier extends Notifier { return completer.future; } - /// Send a raw message over the WebSocket without waiting for acknowledgement. - /// Used for ephemeral events like typing indicators. void sendRaw(List payload) { _socket?.send(payload); } @@ -635,7 +643,8 @@ class RelaySessionNotifier extends Notifier { return; } - // Live subscriptions get batched. + // Live subscriptions get batched. An EVENT proves the stream is active, + // but not that a retry replay is complete; only EOSE is that boundary. final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { _resetClosedRetry(liveSub); @@ -664,19 +673,18 @@ class RelaySessionNotifier extends Notifier { return; } - // Live subscription: signal ready. + // Live subscription: flush replay callbacks before signaling ready. This + // ordering matters for retry replays, whose original ready completer has + // already been released. final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { _resetClosedRetry(liveSub); + _flushBufferedEventsNow(); + liveSub.onStatusChanged?.call(RelaySubscriptionStatus.ready); } if (liveSub != null && liveSub.readyCompleter != null && !liveSub.readyCompleter!.isCompleted) { - // EOSE is the boundary between replay and live delivery. Flush any - // replay events before resolving subscribe(), so callers that begin a - // one-shot query immediately afterwards cannot classify a delayed batch - // callback as having arrived during that query. - _flushBufferedEventsNow(); liveSub.readyCompleter!.complete(); liveSub.readyCompleter = null; } @@ -717,6 +725,7 @@ class RelaySessionNotifier extends Notifier { readyCompleter.complete(); liveSub.readyCompleter = null; } + liveSub.onStatusChanged?.call(RelaySubscriptionStatus.retrying); if (liveSub.closedRetryTimer != null) return; final attempt = liveSub.closedRetryAttempt; @@ -965,35 +974,3 @@ final relaySessionProvider = NotifierProvider( RelaySessionNotifier.new, ); - -String buildNip98AuthHeader({ - required String method, - required String url, - required List bodyBytes, - required String? nsec, -}) { - if (nsec == null || nsec.isEmpty) { - throw Exception('Cannot query relay: no signing key available'); - } - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) { - throw Exception('Invalid nsec'); - } - final payloadHash = SHA256Digest() - .process(Uint8List.fromList(bodyBytes)) - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(); - final event = nostr.Event.from( - kind: 27235, - content: '', - tags: [ - ['u', url], - ['method', method.toUpperCase()], - ['payload', payloadHash], - ['nonce', const Uuid().v4()], - ], - secretKey: privkeyHex, - verify: false, - ); - return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; -} diff --git a/mobile/lib/shared/relay/relay_session_auth.dart b/mobile/lib/shared/relay/relay_session_auth.dart new file mode 100644 index 0000000000..9c6c79ae02 --- /dev/null +++ b/mobile/lib/shared/relay/relay_session_auth.dart @@ -0,0 +1,33 @@ +part of 'relay_session.dart'; + +String buildNip98AuthHeader({ + required String method, + required String url, + required List bodyBytes, + required String? nsec, +}) { + if (nsec == null || nsec.isEmpty) { + throw Exception('Cannot query relay: no signing key available'); + } + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + if (privkeyHex.isEmpty) { + throw Exception('Invalid nsec'); + } + final payloadHash = SHA256Digest() + .process(Uint8List.fromList(bodyBytes)) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final event = nostr.Event.from( + kind: 27235, + content: '', + tags: [ + ['u', url], + ['method', method.toUpperCase()], + ['payload', payloadHash], + ['nonce', const Uuid().v4()], + ], + secretKey: privkeyHex, + verify: false, + ); + return 'Nostr ${base64.encode(utf8.encode(event.toJson()))}'; +} diff --git a/mobile/lib/shared/relay/relay_session_types.dart b/mobile/lib/shared/relay/relay_session_types.dart new file mode 100644 index 0000000000..fe3ac8c582 --- /dev/null +++ b/mobile/lib/shared/relay/relay_session_types.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; + +import 'relay_socket.dart'; + +enum SessionStatus { disconnected, connecting, connected, reconnecting } + +typedef RelaySocketFactory = + RelaySocket Function({ + required String wsUrl, + required String? nsec, + required void Function(List message) onMessage, + required void Function() onConnected, + required void Function(Object? error) onDisconnected, + }); + +@immutable +class SessionState { + final SessionStatus status; + final int reconnectAttempt; + + const SessionState({required this.status, this.reconnectAttempt = 0}); +} + +/// Recovery lifecycle for a live relay subscription. +enum RelaySubscriptionStatus { ready, retrying } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 195dcc25ad..3aabad062e 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -13,6 +13,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; @@ -200,7 +201,12 @@ Widget _buildTestable({ required List messages, List typing = const [], Map users = const {}, - _FakeUserCacheNotifier? userCacheNotifier, + Set? knownAgentPubkeys, + Future> Function()? loadChannelBotPubkeys, + bool watchChannelMembershipUpdates = false, + Future> Function()? loadAgentDirectory, + Future> Function()? loadAgentOwners, + UserCacheNotifier? userCacheNotifier, List members = const [], List huddleMembers = const [], _MutableHuddleMembersNotifier? huddleMembersNotifier, @@ -280,16 +286,24 @@ Widget _buildTestable({ ), if (huddleMembersNotifier != null) _mutableHuddleMembersProvider.overrideWith(() => huddleMembersNotifier), - channelBotPubkeysProvider( - _channelId, - ).overrideWith((ref) async => const {}), + if (!watchChannelMembershipUpdates) + channelBotPubkeysProvider(_channelId).overrideWith( + (ref) async => loadChannelBotPubkeys?.call() ?? const {}, + ), channelBotPubkeysProvider(_huddleChannelId).overrideWith( (ref) async => { for (final member in huddleMembers) if (member.isBot) member.pubkey.toLowerCase(), }, ), - agentOwnersProvider.overrideWith((ref) async => const {}), + agentOwnersProvider.overrideWith( + (ref) async => loadAgentOwners?.call() ?? const {}, + ), + agentDirectoryProvider.overrideWith( + (ref) async => loadAgentDirectory?.call() ?? const [], + ), + if (knownAgentPubkeys != null) + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), if (directoryUsers != null) relayDirectoryUsersProvider.overrideWith((ref) async => directoryUsers), if (createChannelActions != null) @@ -327,8 +341,12 @@ Widget _buildTestable({ ), mediaHttpClientProvider.overrideWithValue(mediaClient), ], - if (relaySessionNotifier != null) - relaySessionProvider.overrideWith(() => relaySessionNotifier), + if (relaySessionNotifier != null || + (resolvedChannel.isDm && + resolvedChannel.participantPubkeys.toSet().length == 2)) + relaySessionProvider.overrideWith( + () => relaySessionNotifier ?? _IdentityUpdateRelaySession(), + ), if (relayConfigNotifier != null) relayConfigProvider.overrideWith(() => relayConfigNotifier), if (huddleMediaFactory != null) @@ -501,8 +519,907 @@ void main() { expect(presence.style?.fontSize, 14); expect(presence.style?.fontWeight, FontWeight.w400); expect(find.byTooltip('View members'), findsNothing); + expect(find.byTooltip('Start Huddle'), findsOneWidget); }); + testWidgets('hides the Huddle action in a one-to-one agent DM', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message with an agent', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + users: const { + 'agent': UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('hides the Huddle action for a channel bot DM', (tester) async { + final dmChannel = Channel( + id: _channelId, + name: 'Bot DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message with a channel bot', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Bot'], + participantPubkeys: const ['self', 'bot'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadChannelBotPubkeys: () async => const {'bot'}, + users: const {'bot': UserProfile(pubkey: 'bot', displayName: 'Bot')}, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps the Huddle action hidden while agent identity loads', ( + tester, + ) async { + final directoryCompleter = Completer>(); + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadAgentDirectory: () => directoryCompleter.future, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pump(); + + expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); + expect(find.byTooltip('Start Huddle'), findsNothing); + + directoryCompleter.complete(const []); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsOneWidget); + }); + + testWidgets('preloads DM participant profiles without a member snapshot', ( + tester, + ) async { + final preloadedPubkeys = []; + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (pubkeys) async { + preloadedPubkeys.addAll(pubkeys); + return true; + }, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + ), + ); + await tester.pumpAndSettle(); + + expect(preloadedPubkeys, containsAll(const ['self', 'alice'])); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + }); + + testWidgets('force-refreshes cached profiles before enabling Huddle', ( + tester, + ) async { + late final _FakeUserCacheNotifier userCache; + userCache = _FakeUserCacheNotifier( + const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Cached Human'), + }, + preload: (_) async { + await Future.delayed(Duration.zero); + userCache.replace( + const UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + ); + return true; + }, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + ), + ); + await tester.pumpAndSettle(); + + expect(userCache.state['agent']?.ownerPubkey, 'owner'); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps Huddle hidden when live owner profile beats refresh', ( + tester, + ) async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final profileRefresh = Completer>(); + final relaySession = _IdentityUpdateRelaySession( + profileRefresh: profileRefresh.future, + ); + final userCache = UserCacheNotifier(); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: ['self', agent.public], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: agent.public, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitProfile( + _profileEvent( + id: 'newer-agent', + pubkey: agent.public, + createdAt: 2, + name: 'Agent', + tags: [_authTag(owner, agent.public)], + ), + ); + profileRefresh.complete([ + _profileEvent( + id: 'older-human', + pubkey: agent.public, + createdAt: 1, + name: 'Human', + ), + ]); + await tester.pumpAndSettle(); + + expect(userCache.state[agent.public]?.ownerPubkey, owner.public); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps Huddle hidden while a verified owner profile loads', ( + tester, + ) async { + final profilePreloadCompleter = Completer(); + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (_) => profilePreloadCompleter.future, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pump(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + + userCache.replace( + const UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + ); + profilePreloadCompleter.complete(true); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('rechecks verified owner profiles after reconnect', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final reconnectPreloadCompleter = Completer(); + var memberPreloadCount = 0; + var blockMemberPreload = false; + final userCache = _FakeUserCacheNotifier( + const {}, + preload: (pubkeys) { + if (pubkeys.length == 1) return Future.value(true); + memberPreloadCount++; + return blockMemberPreload + ? reconnectPreloadCompleter.future + : Future.value(true); + }, + ); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + userCacheNotifier: userCache, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsOneWidget); + final memberPreloadsBeforeReconnect = memberPreloadCount; + blockMemberPreload = true; + + relaySession.disconnect(); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.connect(); + await tester.pump(); + expect(memberPreloadCount, greaterThan(memberPreloadsBeforeReconnect)); + expect(find.byTooltip('Start Huddle'), findsNothing); + + userCache.replace( + const UserProfile( + pubkey: 'agent', + displayName: 'Agent', + ownerPubkey: 'owner', + ), + ); + reconnectPreloadCompleter.complete(true); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + await tester.pump(const Duration(milliseconds: 500)); + }); + + testWidgets('keeps directory-only agent Huddle hidden after disconnect', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + loadAgentDirectory: () async => const [ + AgentDirectoryEntry(pubkey: 'agent'), + ], + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.disconnect(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps bot-role-only Huddle hidden after disconnect', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Bot DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Bot'], + participantPubkeys: const ['self', 'bot'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + loadChannelBotPubkeys: () async => const {'bot'}, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'bot', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.disconnect(); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps Huddle hidden until bot-role replay reaches EOSE', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + watchChannelMembershipUpdates: true, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + + relaySession.beginMembershipReplay(); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitReplayedMembership( + NostrEvent( + id: 'membership-self', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: const [ + ['d', _channelId], + ['p', 'self'], + ], + content: '', + sig: 'sig', + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitReplayedMembership( + NostrEvent( + id: 'membership-bot', + pubkey: 'relay', + createdAt: 2, + kind: 39002, + tags: const [ + ['d', _channelId], + ['p', 'self'], + ['p', 'alice', '', 'bot'], + ], + content: '', + sig: 'sig', + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.finishMembershipReplay(); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps the Huddle action hidden when identity loading fails', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadAgentOwners: () => Future.error('identity unavailable'), + disableRetries: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps the Huddle action hidden when member preload fails', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Agent'], + participantPubkeys: const ['self', 'agent'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadMembers: () => Future.error('members unavailable'), + disableRetries: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('hides Huddle when a participant becomes an agent live', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + var directoryLoadCount = 0; + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + loadAgentDirectory: () async { + directoryLoadCount++; + return directoryLoadCount == 1 + ? const [] + : const [AgentDirectoryEntry(pubkey: 'alice')]; + }, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(relaySession.identityFilter?.kinds, const [0, 10100]); + expect(relaySession.identityFilter?.authors, contains('alice')); + expect(relaySession.identityFilter?.limit, 100); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + + relaySession.emitAgentProfile(pubkey: 'alice'); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('keeps Huddle hidden while identity replay retries', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + + relaySession.retryIdentitySubscription(); + await tester.pump(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.emitAgentProfile(pubkey: 'alice'); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + + relaySession.readyIdentitySubscription(); + await tester.pumpAndSettle(); + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets('queries DM participants directly for agent identity', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(relaySession.directIdentityFilter?.kinds, const [10100]); + expect( + relaySession.directIdentityFilter?.authors, + containsAll(const ['self', 'alice']), + ); + expect(relaySession.directIdentityFilter?.limit, 2); + }); + + testWidgets('hides Huddle for an agent found by direct DM lookup', ( + tester, + ) async { + final relaySession = _IdentityUpdateRelaySession() + ..directIdentityProfiles = const [ + NostrEvent( + id: 'old-agent-profile', + pubkey: 'alice', + createdAt: 1, + kind: 10100, + tags: [], + content: '{"name":"Agent"}', + sig: 'sig', + ), + ]; + final dmChannel = Channel( + id: _channelId, + name: 'Agent DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }); + + testWidgets( + 'keeps Huddle hidden if the live identity subscription closes', + (tester) async { + final relaySession = _IdentityUpdateRelaySession(); + final dmChannel = Channel( + id: _channelId, + name: 'Human DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + relaySessionNotifier: relaySession, + members: [ + ChannelMember( + pubkey: 'self', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'alice', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Start Huddle'), findsOneWidget); + + relaySession.closeIdentitySubscription(); + await tester.pump(); + + expect(find.byTooltip('Start Huddle'), findsNothing); + }, + ); + testWidgets('keeps the Members action for group DMs', (tester) async { final dmChannel = Channel( id: _channelId, @@ -522,6 +1439,7 @@ void main() { _buildTestable( messages: const [], channel: dmChannel, + knownAgentPubkeys: const {'alice'}, users: const { 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), @@ -531,6 +1449,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip('View members'), findsOneWidget); + expect(find.byTooltip('Start Huddle'), findsOneWidget); }); testWidgets( @@ -12295,6 +13214,142 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { } } +class _IdentityUpdateRelaySession extends RelaySessionNotifier { + _IdentityUpdateRelaySession({this.profileRefresh}); + + final Future>? profileRefresh; + NostrFilter? identityFilter; + NostrFilter? directIdentityFilter; + List directIdentityProfiles = const []; + void Function(NostrEvent)? _identityListener; + void Function(String message)? _identityClosedListener; + void Function(RelaySubscriptionStatus status)? _identityStatusListener; + void Function(NostrEvent)? _membershipListener; + void Function(RelaySubscriptionStatus status)? _membershipStatusListener; + NostrEvent? membershipSnapshot; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.length == 1 && filter.kinds.single == 0) { + return profileRefresh ?? const []; + } + if (filter.kinds.contains(10100) && filter.kinds.length == 1) { + directIdentityFilter = filter; + return directIdentityProfiles; + } + return membershipSnapshot == null ? const [] : [membershipSnapshot!]; + } + + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) async { + if (filter.kinds.contains(10100)) { + identityFilter = filter; + _identityListener = onEvent; + _identityClosedListener = onClosed; + _identityStatusListener = onStatusChanged; + onStatusChanged(RelaySubscriptionStatus.ready); + return () { + if (identical(_identityListener, onEvent)) { + _identityListener = null; + _identityClosedListener = null; + _identityStatusListener = null; + } + }; + } + _membershipListener = onEvent; + _membershipStatusListener = onStatusChanged; + onStatusChanged(RelaySubscriptionStatus.ready); + return () { + if (identical(_membershipListener, onEvent)) { + _membershipListener = null; + _membershipStatusListener = null; + } + }; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + if (filter.kinds.contains(10100)) { + identityFilter = filter; + _identityListener = onEvent; + _identityClosedListener = onClosed; + } + return () { + if (identical(_identityListener, onEvent)) { + _identityListener = null; + _identityClosedListener = null; + } + }; + } + + void emitProfile(NostrEvent event) { + _identityListener?.call(event); + } + + void emitAgentProfile({required String pubkey}) { + _identityListener?.call( + NostrEvent( + id: 'agent-profile-$pubkey', + pubkey: pubkey, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + kind: 10100, + tags: const [], + content: '{"name":"Agent"}', + sig: 'sig', + ), + ); + } + + void closeIdentitySubscription() { + _identityClosedListener?.call('unsupported filter'); + } + + void retryIdentitySubscription() { + _identityStatusListener?.call(RelaySubscriptionStatus.retrying); + } + + void readyIdentitySubscription() { + _identityStatusListener?.call(RelaySubscriptionStatus.ready); + } + + void beginMembershipReplay() { + _membershipStatusListener?.call(RelaySubscriptionStatus.retrying); + } + + void emitReplayedMembership(NostrEvent event) { + membershipSnapshot = event; + _membershipListener?.call(event); + _membershipStatusListener?.call(RelaySubscriptionStatus.retrying); + } + + void finishMembershipReplay() { + _membershipStatusListener?.call(RelaySubscriptionStatus.ready); + } + + void disconnect() { + state = const SessionState(status: SessionStatus.disconnected); + } + + void connect() { + state = const SessionState(status: SessionStatus.connected); + } +} + class _HuddleReactionRelaySession extends RelaySessionNotifier { NostrFilter? reactionFilter; void Function(NostrEvent)? _reactionListener; @@ -12437,9 +13492,46 @@ class _FakeChannelMutesNotifier extends ChannelMutesNotifier { } } +NostrEvent _profileEvent({ + required String id, + required String pubkey, + required int createdAt, + required String name, + List> tags = const [], +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: 0, + tags: tags, + content: jsonEncode({'name': name}), + sig: 'sig', +); + +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'), + ), + ); + final message = digest + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return [ + 'auth', + owner.public, + '', + nostr.Schnorr.sign(secretKey: owner.secret, message: message), + ]; +} + class _FakeUserCacheNotifier extends UserCacheNotifier { final Map _users; - _FakeUserCacheNotifier(this._users); + final Future Function(List)? _preload; + _FakeUserCacheNotifier( + this._users, { + Future Function(List)? preload, + }) : _preload = preload; @override Map build() => _users; @@ -12447,6 +13539,13 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { @override UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()]; + @override + Future preload(List pubkeys) => + _preload?.call(pubkeys) ?? Future.value(true); + + @override + Future refresh(List pubkeys) => preload(pubkeys); + void replace(UserProfile profile) { state = {...state, profile.pubkey.toLowerCase(): profile}; } diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 44bb450670..1f3ef745ef 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -90,7 +90,7 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { Map build() => _profiles; @override - Future preload(Iterable pubkeys) async {} + Future preload(List pubkeys) async => true; } void main() { diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart index f584aff041..c5355f26a4 100644 --- a/mobile/test/shared/mentions/agent_identity_provider_test.dart +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -29,8 +29,8 @@ void main() { }); await relaySession.subscribed; expect(relaySession.liveFilters.single.kinds, const [39002]); - expect(relaySession.liveFilters.single.tags['#h'], [_channelId]); - expect(relaySession.liveFilters.single.tags['#d'], isNull); + expect(relaySession.liveFilters.single.tags['#d'], [_channelId]); + expect(relaySession.liveFilters.single.tags['#h'], isNull); relaySession.emit(_membershipEvent(role: 'member')); await _pumpEventQueue(); @@ -76,6 +76,96 @@ void main() { ); }); + test('surfaces bot-role subscription setup failure', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'member'), + ], subscribeError: StateError('subscription unavailable')); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembershipUpdateProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + await _pumpEventQueue(); + + final state = container.read(channelMembershipUpdateProvider(_channelId)); + expect(state.isReady, isFalse); + expect(state.error, isA()); + }); + + test('surfaces terminal bot-role subscription closure', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembershipUpdateProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + await relaySession.subscribed; + await _pumpEventQueue(); + expect( + container.read(channelMembershipUpdateProvider(_channelId)).isReady, + isTrue, + ); + + relaySession.closeSubscription('unsupported filter'); + await _pumpEventQueue(); + + final state = container.read(channelMembershipUpdateProvider(_channelId)); + expect(state.isReady, isFalse); + expect(state.error, isA()); + }); + + test('fails closed while bot-role subscription retries', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembershipUpdateProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + await relaySession.subscribed; + await _pumpEventQueue(); + expect( + container.read(channelMembershipUpdateProvider(_channelId)).isReady, + isTrue, + ); + + relaySession.setSubscriptionStatus(RelaySubscriptionStatus.retrying); + await _pumpEventQueue(); + expect( + container.read(channelMembershipUpdateProvider(_channelId)).isReady, + isFalse, + ); + + relaySession.setSubscriptionStatus(RelaySubscriptionStatus.ready); + await _pumpEventQueue(); + final recovered = container.read( + channelMembershipUpdateProvider(_channelId), + ); + expect(recovered.isReady, isTrue); + expect(recovered.error, isNull); + }); + test('disposes the live role subscription without consumers', () async { final relaySession = _MembershipRelaySessionNotifier([ _membershipEvent(role: 'bot'), @@ -162,13 +252,14 @@ Future _pumpEventQueue() async { class _MembershipRelaySessionNotifier extends RelaySessionNotifier { final List _memberships; + final Object? subscribeError; final List liveFilters = []; final List<_LiveSubscription> _subscriptions = []; final Completer _subscribed = Completer(); var unsubscribeCount = 0; var _membershipIndex = 0; - _MembershipRelaySessionNotifier(this._memberships); + _MembershipRelaySessionNotifier(this._memberships, {this.subscribeError}); Future get subscribed => _subscribed.future; @@ -184,14 +275,22 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { } @override - Future subscribe( + Future subscribeWithStatus( NostrFilter filter, void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, }) async { + if (subscribeError case final error?) throw error; liveFilters.add(filter); - final subscription = _LiveSubscription(filter, onEvent); + final subscription = _LiveSubscription( + filter, + onEvent, + onClosed, + onStatusChanged, + ); _subscriptions.add(subscription); + onStatusChanged(RelaySubscriptionStatus.ready); if (!_subscribed.isCompleted) _subscribed.complete(); return () { unsubscribeCount++; @@ -206,13 +305,32 @@ class _MembershipRelaySessionNotifier extends RelaySessionNotifier { } } } + + void closeSubscription(String message) { + for (final subscription in List.of(_subscriptions)) { + subscription.onClosed?.call(message); + } + } + + void setSubscriptionStatus(RelaySubscriptionStatus status) { + for (final subscription in List.of(_subscriptions)) { + subscription.onStatusChanged?.call(status); + } + } } class _LiveSubscription { final NostrFilter filter; final void Function(NostrEvent) onEvent; + final void Function(String message)? onClosed; + final void Function(RelaySubscriptionStatus status)? onStatusChanged; - const _LiveSubscription(this.filter, this.onEvent); + const _LiveSubscription( + this.filter, + this.onEvent, + this.onClosed, + this.onStatusChanged, + ); } bool _matches(NostrFilter filter, NostrEvent event) { diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart new file mode 100644 index 0000000000..9a27086107 --- /dev/null +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; + +void main() { + test('preload reports a profile batch failure', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(_FailingProfileSession.new), + ], + ); + addTearDown(container.dispose); + + final succeeded = await container.read(userCacheProvider.notifier).preload( + const ['agent'], + ); + + expect(succeeded, isFalse); + }); + + test('refresh queries profiles that are already cached', () async { + final session = _RecordingProfileSession(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.cacheProfileEvent( + _profileEvent(id: 'cached-profile', createdAt: 1, name: 'Cached Human'), + ); + + final succeeded = await cache.refresh(const ['AGENT']); + + expect(succeeded, isTrue); + expect(session.requestedFilter?.kinds, const [0]); + expect(session.requestedFilter?.authors, const ['agent']); + expect(session.requestedFilter?.limit, 1); + }); + + test('older refresh cannot overwrite a newer live profile', () async { + final refreshCompleter = Completer>(); + final session = _RecordingProfileSession(result: refreshCompleter.future); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final refresh = cache.refresh([agent.public]); + + cache.cacheProfileEvent( + _profileEvent( + id: 'newer-agent', + pubkey: agent.public, + createdAt: 2, + name: 'Agent', + tags: [_authTag(owner, agent.public)], + ), + ); + refreshCompleter.complete([ + _profileEvent( + id: 'older-human', + pubkey: agent.public, + createdAt: 1, + name: 'Human', + ), + ]); + + expect(await refresh, isTrue); + expect(cache.state[agent.public]?.displayName, 'Agent'); + expect(cache.state[agent.public]?.ownerPubkey, owner.public); + }); + + test('newer refresh can remove obsolete owner attribution', () async { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final session = _RecordingProfileSession( + result: Future.value([ + _profileEvent( + id: 'newer-human', + pubkey: agent.public, + createdAt: 2, + name: 'Human', + ), + ]), + ); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + cache.cacheProfileEvent( + _profileEvent( + id: 'older-agent', + pubkey: agent.public, + createdAt: 1, + name: 'Agent', + tags: [_authTag(owner, agent.public)], + ), + ); + + expect(await cache.refresh([agent.public]), isTrue); + expect(cache.state[agent.public]?.displayName, 'Human'); + expect(cache.state[agent.public]?.ownerPubkey, isNull); + }); + + test('non-profile history cannot poison profile order', () async { + final session = _RecordingProfileSession( + results: [ + Future.value([ + _profileEvent( + id: 'non-profile-newer', + createdAt: 3, + name: 'Ignored', + kind: 1, + ), + ]), + Future.value([ + _profileEvent(id: 'valid-older', createdAt: 2, name: 'Valid'), + ]), + ], + ); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + + expect(await cache.refresh(const ['agent']), isTrue); + expect(cache.state['agent'], isNull); + expect(await cache.refresh(const ['agent']), isTrue); + expect(cache.state['agent']?.displayName, 'Valid'); + }); + + test('same-second profile tie keeps the lowest event id', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final cache = container.read(userCacheProvider.notifier); + + cache.cacheProfileEvent( + _profileEvent(id: 'b', createdAt: 1, name: 'Larger ID'), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'a', createdAt: 1, name: 'Lower ID'), + ); + cache.cacheProfileEvent( + _profileEvent(id: 'c', createdAt: 1, name: 'Later Larger ID'), + ); + + expect(cache.state['agent']?.displayName, 'Lower ID'); + }); +} + +NostrEvent _profileEvent({ + required String id, + required int createdAt, + required String name, + String pubkey = 'agent', + List> tags = const [], + int kind = 0, +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: jsonEncode({'name': name}), + sig: 'sig', +); + +List _authTag(nostr.Keys owner, String agentPubkey) { + final digest = SHA256Digest().process( + Uint8List.fromList( + utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'), + ), + ); + final message = digest + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + final signature = nostr.Schnorr.sign( + secretKey: owner.secret, + message: message, + ); + return ['auth', owner.public, '', signature]; +} + +class _RecordingProfileSession extends RelaySessionNotifier { + _RecordingProfileSession({ + Future>? result, + List>>? results, + }) : _results = [...?results, ?result]; + + final List>> _results; + NostrFilter? requestedFilter; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + requestedFilter = filter; + return _results.isEmpty ? const [] : _results.removeAt(0); + } +} + +class _FailingProfileSession extends RelaySessionNotifier { + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) => Future.error('profile unavailable'); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 7332075c3f..c6ae6e9bd8 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -749,6 +749,61 @@ void main() { }, ); + test('retryable CLOSED reports retrying until replay is ready', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final deliveredEvents = []; + final statuses = []; + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + + final subscribe = session.subscribeWithStatus( + _channelFilter, + deliveredEvents.add, + onStatusChanged: (status) { + if (status == RelaySubscriptionStatus.ready) { + expect(deliveredEvents, hasLength(statuses.isEmpty ? 0 : 1)); + } + statuses.add(status); + }, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + expect(statuses, [RelaySubscriptionStatus.ready]); + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: relay overloaded']); + expect(statuses, [ + RelaySubscriptionStatus.ready, + RelaySubscriptionStatus.retrying, + ]); + + timers.single.fire(); + await Future.delayed(Duration.zero); + session.debugHandleMessage([ + 'EVENT', + 'l-1', + _event(createdAt: 30).toJson(), + ]); + expect(statuses, [ + RelaySubscriptionStatus.ready, + RelaySubscriptionStatus.retrying, + ]); + + session.debugHandleMessage(['EOSE', 'l-1']); + expect(statuses, [ + RelaySubscriptionStatus.ready, + RelaySubscriptionStatus.retrying, + RelaySubscriptionStatus.ready, + ]); + unsubscribe(); + }); + test('CLOSED retries back off and reset after EOSE', () async { final timers = <_ManualTimer>[]; final socket = _RecordingRelaySocket(); From 9aa332af03b4fb416dd8648b320447617ffd6fa5 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 25 Aug 2026 14:58:55 +0100 Subject: [PATCH 02/24] Add inline profile camera capture (#6680) ## Summary - add inline profile photo capture with smooth avatar-to-viewfinder transitions - add close, flip, shutter, retry, and use-photo states with haptics and front-camera mirroring - polish iOS liquid-glass controls and avatar editor motion ### Related issue Follow-up to #6583. ### Testing - just mobile-check - just mobile-test (1,743 tests) - built, installed, and launched on Pixel 10 and iPhone Air --------- Signed-off-by: kenny lopez Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Signed-off-by: Max Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: Max Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz> Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Claude --- .../ios/Runner/JumpToLatestGlassButton.swift | 184 ++++- .../profile/animated_avatar_capture.dart | 132 ++-- .../capture_controls.dart | 124 ++-- .../animated_avatar_capture/error_text.dart | 7 + .../frame_processing.dart | 20 + .../review_controls.dart | 6 + .../profile/avatar_editor_option_button.dart | 94 ++- .../profile/camera_disposal_barrier.dart | 71 ++ .../profile/image_avatar_capture.dart | 662 ++++++++++++++++++ .../image_avatar_capture/camera_preview.dart | 21 + .../morphing_camera_action.dart | 149 ++++ .../image_avatar_capture/shutter_button.dart | 75 ++ .../profile/profile_avatar_draft.dart | 14 +- .../profile/profile_avatar_editor.dart | 105 ++- .../emoji_avatar_picker.dart | 17 +- .../features/profile/profile_edit_page.dart | 186 ++++- .../features/profile/profile_provider.dart | 48 ++ .../features/profile/profile_text_editor.dart | 94 ++- .../profile/settings_profile_header.dart | 60 +- .../widgets/ios_glass_navigation_button.dart | 178 ++++- .../shared/widgets/playing_avatar_image.dart | 54 ++ .../widgets/progressive_animated_avatar.dart | 29 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../profile/animated_avatar_capture_test.dart | 464 +++++++++++- .../profile/camera_disposal_barrier_test.dart | 58 ++ .../image_avatar_capture_lifecycle_test.dart | 248 +++++++ .../profile/profile_avatar_draft_test.dart | 59 -- .../profile/profile_edit_page_test.dart | 158 ++++- .../image_selection_tests.dart | 468 +++++++++++++ .../motion_and_accessibility_tests.dart | 220 +++--- .../profile/profile_edit_retry_test.dart | 121 ---- .../profile/settings_profile_header_test.dart | 74 +- 33 files changed, 3609 insertions(+), 594 deletions(-) create mode 100644 mobile/lib/features/profile/camera_disposal_barrier.dart create mode 100644 mobile/lib/features/profile/image_avatar_capture.dart create mode 100644 mobile/lib/features/profile/image_avatar_capture/camera_preview.dart create mode 100644 mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart create mode 100644 mobile/lib/features/profile/image_avatar_capture/shutter_button.dart create mode 100644 mobile/test/features/profile/camera_disposal_barrier_test.dart create mode 100644 mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index 8a95831808..0dce3fb048 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -169,10 +169,18 @@ final class NavigationGlassButtonFactory: NSObject, FlutterPlatformViewFactory { } final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { + private static let shutterIconRatio: CGFloat = 80.0 / 115.0 + private static let shutterInsetRatio: CGFloat = 20.0 / 115.0 private let containerView: UIView private let channel: FlutterMethodChannel private let button = NavigationGlassButton(type: .system) + private let activityIndicator = UIActivityIndicatorView(style: .medium) private var buttonLabel: String? + private var buttonIconName = "chevron.backward" + private var contentIcon = "back" + private var buttonImage: UIImage? + private var isBusy = false + private var controlSize: CGFloat = 40 init( frame: CGRect, @@ -196,12 +204,11 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { (arguments?["hitTargetWidth"] as? NSNumber)?.doubleValue ?? 48 let hitTargetHeight = (arguments?["hitTargetHeight"] as? NSNumber)?.doubleValue ?? 48 - let label = arguments?["label"] as? String - buttonLabel = label - let icon = arguments?["icon"] as? String - let symbolName = icon == "close" ? "xmark" : "chevron.backward" let controlWidth = (arguments?["controlWidth"] as? NSNumber)?.doubleValue ?? 40 + controlSize = + (arguments?["controlSize"] as? NSNumber)?.doubleValue ?? 40 + let fillWidth = arguments?["fillWidth"] as? Bool ?? false var configuration: UIButton.Configuration if #available(iOS 26.0, *) { @@ -211,33 +218,15 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { configuration.baseBackgroundColor = UIColor.secondarySystemBackground } configuration.cornerStyle = .capsule - if let label { - configuration.title = label - configuration.titleLineBreakMode = .byClipping - configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { - incoming in - var outgoing = incoming - let preferred = UIFont.preferredFont(forTextStyle: .subheadline) - outgoing.font = UIFont.systemFont(ofSize: preferred.pointSize, weight: .semibold) - return outgoing - } - } else { - configuration.image = UIImage( - systemName: symbolName, - withConfiguration: UIImage.SymbolConfiguration( - pointSize: 17, - weight: .semibold - ) - ) - } button.configuration = configuration + applyContent(from: arguments) button.titleLabel?.numberOfLines = 1 button.titleLabel?.lineBreakMode = .byClipping button.hitTargetInsets = UIEdgeInsets( - top: max(0, (hitTargetHeight - 40) / 2), - left: max(0, buttonCenterX - 20), - bottom: max(0, (hitTargetHeight - 40) / 2), - right: max(0, hitTargetWidth - buttonCenterX - 20) + top: max(0, (hitTargetHeight - controlSize) / 2), + left: max(0, buttonCenterX - controlWidth / 2), + bottom: max(0, (hitTargetHeight - controlSize) / 2), + right: max(0, hitTargetWidth - buttonCenterX - controlWidth / 2) ) button.accessibilityLabel = arguments?["accessibilityLabel"] as? String ?? "Back" button.translatesAutoresizingMaskIntoConstraints = false @@ -248,8 +237,24 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { for: .touchUpInside ) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + activityIndicator.isUserInteractionEnabled = false + button.addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: button.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: button.centerYAnchor), + activityIndicator.widthAnchor.constraint(equalToConstant: 24), + activityIndicator.heightAnchor.constraint(equalToConstant: 24), + ]) + applyAppearance(from: args) channel.setMethodCallHandler { [weak self] call, result in + if call.method == "setContent" { + self?.setContent(from: call.arguments) + result(nil) + return + } guard call.method == "setAppearance" else { result(FlutterMethodNotImplemented) return @@ -260,14 +265,19 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { containerView.addSubview(button) NSLayoutConstraint.activate([ + button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + button.heightAnchor.constraint(equalToConstant: controlSize), + ]) + if fillWidth { + button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true + button.widthAnchor.constraint(equalTo: containerView.widthAnchor).isActive = true + } else { button.centerXAnchor.constraint( equalTo: containerView.leadingAnchor, constant: buttonCenterX - ), - button.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), - button.widthAnchor.constraint(equalToConstant: controlWidth), - button.heightAnchor.constraint(equalToConstant: 40), - ]) + ).isActive = true + button.widthAnchor.constraint(equalToConstant: controlWidth).isActive = true + } } func view() -> UIView { @@ -331,14 +341,25 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { let foregroundColor = colorValue.map(Self.color(from:)) let enabled = arguments?["enabled"] as? Bool ?? true let busy = arguments?["busy"] as? Bool ?? false + let selected = arguments?["selected"] as? Bool ?? false containerView.overrideUserInterfaceStyle = interfaceStyle button.overrideUserInterfaceStyle = interfaceStyle button.isEnabled = enabled - button.configuration?.showsActivityIndicator = busy - button.configuration?.title = busy ? nil : buttonLabel + button.isSelected = selected + isBusy = busy + if selected { + button.accessibilityTraits.insert(.selected) + } else { + button.accessibilityTraits.remove(.selected) + } + button.configuration?.showsActivityIndicator = false if let foregroundColor { + // Glass uses the view tint for its selected treatment. Keep it aligned + // with the Buzz theme instead of falling back to the system blue tint. + button.tintColor = foregroundColor button.configuration?.baseForegroundColor = foregroundColor + activityIndicator.color = foregroundColor } if #unavailable(iOS 26.0) { button.configuration?.baseBackgroundColor = Self.fallbackBackgroundColor( @@ -346,9 +367,104 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { interfaceStyle: interfaceStyle ) } + updateDisplayedContent() button.setNeedsUpdateConfiguration() } + private func applyContent(from value: Any?) { + let arguments = value as? [String: Any] + let icon = arguments?["icon"] as? String + contentIcon = icon ?? "back" + buttonLabel = arguments?["label"] as? String + if let accessibilityLabel = arguments?["accessibilityLabel"] as? String { + button.accessibilityLabel = accessibilityLabel + } + switch icon { + case "close": buttonIconName = "xmark" + case "camera": buttonIconName = "camera" + case "photoLibrary": buttonIconName = "photo.on.rectangle.angled" + case "palette": buttonIconName = "paintpalette" + case "emoji": buttonIconName = "face.smiling" + case "person": buttonIconName = "person" + case "frame": buttonIconName = "rectangle.stack" + case "rotateCamera": buttonIconName = "arrow.triangle.2.circlepath.camera" + case "shutter": buttonIconName = "circle.fill" + default: buttonIconName = "chevron.backward" + } + if buttonLabel != nil { + buttonImage = nil + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: 8, + leading: 8, + bottom: 8, + trailing: 8 + ) + button.configuration?.titleLineBreakMode = .byClipping + button.configuration?.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { incoming in + var outgoing = incoming + let preferred = UIFont.preferredFont(forTextStyle: .subheadline) + outgoing.font = UIFont.systemFont( + ofSize: preferred.pointSize, + weight: .semibold + ) + return outgoing + } + } else { + button.configuration?.titleTextAttributesTransformer = nil + let iconInset: CGFloat = icon == "shutter" + ? controlSize * Self.shutterInsetRatio + : 8 + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + top: iconInset, + leading: iconInset, + bottom: iconInset, + trailing: iconInset + ) + let pointSize: CGFloat = icon == "shutter" + ? controlSize * Self.shutterIconRatio + : 17 + buttonImage = UIImage( + systemName: buttonIconName, + withConfiguration: UIImage.SymbolConfiguration( + pointSize: pointSize, + weight: .semibold + ) + ) + } + updateDisplayedContent() + button.setNeedsUpdateConfiguration() + } + + private func setContent(from value: Any?) { + let arguments = value as? [String: Any] + let nextIcon = arguments?["icon"] as? String ?? "back" + let nextLabel = arguments?["label"] as? String + guard nextIcon != contentIcon || nextLabel != buttonLabel else { + applyContent(from: value) + return + } + let duration = UIAccessibility.isReduceMotionEnabled ? 0 : 0.12 + UIView.transition( + with: button, + duration: duration, + options: [.transitionCrossDissolve, .beginFromCurrentState, .allowAnimatedContent], + animations: { [weak self] in self?.applyContent(from: value) } + ) + } + + private func updateDisplayedContent() { + if isBusy { + button.configuration?.title = nil + button.configuration?.image = nil + activityIndicator.startAnimating() + } else { + button.configuration?.title = buttonLabel + button.configuration?.image = buttonLabel == nil ? buttonImage : nil + activityIndicator.stopAnimating() + } + } + private static func color(from value: UInt32) -> UIColor { let alpha = CGFloat((value >> 24) & 0xFF) / 255 let red = CGFloat((value >> 16) & 0xFF) / 255 diff --git a/mobile/lib/features/profile/animated_avatar_capture.dart b/mobile/lib/features/profile/animated_avatar_capture.dart index 0878b78e6b..45264cf884 100644 --- a/mobile/lib/features/profile/animated_avatar_capture.dart +++ b/mobile/lib/features/profile/animated_avatar_capture.dart @@ -18,7 +18,9 @@ import '../../shared/emoji/emoji_avatar.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import 'avatar_background_grid.dart'; +import 'camera_disposal_barrier.dart'; import 'avatar_editor_option_button.dart'; import 'animated_avatar_orientation.dart'; import 'profile_avatar_draft.dart'; @@ -35,8 +37,6 @@ const _outputSize = 256; const _mobileDefaultPersonScale = 1.15; const _animatedReviewRailHeight = 88.0; -enum _AnimatedReviewSection { person, color, poster } - /// Records and prepares a short camera animation for a profile avatar. class AnimatedAvatarCapture extends HookConsumerWidget { /// Creates an animated-avatar capture and review surface. @@ -45,6 +45,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { required this.height, required this.onPrepareChanged, this.initialFrames = const [], + this.disposalBarrier, }); /// The vertical space available to the capture surface. @@ -56,10 +57,18 @@ class AnimatedAvatarCapture extends HookConsumerWidget { /// Seeds processed frames in lifecycle-focused widget tests. @visibleForTesting final List initialFrames; + + /// Serializes ownership release with another profile capture surface. + final CameraDisposalBarrier? disposalBarrier; + @override Widget build(BuildContext context, WidgetRef ref) { final controller = useState(null); final controllerRef = useRef(null); + final controllerDisposal = useRef( + disposalBarrier ?? CameraDisposalBarrier(), + ); + final candidateRef = useRef(null); final captureEpoch = useRef(0); final cameraGeneration = useState(0); final isInitializing = useState(true); @@ -96,6 +105,8 @@ class AnimatedAvatarCapture extends HookConsumerWidget { shapeOffsetX: shapeOffset.value.dx, shapeOffsetY: shapeOffset.value.dy, ); + final latestEncodeKey = useRef<_EncodeKey?>(null); + latestEncodeKey.value = encodeKey; useEffect(() { encodedCache.value = null; final key = encodeKey; @@ -129,15 +140,25 @@ class AnimatedAvatarCapture extends HookConsumerWidget { isInitializing.value = true; + Future releaseController(CameraController? active) { + if (active == null) return controllerDisposal.value.settled; + return controllerDisposal.value.release(active.dispose); + } + Future initialize() async { + CameraController? next; + CameraDisposalReservation? reservation; + var installed = false; try { + await controllerDisposal.value.settled; + if (disposed) return; final cameras = await availableCameras(); if (disposed || cameras.isEmpty) return; final selected = cameras.firstWhere( (camera) => camera.lensDirection == CameraLensDirection.front, orElse: () => cameras.first, ); - final next = CameraController( + next = CameraController( selected, ResolutionPreset.medium, enableAudio: false, @@ -145,14 +166,33 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ? ImageFormatGroup.bgra8888 : ImageFormatGroup.yuv420, ); - await next.initialize(); + reservation = controllerDisposal.value.reserve(); + candidateRef.value = next; + await reservation.ready; if (disposed) { - await next.dispose(); + if (identical(candidateRef.value, next)) candidateRef.value = null; + await reservation.dispose(next.dispose); return; } + await next.initialize(); + await next.lockCaptureOrientation(DeviceOrientation.portraitUp); + if (disposed) { + if (identical(candidateRef.value, next)) candidateRef.value = null; + await reservation.dispose(next.dispose); + return; + } + candidateRef.value = null; + reservation.complete(); controllerRef.value = next; controller.value = next; + installed = true; } catch (_) { + if (!installed && + next != null && + identical(candidateRef.value, next)) { + candidateRef.value = null; + await reservation?.dispose(next.dispose); + } if (!disposed) error.value = 'Could not access the camera.'; } finally { if (!disposed) isInitializing.value = false; @@ -165,12 +205,12 @@ class AnimatedAvatarCapture extends HookConsumerWidget { captureEpoch.value++; final active = controllerRef.value; controllerRef.value = null; - unawaited(active?.dispose() ?? Future.value()); + unawaited(releaseController(active)); }; }, [lifecycle, frames.value.isEmpty, cameraGeneration.value]); Future prepare() async { - final key = encodeKey; + final key = latestEncodeKey.value; if (key == null) return null; isProcessing.value = true; error.value = null; @@ -242,7 +282,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { if (context.mounted && identical(controller.value, active)) { controller.value = null; } - await active.dispose(); + await controllerDisposal.value.release(active.dispose); } final timer = Timer.periodic(const Duration(milliseconds: 40), (_) { @@ -290,12 +330,14 @@ class AnimatedAvatarCapture extends HookConsumerWidget { await Future.delayed(const Duration(milliseconds: 10)); } if (captureEpoch.value != currentCapture || !context.mounted) return; - await releaseCamera(); if (captured.length < 2) { throw StateError('Not enough frames were captured.'); } isRecording.value = false; + // Enter the processing state before releasing the controller. Clearing + // the camera first briefly exposed the unavailable-camera placeholder. isPreparingFrames.value = true; + await releaseCamera(); // Cut out only the frames each device captured, then resample the // three-second window so Android and iOS use the same playback cadence. final cutouts = await _removeBackgrounds(captured); @@ -342,7 +384,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { final previewTop = activeSection.value == _AnimatedReviewSection.color ? -avatarBackgroundPreviewShift : 0.0; - final controlsTop = previewTop + 220; + final controlsTop = previewTop + _animatedAvatarPreviewSize; return SizedBox( height: height, child: Stack( @@ -357,7 +399,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { left: 0, right: 0, top: previewTop, - height: 220, + height: _animatedAvatarPreviewSize, child: Center( child: _RepositionablePreviewSemantics( offset: offset.value, @@ -385,7 +427,7 @@ class AnimatedAvatarCapture extends HookConsumerWidget { .toDouble(); }, child: SizedBox.square( - dimension: 220, + dimension: _animatedAvatarPreviewSize, child: Stack( fit: StackFit.expand, children: [ @@ -396,13 +438,17 @@ class AnimatedAvatarCapture extends HookConsumerWidget { Center( child: Transform.translate( offset: - const Offset(0, 20.625) + - shapeOffset.value * 51.5625, + const Offset( + 0, + _animatedAvatarShapeYOffset, + ) + + shapeOffset.value * + _animatedAvatarShapeTranslation, child: Transform.scale( scale: shapeScale.value, child: Container( - width: 172, - height: 172, + width: _animatedAvatarShapeSize, + height: _animatedAvatarShapeSize, decoration: BoxDecoration( color: Color(backdropColor.value), shape: BoxShape.circle, @@ -413,7 +459,9 @@ class AnimatedAvatarCapture extends HookConsumerWidget { ), _AnimatedPersonPreview( bytes: selectedFrame, - offset: offset.value * 48, + offset: + offset.value * + _animatedAvatarPersonTranslation, scale: scale.value, outline: personOutline.value, outlineColor: _personOutlineColor( @@ -634,13 +682,6 @@ List _resampleCapturedFrames( }, growable: false); } -Color _personOutlineColor(int backdropColor) { - final color = Color(backdropColor); - return color.computeLuminance() > 0.74 - ? const Color(0xFF111111) - : Colors.white; -} - @immutable class _FramePlane { const _FramePlane(this.bytes, this.bytesPerRow, this.bytesPerPixel); @@ -880,12 +921,14 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { height: _outputSize, numChannels: 4, ); - const previewSize = 220.0; - const previewTranslation = 48.0; + const previewSize = _animatedAvatarPreviewSize; + const previewTranslation = _animatedAvatarPersonTranslation; final translationScale = _outputSize / previewSize; image.compositeImage( person, scaledPerson, + dstW: scaledSize, + dstH: scaledSize, dstX: ((_outputSize - scaledSize) / 2 + request.offsetX * previewTranslation * translationScale) @@ -903,9 +946,9 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { final color = request.backdropColor; image.fillCircle( frame, - x: (_outputSize / 2 + request.shapeOffsetX * 60).round(), - y: (_outputSize / 2 + 24 + request.shapeOffsetY * 60).round(), - radius: (100 * request.shapeScale).round(), + x: _animatedAvatarShapeX(request.shapeOffsetX), + y: _animatedAvatarShapeY(request.shapeOffsetY), + radius: _animatedAvatarShapeRadius(request.shapeScale), color: image.ColorRgba8( (color >> 16) & 0xff, (color >> 8) & 0xff, @@ -923,16 +966,7 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { ..b = (outlineColor.b * 255).round() ..a = (pixel.a * 0.92).round(); } - for (final (x, y) in const [ - (-2, 0), - (2, 0), - (0, -2), - (0, 2), - (-1, -1), - (1, -1), - (-1, 1), - (1, 1), - ]) { + for (final (x, y) in _animatedAvatarOutlineOffsets) { image.compositeImage(frame, outline, dstX: x, dstY: y); } } @@ -956,26 +990,6 @@ _EncodedAvatar _encodeAvatar(_EncodeRequest request) { return _EncodedAvatar(animation, poster); } -/// Encodes one poster frame for validating animated-avatar framing parity. -@visibleForTesting -Uint8List encodeAnimatedAvatarPoster({ - required Uint8List frame, - required double scale, -}) => _encodeAvatar( - _EncodeRequest( - frames: [frame], - posterIndex: 0, - scale: scale, - offsetX: 0, - offsetY: 0, - backdropColor: 0xff0000ff, - personOutline: false, - shapeScale: 1, - shapeOffsetX: 0, - shapeOffsetY: 0, - ), -).poster; - extension on Iterable { Iterable skipLast(int count) { final values = toList(growable: false); diff --git a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart index 0c87c10b50..762337466c 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/capture_controls.dart @@ -1,5 +1,39 @@ part of '../animated_avatar_capture.dart'; +const _animatedAvatarPreviewSize = 220.0; +const _animatedAvatarPersonTranslation = 48.0; +const _animatedAvatarShapeSize = 172.0; +const _animatedAvatarShapeYOffset = 20.625; +const _animatedAvatarShapeTranslation = 51.5625; +const _animatedAvatarOutputScale = _outputSize / _animatedAvatarPreviewSize; +const _animatedAvatarOutlineOffsets = [ + (-3, 0), + (3, 0), + (0, -3), + (0, 3), + (-2, -2), + (2, -2), + (-2, 2), + (2, 2), +]; + +int _animatedAvatarShapeX(double offset) => + (_outputSize / 2 + + offset * + _animatedAvatarShapeTranslation * + _animatedAvatarOutputScale) + .round(); + +int _animatedAvatarShapeY(double offset) => + (_outputSize / 2 + + (_animatedAvatarShapeYOffset + + offset * _animatedAvatarShapeTranslation) * + _animatedAvatarOutputScale) + .round(); + +int _animatedAvatarShapeRadius(double scale) => + (_animatedAvatarShapeSize / 2 * _animatedAvatarOutputScale * scale).round(); + class _AnimatedRecordButton extends StatelessWidget { const _AnimatedRecordButton({required this.busy, required this.onPressed}); @@ -19,39 +53,57 @@ class _AnimatedRecordButton extends StatelessWidget { curve: Curves.easeOutCubic, width: busy ? 64 : constraints.maxWidth, height: 64, - child: Material( - color: context.colors.onSurface, - borderRadius: BorderRadius.circular(Radii.full), - clipBehavior: Clip.antiAlias, - child: InkWell( - key: const ValueKey('animated-avatar-record'), - onTap: busy ? null : onPressed, - child: Center( - child: AnimatedSwitcher( - duration: reduceMotion - ? Duration.zero - : const Duration(milliseconds: 150), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeOutCubic, - child: busy - ? BuzzLoadingIndicator( - key: const ValueKey('animated-avatar-capturing'), - size: 24, - color: context.colors.surface, - semanticLabel: 'Capturing animated avatar', - ) - : Text( - 'Record', - key: const ValueKey('animated-avatar-record-label'), - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.surface, - fontWeight: FontWeight.w600, - ), - ), + child: defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + key: const ValueKey('animated-avatar-record'), + icon: IosGlassNavigationIcon.shutter, + label: busy ? null : 'Record', + semanticLabel: 'Record animated avatar', + onPressed: busy ? null : onPressed, + width: busy ? 64 : constraints.maxWidth, + height: 64, + controlSize: 64, + fillWidth: true, + foregroundColor: context.colors.onSurface, + isBusy: busy, + ) + : Material( + color: context.colors.onSurface, + borderRadius: BorderRadius.circular(Radii.full), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('animated-avatar-record'), + onTap: busy ? null : onPressed, + child: Center( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 150), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: busy + ? BuzzLoadingIndicator( + key: const ValueKey( + 'animated-avatar-capturing', + ), + size: 24, + color: context.colors.surface, + semanticLabel: 'Capturing animated avatar', + ) + : Text( + 'Record', + key: const ValueKey( + 'animated-avatar-record-label', + ), + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.surface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), ), - ), - ), - ), ), ), ); @@ -65,13 +117,7 @@ class _AspectCorrectCameraPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final orientation = controller.value.deviceOrientation; - final isLandscape = - orientation == DeviceOrientation.landscapeLeft || - orientation == DeviceOrientation.landscapeRight; - final aspectRatio = isLandscape - ? controller.value.aspectRatio - : 1 / controller.value.aspectRatio; + final aspectRatio = 1 / controller.value.aspectRatio; return FittedBox( fit: BoxFit.cover, clipBehavior: Clip.hardEdge, diff --git a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart index 022a730009..88086bfc16 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/error_text.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/error_text.dart @@ -20,3 +20,10 @@ class _ErrorText extends StatelessWidget { ), ); } + +Color _personOutlineColor(int backdropColor) { + final color = Color(backdropColor); + return color.computeLuminance() > 0.74 + ? const Color(0xFF111111) + : Colors.white; +} diff --git a/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart index b93175bf73..6627c21dee 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/frame_processing.dart @@ -88,3 +88,23 @@ Uint8List _applySegmentationMask(_MaskRequest request) { } return image.encodePng(result, level: 4); } + +/// Encodes one poster frame for validating animated-avatar framing parity. +@visibleForTesting +Uint8List encodeAnimatedAvatarPoster({ + required Uint8List frame, + required double scale, +}) => _encodeAvatar( + _EncodeRequest( + frames: [frame], + posterIndex: 0, + scale: scale, + offsetX: 0, + offsetY: 0, + backdropColor: 0xff0000ff, + personOutline: false, + shapeScale: 1, + shapeOffsetX: 0, + shapeOffsetY: 0, + ), +).poster; diff --git a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart index 7c9ce4d970..2d45edd683 100644 --- a/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart +++ b/mobile/lib/features/profile/animated_avatar_capture/review_controls.dart @@ -1,5 +1,7 @@ part of '../animated_avatar_capture.dart'; +enum _AnimatedReviewSection { person, color, poster } + class _RepositionablePreviewSemantics extends StatelessWidget { const _RepositionablePreviewSemantics({ required this.offset, @@ -53,6 +55,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.userRound, + iosIcon: IosGlassNavigationIcon.person, label: 'You', selected: selected == _AnimatedReviewSection.person, onTap: () => onSelected(_AnimatedReviewSection.person), @@ -62,6 +65,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.palette, + iosIcon: IosGlassNavigationIcon.palette, label: 'Background', selected: selected == _AnimatedReviewSection.color, onTap: () => onSelected(_AnimatedReviewSection.color), @@ -71,6 +75,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.galleryThumbnails, + iosIcon: IosGlassNavigationIcon.frame, label: 'Frame', selected: selected == _AnimatedReviewSection.poster, onTap: () => onSelected(_AnimatedReviewSection.poster), @@ -82,6 +87,7 @@ class _AnimatedReviewNav extends StatelessWidget { Expanded( child: AvatarEditorOptionButton( icon: LucideIcons.camera, + iosIcon: IosGlassNavigationIcon.camera, label: 'Retake', selected: false, onTap: onRetake, diff --git a/mobile/lib/features/profile/avatar_editor_option_button.dart b/mobile/lib/features/profile/avatar_editor_option_button.dart index 3ad51acb83..c7d0d12da2 100644 --- a/mobile/lib/features/profile/avatar_editor_option_button.dart +++ b/mobile/lib/features/profile/avatar_editor_option_button.dart @@ -1,9 +1,14 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; + +/// Space between avatar-editor controls and their labels. +const avatarEditorOptionLabelGap = Grid.half; /// A labelled circular option used by the profile avatar editor rails. class AvatarEditorOptionButton extends StatelessWidget { @@ -11,6 +16,7 @@ class AvatarEditorOptionButton extends StatelessWidget { const AvatarEditorOptionButton({ super.key, required this.icon, + this.iosIcon, required this.label, required this.selected, required this.onTap, @@ -20,6 +26,9 @@ class AvatarEditorOptionButton extends StatelessWidget { /// The symbol displayed inside the circular control. final IconData icon; + /// Native symbol used by the iOS liquid-glass control. + final IosGlassNavigationIcon? iosIcon; + /// The text displayed beneath the control. final String label; @@ -40,6 +49,59 @@ class AvatarEditorOptionButton extends StatelessWidget { unawaited(HapticFeedback.selectionClick()); onTap!(); }; + Widget labelWidget() { + final width = labelMaxWidth; + if (width != null) { + return SizedBox( + height: 20, + child: OverflowBox( + maxWidth: width, + maxHeight: 20, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ), + ), + ); + } + return Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: selected + ? context.colors.onSurface + : context.colors.onSurfaceVariant, + ), + ); + } + + if (defaultTargetPlatform == TargetPlatform.iOS && iosIcon != null) { + return Column( + children: [ + IosGlassNavigationButton( + icon: iosIcon!, + semanticLabel: label, + onPressed: handleTap, + width: 64, + height: 64, + controlSize: 64, + foregroundColor: selected + ? context.colors.primary + : context.colors.onSurface, + isSelected: selected, + ), + const SizedBox(height: avatarEditorOptionLabelGap), + ExcludeSemantics(child: labelWidget()), + ], + ); + } return Semantics( label: label, button: true, @@ -75,36 +137,8 @@ class AvatarEditorOptionButton extends StatelessWidget { : context.colors.onSurface, ), ), - const SizedBox(height: Grid.quarter), - if (labelMaxWidth case final width?) - SizedBox( - height: 20, - child: OverflowBox( - maxWidth: width, - maxHeight: 20, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: selected - ? context.colors.onSurface - : context.colors.onSurfaceVariant, - ), - ), - ), - ) - else - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: selected - ? context.colors.onSurface - : context.colors.onSurfaceVariant, - ), - ), + const SizedBox(height: avatarEditorOptionLabelGap), + labelWidget(), ], ), ), diff --git a/mobile/lib/features/profile/camera_disposal_barrier.dart b/mobile/lib/features/profile/camera_disposal_barrier.dart new file mode 100644 index 0000000000..238d65267d --- /dev/null +++ b/mobile/lib/features/profile/camera_disposal_barrier.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +/// Serializes camera teardown so replacement sessions never overlap. +/// +/// A native disposal failure is intentionally contained: callers can proceed +/// with the next camera session after the failed release has settled. +class CameraDisposalBarrier { + Future _pending = Future.value(); + + /// Reserves camera ownership while a controller is initializing. + CameraDisposalReservation reserve() { + final reservation = CameraDisposalReservation._(_pending); + _pending = reservation._settled; + return reservation; + } + + /// Completes after earlier and [dispose] callbacks have settled. + /// + /// Exceptions from [dispose] are handled so they cannot prevent a later + /// replacement session from acquiring the camera. + Future release(Future Function() dispose) { + final previous = _pending; + final release = () async { + await previous; + try { + await dispose(); + } catch (_) { + // A failed native release must not permanently block camera recovery. + } + }(); + _pending = release; + return release; + } + + /// Completes once all scheduled teardown work has settled. + Future get settled => _pending; +} + +/// Owns one in-flight camera initialization in a [CameraDisposalBarrier]. +class CameraDisposalReservation { + CameraDisposalReservation._(this._previous) { + _settled = () async { + await _previous; + await _completion.future; + }(); + } + + final Future _previous; + final _completion = Completer(); + late final Future _settled; + + /// Waits for older camera teardown before this candidate initializes. + Future get ready => _previous; + + /// Releases this reservation after a successful initialization. + void complete() { + if (!_completion.isCompleted) _completion.complete(); + } + + /// Disposes a failed or cancelled candidate before allowing replacement. + Future dispose(Future Function() release) async { + await _previous; + try { + await release(); + } catch (_) { + // A failed native release must not permanently block camera recovery. + } finally { + complete(); + } + } +} diff --git a/mobile/lib/features/profile/image_avatar_capture.dart b/mobile/lib/features/profile/image_avatar_capture.dart new file mode 100644 index 0000000000..e9ffda995d --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture.dart @@ -0,0 +1,662 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:image/image.dart' as image; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; +import 'camera_disposal_barrier.dart'; + +part 'image_avatar_capture/camera_preview.dart'; +part 'image_avatar_capture/morphing_camera_action.dart'; +part 'image_avatar_capture/shutter_button.dart'; + +const _avatarPreviewSize = 220.0; + +/// Diameter of the expanded circular viewfinder while taking a profile photo. +const imageAvatarCameraPreviewSize = _avatarPreviewSize * 1.25; +const _cameraControlSize = 64.0; +const _cameraControlRailHeight = 115.0; +const _shutterSize = _cameraControlSize * 1.5625; +const _shutterCoreSize = _shutterSize * (99 / 115); +const _reviewControlWidth = 112.0; +const _expandedControlOffset = 119.5; +const _reviewControlGap = Grid.twelve; +const _captureMotionDuration = Duration(milliseconds: 180); +const _shutterExitDuration = Duration(milliseconds: 150); +const _cameraFlipHalfDuration = Duration(milliseconds: 100); + +/// Builds the inline still-photo camera used by the profile avatar editor. +typedef ImageAvatarCaptureBuilder = + Widget Function({ + required double height, + required ValueChanged onAccepted, + required VoidCallback onClosed, + }); + +/// Captures a still profile photo inside the avatar's circular viewfinder. +class ImageAvatarCapture extends HookConsumerWidget { + /// Creates the inline image capture surface. + const ImageAvatarCapture({ + super.key, + required this.height, + required this.onAccepted, + required this.onClosed, + this.initialPreview, + this.initialCapturedBytes, + this.loadCameras = availableCameras, + this.disposalBarrier, + }); + + /// The vertical space available to the camera and its controls. + final double height; + + /// Accepts the captured, square image as an unsaved avatar draft. + final ValueChanged onAccepted; + + /// Leaves camera mode without changing the current avatar draft. + final VoidCallback onClosed; + + /// The existing avatar shown while the same circular cutout becomes a camera. + final Widget? initialPreview; + + /// Seeds the captured-photo review state in focused widget tests. + @visibleForTesting + final Uint8List? initialCapturedBytes; + + /// Loads device cameras. Overridden by focused widget tests. + @visibleForTesting + final Future> Function() loadCameras; + + /// Serializes ownership release with another profile capture surface. + final CameraDisposalBarrier? disposalBarrier; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final lifecycle = ref.watch(appLifecycleProvider); + final controller = useState(null); + final controllerRef = useRef(null); + final controllerDisposal = useRef( + disposalBarrier ?? CameraDisposalBarrier(), + ); + final candidateRef = useRef(null); + final cameras = useState>(const []); + final selectedLens = useState(CameraLensDirection.front); + final cameraGeneration = useState(0); + final flipAnimation = useAnimationController( + duration: _cameraFlipHalfDuration * 2, + ); + final flipDirection = useState(1.0); + final isInitializing = useState(initialCapturedBytes == null); + final isFlipping = useState(false); + final isCapturing = useState(false); + final isProcessingCapture = useState(false); + final capturedBytes = useState(initialCapturedBytes); + final controlsExpanded = useState(false); + final isClosing = useState(false); + final error = useState(null); + + Future releaseController(CameraController? active) { + if (active == null) return controllerDisposal.value.settled; + return controllerDisposal.value.release(active.dispose); + } + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + controlsExpanded.value = true; + }); + return null; + }, const []); + + useEffect( + () => () { + final active = controllerRef.value; + controllerRef.value = null; + unawaited(releaseController(active)); + }, + const [], + ); + + useEffect(() { + var disposed = false; + final generation = cameraGeneration.value; + + if (lifecycle != AppLifecycleState.resumed || + capturedBytes.value != null) { + isInitializing.value = false; + final active = controllerRef.value; + controllerRef.value = null; + controller.value = null; + unawaited(releaseController(active)); + return null; + } + + isInitializing.value = true; + error.value = null; + + Future initialize() async { + CameraController? next; + CameraDisposalReservation? reservation; + var installed = false; + try { + await controllerDisposal.value.settled; + if (disposed || generation != cameraGeneration.value) return; + final available = await loadCameras(); + if (disposed || generation != cameraGeneration.value) return; + cameras.value = available; + if (available.isEmpty) { + throw CameraException( + 'no-cameras', + 'No cameras are available on this device.', + ); + } + final description = available.firstWhere( + (candidate) => candidate.lensDirection == selectedLens.value, + orElse: () => available.first, + ); + selectedLens.value = description.lensDirection; + next = CameraController( + description, + ResolutionPreset.high, + enableAudio: false, + ); + reservation = controllerDisposal.value.reserve(); + candidateRef.value = next; + await reservation.ready; + if (disposed) { + if (identical(candidateRef.value, next)) candidateRef.value = null; + await reservation.dispose(next.dispose); + return; + } + await next.initialize(); + await next.lockCaptureOrientation(DeviceOrientation.portraitUp); + if (disposed || generation != cameraGeneration.value) { + if (identical(candidateRef.value, next)) candidateRef.value = null; + await reservation.dispose(next.dispose); + return; + } + candidateRef.value = null; + reservation.complete(); + final previous = controllerRef.value; + controllerRef.value = next; + controller.value = next; + installed = true; + if (previous != null && previous != next) { + unawaited(releaseController(previous)); + } + } catch (_) { + if (!installed && next != null) { + final activeCandidate = identical(candidateRef.value, next); + if (activeCandidate) candidateRef.value = null; + if (activeCandidate) { + await reservation?.dispose(next.dispose); + } + } + if (!disposed && generation == cameraGeneration.value) { + final active = controllerRef.value; + if (active != null) { + selectedLens.value = active.description.lensDirection; + } + error.value = 'Could not access the camera.'; + } + } finally { + if (!disposed && generation == cameraGeneration.value) { + isInitializing.value = false; + } + } + } + + unawaited(initialize()); + return () { + disposed = true; + }; + }, [lifecycle, capturedBytes.value == null, cameraGeneration.value]); + + Future capture() async { + final active = controller.value; + if (active == null || isCapturing.value || active.value.isTakingPicture) { + return; + } + isCapturing.value = true; + error.value = null; + XFile? photo; + try { + unawaited(HapticFeedback.mediumImpact()); + photo = await active.takePicture(); + try { + await active.pausePreview(); + } on CameraException { + // Some camera backends pause automatically after a still capture. + } + if (context.mounted) isProcessingCapture.value = true; + final prepared = await ref + .read(mediaUploadServiceProvider) + .prepareImageBytes(photo); + final cropped = await compute(_centerCropCameraImage, ( + bytes: prepared, + mirror: active.description.lensDirection == CameraLensDirection.front, + )); + if (context.mounted) capturedBytes.value = cropped; + } catch (_) { + if (context.mounted) { + error.value = "We couldn't take that photo. Try again."; + try { + await active.resumePreview(); + } on CameraException { + if (identical(controllerRef.value, active)) { + controllerRef.value = null; + controller.value = null; + await releaseController(active); + if (context.mounted) cameraGeneration.value++; + } + } + } + } finally { + final path = photo?.path; + if (path != null && path.isNotEmpty) { + try { + await File(path).delete(); + } on FileSystemException { + // The camera plugin can remove its temporary file independently. + } + } + if (context.mounted) { + isCapturing.value = false; + isProcessingCapture.value = false; + } + } + } + + Future flipCamera() async { + if (isInitializing.value || + isFlipping.value || + isCapturing.value || + cameras.value.length < 2) { + return; + } + final active = controller.value; + if (active == null) return; + final nextLens = selectedLens.value == CameraLensDirection.front + ? CameraLensDirection.back + : CameraLensDirection.front; + final matches = cameras.value.where( + (camera) => camera.lensDirection == nextLens, + ); + if (matches.isEmpty) return; + unawaited(HapticFeedback.selectionClick()); + isFlipping.value = true; + flipDirection.value = nextLens == CameraLensDirection.back ? 1 : -1; + error.value = null; + final flipMotion = reduceMotion + ? null + : flipAnimation.animateTo( + 1, + duration: _cameraFlipHalfDuration * 2, + curve: Curves.easeInOutCubic, + ); + try { + if (!reduceMotion) await Future.delayed(_cameraFlipHalfDuration); + if (!context.mounted) return; + await active.setDescription(matches.first); + if (context.mounted) selectedLens.value = nextLens; + await active.lockCaptureOrientation(DeviceOrientation.portraitUp); + } on CameraException { + if (context.mounted) error.value = 'Could not switch cameras.'; + } finally { + if (context.mounted && flipMotion != null) { + try { + await flipMotion.orCancel; + } on TickerCanceled { + // The view was disposed while the camera was switching. + } + } + if (context.mounted) { + flipAnimation.value = 0; + isFlipping.value = false; + } + } + } + + void retake() { + unawaited(HapticFeedback.selectionClick()); + capturedBytes.value = null; + error.value = null; + cameraGeneration.value++; + } + + Future leaveCamera(Uint8List? acceptedBytes) async { + if (isClosing.value) return; + unawaited( + acceptedBytes == null + ? HapticFeedback.selectionClick() + : HapticFeedback.mediumImpact(), + ); + isClosing.value = true; + controlsExpanded.value = false; + if (!reduceMotion) await Future.delayed(_captureMotionDuration); + if (!context.mounted) return; + if (acceptedBytes == null) { + onClosed(); + } else { + onAccepted(acceptedBytes); + } + } + + final captured = capturedBytes.value; + final previewSize = controlsExpanded.value + ? imageAvatarCameraPreviewSize + : _avatarPreviewSize; + final captureEnabled = + controller.value != null && + !isInitializing.value && + !isFlipping.value && + !isCapturing.value && + !isClosing.value; + final hasOppositeLens = cameras.value.any( + (camera) => + camera.lensDirection == + (selectedLens.value == CameraLensDirection.front + ? CameraLensDirection.back + : CameraLensDirection.front), + ); + final flipEnabled = + hasOppositeLens && + !isInitializing.value && + !isFlipping.value && + !isCapturing.value && + !isClosing.value; + + return SizedBox( + key: const ValueKey('image-avatar-camera'), + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + top: 0, + height: imageAvatarCameraPreviewSize, + child: Center( + child: AnimatedBuilder( + animation: flipAnimation, + builder: (context, child) { + final progress = flipAnimation.value; + final angle = progress <= 0.5 + ? progress * pi + : (progress - 1) * pi; + return Transform( + key: const ValueKey('image-camera-preview-flip'), + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.0015) + ..rotateY(angle * flipDirection.value), + child: child, + ); + }, + child: AnimatedContainer( + key: const ValueKey('image-camera-preview-size'), + duration: reduceMotion + ? Duration.zero + : _captureMotionDuration, + curve: Curves.easeOutCubic, + width: previewSize, + height: previewSize, + child: ClipOval( + child: ColoredBox( + color: Colors.black, + child: captured != null + ? Image.memory(captured, fit: BoxFit.cover) + : controller.value != null + ? _CameraPreview(controller: controller.value!) + : initialPreview != null + ? FittedBox( + key: const ValueKey( + 'image-camera-initial-preview-scale', + ), + fit: BoxFit.cover, + child: SizedBox.square( + dimension: _avatarPreviewSize, + child: initialPreview, + ), + ) + : Center( + child: isInitializing.value + ? const BuzzLoadingIndicator( + semanticLabel: 'Starting camera', + ) + : const Icon(LucideIcons.cameraOff, size: 32), + ), + ), + ), + ), + ), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _cameraControlRailHeight, + child: TweenAnimationBuilder( + tween: Tween(end: controlsExpanded.value ? 1 : 0), + duration: reduceMotion ? Duration.zero : _captureMotionDuration, + curve: Curves.easeOutCubic, + builder: (context, progress, _) => LayoutBuilder( + builder: (context, constraints) { + final collapsedControlOffset = + (constraints.maxWidth + Grid.half * 3) / 8; + final sideOffset = + collapsedControlOffset + + (_expandedControlOffset - collapsedControlOffset) * + progress; + return TweenAnimationBuilder( + tween: Tween( + end: captured == null || isClosing.value ? 0 : 1, + ), + duration: reduceMotion + ? Duration.zero + : _captureMotionDuration, + curve: Curves.easeInOutCubic, + builder: (context, reviewProgress, _) { + final sideWidth = + _cameraControlSize + + (_reviewControlWidth - _cameraControlSize) * + reviewProgress; + final reviewSideOffset = + _reviewControlWidth / 2 + _reviewControlGap / 2; + final effectiveSideOffset = + sideOffset + + (reviewSideOffset - sideOffset) * reviewProgress; + return Stack( + alignment: Alignment.center, + children: [ + Positioned( + left: + constraints.maxWidth / 2 - + effectiveSideOffset - + sideWidth / 2, + width: sideWidth, + height: _cameraControlRailHeight, + child: _MorphingCameraAction( + controlKey: const ValueKey( + 'image-camera-left-action', + ), + width: sideWidth, + icon: isClosing.value + ? LucideIcons.camera + : LucideIcons.x, + iosIcon: isClosing.value + ? IosGlassNavigationIcon.camera + : IosGlassNavigationIcon.close, + label: captured != null && !isClosing.value + ? 'Retry' + : null, + transitionLabel: isClosing.value + ? 'Camera' + : null, + transitionLabelMaxWidth: 96, + transitionProgress: isClosing.value + ? 1 - progress + : 0, + showEnabledAppearance: isClosing.value, + semanticLabel: isClosing.value + ? 'Camera' + : captured == null + ? 'Close camera' + : 'Retry', + onTap: + isFlipping.value || + isCapturing.value || + isClosing.value + ? null + : captured == null + ? () => unawaited(leaveCamera(null)) + : retake, + ), + ), + TweenAnimationBuilder( + tween: Tween(end: controlsExpanded.value ? 1 : 0), + duration: reduceMotion + ? Duration.zero + : isClosing.value + ? _shutterExitDuration + : _captureMotionDuration, + curve: Curves.easeOutCubic, + builder: (context, shutterProgress, _) => + Transform.scale( + scale: + (0.73 + 0.27 * shutterProgress) * + (1 - 0.28 * reviewProgress), + child: Opacity( + key: const ValueKey( + 'image-camera-shutter-exit-opacity', + ), + opacity: + shutterProgress * (1 - reviewProgress), + child: IgnorePointer( + ignoring: captured != null, + child: _ShutterButton( + busy: isProcessingCapture.value, + onTap: captureEnabled + ? () => unawaited(capture()) + : null, + ), + ), + ), + ), + ), + Positioned( + left: + constraints.maxWidth / 2 + + effectiveSideOffset - + sideWidth / 2, + width: sideWidth, + height: _cameraControlRailHeight, + child: _MorphingCameraAction( + controlKey: const ValueKey( + 'image-camera-right-action', + ), + width: sideWidth, + icon: isClosing.value + ? LucideIcons.images + : LucideIcons.switchCamera, + iosIcon: isClosing.value + ? IosGlassNavigationIcon.photoLibrary + : IosGlassNavigationIcon.rotateCamera, + label: captured != null && !isClosing.value + ? 'Use Photo' + : null, + transitionLabel: isClosing.value + ? 'Photo Library' + : null, + transitionLabelMaxWidth: 104, + transitionProgress: isClosing.value + ? 1 - progress + : 0, + showEnabledAppearance: isClosing.value, + semanticLabel: isClosing.value + ? 'Photo Library' + : captured == null + ? 'Flip camera' + : 'Use Photo', + onTap: isClosing.value + ? null + : captured != null + ? () => unawaited(leaveCamera(captured)) + : flipEnabled + ? () => unawaited(flipCamera()) + : null, + ), + ), + ], + ); + }, + ); + }, + ), + ), + ), + if (error.value != null) + Positioned( + left: 0, + right: 0, + bottom: _cameraControlRailHeight + Grid.xs, + child: Semantics( + liveRegion: true, + child: Text( + error.value!, + textAlign: TextAlign.center, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + ), + ], + ), + ); + } +} + +Uint8List _centerCropCameraImage(({Uint8List bytes, bool mirror}) request) { + var decoded = image.decodeImage(request.bytes); + if (decoded == null) throw const FormatException('Invalid camera image'); + if (request.mirror) decoded = image.flipHorizontal(decoded); + final side = min(decoded.width, decoded.height); + final cropped = image.copyCrop( + decoded, + x: (decoded.width - side) ~/ 2, + y: (decoded.height - side) ~/ 2, + width: side, + height: side, + ); + final resized = side == 512 + ? cropped + : image.copyResize( + cropped, + width: 512, + height: 512, + interpolation: image.Interpolation.cubic, + ); + return Uint8List.fromList(image.encodeJpg(resized, quality: 92)); +} + +/// Prepares a camera image with the same mirroring and crop used by capture. +@visibleForTesting +Uint8List prepareCameraImageForTesting( + Uint8List bytes, { + required bool mirror, +}) => _centerCropCameraImage((bytes: bytes, mirror: mirror)); diff --git a/mobile/lib/features/profile/image_avatar_capture/camera_preview.dart b/mobile/lib/features/profile/image_avatar_capture/camera_preview.dart new file mode 100644 index 0000000000..29a00d0209 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture/camera_preview.dart @@ -0,0 +1,21 @@ +part of '../image_avatar_capture.dart'; + +class _CameraPreview extends StatelessWidget { + const _CameraPreview({required this.controller}); + + final CameraController controller; + + @override + Widget build(BuildContext context) { + final aspectRatio = 1 / controller.value.aspectRatio; + return FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: imageAvatarCameraPreviewSize * aspectRatio, + height: imageAvatarCameraPreviewSize, + child: CameraPreview(controller), + ), + ); + } +} diff --git a/mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart b/mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart new file mode 100644 index 0000000000..9211b20f42 --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture/morphing_camera_action.dart @@ -0,0 +1,149 @@ +part of '../image_avatar_capture.dart'; + +class _MorphingCameraAction extends StatelessWidget { + const _MorphingCameraAction({ + required this.controlKey, + required this.width, + required this.icon, + required this.iosIcon, + required this.label, + required this.transitionLabel, + required this.transitionLabelMaxWidth, + required this.transitionProgress, + required this.showEnabledAppearance, + required this.semanticLabel, + required this.onTap, + }); + + final Key controlKey; + final double width; + final IconData icon; + final IosGlassNavigationIcon iosIcon; + final String? label; + final String? transitionLabel; + final double transitionLabelMaxWidth; + final double transitionProgress; + final bool showEnabledAppearance; + final String semanticLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + Widget control; + if (defaultTargetPlatform == TargetPlatform.iOS) { + control = IgnorePointer( + ignoring: onTap == null, + child: IosGlassNavigationButton( + icon: iosIcon, + label: label, + semanticLabel: semanticLabel, + onPressed: onTap ?? (showEnabledAppearance ? () {} : null), + width: width, + height: _cameraControlSize, + controlSize: _cameraControlSize, + fillWidth: true, + foregroundColor: context.colors.onSurface, + ), + ); + } else { + final dimmed = onTap == null && !showEnabledAppearance; + control = Semantics( + label: semanticLabel, + button: true, + enabled: onTap != null, + child: ExcludeSemantics( + child: Material( + color: context.colors.surfaceContainerHighest, + shape: const StadiumBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: width, + height: _cameraControlSize, + child: Center( + child: AnimatedSwitcher( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 120), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: label == null + ? Icon( + icon, + key: ValueKey('camera-action-icon-${iosIcon.name}'), + size: 26, + color: dimmed + ? context.colors.onSurface.withValues( + alpha: 0.38, + ) + : context.colors.onSurface, + ) + : Text( + label!, + key: ValueKey(label), + maxLines: 1, + style: context.textTheme.labelMedium?.copyWith( + color: dimmed + ? context.colors.onSurface.withValues( + alpha: 0.38, + ) + : context.colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + final labelProgress = transitionProgress.clamp(0.0, 1.0); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: controlKey, + left: 0, + right: 0, + top: (_cameraControlRailHeight - _cameraControlSize) / 2, + height: _cameraControlSize, + child: Transform.translate( + offset: Offset(0, 1.5 * labelProgress), + child: control, + ), + ), + if (transitionLabel != null) + Positioned( + left: 0, + right: 0, + top: _cameraControlRailHeight - 20, + height: 20, + child: Opacity( + key: ValueKey('camera-transition-label-${transitionLabel!}'), + opacity: labelProgress, + child: Transform.translate( + offset: Offset(0, 2 * (1 - labelProgress)), + child: OverflowBox( + minWidth: 0, + maxWidth: transitionLabelMaxWidth, + maxHeight: 20, + child: Text( + transitionLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/profile/image_avatar_capture/shutter_button.dart b/mobile/lib/features/profile/image_avatar_capture/shutter_button.dart new file mode 100644 index 0000000000..e68a2b837e --- /dev/null +++ b/mobile/lib/features/profile/image_avatar_capture/shutter_button.dart @@ -0,0 +1,75 @@ +part of '../image_avatar_capture.dart'; + +class _ShutterButton extends StatelessWidget { + const _ShutterButton({required this.busy, required this.onTap}); + + final bool busy; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + final content = SizedBox( + key: const ValueKey('image-camera-shutter-morph'), + width: _shutterSize, + height: _shutterSize, + child: isIos + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.shutter, + semanticLabel: 'Take photo', + onPressed: onTap, + width: _shutterSize, + height: _shutterSize, + controlSize: _shutterSize, + foregroundColor: context.colors.onSurface, + isBusy: busy, + ) + : Material( + color: context.colors.surfaceContainerHighest, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('image-camera-shutter'), + onTap: onTap, + child: Center( + child: AnimatedSwitcher( + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 140), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: busy + ? BuzzLoadingIndicator( + key: const ValueKey('image-camera-capturing'), + size: 24, + color: context.colors.onSurface, + semanticLabel: 'Taking photo', + ) + : Container( + key: const ValueKey('image-camera-shutter-icon'), + width: _shutterCoreSize, + height: _shutterCoreSize, + decoration: BoxDecoration( + color: context.colors.onSurface, + shape: BoxShape.circle, + border: Border.all( + color: context.colors.surface, + width: 1.5, + ), + ), + ), + ), + ), + ), + ), + ); + if (isIos) return content; + return Semantics( + label: 'Take photo', + button: true, + enabled: onTap != null, + child: ExcludeSemantics(child: content), + ); + } +} diff --git a/mobile/lib/features/profile/profile_avatar_draft.dart b/mobile/lib/features/profile/profile_avatar_draft.dart index efb6900460..8fc48efaf3 100644 --- a/mobile/lib/features/profile/profile_avatar_draft.dart +++ b/mobile/lib/features/profile/profile_avatar_draft.dart @@ -115,14 +115,12 @@ final class ProfileAnimatedAvatarDraft extends ProfileAvatarDraft { } final existing = _uploadedUrl; if (existing != null) return existing; - // Cache each content-addressed part independently. Upload sequentially so - // relays configured to allow only one in-flight media request can accept an - // animated avatar in a single Save attempt. - final upload = _uploadPoster(service).then( - (poster) => _uploadAnimation( - service, - ).then((animation) => buildAnimatedAvatarUrl(poster.url, animation.url)), - ); + // Cache each content-addressed part independently. If one request fails, + // retry only that part so a successful counterpart remains attached to + // this draft instead of becoming an abandoned duplicate. + final upload = Future.wait( + [_uploadPoster(service), _uploadAnimation(service)], + ).then((uploads) => buildAnimatedAvatarUrl(uploads[0].url, uploads[1].url)); _uploadedUrl = upload; try { return await upload; diff --git a/mobile/lib/features/profile/profile_avatar_editor.dart b/mobile/lib/features/profile/profile_avatar_editor.dart index 4cc495eecb..ec55df8173 100644 --- a/mobile/lib/features/profile/profile_avatar_editor.dart +++ b/mobile/lib/features/profile/profile_avatar_editor.dart @@ -18,12 +18,15 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/ios_native_segmented_control.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/ios_native_skin_tone_control.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'animated_avatar_capture.dart'; +import 'camera_disposal_barrier.dart'; import 'avatar_background_grid.dart'; import 'avatar_editor_option_button.dart'; import 'emoji_avatar_tile.dart'; +import 'image_avatar_capture.dart'; import 'profile_avatar_crop_page.dart'; import 'profile_avatar_draft.dart'; @@ -79,7 +82,9 @@ class ProfileAvatarEditor extends HookConsumerWidget { required this.onModeChanged, required this.onDraftChanged, required this.onAnimatedPrepareChanged, + required this.onImageCameraActiveChanged, this.animatedCaptureBuilder, + this.imageCaptureBuilder, }); /// The avatar URL shown until the user selects a new draft. @@ -107,9 +112,15 @@ class ProfileAvatarEditor extends HookConsumerWidget { final ValueChanged Function()?> onAnimatedPrepareChanged; + /// Reports whether the inline still camera currently owns the image editor. + final ValueChanged onImageCameraActiveChanged; + /// Overrides the animated capture surface, primarily for tests. final AnimatedAvatarCaptureBuilder? animatedCaptureBuilder; + /// Overrides the still-image capture surface, primarily for tests. + final ImageAvatarCaptureBuilder? imageCaptureBuilder; + @override Widget build(BuildContext context, WidgetRef ref) { final reduceMotion = MediaQuery.disableAnimationsOf(context); @@ -128,6 +139,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { final emojiSection = useState(_EmojiEditorSection.emoji); final emojiPreviewKey = useState(0); final isPickingImage = useState(false); + final isCapturingImage = useState(false); + final cameraDisposal = useRef(CameraDisposalBarrier()); final imageSelectionGeneration = useRef(0); final currentMode = useRef(mode)..value = mode; final error = useState(null); @@ -154,6 +167,8 @@ class ProfileAvatarEditor extends HookConsumerWidget { if (mode == ProfileAvatarMode.image) { imageSelectionGeneration.value++; isPickingImage.value = false; + isCapturingImage.value = false; + onImageCameraActiveChanged(false); } if (!reduceMotion) modeTransitionController.value = 0; onModeChanged(nextMode); @@ -180,7 +195,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ); } - Future selectImage({required bool camera}) async { + Future selectGalleryImage() async { if (isPickingImage.value) return; final operation = ++imageSelectionGeneration.value; bool isCurrentOperation() => @@ -192,9 +207,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { try { final service = ref.read(mediaUploadServiceProvider); unawaited(HapticFeedback.lightImpact()); - final picked = camera - ? await service.captureImage() - : await service.pickGalleryImage(); + final picked = await service.pickGalleryImage(); if (picked == null || !isCurrentOperation()) return; final preparedPhoto = await service.prepareImageBytes(picked); if (!context.mounted || !isCurrentOperation()) return; @@ -216,6 +229,16 @@ class ProfileAvatarEditor extends HookConsumerWidget { } } + void closeImageCamera() { + isCapturingImage.value = false; + onImageCameraActiveChanged(false); + } + + void acceptCameraImage(Uint8List bytes) { + onDraftChanged(ProfileImageAvatarDraft(bytes)); + closeImageCamera(); + } + final previewUrl = switch (draft) { ProfileUrlAvatarDraft(:final url) => url, _ => currentAvatarUrl, @@ -308,9 +331,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ); final requestedShift = mode != ProfileAvatarMode.emoji ? 0.0 - : emojiSection.value == _EmojiEditorSection.emoji - ? _emojiPickerPreviewShift - : avatarBackgroundPreviewShift; + : _emojiPickerPreviewShift; final previewShift = min(requestedShift, maximumShift); final previewTop = basePreviewTop - previewShift; final returningToEmoji = @@ -332,20 +353,46 @@ class ProfileAvatarEditor extends HookConsumerWidget { } final fixedContentTop = previewTop + _previewBlockSize + _previewControlGap; + final imageCameraTop = + basePreviewTop + + _previewBlockSize / 2 - + imageAvatarCameraPreviewSize / 2; final modeTop = mode == ProfileAvatarMode.animated ? basePreviewTop + : mode == ProfileAvatarMode.image && isCapturingImage.value + ? imageCameraTop : fixedContentTop; final modeHeight = max( 0.0, viewportHeight - _editorControlsBottom - modeTop, ); final modeContent = switch (mode) { + ProfileAvatarMode.image when isCapturingImage.value => KeyedSubtree( + key: const ValueKey('image-camera-mode'), + child: + imageCaptureBuilder?.call( + height: modeHeight, + onAccepted: acceptCameraImage, + onClosed: closeImageCamera, + ) ?? + ImageAvatarCapture( + height: modeHeight, + initialPreview: fixedPreview, + onAccepted: acceptCameraImage, + onClosed: closeImageCamera, + disposalBarrier: cameraDisposal.value, + ), + ), ProfileAvatarMode.image => _ImageMode( key: const ValueKey(0), height: modeHeight, isPicking: isPickingImage.value, - onCamera: () => unawaited(selectImage(camera: true)), - onLibrary: () => unawaited(selectImage(camera: false)), + onCamera: () { + error.value = null; + isCapturingImage.value = true; + onImageCameraActiveChanged(true); + }, + onLibrary: () => unawaited(selectGalleryImage()), ), ProfileAvatarMode.emoji => _EmojiMode( key: const ValueKey(1), @@ -376,6 +423,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { AnimatedAvatarCapture( height: modeHeight, onPrepareChanged: onAnimatedPrepareChanged, + disposalBarrier: cameraDisposal.value, ), ), }; @@ -442,7 +490,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), ), - if (fixedPreview != null) + if (fixedPreview != null && !isCapturingImage.value) AnimatedPositioned( key: const ValueKey('avatar-preview-position'), curve: Curves.easeOutCubic, @@ -479,7 +527,7 @@ class ProfileAvatarEditor extends HookConsumerWidget { ), ), AnimatedPositioned( - duration: reduceMotion + duration: isCapturingImage.value || reduceMotion ? Duration.zero : const Duration(milliseconds: 150), curve: Curves.easeOutCubic, @@ -660,22 +708,22 @@ class _ImageMode extends StatelessWidget { children: [ const Spacer(), Expanded( - child: AvatarEditorOptionButton( + child: _ImageSourceOption( key: const ValueKey('image-source-camera'), icon: LucideIcons.camera, + iosIcon: IosGlassNavigationIcon.camera, label: 'Camera', - selected: false, onTap: isPicking ? null : onCamera, labelMaxWidth: 96, ), ), const SizedBox(width: Grid.half), Expanded( - child: AvatarEditorOptionButton( + child: _ImageSourceOption( key: const ValueKey('image-source-library'), icon: LucideIcons.images, + iosIcon: IosGlassNavigationIcon.photoLibrary, label: 'Photo Library', - selected: false, onTap: isPicking ? null : onLibrary, labelMaxWidth: 104, ), @@ -687,3 +735,30 @@ class _ImageMode extends StatelessWidget { ), ); } + +class _ImageSourceOption extends StatelessWidget { + const _ImageSourceOption({ + super.key, + required this.icon, + required this.iosIcon, + required this.label, + required this.onTap, + required this.labelMaxWidth, + }); + + final IconData icon; + final IosGlassNavigationIcon iosIcon; + final String label; + final VoidCallback? onTap; + final double labelMaxWidth; + + @override + Widget build(BuildContext context) => AvatarEditorOptionButton( + icon: icon, + iosIcon: iosIcon, + label: label, + selected: false, + onTap: onTap, + labelMaxWidth: labelMaxWidth, + ); +} diff --git a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart index 1712c0c99b..6d658935f2 100644 --- a/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart +++ b/mobile/lib/features/profile/profile_avatar_editor/emoji_avatar_picker.dart @@ -83,11 +83,16 @@ class _EmojiMode extends HookConsumerWidget { child: Opacity( opacity: transitionProgress, child: activeSection == _EmojiEditorSection.background - ? AvatarBackgroundGrid( - key: const ValueKey('emoji-background-editor'), - selectedColor: selectedColor, - onColorSelected: onColorSelected, - colorKeyPrefix: 'emoji-avatar-color', + ? Center( + key: const ValueKey( + 'emoji-background-editor-alignment', + ), + child: AvatarBackgroundGrid( + key: const ValueKey('emoji-background-editor'), + selectedColor: selectedColor, + onColorSelected: onColorSelected, + colorKeyPrefix: 'emoji-avatar-color', + ), ) : Column( key: const ValueKey('emoji-glyph-editor'), @@ -223,6 +228,7 @@ class _EmojiMode extends HookConsumerWidget { child: AvatarEditorOptionButton( key: const ValueKey('emoji-editor-background'), icon: LucideIcons.palette, + iosIcon: IosGlassNavigationIcon.palette, label: 'Background', selected: activeSection == _EmojiEditorSection.background, @@ -236,6 +242,7 @@ class _EmojiMode extends HookConsumerWidget { child: AvatarEditorOptionButton( key: const ValueKey('emoji-editor-emoji'), icon: LucideIcons.smile, + iosIcon: IosGlassNavigationIcon.emoji, label: 'Emoji', selected: activeSection == _EmojiEditorSection.emoji, onTap: () => diff --git a/mobile/lib/features/profile/profile_edit_page.dart b/mobile/lib/features/profile/profile_edit_page.dart index 12e4e3efd7..3584808bee 100644 --- a/mobile/lib/features/profile/profile_edit_page.dart +++ b/mobile/lib/features/profile/profile_edit_page.dart @@ -21,11 +21,11 @@ import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/playing_avatar_image.dart'; import 'ios_profile_text_editor.dart'; +import 'image_avatar_capture.dart'; import 'profile_avatar_editor.dart'; import 'profile_avatar_draft.dart'; import 'profile_provider.dart'; import 'profile_text_editor.dart'; -import 'profile_text_edit_sheet.dart'; /// Edits the current user's public profile metadata. class ProfileEditPage extends HookConsumerWidget { @@ -34,6 +34,7 @@ class ProfileEditPage extends HookConsumerWidget { super.key, this.startInPhotoEditor = false, this.animatedAvatarCaptureBuilder, + this.imageAvatarCaptureBuilder, }); /// Opens directly into the photo editor when launched from Settings. @@ -42,6 +43,9 @@ class ProfileEditPage extends HookConsumerWidget { /// Overrides animated capture for focused integration tests. final AnimatedAvatarCaptureBuilder? animatedAvatarCaptureBuilder; + /// Overrides still-image capture for focused integration tests. + final ImageAvatarCaptureBuilder? imageAvatarCaptureBuilder; + static const _avatarRadius = 64.0; @override @@ -62,6 +66,7 @@ class ProfileEditPage extends HookConsumerWidget { useRef Function()?>(null); final avatarSaveError = useState(null); final canPrepareAnimatedAvatar = useState(false); + final isImageCameraActive = useState(false); final avatarMode = useState(ProfileAvatarMode.image); final avatarTransition = useAnimationController( duration: reduceMotion @@ -87,11 +92,10 @@ class ProfileEditPage extends HookConsumerWidget { bool multiline = false, }) => showBuzzModalBottomSheet( context: context, + title: title, isScrollControlled: true, requestFocus: true, - showCloseButton: false, - builder: (_) => ProfileTextEditSheet( - title: title, + builder: (_) => _ProfileTextEditSheet( initialValue: initialValue, hintText: hintText, multiline: multiline, @@ -172,6 +176,7 @@ class ProfileEditPage extends HookConsumerWidget { avatarEditConfig.value = null; prepareAnimatedAvatar.value = null; canPrepareAnimatedAvatar.value = false; + isImageCameraActive.value = false; avatarMode.value = ProfileAvatarMode.image; isClosingAvatar.value = false; } @@ -224,6 +229,19 @@ class ProfileEditPage extends HookConsumerWidget { requireCurrentCommunity(); await ref.read(profileProvider.notifier).updateAvatarUrl(nextAvatar); requireCurrentCommunity(); + if (nextDraft is ProfileAnimatedAvatarDraft) { + ref + .read(profileAvatarHandoffProvider.notifier) + .show( + ProfileAvatarHandoff( + avatarUrl: nextAvatar, + animation: nextDraft.animation, + poster: nextDraft.poster, + ), + ); + } else { + ref.read(profileAvatarHandoffProvider.notifier).clearAny(); + } if (context.mounted) await closeAvatarEditor(whileSaving: true); } on ProfileCommunityChangedException { await discardStaleEditor(); @@ -243,6 +261,7 @@ class ProfileEditPage extends HookConsumerWidget { final canSaveAvatar = profileHydrated && + !isImageCameraActive.value && (avatarMode.value == ProfileAvatarMode.animated ? canPrepareAnimatedAvatar.value : avatarDraftMode.value == avatarMode.value && @@ -250,6 +269,7 @@ class ProfileEditPage extends HookConsumerWidget { final activeDraft = avatarDraftMode.value == avatarMode.value ? avatarDraft.value : null; + final avatarHandoff = ref.watch(profileAvatarHandoffProvider); return PopScope( canPop: !isEditingAvatar.value, @@ -369,8 +389,12 @@ class ProfileEditPage extends HookConsumerWidget { avatarDraftMode.value = null; } }, + onImageCameraActiveChanged: (active) { + isImageCameraActive.value = active; + }, animatedCaptureBuilder: animatedAvatarCaptureBuilder, + imageCaptureBuilder: imageAvatarCaptureBuilder, ), ), ), @@ -410,6 +434,7 @@ class ProfileEditPage extends HookConsumerWidget { children: [ _ProfilePhotoEditor( profile: profile, + handoff: avatarHandoff, onEditPhoto: profileHydrated ? openAvatarEditor : null, ), AppListCard( @@ -488,36 +513,57 @@ String _fieldValue(String? value) { return trimmed.isEmpty ? 'Not set' : trimmed; } -class _ProfilePhotoEditor extends StatelessWidget { - const _ProfilePhotoEditor({required this.profile, required this.onEditPhoto}); +class _ProfilePhotoEditor extends ConsumerWidget { + const _ProfilePhotoEditor({ + required this.profile, + required this.handoff, + required this.onEditPhoto, + }); final UserProfile? profile; + final ProfileAvatarHandoff? handoff; final VoidCallback? onEditPhoto; @override - Widget build(BuildContext context) => Column( - children: [ - PlayingAvatarImage( - key: const ValueKey('profile-edit-avatar'), - imageUrl: profile?.avatarUrl, - radius: ProfileEditPage._avatarRadius, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.displaySmall?.copyWith( - color: context.colors.onPrimaryContainer, + Widget build(BuildContext context, WidgetRef ref) { + final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl + ? handoff + : null; + return Column( + children: [ + PlayingAvatarImage( + key: const ValueKey('profile-edit-avatar'), + imageUrl: profile?.avatarUrl, + radius: ProfileEditPage._avatarRadius, + backgroundColor: context.colors.primaryContainer, + loadingImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.animation), + loadingPosterImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.poster), + onAnimationReady: activeHandoff == null + ? null + : () => ref + .read(profileAvatarHandoffProvider.notifier) + .clear(activeHandoff.avatarUrl), + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), ), ), - ), - const SizedBox(height: Grid.twelve), - _ProfileActionPill( - key: const ValueKey('profile-edit-photo-pill'), - semanticLabel: 'Edit profile photo', - label: 'Edit Photo', - onTap: onEditPhoto, - ), - ], - ); + const SizedBox(height: Grid.twelve), + _ProfileActionPill( + key: const ValueKey('profile-edit-photo-pill'), + semanticLabel: 'Edit profile photo', + label: 'Edit Photo', + onTap: onEditPhoto, + ), + ], + ); + } } class _ProfileActionPill extends StatelessWidget { @@ -582,3 +628,89 @@ class _EditChevron extends StatelessWidget { color: context.colors.onSurfaceVariant, ); } + +class _ProfileTextEditSheet extends HookWidget { + const _ProfileTextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } on ProfileCommunityChangedException { + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 38231d0f0d..93621f7608 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -17,6 +18,53 @@ class ProfileCommunityChangedException extends StateError { : super('Profile update cancelled because the active community changed.'); } +/// Keeps a freshly saved animated avatar local until its remote first frame is +/// ready, preserving the editor-to-profile visual handoff. +@immutable +class ProfileAvatarHandoff { + /// Creates a local handoff for [avatarUrl]. + const ProfileAvatarHandoff({ + required this.avatarUrl, + required this.animation, + required this.poster, + }); + + /// The persisted animated-avatar descriptor this handoff belongs to. + final String avatarUrl; + + /// The locally encoded animation shown while remote media warms up. + final Uint8List animation; + + /// The locally encoded poster used when motion is disabled. + final Uint8List poster; +} + +/// Owns the temporary local avatar shown across the save route transition. +class ProfileAvatarHandoffNotifier extends Notifier { + @override + ProfileAvatarHandoff? build() { + ref.watch(relayConfigProvider); + return null; + } + + /// Starts a handoff for a freshly published animated avatar. + void show(ProfileAvatarHandoff handoff) => state = handoff; + + /// Clears the handoff once the matching remote animation is ready. + void clear(String avatarUrl) { + if (state?.avatarUrl == avatarUrl) state = null; + } + + /// Clears a handoff superseded by a non-animated avatar save. + void clearAny() => state = null; +} + +/// The freshly saved local animated avatar, while its remote copy warms up. +final profileAvatarHandoffProvider = + NotifierProvider( + ProfileAvatarHandoffNotifier.new, + ); + /// The current user's profile (kind:0 metadata) loaded over the relay /// WebSocket. Returns null when no nsec is configured or when the user has /// not yet published a profile. diff --git a/mobile/lib/features/profile/profile_text_editor.dart b/mobile/lib/features/profile/profile_text_editor.dart index f37c113c9f..2804c36d71 100644 --- a/mobile/lib/features/profile/profile_text_editor.dart +++ b/mobile/lib/features/profile/profile_text_editor.dart @@ -3,13 +3,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; import '../../shared/widgets/modal_presentation.dart'; import 'ios_profile_text_editor.dart'; import 'profile_provider.dart'; -import 'profile_text_edit_sheet.dart'; /// Opens the current user's display-name editor from a profile action surface. Future showProfileDisplayNameEditor(BuildContext context) async { @@ -123,11 +124,10 @@ Future _showProfileTextEditor({ if (!context.mounted) return; await showBuzzModalBottomSheet( context: context, + title: title, isScrollControlled: true, requestFocus: true, - showCloseButton: false, - builder: (_) => ProfileTextEditSheet( - title: title, + builder: (_) => _ProfileTextEditSheet( initialValue: initialValue, hintText: hintText, multiline: multiline, @@ -141,3 +141,89 @@ void _showSaveError(BuildContext context) { const SnackBar(content: Text("We couldn't save this change. Try again.")), ); } + +class _ProfileTextEditSheet extends HookWidget { + const _ProfileTextEditSheet({ + required this.initialValue, + required this.hintText, + required this.multiline, + required this.onSave, + }); + + final String initialValue; + final String hintText; + final bool multiline; + final Future Function(String value) onSave; + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + useListenable(controller); + final isSaving = useState(false); + final error = useState(null); + final hasChanges = controller.text.trim() != initialValue.trim(); + + Future save() async { + if (!hasChanges || isSaving.value) return; + isSaving.value = true; + error.value = null; + try { + await onSave(controller.text); + if (context.mounted) Navigator.of(context).pop(); + } on ProfileCommunityChangedException { + if (context.mounted) Navigator.of(context).pop(); + } catch (_) { + error.value = "We couldn't save this change. Try again."; + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + key: const ValueKey('profile-field-input'), + controller: controller, + autofocus: true, + enabled: !isSaving.value, + minLines: multiline ? 4 : 1, + maxLines: multiline ? 6 : 1, + textCapitalization: TextCapitalization.sentences, + textInputAction: multiline + ? TextInputAction.newline + : TextInputAction.done, + onSubmitted: multiline ? null : (_) => unawaited(save()), + decoration: InputDecoration(hintText: hintText), + ), + if (error.value != null) ...[ + const SizedBox(height: Grid.xxs), + Text( + error.value!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ], + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('profile-field-save'), + onPressed: hasChanges && !isSaving.value ? save : null, + child: Text(isSaving.value ? 'Saving…' : 'Save'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index ac2df53ef4..1852188ecf 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/animated_avatar.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; +import '../../shared/relay/media_image.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; @@ -34,6 +35,11 @@ class SettingsProfileHeader extends HookConsumerWidget { final hasStatus = status != null && !status.isEmpty; final presence = ref.watch(presenceProvider).value ?? 'offline'; final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); + final animatedPosterUrl = animatedAvatar?.posterUrl; + final handoff = ref.watch(profileAvatarHandoffProvider); + final activeHandoff = handoff?.avatarUrl == profile?.avatarUrl + ? handoff + : null; final stoppedAnimationUrl = useState(null); final avatarUrl = animatedAvatar == null ? profile?.avatarUrl @@ -71,10 +77,62 @@ class SettingsProfileHeader extends HookConsumerWidget { key: ValueKey(animatedAvatar.animationUrl), descriptor: animatedAvatar, fallback: _AvatarFallback(initial: profile?.initial), + loadingImage: activeHandoff == null + ? null + : MemoryImage(activeHandoff.animation), + onAnimationReady: activeHandoff == null + ? null + : () => ref + .read(profileAvatarHandoffProvider.notifier) + .clear(activeHandoff.avatarUrl), ) - : AvatarImageContent( + : activeHandoff == null || animatedPosterUrl == null + ? AvatarImageContent( imageUrl: avatarUrl, fallback: _AvatarFallback(initial: profile?.initial), + ) + : Stack( + fit: StackFit.expand, + children: [ + Image( + image: MemoryImage(activeHandoff.poster), + fit: BoxFit.cover, + gaplessPlayback: true, + ), + Offstage( + offstage: true, + child: MediaImage( + key: ValueKey( + 'settings-profile-paused-handoff-${activeHandoff.avatarUrl}', + ), + url: animatedPosterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => + const SizedBox.shrink(), + frameBuilder: + ( + context, + child, + frame, + wasSynchronouslyLoaded, + ) { + if (wasSynchronouslyLoaded || + frame != null) { + WidgetsBinding.instance + .addPostFrameCallback((_) { + ref + .read( + profileAvatarHandoffProvider + .notifier, + ) + .clear(activeHandoff.avatarUrl); + }); + } + return child; + }, + ), + ), + ], ), ), ), diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index f54c898d7c..802b7c4f05 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -9,7 +9,18 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../theme/theme.dart'; /// The navigation glyph displayed by [IosGlassNavigationButton]. -enum IosGlassNavigationIcon { back, close } +enum IosGlassNavigationIcon { + back, + close, + camera, + photoLibrary, + palette, + emoji, + person, + frame, + rotateCamera, + shutter, +} /// Leading width used by iOS channel-style headers. const iosGlassChannelHeaderLeadingWidth = 58.0; @@ -30,22 +41,57 @@ class IosGlassNavigationButton extends HookWidget { required this.icon, required this.semanticLabel, required this.onPressed, + this.label, this.width = 48, this.height = 48, + this.controlSize = 40, + this.fillWidth = false, this.buttonCenterX, this.foregroundColor, + this.isBusy = false, + this.isSelected = false, this.nativeViewSuppressed, }); static const viewType = 'buzz/navigation_glass'; + /// The SF Symbol-style glyph rendered by the native control. final IosGlassNavigationIcon icon; + + /// Optional text rendered by the native control instead of [icon]. + final String? label; + + /// Accessibility label exposed by the native view or Flutter fallback. final String semanticLabel; + + /// Invoked when the enabled control is activated. final VoidCallback? onPressed; + + /// Width of the platform-view hit target. final double width; + + /// Height of the platform-view hit target. final double height; + + /// Diameter of the visual glass control inside its hit target. + final double controlSize; + + /// Whether the native visual control fills the available width. + final bool fillWidth; + + /// Horizontal center for the visual control within its hit target. final double? buttonCenterX; + + /// Optional foreground tint for the native control and Flutter fallback. final Color? foregroundColor; + + /// Whether the native control presents its busy state. + final bool isBusy; + + /// Whether the native control exposes its selected state. + final bool isSelected; + + /// When true, substitutes an accessible Flutter control for the native view. final ValueListenable? nativeViewSuppressed; @override @@ -69,19 +115,44 @@ class IosGlassNavigationButton extends HookWidget { return () => channel.setMethodCallHandler(null); }, [nativeChannel.value]); + useEffect( + () { + final channel = nativeChannel.value; + if (channel != null) { + unawaited( + channel.invokeMethod('setAppearance', { + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + 'selected': isSelected, + }), + ); + } + return null; + }, + [ + nativeChannel.value, + brightness, + foregroundValue, + enabled, + isBusy, + isSelected, + ], + ); + useEffect(() { final channel = nativeChannel.value; if (channel != null) { - unawaited( - channel.invokeMethod('setAppearance', { - 'brightness': brightness, - 'foregroundColor': foregroundValue, - 'enabled': enabled, - }), - ); + final content = { + 'icon': icon.name, + 'accessibilityLabel': semanticLabel, + }; + if (label != null) content['label'] = label!; + unawaited(channel.invokeMethod('setContent', content)); } return null; - }, [nativeChannel.value, brightness, foregroundValue, enabled]); + }, [nativeChannel.value, icon, label, semanticLabel]); Widget buildControl({required bool suppressNativeView}) { if (suppressNativeView) { @@ -90,6 +161,7 @@ class IosGlassNavigationButton extends HookWidget { container: true, button: true, enabled: enabled, + selected: isSelected, label: semanticLabel, onTap: onPressed, child: ExcludeSemantics( @@ -97,10 +169,10 @@ class IosGlassNavigationButton extends HookWidget { key: const ValueKey('ios-glass-navigation-flutter-fallback'), children: [ Positioned( - left: resolvedButtonCenterX - 20, - top: (height - 40) / 2, - width: 40, - height: 40, + left: resolvedButtonCenterX - controlSize / 2, + top: (height - controlSize) / 2, + width: controlSize, + height: controlSize, child: DecoratedBox( decoration: BoxDecoration( color: context.colors.surface.withValues(alpha: 0.72), @@ -111,13 +183,52 @@ class IosGlassNavigationButton extends HookWidget { ), ), ), - child: Icon( - icon == IosGlassNavigationIcon.back - ? Icons.arrow_back_ios_new_rounded - : Icons.close_rounded, - size: 20, - color: effectiveForeground, - ), + child: isBusy + ? Center( + child: SizedBox.square( + dimension: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: effectiveForeground, + ), + ), + ) + : label != null + ? Text( + label!, + maxLines: 1, + style: context.textTheme.labelMedium?.copyWith( + color: effectiveForeground, + fontWeight: FontWeight.w600, + ), + ) + : Icon( + switch (icon) { + IosGlassNavigationIcon.back => + Icons.arrow_back_ios_new_rounded, + IosGlassNavigationIcon.close => + Icons.close_rounded, + IosGlassNavigationIcon.camera => + Icons.camera_alt_rounded, + IosGlassNavigationIcon.photoLibrary => + Icons.photo_library_rounded, + IosGlassNavigationIcon.palette => + Icons.palette_rounded, + IosGlassNavigationIcon.emoji => + Icons.emoji_emotions_rounded, + IosGlassNavigationIcon.person => + Icons.person_rounded, + IosGlassNavigationIcon.frame => + Icons.photo_size_select_actual_rounded, + IosGlassNavigationIcon.rotateCamera => + Icons.cameraswitch_rounded, + IosGlassNavigationIcon.shutter => Icons.circle, + }, + size: icon == IosGlassNavigationIcon.shutter + ? controlSize * 0.72 + : 22, + color: effectiveForeground, + ), ), ), ], @@ -125,19 +236,26 @@ class IosGlassNavigationButton extends HookWidget { ), ); } + final creationParams = { + 'icon': icon.name, + 'accessibilityLabel': semanticLabel, + 'brightness': brightness, + 'foregroundColor': foregroundValue, + 'enabled': enabled, + 'busy': isBusy, + 'selected': isSelected, + 'controlSize': controlSize, + 'controlWidth': controlSize, + 'fillWidth': fillWidth, + 'buttonCenterX': buttonCenterX ?? width / 2, + 'hitTargetWidth': width, + 'hitTargetHeight': height, + }; + if (label != null) creationParams['label'] = label!; return UiKitView( viewType: viewType, hitTestBehavior: PlatformViewHitTestBehavior.opaque, - creationParams: { - 'icon': icon.name, - 'accessibilityLabel': semanticLabel, - 'brightness': brightness, - 'foregroundColor': foregroundValue, - 'enabled': enabled, - 'buttonCenterX': buttonCenterX ?? width / 2, - 'hitTargetWidth': width, - 'hitTargetHeight': height, - }, + creationParams: creationParams, creationParamsCodec: const StandardMessageCodec(), onPlatformViewCreated: (viewId) { nativeChannel.value = MethodChannel('$viewType/$viewId'); diff --git a/mobile/lib/shared/widgets/playing_avatar_image.dart b/mobile/lib/shared/widgets/playing_avatar_image.dart index ef84d4549e..8715e39217 100644 --- a/mobile/lib/shared/widgets/playing_avatar_image.dart +++ b/mobile/lib/shared/widgets/playing_avatar_image.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../relay/media_image.dart'; import '../animated_avatar.dart'; import 'avatar_image.dart'; import 'progressive_animated_avatar.dart'; @@ -13,6 +14,9 @@ class PlayingAvatarImage extends StatelessWidget { required this.radius, required this.fallback, this.backgroundColor, + this.loadingImage, + this.loadingPosterImage, + this.onAnimationReady, }); /// The still-image URL or encoded animated-avatar descriptor. @@ -24,12 +28,60 @@ class PlayingAvatarImage extends StatelessWidget { /// Optional background shown behind still-image content. final Color? backgroundColor; + /// A local image shown while the persisted animation warms up. + final ImageProvider? loadingImage; + + /// A local poster shown when motion is disabled while remote media loads. + final ImageProvider? loadingPosterImage; + + /// Called after persisted avatar media produces its first frame. + final VoidCallback? onAnimationReady; + /// Content shown while media is unavailable or still loading. final Widget fallback; @override Widget build(BuildContext context) { final descriptor = parseAnimatedAvatarUrl(imageUrl); + if (descriptor != null && + MediaQuery.disableAnimationsOf(context) && + loadingPosterImage != null) { + return CircleAvatar( + radius: radius, + backgroundColor: Colors.transparent, + child: ClipOval( + child: SizedBox.square( + dimension: radius * 2, + child: Stack( + fit: StackFit.expand, + children: [ + Image( + image: loadingPosterImage!, + fit: BoxFit.cover, + gaplessPlayback: true, + ), + Offstage( + offstage: true, + child: MediaImage( + url: descriptor.posterUrl, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onAnimationReady?.call(); + }); + } + return child; + }, + ), + ), + ], + ), + ), + ), + ); + } if (descriptor == null || MediaQuery.disableAnimationsOf(context)) { return AvatarImage( imageUrl: descriptor?.posterUrl ?? imageUrl, @@ -48,6 +100,8 @@ class PlayingAvatarImage extends StatelessWidget { child: ProgressiveAnimatedAvatar( descriptor: descriptor, fallback: fallback, + loadingImage: loadingImage, + onAnimationReady: onAnimationReady, ), ), ), diff --git a/mobile/lib/shared/widgets/progressive_animated_avatar.dart b/mobile/lib/shared/widgets/progressive_animated_avatar.dart index 94a4785fdf..ed0c54e089 100644 --- a/mobile/lib/shared/widgets/progressive_animated_avatar.dart +++ b/mobile/lib/shared/widgets/progressive_animated_avatar.dart @@ -15,12 +15,20 @@ class ProgressiveAnimatedAvatar extends HookWidget { required this.descriptor, required this.fallback, this.fit = BoxFit.cover, + this.loadingImage, + this.onAnimationReady, }); final AnimatedAvatarDescriptor descriptor; final Widget fallback; final BoxFit fit; + /// A local image shown instead of the poster while remote media warms up. + final ImageProvider? loadingImage; + + /// Called after the remote animation has produced its first frame. + final VoidCallback? onAnimationReady; + @override Widget build(BuildContext context) { final readyAnimationUrl = useState(null); @@ -42,6 +50,7 @@ class ProgressiveAnimatedAvatar extends HookWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { readyAnimationUrl.value = descriptor.animationUrl; + onAnimationReady?.call(); } }); } @@ -60,12 +69,20 @@ class ProgressiveAnimatedAvatar extends HookWidget { return Stack( fit: StackFit.expand, children: [ - AvatarImageContent( - key: const ValueKey('progressive-animated-avatar-poster'), - imageUrl: descriptor.posterUrl, - fallback: fallback, - fit: fit, - ), + if (loadingImage case final image?) + Image( + key: const ValueKey('progressive-animated-avatar-local-handoff'), + image: image, + fit: fit, + gaplessPlayback: true, + ) + else + AvatarImageContent( + key: const ValueKey('progressive-animated-avatar-poster'), + imageUrl: descriptor.posterUrl, + fallback: fallback, + fit: fit, + ), Offstage( key: const ValueKey('progressive-animated-avatar-animation-loading'), offstage: true, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0d4e95a80c..46ea8cf564 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -162,7 +162,7 @@ packages: source: hosted version: "0.10.2" camera_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: camera_platform_interface sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6c51dc9cc5..4543888551 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -51,6 +51,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + camera_platform_interface: ^2.13.1 crypto: ^3.0.7 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 diff --git a/mobile/test/features/profile/animated_avatar_capture_test.dart b/mobile/test/features/profile/animated_avatar_capture_test.dart index 6305926632..6558d0ac36 100644 --- a/mobile/test/features/profile/animated_avatar_capture_test.dart +++ b/mobile/test/features/profile/animated_avatar_capture_test.dart @@ -1,12 +1,15 @@ import 'dart:io'; +import 'dart:async'; import 'dart:ui' show SemanticsAction; -import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; import 'package:buzz/features/profile/animated_avatar_capture.dart'; +import 'package:buzz/features/profile/animated_avatar_orientation.dart'; +import 'package:buzz/features/profile/camera_disposal_barrier.dart'; +import 'package:buzz/features/profile/image_avatar_capture.dart'; import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; -import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -28,6 +31,18 @@ void main() { expect(center.r, 255); }); + test('encoded poster preserves avatar scales above one', () { + final source = image.Image(width: 256, height: 256, numChannels: 4); + image.fill(source, color: image.ColorRgba8(255, 0, 0, 255)); + + final poster = image.decodePng( + encodeAnimatedAvatarPoster(frame: image.encodePng(source), scale: 1.5), + )!; + + expect(poster.getPixel(8, 8).r, 255); + expect(poster.getPixel(247, 247).r, 255); + }); + test('capture frame workspaces are isolated', () async { final first = await createAnimatedAvatarFrameDirectory( parent: Directory.systemTemp, @@ -43,46 +58,238 @@ void main() { expect(first.path, isNot(second.path)); }); - group('animatedAvatarFrameRotationDegrees', () { - test('compensates front camera frames for every device orientation', () { - const expected = { - DeviceOrientation.portraitUp: 270, - DeviceOrientation.landscapeRight: 180, - DeviceOrientation.portraitDown: 90, - DeviceOrientation.landscapeLeft: 0, - }; + test('accounts for device orientation and lens direction', () { + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: DeviceOrientation.landscapeRight, + lensDirection: CameraLensDirection.front, + ), + 180, + ); + expect( + animatedAvatarFrameRotationDegrees( + sensorOrientation: 270, + deviceOrientation: DeviceOrientation.landscapeRight, + lensDirection: CameraLensDirection.back, + ), + 0, + ); + }); - for (final entry in expected.entries) { - expect( - animatedAvatarFrameRotationDegrees( - sensorOrientation: 270, - deviceOrientation: entry.key, - lensDirection: CameraLensDirection.front, - ), - entry.value, - ); - } + testWidgets('animated capture releases a failed orientation-lock candidate', ( + tester, + ) async { + final platform = _TestCameraPlatform(failLockForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() { + CameraPlatform.instance = previousPlatform; }); - test('compensates back camera frames for every device orientation', () { - const expected = { - DeviceOrientation.portraitUp: 90, - DeviceOrientation.landscapeRight: 180, - DeviceOrientation.portraitDown: 270, - DeviceOrientation.landscapeLeft: 0, - }; - - for (final entry in expected.entries) { - expect( - animatedAvatarFrameRotationDegrees( - sensorOrientation: 90, - deviceOrientation: entry.key, - lensDirection: CameraLensDirection.back, + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 600, + child: AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + ), + ), ), - entry.value, - ); - } - }); + ), + ), + ); + await tester.pump(); + await tester.pump(); + + expect(platform.createdCameraIds, [1]); + expect(platform.disposedCameraIds, [1]); + expect(find.text('Could not access the camera.'), findsOneWidget); + final error = find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.liveRegion == true, + ); + expect(error, findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('animated capture waits for disposal before lifecycle resume', ( + tester, + ) async { + final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 600, + child: AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.failDispose(1); + await tester.pump(); + await tester.pump(); + + expect(platform.createdCameraIds, [1, 2]); + final record = tester.widget( + find.byKey(const ValueKey('animated-avatar-record')), + ); + expect(record.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('waits for an in-flight camera before lifecycle replacement', ( + tester, + ) async { + final platform = _TestCameraPlatform( + blockDisposeForCameraIds: {1}, + blockInitializeForCameraIds: {1}, + ); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 600, + child: AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.completeInitialize(1); + await tester.pump(); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + expect(platform.createdCameraIds, [1]); + + platform.completeDispose(1); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1, 2]); + final record = tester.widget( + find.byKey(const ValueKey('animated-avatar-record')), + ); + expect(record.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('shared disposal serializes camera-mode switches', ( + tester, + ) async { + for (final startsAnimated in [false, true]) { + final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + final barrier = CameraDisposalBarrier(); + var animated = startsAnimated; + late StateSetter setMode; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + setMode = setState; + return SizedBox( + height: 600, + child: animated + ? AnimatedAvatarCapture( + height: 600, + onPrepareChanged: (_) {}, + disposalBarrier: barrier, + ) + : ImageAvatarCapture( + height: 600, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: platform.availableCameras, + disposalBarrier: barrier, + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + setMode(() => animated = !animated); + await tester.pump(); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + expect(platform.createdCameraIds, [1]); + + platform.completeDispose(1); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1, 2]); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + CameraPlatform.instance = previousPlatform; + } }); testWidgets('completed review frames survive lifecycle changes', ( @@ -218,6 +425,187 @@ void main() { expect(tester.getSemantics(position).value, '10 horizontal, 0 vertical'); semantics.dispose(); }); + + testWidgets('an existing Save callback uses the latest cutout position', ( + tester, + ) async { + final source = image.Image(width: 256, height: 256, numChannels: 4); + image.fillRect( + source, + x1: 108, + y1: 108, + x2: 147, + y2: 147, + color: image.ColorRgba8(0, 255, 0, 255), + ); + final frame = image.encodePng(source); + Future Function()? prepare; + await tester.pumpWidget( + ProviderScope( + overrides: [ + appLifecycleProvider.overrideWith(_TestLifecycleNotifier.new), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AnimatedAvatarCapture( + height: 600, + initialFrames: [frame], + onPrepareChanged: (value) => prepare = value, + ), + ), + ), + ), + ); + await tester.pump(); + final originalPrepare = prepare!; + final centered = await tester.runAsync(originalPrepare); + + final positionWidget = tester.widget( + find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'Avatar position', + ), + ); + positionWidget.properties.customSemanticsActions!.entries + .firstWhere((entry) => entry.key.label == 'Move right') + .value(); + await tester.pump(); + final moved = await tester.runAsync(originalPrepare); + + expect( + _greenCenterX((moved! as ProfileAnimatedAvatarDraft).poster), + greaterThan( + _greenCenterX((centered! as ProfileAnimatedAvatarDraft).poster) + 3, + ), + ); + }); +} + +double _greenCenterX(Uint8List bytes) { + final decoded = image.decodePng(bytes)!; + final matchingX = []; + for (final pixel in decoded) { + if (pixel.g > 200 && pixel.r < 30 && pixel.b < 30) { + matchingX.add(pixel.x); + } + } + return matchingX.reduce((left, right) => left + right) / matchingX.length; +} + +class _TestCameraPlatform extends CameraPlatform { + _TestCameraPlatform({ + Set? failLockForCameraIds, + Set? blockDisposeForCameraIds, + Set? blockInitializeForCameraIds, + }) : _failLockForCameraIds = failLockForCameraIds ?? const {}, + _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}, + _blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {}; + + final Set _failLockForCameraIds; + final Set _blockDisposeForCameraIds; + final Set _blockInitializeForCameraIds; + final _disposeCompleters = >{}; + final _initializeCompleters = >{}; + final _initializedControllers = + >{}; + final _errorControllers = >{}; + final _orientationController = + StreamController.broadcast(); + final createdCameraIds = []; + final disposedCameraIds = []; + var _nextCameraId = 1; + + @override + Future> availableCameras() async => const [ + CameraDescription( + name: 'front', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ), + ]; + + @override + Future createCameraWithSettings( + CameraDescription description, + MediaSettings mediaSettings, + ) async { + final cameraId = _nextCameraId++; + createdCameraIds.add(cameraId); + _initializedControllers[cameraId] = + StreamController.broadcast(); + _errorControllers[cameraId] = + StreamController.broadcast(); + return cameraId; + } + + @override + Stream onCameraInitialized(int cameraId) => + _initializedControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onCameraError(int cameraId) => + _errorControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onDeviceOrientationChanged() => + _orientationController.stream; + + @override + Future initializeCamera( + int cameraId, { + ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, + }) async { + if (_blockInitializeForCameraIds.contains(cameraId)) { + await (_initializeCompleters[cameraId] ??= Completer()).future; + } + _initializedControllers[cameraId]!.add( + CameraInitializedEvent( + cameraId, + 640, + 480, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), + ); + } + + @override + Future lockCaptureOrientation( + int cameraId, + DeviceOrientation orientation, + ) async { + if (_failLockForCameraIds.contains(cameraId)) { + throw PlatformException(code: 'orientation-failed'); + } + } + + @override + Widget buildPreview(int cameraId) => const SizedBox.expand(); + + @override + Future dispose(int cameraId) async { + disposedCameraIds.add(cameraId); + if (_blockDisposeForCameraIds.contains(cameraId)) { + await (_disposeCompleters[cameraId] ??= Completer()).future; + } + } + + void completeInitialize(int cameraId) { + _initializeCompleters[cameraId]!.complete(); + } + + void failDispose(int cameraId) { + _disposeCompleters[cameraId]!.completeError( + PlatformException(code: 'dispose-failed'), + ); + } + + void completeDispose(int cameraId) { + _disposeCompleters[cameraId]!.complete(); + } } class _TestLifecycleNotifier extends AppLifecycleNotifier { diff --git a/mobile/test/features/profile/camera_disposal_barrier_test.dart b/mobile/test/features/profile/camera_disposal_barrier_test.dart new file mode 100644 index 0000000000..fc54132ffd --- /dev/null +++ b/mobile/test/features/profile/camera_disposal_barrier_test.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/camera_disposal_barrier.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('failed disposal settles before a replacement camera release', () async { + final barrier = CameraDisposalBarrier(); + final firstDispose = Completer(); + final secondDispose = Completer(); + var secondStarted = false; + + final first = barrier.release(() => firstDispose.future); + final second = barrier.release(() { + secondStarted = true; + return secondDispose.future; + }); + + expect(secondStarted, isFalse); + firstDispose.completeError(StateError('native camera teardown failed')); + await first; + await Future.delayed(Duration.zero); + expect(secondStarted, isTrue); + + secondDispose.complete(); + await second; + await barrier.settled; + }); + + test( + 'serializes replacement releases without overlapping ownership', + () async { + final barrier = CameraDisposalBarrier(); + final firstDispose = Completer(); + var activeDisposals = 0; + var overlapped = false; + + final first = barrier.release(() async { + activeDisposals++; + if (activeDisposals > 1) overlapped = true; + await firstDispose.future; + activeDisposals--; + }); + final second = barrier.release(() async { + activeDisposals++; + if (activeDisposals > 1) overlapped = true; + activeDisposals--; + }); + + await Future.delayed(Duration.zero); + expect(activeDisposals, 1); + firstDispose.complete(); + await Future.wait([first, second]); + + expect(overlapped, isFalse); + }, + ); +} diff --git a/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart new file mode 100644 index 0000000000..790a00cccf --- /dev/null +++ b/mobile/test/features/profile/image_avatar_capture_lifecycle_test.dart @@ -0,0 +1,248 @@ +import 'dart:async'; + +import 'package:buzz/features/profile/image_avatar_capture.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:camera_platform_interface/camera_platform_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('resumes with a replacement after native disposal fails', ( + tester, + ) async { + final platform = _TestCameraPlatform(blockDisposeForCameraIds: {1}); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: platform.availableCameras, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.failDispose(1); + await tester.pump(); + await tester.pump(); + + expect(platform.createdCameraIds, [1, 2]); + final shutter = tester.widget( + find.byKey(const ValueKey('image-camera-shutter')), + ); + expect(shutter.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); + + testWidgets('waits for an in-flight camera before lifecycle replacement', ( + tester, + ) async { + final platform = _TestCameraPlatform( + blockDisposeForCameraIds: {1}, + blockInitializeForCameraIds: {1}, + ); + final previousPlatform = CameraPlatform.instance; + CameraPlatform.instance = platform; + addTearDown(() => CameraPlatform.instance = previousPlatform); + final lifecycle = _TestLifecycleNotifier(); + + await tester.pumpWidget( + ProviderScope( + overrides: [appLifecycleProvider.overrideWith(() => lifecycle)], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: platform.availableCameras, + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + lifecycle.setLifecycle(AppLifecycleState.paused); + await tester.pump(); + lifecycle.setLifecycle(AppLifecycleState.resumed); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1]); + + platform.completeInitialize(1); + await tester.pump(); + await tester.pump(); + expect(platform.disposedCameraIds, [1]); + expect(platform.createdCameraIds, [1]); + + platform.completeDispose(1); + await tester.pump(); + await tester.pump(); + expect(platform.createdCameraIds, [1, 2]); + final shutter = tester.widget( + find.byKey(const ValueKey('image-camera-shutter')), + ); + expect(shutter.onTap, isNotNull); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + }); +} + +class _TestLifecycleNotifier extends AppLifecycleNotifier { + AppLifecycleState _lifecycle = AppLifecycleState.resumed; + + @override + AppLifecycleState build() => _lifecycle; + + void setLifecycle(AppLifecycleState value) { + _lifecycle = value; + state = value; + } +} + +class _TestCameraPlatform extends CameraPlatform { + _TestCameraPlatform({ + Set? blockDisposeForCameraIds, + Set? blockInitializeForCameraIds, + }) : _blockDisposeForCameraIds = blockDisposeForCameraIds ?? const {}, + _blockInitializeForCameraIds = blockInitializeForCameraIds ?? const {}; + + final Set _blockDisposeForCameraIds; + final Set _blockInitializeForCameraIds; + final _disposeCompleters = >{}; + final _initializeCompleters = >{}; + final _initializedControllers = + >{}; + final _errorControllers = >{}; + final _orientationController = + StreamController.broadcast(); + final createdCameraIds = []; + final disposedCameraIds = []; + var _nextCameraId = 1; + + @override + Future> availableCameras() async => const [ + CameraDescription( + name: 'front', + lensDirection: CameraLensDirection.front, + sensorOrientation: 0, + ), + ]; + + @override + Future createCameraWithSettings( + CameraDescription description, + MediaSettings mediaSettings, + ) async { + final cameraId = _nextCameraId++; + createdCameraIds.add(cameraId); + _initializedControllers[cameraId] = + StreamController.broadcast(); + _errorControllers[cameraId] = + StreamController.broadcast(); + return cameraId; + } + + @override + Stream onCameraInitialized(int cameraId) => + _initializedControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onCameraError(int cameraId) => + _errorControllers[cameraId]!.stream.asBroadcastStream(); + + @override + Stream onDeviceOrientationChanged() => + _orientationController.stream; + + @override + Future initializeCamera( + int cameraId, { + ImageFormatGroup imageFormatGroup = ImageFormatGroup.unknown, + }) async { + if (_blockInitializeForCameraIds.contains(cameraId)) { + await (_initializeCompleters[cameraId] ??= Completer()).future; + } + _initializedControllers[cameraId]!.add( + CameraInitializedEvent( + cameraId, + 640, + 480, + ExposureMode.auto, + false, + FocusMode.auto, + false, + ), + ); + } + + @override + Future lockCaptureOrientation( + int cameraId, + DeviceOrientation orientation, + ) async {} + + @override + Widget buildPreview(int cameraId) => const SizedBox.expand(); + + @override + Future dispose(int cameraId) async { + disposedCameraIds.add(cameraId); + if (_blockDisposeForCameraIds.contains(cameraId)) { + await (_disposeCompleters[cameraId] ??= Completer()).future; + } + } + + void completeInitialize(int cameraId) { + _initializeCompleters[cameraId]!.complete(); + } + + void failDispose(int cameraId) { + _disposeCompleters[cameraId]!.completeError( + PlatformException(code: 'dispose-failed'), + ); + } + + void completeDispose(int cameraId) { + _disposeCompleters[cameraId]!.complete(); + } +} diff --git a/mobile/test/features/profile/profile_avatar_draft_test.dart b/mobile/test/features/profile/profile_avatar_draft_test.dart index 1c92ac9fdc..6cf3f0b578 100644 --- a/mobile/test/features/profile/profile_avatar_draft_test.dart +++ b/mobile/test/features/profile/profile_avatar_draft_test.dart @@ -28,25 +28,6 @@ void main() { expect(service.uploadedParts, ['poster', 'animation', 'animation']); }); - test('animated draft supports one upload at a time', () async { - final service = _SingleUploadService(); - addTearDown(service.dispose); - final draft = ProfileAnimatedAvatarDraft( - poster: Uint8List.fromList([1]), - animation: Uint8List.fromList([2]), - ); - - final url = await draft.upload(service); - - expect(service.maxInFlight, 1); - expect(service.uploadedParts, ['poster', 'animation']); - expect(parseAnimatedAvatarUrl(url)?.posterUrl, 'https://relay/poster.png'); - expect( - parseAnimatedAvatarUrl(url)?.animationUrl, - 'https://relay/animation.png', - ); - }); - test('animated draft reuploads every part for a new community', () async { final first = _RecordingUploadService('first'); final second = _RecordingUploadService('second'); @@ -67,46 +48,6 @@ void main() { }); } -final class _SingleUploadService extends MediaUploadService { - _SingleUploadService() - : super( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); - - final uploadedParts = []; - int _inFlight = 0; - int maxInFlight = 0; - - @override - Future uploadBytes( - Uint8List bytes, { - required String mimeType, - ValueChanged? onProgress, - UploadCancellationToken? cancellationToken, - }) async { - _inFlight++; - maxInFlight = _inFlight > maxInFlight ? _inFlight : maxInFlight; - if (_inFlight > 1) { - _inFlight--; - throw Exception('upload concurrency limit reached'); - } - final part = bytes.single == 1 ? 'poster' : 'animation'; - uploadedParts.add(part); - await Future.delayed(Duration.zero); - _inFlight--; - return BlobDescriptor( - url: 'https://relay/$part.png', - sha256: '$part-hash', - size: bytes.length, - type: mimeType, - uploaded: 1, - ); - } -} - final class _RecordingUploadService extends MediaUploadService { _RecordingUploadService(this.community) : super( diff --git a/mobile/test/features/profile/profile_edit_page_test.dart b/mobile/test/features/profile/profile_edit_page_test.dart index d100e0f5ae..4e9d724cb7 100644 --- a/mobile/test/features/profile/profile_edit_page_test.dart +++ b/mobile/test/features/profile/profile_edit_page_test.dart @@ -2,10 +2,13 @@ import 'dart:async'; import 'dart:math' as math; import 'package:buzz/features/profile/profile_edit_page.dart'; +import 'package:buzz/features/profile/profile_avatar_draft.dart'; import 'package:buzz/features/profile/profile_avatar_crop_page.dart'; import 'package:buzz/features/profile/avatar_background_grid.dart'; import 'package:buzz/features/profile/avatar_editor_option_button.dart'; +import 'package:buzz/features/profile/animated_avatar_capture.dart'; import 'package:buzz/features/profile/emoji_avatar_tile.dart'; +import 'package:buzz/features/profile/image_avatar_capture.dart'; import 'package:buzz/shared/widgets/immediate_page_route.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; @@ -18,6 +21,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/ios_native_segmented_control.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/shared/widgets/playing_avatar_image.dart'; import 'package:buzz/shared/widgets/progressive_animated_avatar.dart'; import 'package:flutter/foundation.dart'; @@ -540,13 +544,19 @@ void main() { await tester.pump(const Duration(milliseconds: 150)); expect( tester.getCenter(find.byKey(const ValueKey('emoji-avatar-preview'))).dy, - closeTo(screenSize.height / 2 - avatarBackgroundPreviewShift, 0.01), + closeTo(screenSize.height / 2 - 140, 0.01), ); expect( tester .getSize(find.byKey(const ValueKey('emoji-avatar-picker-content'))) .height, - lessThan(expandedPickerHeight), + expandedPickerHeight, + ); + expect( + tester.getCenter(find.byKey(const ValueKey('emoji-background-editor'))), + tester.getCenter( + find.byKey(const ValueKey('emoji-background-editor-alignment')), + ), ); await tester.tap(find.text('Animated')); @@ -632,7 +642,14 @@ void main() { profileProvider.overrideWith(() => notifier), mediaUploadServiceProvider.overrideWithValue(uploadService), ], - child: const ProfileEditPage(), + child: ProfileEditPage( + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), ), ); await tester.pumpAndSettle(); @@ -640,18 +657,67 @@ void main() { await tester.tap(find.text('Edit Photo')); await tester.pumpAndSettle(); await tester.tap(find.text('Camera')); - await _waitForAvatarCropToLoad(tester); - expect(find.text('Position Photo'), findsOneWidget); - await tester.tap(find.byKey(const ValueKey('avatar-crop-use-photo'))); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 200)), - ); + await tester.pump(); + expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept'))); await tester.pumpAndSettle(); expect(notifier.savedAvatarUrls, isEmpty); await tester.tap(find.byKey(const ValueKey('avatar-save'))); await tester.pumpAndSettle(); - expect(notifier.savedAvatarUrls, ['https://relay.example/camera.png']); + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + }); + + testWidgets('keeps the local animation visible after profile Save', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(updatesProfileState: true); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + final frame = Uint8List.fromList( + image.encodePng( + image.Image(width: 8, height: 8)..setPixelRgba(4, 4, 255, 0, 0, 255), + ), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + animatedAvatarCaptureBuilder: + ({required height, required onPrepareChanged}) { + WidgetsBinding.instance.addPostFrameCallback((_) { + onPrepareChanged( + () async => ProfileAnimatedAvatarDraft( + animation: frame, + poster: frame, + ), + ); + }); + return const SizedBox( + key: ValueKey('fake-animated-avatar-review'), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Animated')); + await tester.pump(); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-local-handoff')), + findsOneWidget, + ); + expect(notifier.savedAvatarUrls.single, contains('#buzz-anim=')); }); testWidgets('saves a desktop-compatible emoji avatar', (tester) async { @@ -847,10 +913,12 @@ class _FakeProfileNotifier extends ProfileNotifier { about: 'Building Buzz', ), this.failedAvatarSaves = 0, + this.updatesProfileState = false, }); final UserProfile profile; int failedAvatarSaves; + final bool updatesProfileState; final savedDisplayNames = []; final savedDescriptions = []; final savedAvatarUrls = []; @@ -895,27 +963,35 @@ class _FakeProfileNotifier extends ProfileNotifier { throw Exception('profile publish failed'); } savedAvatarUrls.add(avatarUrl); - } -} - -class _FailingPreparationMediaUploadService extends _FakeMediaUploadService { - @override - Future prepareImageBytes(XFile image) async { - throw Exception('image preparation failed'); + if (updatesProfileState) { + final current = state.requireValue!; + state = AsyncData( + UserProfile( + pubkey: current.pubkey, + displayName: current.displayName, + avatarUrl: avatarUrl, + about: current.about, + nip05Handle: current.nip05Handle, + ), + ); + } } } class _FakeMediaUploadService extends MediaUploadService { - _FakeMediaUploadService({this.delayGallery = false}) - : super( - baseUrl: 'https://relay.example', - nsec: null, - pickGalleryImage: () async => null, - pickGalleryVideo: () async => null, - ); + _FakeMediaUploadService({ + this.delayGallery = false, + this.failImagePreparation = false, + }) : super( + baseUrl: 'https://relay.example', + nsec: null, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); var _camera = false; final bool delayGallery; + final bool failImagePreparation; final _gallerySelection = Completer(); int uploadCount = 0; @@ -941,7 +1017,10 @@ class _FakeMediaUploadService extends MediaUploadService { } @override - Future prepareImageBytes(XFile image) => image.readAsBytes(); + Future prepareImageBytes(XFile image) async { + if (failImagePreparation) throw Exception('image preparation failed'); + return image.readAsBytes(); + } @override Future uploadBytes( @@ -962,3 +1041,32 @@ class _FakeMediaUploadService extends MediaUploadService { ); } } + +class _FakeImageAvatarCapture extends StatelessWidget { + const _FakeImageAvatarCapture({ + required this.onAccepted, + required this.onClosed, + }); + + final ValueChanged onAccepted; + final VoidCallback onClosed; + + @override + Widget build(BuildContext context) => Row( + key: const ValueKey('fake-image-camera'), + children: [ + TextButton( + key: const ValueKey('fake-image-camera-close'), + onPressed: onClosed, + child: const Text('Close fake camera'), + ), + TextButton( + key: const ValueKey('fake-image-camera-accept'), + onPressed: () => onAccepted( + Uint8List.fromList(image.encodeJpg(image.Image(width: 8, height: 8))), + ), + child: const Text('Accept fake photo'), + ), + ], + ); +} diff --git a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart index 62c5455dc1..92e7120bbf 100644 --- a/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/image_selection_tests.dart @@ -1,6 +1,474 @@ part of '../profile_edit_page_test.dart'; +double _paintedWidth(WidgetTester tester, String key) { + final box = tester.renderObject(find.byKey(ValueKey(key))); + final left = box.localToGlobal(Offset.zero); + final right = box.localToGlobal(Offset(box.size.width, 0)); + return (right - left).distance; +} + void runProfileEditImageSelectionTests() { + testWidgets('uses glass image source controls on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pumpAndSettle(); + + final nativeControls = tester + .widgetList(find.byType(UiKitView)) + .where((view) => view.viewType == IosGlassNavigationButton.viewType) + .map((view) => view.creationParams as Map) + .toList(); + expect(nativeControls.any((params) => params['icon'] == 'camera'), isTrue); + expect( + nativeControls.any((params) => params['icon'] == 'photoLibrary'), + isTrue, + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('opens the inline camera around the existing avatar center', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [profileProvider.overrideWith(_FakeProfileNotifier.new)], + child: const ProfileEditPage(startInPhotoEditor: true), + ), + ); + await tester.pumpAndSettle(); + + final avatarCenter = tester.getCenter( + find.byKey(const ValueKey('avatar-editor-fixed-preview')), + ); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + + expect( + tester.getCenter(find.byKey(const ValueKey('image-camera-preview-size'))), + avatarCenter, + ); + }); + + testWidgets('expands the avatar with the controls while camera loads', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + initialPreview: const SizedBox.square( + dimension: 220, + child: ColoredBox( + key: ValueKey('existing-avatar-preview'), + color: Colors.pink, + ), + ), + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + + final preview = find.byKey(const ValueKey('image-camera-preview-size')); + expect(tester.getSize(preview), const Size.square(220)); + expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final midSize = tester.getSize(preview).width; + expect(midSize, greaterThan(220)); + expect(midSize, lessThan(275)); + expect( + _paintedWidth(tester, 'existing-avatar-preview'), + closeTo(midSize, 1), + ); + expect(tester.getCenter(preview).dy, imageAvatarCameraPreviewSize / 2); + await tester.pump(const Duration(milliseconds: 90)); + expect(tester.getSize(preview), const Size.square(275)); + expect(_paintedWidth(tester, 'existing-avatar-preview'), closeTo(275, 1)); + expect( + find.byKey(const ValueKey('existing-avatar-preview')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))), + const Size.square(64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-right-action'))), + const Size.square(64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-shutter-morph'))), + const Size.square(100), + ); + expect(find.bySemanticsLabel('Close camera'), findsOneWidget); + expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); + expect(find.bySemanticsLabel('Take photo'), findsOneWidget); + }); + + testWidgets('reverses the camera controls before closing', (tester) async { + var closed = false; + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + onAccepted: (_) {}, + onClosed: () => closed = true, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + final leftAction = find.byKey(const ValueKey('image-camera-left-action')); + final rightAction = find.byKey(const ValueKey('image-camera-right-action')); + final expandedDistance = + tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx; + + tester + .widget( + find.descendant(of: leftAction, matching: find.byType(InkWell)), + ) + .onTap!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final midDistance = + tester.getCenter(rightAction).dx - tester.getCenter(leftAction).dx; + expect(midDistance, lessThan(expandedDistance)); + expect( + find.byKey(const ValueKey('camera-action-icon-camera')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('camera-action-icon-photoLibrary')), + findsOneWidget, + ); + expect(find.text('Camera'), findsOneWidget); + expect(find.text('Photo Library'), findsOneWidget); + final cameraIcon = tester.widget( + find.byKey(const ValueKey('camera-action-icon-camera')), + ); + expect(cameraIcon.color?.a, 1); + expect(closed, isFalse); + + await tester.pump(const Duration(milliseconds: 60)); + expect( + tester + .widget( + find.byKey(const ValueKey('image-camera-shutter-exit-opacity')), + ) + .opacity, + 0, + ); + expect(closed, isFalse); + + await tester.pump(const Duration(milliseconds: 30)); + expect(closed, isTrue); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), + const Size.square(220), + ); + }); + + test('front camera output keeps the mirrored preview orientation', () { + final source = image.Image(width: 2, height: 2); + for (var y = 0; y < 2; y++) { + source.setPixelRgb(0, y, 255, 0, 0); + source.setPixelRgb(1, y, 0, 0, 255); + } + final encoded = Uint8List.fromList(image.encodePng(source)); + + final regular = image.decodeJpg( + prepareCameraImageForTesting(encoded, mirror: false), + )!; + final mirrored = image.decodeJpg( + prepareCameraImageForTesting(encoded, mirror: true), + )!; + final regularLeft = regular.getPixel(40, 256); + final mirroredLeft = mirrored.getPixel(40, 256); + + expect(regularLeft.r, greaterThan(regularLeft.b)); + expect(mirroredLeft.b, greaterThan(mirroredLeft.r)); + }); + + testWidgets('provides haptics when closing or accepting a camera photo', ( + tester, + ) async { + final haptics = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'HapticFeedback.vibrate') { + haptics.add(call.arguments); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: ImageAvatarCapture( + key: const ValueKey('close-haptic-camera'), + height: 400, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + final closeAction = find.byKey(const ValueKey('image-camera-left-action')); + tester + .widget( + find.descendant(of: closeAction, matching: find.byType(InkWell)), + ) + .onTap!(); + await tester.pump(); + expect(haptics, contains('HapticFeedbackType.selectionClick')); + await tester.pump(const Duration(milliseconds: 180)); + + final bytes = Uint8List.fromList( + image.encodeJpg(image.Image(width: 8, height: 8)), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: ImageAvatarCapture( + key: const ValueKey('accept-haptic-camera'), + height: 400, + initialCapturedBytes: bytes, + onAccepted: (_) {}, + onClosed: () {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use Photo')); + await tester.pump(); + expect(haptics, contains('HapticFeedbackType.mediumImpact')); + await tester.pump(const Duration(milliseconds: 180)); + }); + + testWidgets('reviews a captured photo before scaling down to accept it', ( + tester, + ) async { + final bytes = Uint8List.fromList( + image.encodeJpg(image.Image(width: 8, height: 8)), + ); + Uint8List? accepted; + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + initialCapturedBytes: bytes, + onAccepted: (value) => accepted = value, + onClosed: () {}, + ), + ), + ), + ), + ); + final preview = find.byKey(const ValueKey('image-camera-preview-size')); + final compactCenter = tester.getCenter(preview); + expect(tester.getSize(preview), const Size.square(220)); + await tester.pumpAndSettle(); + + expect(tester.getSize(preview), const Size.square(275)); + expect(tester.getCenter(preview), compactCenter); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Use Photo'), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-left-action'))), + const Size(112, 64), + ); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-right-action'))), + const Size(112, 64), + ); + final leftRect = tester.getRect( + find.byKey(const ValueKey('image-camera-left-action')), + ); + final rightRect = tester.getRect( + find.byKey(const ValueKey('image-camera-right-action')), + ); + expect(rightRect.left - leftRect.right, 12); + + await tester.tap(find.text('Use Photo')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 90)); + final midSize = tester + .getSize(find.byKey(const ValueKey('image-camera-preview-size'))) + .width; + expect(midSize, greaterThan(220)); + expect(midSize, lessThan(275)); + expect(accepted, isNull); + await tester.pump(const Duration(milliseconds: 90)); + expect(accepted, same(bytes)); + expect( + tester.getSize(find.byKey(const ValueKey('image-camera-preview-size'))), + const Size.square(220), + ); + }); + + testWidgets('retry moves the review actions apart again', (tester) async { + final bytes = Uint8List.fromList( + image.encodeJpg(image.Image(width: 8, height: 8)), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + child: Scaffold( + body: SizedBox( + height: 400, + child: ImageAvatarCapture( + height: 400, + initialCapturedBytes: bytes, + onAccepted: (_) {}, + onClosed: () {}, + loadCameras: () async => const [], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final leftAction = find.byKey(const ValueKey('image-camera-left-action')); + final rightAction = find.byKey(const ValueKey('image-camera-right-action')); + final reviewGap = + tester.getRect(rightAction).left - tester.getRect(leftAction).right; + + await tester.tap(find.text('Retry')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 180)); + + final cameraGap = + tester.getRect(rightAction).left - tester.getRect(leftAction).right; + expect(reviewGap, 12); + expect(cameraGap, greaterThan(reviewGap)); + expect(find.bySemanticsLabel('Close camera'), findsOneWidget); + expect(find.bySemanticsLabel('Flip camera'), findsOneWidget); + }); + + testWidgets('clears gallery errors when opening the inline camera', ( + tester, + ) async { + final uploadService = _FakeMediaUploadService(failImagePreparation: true); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('image-source-library'))); + await tester.pumpAndSettle(); + + expect( + find.text("We couldn't prepare that photo. Try again."), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + + expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget); + expect( + find.text("We couldn't prepare that photo. Try again."), + findsNothing, + ); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept'))); + await tester.pumpAndSettle(); + + expect( + find.text("We couldn't prepare that photo. Try again."), + findsNothing, + ); + }); + + testWidgets('accepts an inline camera photo before enabling profile Save', ( + tester, + ) async { + final notifier = _FakeProfileNotifier(); + final uploadService = _FakeMediaUploadService(); + addTearDown(uploadService.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(() => notifier), + mediaUploadServiceProvider.overrideWithValue(uploadService), + ], + child: ProfileEditPage( + imageAvatarCaptureBuilder: + ({required height, required onAccepted, required onClosed}) => + _FakeImageAvatarCapture( + onAccepted: onAccepted, + onClosed: onClosed, + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Photo')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('image-source-camera'))); + await tester.pump(); + + expect(find.byKey(const ValueKey('fake-image-camera')), findsOneWidget); + expect(find.byKey(const ValueKey('image-source-library')), findsNothing); + await tester.tap(find.byKey(const ValueKey('fake-image-camera-accept'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('fake-image-camera')), findsNothing); + expect(find.byKey(const ValueKey('image-source-camera')), findsOneWidget); + expect(find.byKey(const ValueKey('image-source-library')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('avatar-save'))); + await tester.pumpAndSettle(); + + expect(notifier.savedAvatarUrls, ['https://relay.example/profile.png']); + expect(uploadService.uploadCount, 1); + }); + testWidgets('duplicate avatar Back taps pop only the editor route', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart index 2cffbb0891..b5bed5eddc 100644 --- a/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart +++ b/mobile/test/features/profile/profile_edit_page_test/motion_and_accessibility_tests.dart @@ -53,97 +53,6 @@ void runProfileEditMotionAndAccessibilityTests() { ); }); - testWidgets( - 'keeps the multiline text editor usable with large text and the keyboard', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(320, 568); - tester.platformDispatcher.textScaleFactorTestValue = 2; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - addTearDown(tester.view.reset); - final notifier = _FakeProfileNotifier(); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - final descriptionRow = find.byKey( - const ValueKey('profile-description-row'), - ); - await tester.ensureVisible(descriptionRow); - await tester.pumpAndSettle(); - await tester.tap(descriptionRow); - await tester.pumpAndSettle(); - - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - await tester.pumpAndSettle(); - - expect( - MediaQuery.textScalerOf( - tester.element(find.byKey(const ValueKey('profile-field-input'))), - ).scale(10), - 20, - ); - expect( - MediaQuery.viewInsetsOf( - tester.element(find.byKey(const ValueKey('profile-field-input'))), - ).bottom, - 300, - ); - expect(tester.takeException(), isNull); - expect( - find.byKey(const ValueKey('profile-field-scroll-view')), - findsOneWidget, - ); - await tester.enterText( - find.byKey(const ValueKey('profile-field-input')), - 'Making collaboration feel effortless.', - ); - await tester.pump(); - final save = find.byKey(const ValueKey('profile-field-save')); - await tester.ensureVisible(save); - await tester.pumpAndSettle(); - expect(save.hitTestable(), findsOneWidget); - await tester.tap(save); - await tester.pumpAndSettle(); - expect(notifier.savedDescriptions, [ - 'Making collaboration feel effortless.', - ]); - debugDefaultTargetPlatformOverride = null; - }, - ); - - testWidgets('photo preparation errors are accessibility live regions', ( - tester, - ) async { - final uploadService = _FailingPreparationMediaUploadService(); - addTearDown(uploadService.dispose); - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [ - profileProvider.overrideWith(_FakeProfileNotifier.new), - mediaUploadServiceProvider.overrideWithValue(uploadService), - ], - child: const ProfileEditPage(startInPhotoEditor: true), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Photo Library')); - await tester.pumpAndSettle(); - - final message = find.text("We couldn't prepare that photo. Try again."); - expect(message, findsOneWidget); - final errorSemantics = tester.widget( - find.ancestor(of: message, matching: find.byType(Semantics)).first, - ); - expect(errorSemantics.properties.liveRegion, isTrue); - }); - testWidgets('exposes the selected avatar mode on Android', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -472,6 +381,135 @@ void runProfileEditMotionAndAccessibilityTests() { expect(selectedTile.properties.selected, isTrue); }); + testWidgets('uses liquid-glass avatar rail icons on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + child: Row( + children: [ + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.palette, + iosIcon: IosGlassNavigationIcon.palette, + label: 'Background', + selected: true, + onTap: () {}, + ), + ), + Expanded( + child: AvatarEditorOptionButton( + icon: Icons.face, + iosIcon: IosGlassNavigationIcon.emoji, + label: 'Emoji', + selected: false, + onTap: () {}, + ), + ), + ], + ), + ), + ); + + final nativeIcons = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .map((params) => params['icon']) + .toList(); + expect(nativeIcons, containsAll(['palette', 'emoji'])); + final selectedControls = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .where((params) => params['selected'] == true) + .toList(); + expect(selectedControls.single['icon'], 'palette'); + expect( + selectedControls.single['foregroundColor'], + AppTheme.light().colorScheme.primary.toARGB32(), + ); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('doubles the avatar rail icon-to-label spacing', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: AvatarEditorOptionButton( + icon: Icons.palette, + label: 'Background', + selected: false, + onTap: () {}, + ), + ), + ); + + final surface = find.byType(AnimatedContainer); + expect( + tester.getRect(find.text('Background')).top - + tester.getRect(surface).bottom, + avatarEditorOptionLabelGap, + ); + }); + + testWidgets('uses liquid glass for animated capture and review on iOS', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 500, + child: AnimatedAvatarCapture( + key: ValueKey('animated-capture-glass-test'), + height: 500, + onPrepareChanged: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + var nativeControls = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .toList(); + expect( + nativeControls.any( + (params) => params['icon'] == 'shutter' && params['label'] == 'Record', + ), + isTrue, + ); + + final frame = Uint8List.fromList( + image.encodePng(image.Image(width: 8, height: 8)), + ); + await tester.pumpWidget( + WidgetHelpers.testable( + child: SizedBox( + height: 500, + child: AnimatedAvatarCapture( + key: ValueKey('animated-review-glass-test'), + height: 500, + initialFrames: [frame, frame], + onPrepareChanged: (_) {}, + ), + ), + ), + ); + await tester.pump(); + + nativeControls = tester + .widgetList(find.byType(UiKitView)) + .map((view) => view.creationParams as Map) + .toList(); + final reviewIcons = nativeControls + .map((params) => params['icon']) + .whereType() + .toList(); + expect(reviewIcons, containsAll(['person', 'palette', 'frame', 'camera'])); + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('uses the shared animated background grid for emoji avatars', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_edit_retry_test.dart b/mobile/test/features/profile/profile_edit_retry_test.dart index 6eea4c9915..0c157c6930 100644 --- a/mobile/test/features/profile/profile_edit_retry_test.dart +++ b/mobile/test/features/profile/profile_edit_retry_test.dart @@ -274,127 +274,6 @@ void main() { debugDefaultTargetPlatformOverride = null; }); - testWidgets('standalone Flutter text draft survives Back while saving', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _DeferredFailureProfileNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: Builder( - builder: (context) => TextButton( - onPressed: () => unawaited(showProfileDisplayNameEditor(context)), - child: const Text('Open editor'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Open editor')); - await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const ValueKey('profile-field-input')), - 'Pending standalone draft', - ); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('profile-field-save'))); - await tester.pump(); - - await tester.binding.handlePopRoute(); - await tester.pump(); - expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget); - expect( - tester - .widget(find.byKey(const ValueKey('profile-field-close'))) - .onPressed, - isNull, - ); - - notifier.failSave(); - await tester.pumpAndSettle(); - expect( - find.text("We couldn't save this change. Try again."), - findsOneWidget, - ); - expect( - tester - .widget( - find - .ancestor( - of: find.text("We couldn't save this change. Try again."), - matching: find.byType(Semantics), - ) - .first, - ) - .properties - .liveRegion, - isTrue, - ); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; - }); - - testWidgets('in-page Flutter text draft survives Back while saving', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.android; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - final notifier = _DeferredFailureProfileNotifier(); - - await tester.pumpWidget( - WidgetHelpers.testable( - overrides: [profileProvider.overrideWith(() => notifier)], - child: const ProfileEditPage(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('profile-display-name-row'))); - await tester.pumpAndSettle(); - await tester.enterText( - find.byKey(const ValueKey('profile-field-input')), - 'Pending in-page draft', - ); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('profile-field-save'))); - await tester.pump(); - - await tester.binding.handlePopRoute(); - await tester.pump(); - expect(find.byKey(const ValueKey('profile-field-input')), findsOneWidget); - expect( - tester - .widget(find.byKey(const ValueKey('profile-field-close'))) - .onPressed, - isNull, - ); - - notifier.failSave(); - await tester.pumpAndSettle(); - expect( - find.text("We couldn't save this change. Try again."), - findsOneWidget, - ); - expect( - tester - .widget( - find - .ancestor( - of: find.text("We couldn't save this change. Try again."), - matching: find.byType(Semantics), - ) - .first, - ) - .properties - .liveRegion, - isTrue, - ); - expect(tester.takeException(), isNull); - debugDefaultTargetPlatformOverride = null; - }); - testWidgets('in-page text editor rejects its first save after a switch', ( tester, ) async { diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index a22c8c58f8..67682a86bc 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -13,6 +13,7 @@ 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:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -124,7 +125,6 @@ void main() { .map((image) => image.url), containsAll([posterUrl, animationUrl]), ); - animationResponse.complete(http.Response.bytes(_transparentPng, 200)); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), @@ -168,6 +168,78 @@ void main() { ); }); + testWidgets('warms and clears a paused animated-avatar handoff', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final posterResponse = Completer(); + final animationResponse = Completer(); + final client = http_testing.MockClient( + (request) => switch (request.url.toString()) { + posterUrl => posterResponse.future, + animationUrl => animationResponse.future, + _ => Future.value(http.Response.bytes(_transparentPng, 200)), + }, + ); + addTearDown(client.close); + final container = ProviderContainer( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier(avatarUrl: profileUrl), + ), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(client), + ], + ); + addTearDown(container.dispose); + container + .read(profileAvatarHandoffProvider.notifier) + .show( + ProfileAvatarHandoff( + avatarUrl: profileUrl, + animation: _transparentPng, + poster: _transparentPng, + ), + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + theme: AppTheme.light(), + home: const Scaffold(body: SettingsProfileHeader()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('settings-profile-avatar'))); + await tester.pump(); + + expect( + find.byKey( + ValueKey('settings-profile-paused-handoff-$profileUrl'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect(container.read(profileAvatarHandoffProvider), isNotNull); + + posterResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); + + expect(container.read(profileAvatarHandoffProvider), isNull); + }); testWidgets('uses a bounded icon for an unresolved status shortcode', ( tester, ) async { From 8b812017a79c9279594330fb2b04c1eaf0e6e6c1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 25 Aug 2026 14:59:19 +0100 Subject: [PATCH 03/24] Fix mobile Huddle agent voice turn states (#6611) ## Summary - ignore mobile room tone when deciding whether human speech should interrupt agent audio - show a subtle waiting animation while an agent prepares a response, then restore its avatar when speech begins - render agents from Huddle membership before their audio peer starts transmitting ## Testing - `just ci` - signed Release build installed and launched on a physical iPhone - live Huddle voice and agent-membership behavior verified on device --------- Signed-off-by: kenny lopez Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/huddle/playout.rs | 93 +- .../channels/channel_detail_page.dart | 1 + .../huddle_call_avatar.dart | 121 ++- .../huddle_call_participants.dart | 4 + .../huddle_participant_cluster.dart | 6 + .../huddle_participant_overlay.dart | 29 +- .../channel_detail_page/huddle_sheet.dart | 94 +- .../widgets/bouncing_dots_indicator.dart | 90 ++ .../channels/channel_detail_page_test.dart | 819 +++++++++++++++--- 9 files changed, 1060 insertions(+), 197 deletions(-) create mode 100644 mobile/lib/shared/widgets/bouncing_dots_indicator.dart diff --git a/desktop/src-tauri/src/huddle/playout.rs b/desktop/src-tauri/src/huddle/playout.rs index 5bfce3adad..66c80f5d6c 100644 --- a/desktop/src-tauri/src/huddle/playout.rs +++ b/desktop/src-tauri/src/huddle/playout.rs @@ -44,6 +44,9 @@ const SPEAKER_LEVEL_TICK_MS: u64 = 50; /// Per-peer arrival window for the TTS interrupt frame counter. const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500); const REMOTE_RELEASE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(500); +/// Match Mobile's speaking treatment: an open microphone can emit continuous +/// non-DTX Opus for room tone, so packet type alone is not evidence of speech. +const REMOTE_SPEECH_LEVEL_DBOV: i8 = -55; /// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms. const PLAYOUT_TICK_MS: u64 = 10; @@ -86,19 +89,30 @@ fn normalized_speaker_level(level_dbov: i8) -> f32 { ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) } +fn is_remote_speech_frame(is_dtx: bool, level_dbov: i8) -> bool { + !is_dtx && level_dbov >= REMOTE_SPEECH_LEVEL_DBOV +} + fn update_remote_release_deadline( peer: u8, - is_dtx: bool, + is_speech: bool, remote_floor_owners: &std::collections::HashSet, deadlines: &mut std::collections::HashMap, now: tokio::time::Instant, ) { - if !is_dtx { - deadlines.remove(&peer); - } else if remote_floor_owners.contains(&peer) { - deadlines - .entry(peer) - .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + if remote_floor_owners.contains(&peer) { + if is_speech { + // Refresh from audible speech itself. Some mobile capture paths + // stop producing packets once speech ends, so waiting for a DTX + // or quiet packet can otherwise hold the human floor forever. + deadlines.insert(peer, now + REMOTE_RELEASE_DEBOUNCE); + } else { + // Preserve the deadline from the last audible frame. Continuous + // room-tone packets must not keep extending the human floor. + deadlines + .entry(peer) + .or_insert(now + REMOTE_RELEASE_DEBOUNCE); + } } } @@ -437,19 +451,20 @@ pub(crate) async fn run_playout_recv_loop( continue; } let is_dtx = (header.flags & FLAG_DTX) != 0; - // Only count non-DTX arrivals toward the UI's - // active-speaker set. DTX/comfort packets are emitted - // by an idle peer to keep the codec alive — they - // don't mean the peer is speaking, and shouldn't - // make their tile flash for the 500 ms speaker tick. + let is_remote_speech = + is_remote_speech_frame(is_dtx, header.level_dbov); + // Only count audible arrivals toward the UI's + // active-speaker set. An open mobile microphone can + // continuously emit non-DTX room tone, so require an + // audible level before treating a packet as speech. update_remote_release_deadline( peer_idx, - is_dtx, + is_remote_speech, &remote_floor_owners, &mut remote_release_deadlines, tokio::time::Instant::now(), ); - if !is_dtx { + if is_remote_speech { active_indices.insert(peer_idx); let level = normalized_speaker_level(header.level_dbov); speaker_levels @@ -497,7 +512,7 @@ pub(crate) async fn run_playout_recv_loop( // Count only remote-human speech toward floor onset. // Agent audio still plays, but it must not acquire the // human floor or suppress another agent's response. - if !is_dtx && remote_human { + if is_remote_speech && remote_human { if last_frame_reset.elapsed() >= FRAME_WINDOW { frame_counts.clear(); last_frame_reset = tokio::time::Instant::now(); @@ -507,6 +522,14 @@ pub(crate) async fn run_playout_recv_loop( if *count >= REMOTE_SPEECH_THRESHOLD { human_floor.enter_remote(peer_idx); remote_floor_owners.insert(peer_idx); + // The threshold-crossing frame is processed + // before this peer becomes an owner. Arm its + // release here so silence need not arrive in a + // later packet to let queued TTS continue. + remote_release_deadlines.insert( + peer_idx, + tokio::time::Instant::now() + REMOTE_RELEASE_DEBOUNCE, + ); if tts_active.load(Ordering::Acquire) { tts_cancel.store(true, Ordering::Release); } @@ -647,18 +670,18 @@ mod tests { use super::*; #[test] - fn continuous_dtx_does_not_extend_remote_floor_deadline() { + fn continuous_silence_does_not_extend_remote_floor_deadline() { let peer = 7; let started = tokio::time::Instant::now(); let owners = std::collections::HashSet::from([peer]); let mut deadlines = std::collections::HashMap::new(); - update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + update_remote_release_deadline(peer, false, &owners, &mut deadlines, started); let armed = deadlines[&peer]; for elapsed_ms in [100, 200, 300, 400] { update_remote_release_deadline( peer, - true, + false, &owners, &mut deadlines, started + std::time::Duration::from_millis(elapsed_ms), @@ -678,11 +701,32 @@ mod tests { } #[test] - fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() { + fn last_speech_frame_arms_remote_floor_release_without_follow_up_audio() { + let peer = 7; + let started = tokio::time::Instant::now(); + let owners = std::collections::HashSet::from([peer]); + let mut deadlines = std::collections::HashMap::new(); + + update_remote_release_deadline(peer, true, &owners, &mut deadlines, started); + let armed = started + REMOTE_RELEASE_DEBOUNCE; + assert_eq!(deadlines[&peer], armed); + + let human_floor = HumanFloor::new(); + human_floor.enter_remote(peer); + let mut owners = owners; + release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor); + + assert!(!human_floor.is_blocked()); + assert!(owners.is_empty()); + assert!(deadlines.is_empty()); + } + + #[test] + fn silence_from_non_owner_does_not_arm_remote_floor_deadline() { let mut deadlines = std::collections::HashMap::new(); update_remote_release_deadline( 7, - true, + false, &std::collections::HashSet::new(), &mut deadlines, tokio::time::Instant::now(), @@ -690,6 +734,15 @@ mod tests { assert!(deadlines.is_empty()); } + #[test] + fn remote_speech_requires_non_dtx_audio_above_the_activity_floor() { + assert!(!is_remote_speech_frame(true, 0)); + assert!(!is_remote_speech_frame(false, -127)); + assert!(!is_remote_speech_frame(false, -56)); + assert!(is_remote_speech_frame(false, -55)); + assert!(is_remote_speech_frame(false, -12)); + } + #[test] fn speaker_level_maps_conversational_range() { assert_eq!(normalized_speaker_level(-127), 0.0); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a5493de11b..d07cf2425b 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -19,6 +19,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/bouncing_dots_indicator.dart'; import '../../shared/widgets/concentric_sheet_surface.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index d89ca027e1..7442cb8155 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -1,5 +1,7 @@ part of '../channel_detail_page.dart'; +const _huddleAgentResponseResetDelay = Duration(milliseconds: 1200); + class _HuddleCallAvatar extends HookConsumerWidget { const _HuddleCallAvatar({ required this.pubkey, @@ -7,6 +9,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { required this.fallbackLabel, required this.active, required this.speakerLevel, + required this.preparingResponse, required this.onTap, this.isSelf = false, this.frameSize = _huddleAvatarFrameSize, @@ -17,6 +20,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { final String? fallbackLabel; final bool active; final double speakerLevel; + final bool preparingResponse; final VoidCallback? onTap; final bool isSelf; final double frameSize; @@ -24,6 +28,62 @@ class _HuddleCallAvatar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final reducedMotion = MediaQuery.disableAnimationsOf(context); + final responseHasStarted = useState(false); + final responseResetTimer = useRef(null); + // Tracks the previous working signal so we can detect its edges, and + // whether the working signal has cleared since audio latched the response. + // A completed working cycle distinguishes a genuinely new turn from late + // typing that merely trails the turn already spoken. + final wasPreparing = useRef(false); + final workingCycleCompleted = useRef(false); + useEffect(() { + final preparingStarted = preparingResponse && !wasPreparing.value; + final preparingCleared = !preparingResponse && wasPreparing.value; + wasPreparing.value = preparingResponse; + if (active) { + // Audio for the current turn latches that a response has begun and + // starts a fresh turn, so any prior working cycle no longer applies. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + responseHasStarted.value = true; + workingCycleCompleted.value = false; + } else if (preparingStarted && + responseHasStarted.value && + workingCycleCompleted.value) { + // A new working turn began after the previous turn's working signal + // already cleared, so the "already spoke" suppression no longer + // applies — allow the preparing indicator to show again. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + responseHasStarted.value = false; + workingCycleCompleted.value = false; + } else if (preparingResponse) { + // Working signal (including late typing for the turn just spoken) holds + // the suppression alive; keep the reset timer cancelled. + responseResetTimer.value?.cancel(); + responseResetTimer.value = null; + } else if (responseHasStarted.value && responseResetTimer.value == null) { + responseResetTimer.value = Timer(_huddleAgentResponseResetDelay, () { + responseResetTimer.value = null; + responseHasStarted.value = false; + workingCycleCompleted.value = false; + }); + } + // Record that this turn's working signal has completed a cycle once it + // clears after audio latched, so the next working turn is not mistaken + // for trailing typing. + if (preparingCleared && responseHasStarted.value) { + workingCycleCompleted.value = true; + } + return null; + }, [active, preparingResponse, responseHasStarted.value]); + useEffect( + () => + () => responseResetTimer.value?.cancel(), + const [], + ); + final showPreparingResponse = + preparingResponse && !active && !responseHasStarted.value; final scale = frameSize / _huddleAvatarFrameSize; final avatarRadius = _huddleAvatarRadius * scale; final speakingRingSize = _huddleSpeakingRingSize * scale; @@ -65,12 +125,22 @@ class _HuddleCallAvatar extends HookConsumerWidget { isSelf: isSelf, ); + final semanticStates = [ + label, + if (showPreparingResponse) 'preparing a response', + if (active) 'speaking', + ].join(', '); + return SizedBox( width: frameSize, child: Semantics( - label: active ? '$label, speaking' : label, + label: semanticStates, hint: onTap == null ? null : 'Tap to focus participant', button: onTap != null, + // The outer node excludes descendant semantics, so the child + // indicator's live region never reaches assistive tech. Promote this + // node to a live region while preparing so the label change announces. + liveRegion: showPreparingResponse, onTap: onTap, excludeSemantics: true, child: GestureDetector( @@ -121,15 +191,46 @@ class _HuddleCallAvatar extends HookConsumerWidget { ), ), ), - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: avatarRadius, - backgroundColor: context.colors.primaryContainer, - fallback: Icon( - LucideIcons.userRound, - size: fallbackIconSize, - color: context.colors.onPrimaryContainer, - ), + AnimatedSwitcher( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: showPreparingResponse + ? Container( + key: ValueKey( + 'huddle-agent-preparing-response-$pubkey', + ), + width: avatarRadius * 2, + height: avatarRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primaryContainer, + ), + alignment: Alignment.center, + child: BouncingDotsIndicator( + color: context.colors.onPrimaryContainer, + dotSize: 6 * scale, + gap: 4 * scale, + semanticLabel: + '$label is preparing a response', + ), + ) + : AvatarImage( + key: ValueKey('huddle-avatar-image-$pubkey'), + imageUrl: profile?.avatarUrl, + radius: avatarRadius, + backgroundColor: + context.colors.primaryContainer, + fallback: Icon( + LucideIcons.userRound, + size: fallbackIconSize, + color: context.colors.onPrimaryContainer, + ), + ), ), ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart index ee2a564b2e..5e0d7922d0 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_participants.dart @@ -10,6 +10,7 @@ class _HuddleCallParticipants extends StatelessWidget { required this.localPubkey, required this.activeSpeakerPubkeys, required this.speakerLevels, + required this.workingAgentPubkeys, required this.retryTooltip, required this.retryIcon, required this.onRetry, @@ -25,6 +26,7 @@ class _HuddleCallParticipants extends StatelessWidget { final String? localPubkey; final Set activeSpeakerPubkeys; final Map speakerLevels; + final Set workingAgentPubkeys; final String retryTooltip; final IconData retryIcon; final VoidCallback onRetry; @@ -126,6 +128,7 @@ class _HuddleCallParticipants extends StatelessWidget { speakerLevel: localPubkey == null ? 0 : speakerLevels[localPubkey] ?? 0, + preparingResponse: false, isSelf: true, onTap: null, ), @@ -145,6 +148,7 @@ class _HuddleCallParticipants extends StatelessWidget { fallbackLabels: fallbackLabels, activeSpeakerPubkeys: activeSpeakerPubkeys, speakerLevels: speakerLevels, + workingAgentPubkeys: workingAgentPubkeys, movementDuration: movementDuration, entryDuration: entryDuration, exitDuration: exitDuration, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart index 7f0f80b17d..d966d26058 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_cluster.dart @@ -40,6 +40,7 @@ class _HuddleParticipantCluster extends HookWidget { required this.fallbackLabels, required this.activeSpeakerPubkeys, required this.speakerLevels, + required this.workingAgentPubkeys, required this.movementDuration, required this.entryDuration, required this.exitDuration, @@ -52,6 +53,7 @@ class _HuddleParticipantCluster extends HookWidget { final Map fallbackLabels; final Set activeSpeakerPubkeys; final Map speakerLevels; + final Set workingAgentPubkeys; final Duration movementDuration; final Duration entryDuration; final Duration exitDuration; @@ -139,6 +141,7 @@ class _HuddleParticipantCluster extends HookWidget { fallbackLabel: fallbackLabels[pubkey], active: activeSpeakerPubkeys.contains(pubkey), speakerLevel: speakerLevels[pubkey] ?? 0, + preparingResponse: workingAgentPubkeys.contains(pubkey), slot: lastSlots[pubkey]!, present: visiblePubkeySet.contains(pubkey), movementDuration: movementDuration, @@ -173,6 +176,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { required this.fallbackLabel, required this.active, required this.speakerLevel, + required this.preparingResponse, required this.slot, required this.present, required this.movementDuration, @@ -186,6 +190,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { final String? fallbackLabel; final bool active; final double speakerLevel; + final bool preparingResponse; final _HuddleClusterSlot slot; final bool present; final Duration movementDuration; @@ -237,6 +242,7 @@ class _HuddleAnimatedParticipant extends StatelessWidget { fallbackLabel: fallbackLabel, active: active, speakerLevel: speakerLevel, + preparingResponse: preparingResponse, frameSize: frameSize, onTap: onTap, ), diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 247d116830..215009365f 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -5,6 +5,7 @@ const _huddleParticipantSpotlightRadius = 68.0; void _showHuddleParticipantSpotlight({ required BuildContext context, + required String ephemeralChannelId, required String pubkey, required bool isSelf, }) { @@ -12,6 +13,7 @@ void _showHuddleParticipantSpotlight({ context: context, barrierLabel: 'Dismiss participant', childBuilder: (dialogContext) => _HuddleParticipantSpotlight( + ephemeralChannelId: ephemeralChannelId, pubkey: pubkey, isSelf: isSelf, onDismiss: () => Navigator.of(dialogContext).pop(), @@ -19,11 +21,15 @@ void _showHuddleParticipantSpotlight({ ); } -void _showHuddleParticipantRoster({required BuildContext context}) { +void _showHuddleParticipantRoster({ + required BuildContext context, + required String ephemeralChannelId, +}) { _showHuddleParticipantOverlay( context: context, barrierLabel: 'Dismiss participant list', - childBuilder: (_) => const _HuddleParticipantRoster(), + childBuilder: (_) => + _HuddleParticipantRoster(ephemeralChannelId: ephemeralChannelId), ); } @@ -123,22 +129,24 @@ class _HuddleParticipantOverlay extends StatelessWidget { class _HuddleParticipantSpotlight extends ConsumerWidget { const _HuddleParticipantSpotlight({ + required this.ephemeralChannelId, required this.pubkey, required this.isSelf, required this.onDismiss, }); + final String ephemeralChannelId; final String pubkey; final bool isSelf; final VoidCallback onDismiss; @override Widget build(BuildContext context, WidgetRef ref) { - final participantPresent = ref.watch( - huddleSessionProvider.select( - (session) => isSelf || session.participantPubkeys.contains(pubkey), - ), - ); + final participantPresent = + isSelf || + ref + .watch(_huddleLogicalParticipantPubkeysProvider(ephemeralChannelId)) + .contains(pubkey); if (!participantPresent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) onDismiss(); @@ -233,13 +241,16 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { } class _HuddleParticipantRoster extends ConsumerWidget { - const _HuddleParticipantRoster(); + const _HuddleParticipantRoster({required this.ephemeralChannelId}); + + final String ephemeralChannelId; @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(huddleSessionProvider); final localPubkey = session.currentPubkey?.toLowerCase(); - final pubkeys = session.participantPubkeys + final pubkeys = ref + .watch(_huddleLogicalParticipantPubkeysProvider(ephemeralChannelId)) .where((pubkey) => pubkey != localPubkey) .skip(_huddleClusterVisibleParticipantCount) .toList(growable: false); diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 6f1b21339c..5b0abb5766 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -12,6 +12,35 @@ final _huddleParticipantProfileUpdatesProvider = NotifierProvider.autoDispose _HuddleParticipantProfileUpdates.new, ); +final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + // Select only roster-relevant fields. Watching the whole session would + // recompute at the 50 ms speaker-level flush cadence, restarting the + // downstream profile subscription ~20x/sec while anyone is speaking. + final session = ref.watch( + huddleSessionProvider.select( + (state) => ( + ephemeralChannelId: state.ephemeralChannelId, + currentPubkey: state.currentPubkey, + participantPubkeys: state.participantPubkeys, + wasAdmitted: state.wasAdmitted, + ), + ), + ); + final backingMembers = + ref.watch(channelMembersProvider(channelId)).value ?? + const []; + return _huddleParticipantPubkeys( + sessionParticipantPubkeys: session.ephemeralChannelId == channelId + ? session.participantPubkeys + : const [], + currentPubkey: session.currentPubkey, + members: session.wasAdmitted + ? backingMembers.where((member) => member.isBot) + : backingMembers, + ); + }); + class _HuddleParticipantProfileUpdates extends Notifier { _HuddleParticipantProfileUpdates(this.channelId); @@ -21,27 +50,9 @@ class _HuddleParticipantProfileUpdates extends Notifier { @override int build() { - final session = ref.watch( - huddleSessionProvider.select( - (state) => ( - ephemeralChannelId: state.ephemeralChannelId, - currentPubkey: state.currentPubkey, - participantPubkeys: state.participantPubkeys, - wasAdmitted: state.wasAdmitted, - ), - ), - ); - final members = session.wasAdmitted - ? const [] - : ref.watch(channelMembersProvider(channelId)).value ?? - const []; final relayState = ref.watch(relaySessionProvider); - final participantPubkeys = _huddleParticipantPubkeys( - sessionParticipantPubkeys: session.ephemeralChannelId == channelId - ? session.participantPubkeys - : const [], - currentPubkey: session.currentPubkey, - members: members, + final participantPubkeys = ref.watch( + _huddleLogicalParticipantPubkeysProvider(channelId), ); final subscriptionVersion = ++_subscriptionVersion; _clearSubscription(); @@ -547,13 +558,11 @@ class _MobileHuddleCallPage extends ConsumerWidget { !unavailable && session.microphonePermissionRequired; final localPubkey = session.currentPubkey?.toLowerCase(); - final backingMembers = - ref.watch(channelMembersProvider(invite.ephemeralChannelId)).value ?? - const []; - final participantPubkeys = _huddleParticipantPubkeys( - sessionParticipantPubkeys: session.participantPubkeys, - currentPubkey: localPubkey, - members: session.wasAdmitted ? const [] : backingMembers, + // Audio peers remain authoritative for humans after admission. Agents are + // logical Huddle participants as soon as their bot membership is published, + // before their send-only audio connection starts speaking. + final participantPubkeys = ref.watch( + _huddleLogicalParticipantPubkeysProvider(invite.ephemeralChannelId), ); final remotePubkeys = participantPubkeys .where((pubkey) => pubkey != localPubkey) @@ -564,6 +573,29 @@ class _MobileHuddleCallPage extends ConsumerWidget { ); final profiles = ref.watch(userCacheProvider); final directoryDisplayNames = ref.watch(agentDirectoryDisplayNamesProvider); + final huddleTypingEntries = ref.watch( + channelTypingProvider(invite.ephemeralChannelId), + ); + final parentAgentPubkeys = ref.watch( + agentMentionPubkeysProvider(invite.parentChannelId), + ); + // Ephemeral Huddle bot membership is authoritative for native enrollment; + // parent classification is only best-effort and may miss a valid Huddle + // bot. Union both so a Huddle-only agent still enters the preparing state. + final huddleBotPubkeys = { + for (final member + in ref + .watch(channelMembersProvider(invite.ephemeralChannelId)) + .value ?? + const []) + if (member.isBot) member.pubkey.trim().toLowerCase(), + }; + final workingAgentPubkeys = { + for (final entry in huddleTypingEntries) + if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase()) || + huddleBotPubkeys.contains(entry.pubkey.toLowerCase())) + entry.pubkey.toLowerCase(), + }; final reactionSenderName = _huddleReactionSenderName( localPubkey: localPubkey, profile: localPubkey == null ? null : profiles[localPubkey], @@ -651,6 +683,7 @@ class _MobileHuddleCallPage extends ConsumerWidget { localPubkey: localPubkey, activeSpeakerPubkeys: session.activeSpeakerPubkeys, speakerLevels: session.speakerLevels, + workingAgentPubkeys: workingAgentPubkeys, retryTooltip: retryTooltip, retryIcon: retryIcon, onRetry: onRetry, @@ -658,12 +691,15 @@ class _MobileHuddleCallPage extends ConsumerWidget { final isSelf = pubkey == localPubkey || pubkey.isEmpty; _showHuddleParticipantSpotlight( context: context, + ephemeralChannelId: invite.ephemeralChannelId, pubkey: pubkey, isSelf: isSelf, ); }, - onOverflowTap: () => - _showHuddleParticipantRoster(context: context), + onOverflowTap: () => _showHuddleParticipantRoster( + context: context, + ephemeralChannelId: invite.ephemeralChannelId, + ), ), ), if (connected) diff --git a/mobile/lib/shared/widgets/bouncing_dots_indicator.dart b/mobile/lib/shared/widgets/bouncing_dots_indicator.dart new file mode 100644 index 0000000000..6026c1db0f --- /dev/null +++ b/mobile/lib/shared/widgets/bouncing_dots_indicator.dart @@ -0,0 +1,90 @@ +import 'dart:math' show pi, sin; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Three subtly bouncing dots for short, indeterminate waiting states. +class BouncingDotsIndicator extends HookWidget { + /// The color of each dot. + final Color color; + + /// The diameter of each dot. + final double dotSize; + + /// The horizontal space between dots. + final double gap; + + /// The accessibility announcement for this waiting state. + final String semanticLabel; + + /// Creates a three-dot indeterminate activity indicator. + const BouncingDotsIndicator({ + required this.color, + required this.semanticLabel, + this.dotSize = 6, + this.gap = 4, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final animation = useAnimationController( + duration: const Duration(milliseconds: 1000), + ); + + useEffect(() { + if (reducedMotion) { + animation + ..stop() + ..value = 0; + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + + return Semantics( + label: semanticLabel, + liveRegion: true, + child: ExcludeSemantics( + child: AnimatedBuilder( + animation: animation, + builder: (context, _) => Row( + key: const ValueKey('bouncing-dots-indicator'), + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < 3; index++) ...[ + if (index > 0) SizedBox(width: gap), + Transform.translate( + key: ValueKey('bouncing-dot-$index'), + offset: Offset( + 0, + reducedMotion + ? 0 + : -dotSize * + 0.32 * + _positiveWave(animation.value, index), + ), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + ), + child: SizedBox.square(dimension: dotSize), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +double _positiveWave(double progress, int index) { + // Match desktop onboarding's 120 ms stagger while keeping the motion soft. + final staggeredProgress = progress - (index * 0.12); + return sin(staggeredProgress * 2 * pi).clamp(0, 1).toDouble(); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 3aabad062e..08b7e570c8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -220,6 +220,7 @@ Widget _buildTestable({ ReadStateNotifier? readStateNotifier, _FakeMessagesNotifier? messagesNotifier, _FakeTypingNotifier? typingNotifier, + _FakeTypingNotifier? huddleTypingNotifier, String? canvasContent, String? initialMessageId, String? initialThreadRootId, @@ -259,6 +260,11 @@ Widget _buildTestable({ channelTypingProvider( _channelId, ).overrideWith(() => typingNotifier ?? _FakeTypingNotifier(typing)), + channelTypingProvider(_huddleChannelId).overrideWith( + () => + huddleTypingNotifier ?? + _FakeTypingNotifier(const [], channelId: _huddleChannelId), + ), userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), @@ -5622,6 +5628,431 @@ void main() { ); }); + testWidgets('shows response dots only until the agent starts speaking', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + members: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + loadChannelBotPubkeys: () async => const {'agent'}, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + // The preparing transition must be announced live. The outer avatar node + // excludes descendant semantics, so it must itself become a live region. + expect( + tester + .getSemantics( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + ) + .flagsCollection + .isLiveRegion, + isTrue, + ); + final dotFinder = find.byKey(const ValueKey('bouncing-dot-2')); + final initialDotOffset = tester + .widget(dotFinder) + .transform + .getTranslation() + .y; + await tester.pump(const Duration(milliseconds: 120)); + expect( + tester.widget(dotFinder).transform.getTranslation().y, + isNot(initialDotOffset), + ); + + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Pollen, speaking'), findsOneWidget); + + // A brief audio gap must not revive the waiting state while the same + // working signal is still active. + await tester.pump(const Duration(milliseconds: 650)); + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + + typing.setEntries(const []); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + }); + + testWidgets( + 'shows preparing indicator for a Huddle-only bot without a parent role', + (tester) async { + // Regression: a valid ephemeral Huddle bot with no parent bot role and + // no directory identity must still enter the preparing state from its + // Huddle typing. Classification must derive from authoritative + // ephemeral Huddle bot membership, not parent-channel classification. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'huddle-only-working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + // Bot membership is supplied ONLY through the ephemeral Huddle, and + // deliberately not through the parent channel (`members`). + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + }, + ); + + testWidgets('does not revive response dots when typing follows audio', ( + tester, + ) async { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier(const [], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'audio-before-working-signal', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + expect(find.bySemanticsLabel('Pollen, speaking'), findsOneWidget); + + // Let the active-speaker window close before the independently + // transported typing signal arrives. + await tester.pump(const Duration(milliseconds: 650)); + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsOneWidget, + ); + }); + + testWidgets('revives response dots for a new turn after audio completes', ( + tester, + ) async { + // Regression: a completed working cycle followed by a genuinely new one + // must show the preparing indicator again. A single audio-seen latch + // conflates late same-turn typing (suppress) with a fresh turn (show). + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier(const [], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'new-turn-after-audio', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + // Turn 1: audio speaks, then late same-turn typing must stay suppressed. + transport.emitRemoteAudio(peerIndex: 2); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 650)); + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsNothing, + ); + + // Turn 1's working signal completes. + typing.setEntries(const []); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + // Turn 2: a fresh working signal within the 1.2 s cooldown must show the + // preparing indicator and announce it live again. + typing.setEntries([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-avatar-image-agent')), + findsNothing, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + expect( + tester + .getSemantics( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + ) + .flagsCollection + .isLiveRegion, + isTrue, + ); + }); + + testWidgets( + 'does not restart the profile subscription on speaker-level updates', + (tester) async { + // Regression: the logical-participant provider must select only + // roster-relevant session fields. Watching the whole session would + // recompute at the 50 ms speaker-level flush cadence, tearing down and + // recreating the kind-0 profile subscription ~20x/sec while anyone is + // speaking. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final relaySession = _ProfileSubscriptionRelaySession(); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'speaker-level-profile-churn', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + relaySessionNotifier: relaySession, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + final baseline = relaySession.profileSubscriptions; + expect(baseline, greaterThan(0)); + + // Drive continuous speaker-level flushes: each frame within the 600 ms + // active window refreshes the level and schedules a 50 ms flush that + // republishes the whole session state. + for (var i = 0; i < 10; i++) { + transport.emitRemoteAudio(peerIndex: 2, sequence: i + 1); + await tester.pump(const Duration(milliseconds: 50)); + } + await tester.pumpAndSettle(); + + expect(relaySession.profileSubscriptions, baseline); + }, + ); + testWidgets( 'opens the sparse full-screen call with avatar and audio controls', (tester) async { @@ -6260,6 +6691,7 @@ void main() { testWidgets( 'caps a dense Huddle roster at ten avatars with an overflow count', (tester) async { + const membershipAgentPubkey = 'membership-agent'; tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -6293,6 +6725,10 @@ void main() { ], users: { 'self': const UserProfile(pubkey: 'self', displayName: 'Self'), + membershipAgentPubkey: const UserProfile( + pubkey: membershipAgentPubkey, + displayName: 'Membership agent', + ), for (final pubkey in remotePubkeys) pubkey: UserProfile(pubkey: pubkey, displayName: pubkey), }, @@ -6303,6 +6739,11 @@ void main() { role: 'member', joinedAt: DateTime(2025), ), + ChannelMember( + pubkey: membershipAgentPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), ], relayConfigNotifier: _HuddleRelayConfigNotifier(), huddleCurrentPubkey: 'self', @@ -6345,7 +6786,7 @@ void main() { find.byKey(const ValueKey('huddle-participant-overflow')), findsOneWidget, ); - expect(find.text('+14'), findsOneWidget); + expect(find.text('+15'), findsOneWidget); await tester.tap( find.byKey(const ValueKey('huddle-participant-overflow')), @@ -6356,7 +6797,7 @@ void main() { find.byKey(const ValueKey('huddle-participant-roster')), findsOneWidget, ); - expect(find.text('14 more people'), findsOneWidget); + expect(find.text('15 more people'), findsOneWidget); expect( find.byKey(const ValueKey('huddle-participant-roster-row-guest-10')), findsOneWidget, @@ -6377,6 +6818,20 @@ void main() { find.byKey(const ValueKey('huddle-participant-roster-row-guest-23')), findsOneWidget, ); + final membershipAgentRow = find.byKey( + const ValueKey( + 'huddle-participant-roster-row-$membershipAgentPubkey', + ), + ); + await tester.scrollUntilVisible( + membershipAgentRow, + 200, + scrollable: find.descendant( + of: find.byKey(const ValueKey('huddle-participant-roster-list')), + matching: find.byType(Scrollable), + ), + ); + expect(membershipAgentRow, findsOneWidget); await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); expect( @@ -6530,138 +6985,211 @@ void main() { }, ); - testWidgets('springs admitted participants in and out', (tester) async { - const addedMemberPubkey = - 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - final membersNotifier = _MutableHuddleMembersNotifier(const []); - final transport = _HuddleTestTransport(); + testWidgets( + 'shows admitted agents from membership before their audio peer joins', + (tester) async { + const addedMemberPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final membersNotifier = _MutableHuddleMembersNotifier(const []); + final transport = _HuddleTestTransport(); - await tester.pumpWidget( - _buildTestable( - messages: [ - _huddleMsg( - id: 'authoritative-live-roster', - kind: EventKind.huddleStarted, - pubkey: 'self', - createdAt: now, - ), - ], - users: const { - 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), - 'self': UserProfile(pubkey: 'self', displayName: 'Self'), - addedMemberPubkey: UserProfile( - pubkey: addedMemberPubkey, - displayName: 'Added member', - ), - }, - huddleMembersNotifier: membersNotifier, - relayConfigNotifier: _HuddleRelayConfigNotifier(), - relaySessionNotifier: _ReconnectingRelaySession(), - huddleCurrentPubkey: 'self', - huddleMediaFactory: _HuddleTestMedia.new, - huddleTransportFactory: (_) => transport, - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Join')); - await tester.pumpAndSettle(); + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'authoritative-live-roster', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Miles'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + addedMemberPubkey: UserProfile( + pubkey: addedMemberPubkey, + displayName: 'Added member', + ), + }, + huddleMembersNotifier: membersNotifier, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + relaySessionNotifier: _ReconnectingRelaySession(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); - final desktopAvatar = find.byKey( - const ValueKey('huddle-speaking-ring-desktop'), - ); - final initialDesktopCenter = tester.getCenter(desktopAvatar); + final desktopAvatar = find.byKey( + const ValueKey('huddle-speaking-ring-desktop'), + ); + final initialDesktopCenter = tester.getCenter(desktopAvatar); - membersNotifier.replace([ - ChannelMember( - pubkey: addedMemberPubkey, - role: 'member', - joinedAt: DateTime(2025), - ), - ]); - await tester.pump(); - await tester.pump(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsNothing, - ); + membersNotifier.replace([ + ChannelMember( + pubkey: addedMemberPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); - transport.emitPeerJoin( - const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0), - ); - await tester.pump(); + final addedMemberScale = find.byKey( + const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'), + ); + expect( + tester.widget(addedMemberScale).transform.storage[0], + closeTo(0.72, 0.01), + ); + await tester.pump(const Duration(milliseconds: 120)); + final movingDesktopCenter = tester.getCenter(desktopAvatar); + expect( + tester.widget(addedMemberScale).transform.storage[0], + greaterThan(0.72), + ); + expect( + (movingDesktopCenter - initialDesktopCenter).distance, + greaterThan(1), + ); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-participant-avatar-desktop')), + findsOneWidget, + ); - final addedMemberScale = find.byKey( - const ValueKey('huddle-participant-entry-scale-$addedMemberPubkey'), - ); - expect( - tester.widget(addedMemberScale).transform.storage[0], - closeTo(0.72, 0.01), - ); - await tester.pump(const Duration(milliseconds: 120)); - final movingDesktopCenter = tester.getCenter(desktopAvatar); - expect( - tester.widget(addedMemberScale).transform.storage[0], - greaterThan(0.72), - ); - expect( - (movingDesktopCenter - initialDesktopCenter).distance, - greaterThan(1), - ); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('huddle-participant-avatar-desktop')), - findsOneWidget, - ); + await tester.tap( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('huddle-participant-spotlight')), + findsOneWidget, + ); + expect(find.text('Added member'), findsOneWidget); + await tester.tapAt(const Offset(8, 8)); + await tester.pumpAndSettle(); - await tester.pumpAndSettle(); - final settledDesktopCenter = tester.getCenter(desktopAvatar); - expect( - (settledDesktopCenter - movingDesktopCenter).distance, - greaterThan(1), - ); - expect( - (settledDesktopCenter - initialDesktopCenter).distance, - greaterThan(1), - ); - expect( - tester.widget(addedMemberScale).transform.storage[0], - closeTo(1, 0.01), - ); + await tester.pumpAndSettle(); + final settledDesktopCenter = tester.getCenter(desktopAvatar); + expect( + (settledDesktopCenter - movingDesktopCenter).distance, + greaterThan(1), + ); + expect( + (settledDesktopCenter - initialDesktopCenter).distance, + greaterThan(1), + ); + expect( + tester.widget(addedMemberScale).transform.storage[0], + closeTo(1, 0.01), + ); - transport.emitPeerLeave(3); - await tester.pump(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsOneWidget, - ); - await tester.pump(const Duration(milliseconds: 54)); - expect( - tester.widget(addedMemberScale).transform.storage[0], - greaterThan(1.05), - ); - await tester.pump(const Duration(milliseconds: 140)); - expect( - tester.widget(addedMemberScale).transform.storage[0], - lessThan(1), - ); - await tester.pumpAndSettle(); - expect( - find.byKey( - const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), - ), - findsNothing, - ); - }); + // The avatar reflects logical membership, so an audio connection ending + // does not remove the agent. Removing its membership does. + transport.emitPeerJoin( + const HuddlePeer(pubkey: addedMemberPubkey, peerIndex: 3, epoch: 0), + ); + await tester.pump(); + transport.emitPeerLeave(3); + await tester.pump(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsOneWidget, + ); + + membersNotifier.replace(const []); + await tester.pumpAndSettle(); + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$addedMemberPubkey'), + ), + findsNothing, + ); + }, + ); + + testWidgets( + 'does not show admitted human membership without an audio peer', + (tester) async { + const invitedHumanPubkey = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final membersNotifier = _MutableHuddleMembersNotifier(const []); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'human-membership-is-not-audio-presence', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'desktop': UserProfile(pubkey: 'desktop', displayName: 'Desktop'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + invitedHumanPubkey: UserProfile( + pubkey: invitedHumanPubkey, + displayName: 'Invited human', + ), + }, + huddleMembersNotifier: membersNotifier, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => _HuddleTestTransport(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + membersNotifier.replace([ + ChannelMember( + pubkey: invitedHumanPubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ]); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + find.byKey( + const ValueKey('huddle-participant-avatar-$invitedHumanPubkey'), + ), + findsNothing, + ); + }, + ); testWidgets('top-right call end leaves audio and the backing channel', ( tester, @@ -13350,6 +13878,35 @@ class _IdentityUpdateRelaySession extends RelaySessionNotifier { } } +class _ProfileSubscriptionRelaySession extends RelaySessionNotifier { + int profileSubscriptions = 0; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => const []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async => event; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + if (filter.kinds.contains(0)) profileSubscriptions++; + return () {}; + } +} + class _HuddleReactionRelaySession extends RelaySessionNotifier { NostrFilter? reactionFilter; void Function(NostrEvent)? _reactionListener; @@ -13861,10 +14418,14 @@ final class _HuddleTestTransport implements HuddleTransportClient { ); } - void emitRemoteAudio({int levelDbov = -30, int sequence = 1}) { + void emitRemoteAudio({ + int peerIndex = 1, + int levelDbov = -30, + int sequence = 1, + }) { _remoteFrames.add( HuddleRemoteAudioFrame( - peerIndex: 1, + peerIndex: peerIndex, epoch: 0, header: HuddleAudioHeader( sequence: sequence, From 9d1e4b257657f382d3111ce748f3da8d063b7671 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 25 Aug 2026 10:05:32 -0400 Subject: [PATCH 04/24] Extract community persistence (#6668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Community persistence is the next incremental `buzz-db` store extraction, keeping tenant lifecycle SQL, records, tests, and instrumentation out of the database runtime module without changing behavior. ## What - Move community records and the existing `impl Db` operations into `community.rs` while preserving crate-root re-exports. - Move focused PostgreSQL tests with the implementation and enforce single ownership for each method and datastore span. ## Risk Assessment Low — this is a structural move of the existing records, SQL, method bodies, and focused tests; database runtime concerns, schema, and behavior remain unchanged. ## References - Architecture guardrail: https://github.com/TheSentinel454/buzz/issues/34 - Incremental tracker: https://github.com/TheSentinel454/buzz/issues/2 - Primary task: https://github.com/TheSentinel454/buzz/issues/5 - Stacked on: https://github.com/block/buzz/pull/6660 Generated with Codex Signed-off-by: tornquist --- crates/buzz-db/src/community.rs | 989 ++++++++++++++++++++++++++++++++ crates/buzz-db/src/lib.rs | 879 +--------------------------- 2 files changed, 996 insertions(+), 872 deletions(-) create mode 100644 crates/buzz-db/src/community.rs diff --git a/crates/buzz-db/src/community.rs b/crates/buzz-db/src/community.rs new file mode 100644 index 0000000000..64c116a520 --- /dev/null +++ b/crates/buzz-db/src/community.rs @@ -0,0 +1,989 @@ +//! Community lifecycle and host-map persistence. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use chrono::{DateTime, Utc}; +use sqlx::Row; +use uuid::Uuid; + +use crate::{relay_members, Db, DbError, Result}; + +/// Community host-map row returned by [`Db::lookup_community_by_host`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, +} + +/// Community row returned by idempotent community ensure/create operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EnsuredCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// True only when this call inserted the `communities` row. + pub created: bool, +} + +/// Community row returned by an atomic create-with-owner operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host stored for the community. + pub host: String, +} + +/// Result of atomically creating a community with its initial owner. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CreateCommunityWithOwnerResult { + /// The community was created, or an identical retried create found it. + Created(CreatedCommunityRecord), + /// The host already belongs to another owner. + HostExists, + /// The intended owner already owns the maximum number of communities. + LimitReached, +} + +/// Community row returned by operator-plane ownership reads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwnedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, + /// When the community row was created. + pub created_at: DateTime, + /// When the community was archived; absent while active. + pub archived_at: Option>, +} + +/// Community row returned by an owner-authorized archive operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArchivedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Reserved canonical host. + pub host: String, + /// Durable first-archive timestamp. + pub archived_at: DateTime, +} + +/// Community row returned by an owner-authorized unarchive operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UnarchivedCommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Reserved canonical host restored to active admission. + pub host: String, +} + +impl Db { + /// Returns the community mapped to a normalized request host, if one exists. + /// + /// The caller owns host normalization and turns `None` into the fail-closed + /// request/connection error. buzz-db only reads the durable host map. + #[datastore_span(name = "lookup_community_by_host", system = "postgresql")] + pub async fn lookup_community_by_host( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, host + FROM communities + WHERE lower(host) = lower($1) + AND archived_at IS NULL + AND deleted_at IS NULL + AND deletion_state = 'active' + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + }) + .transpose() + } + + /// Returns whether a community id still exists in the active lifecycle state. + #[datastore_span(name = "is_community_active", system = "postgresql")] + pub async fn is_community_active(&self, community_id: CommunityId) -> Result { + let active = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active')", + ) + .bind(community_id.as_uuid()) + .fetch_one(&self.pool) + .await?; + Ok(active) + } + + /// Returns a community by host regardless of lifecycle state. Operator-plane only. + #[datastore_span( + name = "lookup_community_by_host_for_management", + system = "postgresql" + )] + pub async fn lookup_community_by_host_for_management( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(CommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Lists communities where `owner_pubkey` currently holds the `owner` role. + /// + /// This is an operator-plane helper, not a tenant-scoped data-plane read: + /// callers must gate it on deployment-level operator auth before exposing it. + #[datastore_span(name = "list_communities_owned_by", system = "postgresql")] + pub async fn list_communities_owned_by( + &self, + owner_pubkey: &str, + ) -> Result> { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let rows = sqlx::query( + r#" + SELECT c.id, c.host, c.created_at, c.archived_at + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE rm.pubkey = $1 + AND rm.role = 'owner' + ORDER BY c.created_at ASC, c.host ASC + "#, + ) + .bind(owner_pubkey) + .fetch_all(&self.pool) + .await?; + + rows.into_iter() + .map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created_at: DateTime = row.try_get("created_at")?; + let archived_at: Option> = row.try_get("archived_at")?; + Ok(OwnedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created_at, + archived_at, + }) + }) + .collect() + } + + /// Returns the normalized host mapped to a community id, if the community + /// exists. + /// + /// The reverse of [`lookup_community_by_host`]: used by side-effect + /// producers that already hold a server-resolved `CommunityId` (e.g. the + /// workflow action sink running a run owned by some community) and need a + /// fully-formed [`buzz_core::tenant::TenantContext`] — host included — to + /// fan out under *that* community rather than the deployment default. The + /// community is authoritative; the host is read back for labelling only and + /// is never used to re-derive the community. + #[datastore_span(name = "lookup_community_host", system = "postgresql")] + pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result> { + let row = sqlx::query( + r#" + SELECT host + FROM communities + WHERE id = $1 + AND archived_at IS NULL + AND deleted_at IS NULL + AND deletion_state = 'active' + "#, + ) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let host: String = row.try_get("host")?; + Ok(host) + }) + .transpose() + } + + /// Returns the community's workspace icon (NIP-11 `icon`), if set. + /// + /// Set by relay admins/owners via the kind:9033 command; the value is + /// validated and size-capped at that write path. + #[datastore_span(name = "get_community_icon", system = "postgresql")] + pub async fn get_community_icon(&self, community_id: CommunityId) -> Result> { + let row = sqlx::query( + r#" + SELECT icon + FROM communities + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + + Ok(row + .map(|row| row.try_get::, _>("icon")) + .transpose()? + .flatten() + .filter(|icon| !icon.is_empty())) + } + + /// Sets or clears (`None`) the community's workspace icon. + #[datastore_span(name = "set_community_icon", system = "postgresql")] + pub async fn set_community_icon( + &self, + community_id: CommunityId, + icon: Option<&str>, + ) -> Result<()> { + sqlx::query( + r#" + UPDATE communities + SET icon = $2 + WHERE id = $1 + "#, + ) + .bind(community_id.as_uuid()) + .bind(icon) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Ensure a configured community host exists and return its row. + /// + /// This is the startup/config seeding path for N=1 deployments. Migrations + /// create the schema only; deployment-specific hosts are not hardcoded into + /// schema history. + #[datastore_span(name = "ensure_configured_community", system = "postgresql")] + pub async fn ensure_configured_community( + &self, + normalized_host: &str, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host + WHERE communities.deletion_state = 'active' + AND communities.deleted_at IS NULL + RETURNING id, host, (xmax = 0) AS created + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| { + DbError::AccessDenied(format!( + "community host {normalized_host:?} is permanently tombstoned" + )) + })?; + + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + let created: bool = row.try_get("created")?; + + Ok(EnsuredCommunityRecord { + id: CommunityId::from_uuid(id), + host, + created, + }) + } + + /// Atomically creates a community and its initial owner. + /// + /// Holds a per-owner advisory lock while enforcing the ownership limit. + /// Identical create retries return the original record; host collisions and + /// limit failures remain distinguishable to the operator API. + #[datastore_span(name = "create_community_with_owner", system = "postgresql")] + pub async fn create_community_with_owner( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result { + let owner_pubkey = owner_pubkey.to_ascii_lowercase(); + let mut tx = self.pool.begin().await?; + + // Serialize on the owner pubkey so concurrent creates to the same + // owner cannot both pass the ownership count check. + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx) + .await?; + + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (lower(host)) DO NOTHING + RETURNING id, host + "#, + ) + .bind(normalized_host) + .fetch_optional(&mut *tx) + .await?; + + let (id, host) = if let Some(row) = row { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + // Enforce the limit before inserting the new owner row. + let owned_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", + ) + .bind(&owner_pubkey) + .fetch_one(&mut *tx) + .await?; + + if owned_count >= relay_members::max_communities_per_owner() { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::LimitReached); + } + + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", + ) + .bind(id) + .bind(&owner_pubkey) + .execute(&mut *tx) + .await?; + (id, host) + } else { + let existing = sqlx::query( + r#" + SELECT c.id, c.host + FROM communities c + JOIN relay_members rm ON rm.community_id = c.id + WHERE lower(c.host) = lower($1) + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND c.archived_at IS NULL + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + "#, + ) + .bind(normalized_host) + .bind(&owner_pubkey) + .fetch_optional(&mut *tx) + .await?; + let Some(existing) = existing else { + tx.rollback().await?; + return Ok(CreateCommunityWithOwnerResult::HostExists); + }; + (existing.try_get("id")?, existing.try_get("host")?) + }; + + tx.commit().await?; + Ok(CreateCommunityWithOwnerResult::Created( + CreatedCommunityRecord { + id: CommunityId::from_uuid(id), + host, + }, + )) + } + + /// Idempotently archives a community when the asserted pubkey is its current owner. + #[datastore_span(name = "archive_community_owned_by", system = "postgresql")] + pub async fn archive_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + protected_deployment_host: &str, + ) -> Result> { + let row = sqlx::query( + r#"UPDATE communities c + SET archived_at = COALESCE(c.archived_at, now()) + FROM relay_members rm + WHERE lower(c.host) = lower($1) + AND rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND lower(c.host) <> lower($3) + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + RETURNING c.id, c.host, c.archived_at"#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .bind(protected_deployment_host) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(ArchivedCommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + archived_at: row.try_get("archived_at")?, + }) + }) + .transpose() + } + + /// Idempotently restores a community when the asserted pubkey is its current owner. + #[datastore_span(name = "unarchive_community_owned_by", system = "postgresql")] + pub async fn unarchive_community_owned_by( + &self, + normalized_host: &str, + owner_pubkey: &str, + ) -> Result> { + let row = sqlx::query( + r#"UPDATE communities c + SET archived_at = NULL + FROM relay_members rm + WHERE lower(c.host) = lower($1) + AND rm.community_id = c.id + AND lower(rm.pubkey) = lower($2) + AND rm.role = 'owner' + AND c.deletion_state = 'active' + AND c.deleted_at IS NULL + RETURNING c.id, c.host"#, + ) + .bind(normalized_host) + .bind(owner_pubkey) + .fetch_optional(&self.pool) + .await?; + row.map(|row| { + Ok(UnarchivedCommunityRecord { + id: CommunityId::from_uuid(row.try_get("id")?), + host: row.try_get("host")?, + }) + }) + .transpose() + } + + /// Returns the community that owns a channel, if the channel exists. + /// + /// Internal relay producers use this to derive tenant context from the row + /// they are acting on, rather than falling back to an implicit default. + #[datastore_span(name = "community_of_channel", system = "postgresql")] + pub async fn community_of_channel(&self, channel_id: Uuid) -> Result> { + let row = sqlx::query( + r#" + SELECT community_id + FROM channels + WHERE id = $1 + AND deleted_at IS NULL + "#, + ) + .bind(channel_id) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("community_id")?; + Ok(CommunityId::from_uuid(id)) + }) + .transpose() + } + + /// Batched version of [`Self::community_of_channel`]: given a list of + /// channel UUIDs, returns a map from channel id → owning community + /// for every channel that exists (soft-deletes excluded). + /// + /// Used by the runtime conformance read-seam emitters in `buzz-relay`: + /// after a `query_events`/`get_events_by_ids` returns N rows, the + /// emitter collects distinct `channel_id`s, calls this once, then + /// projects each row's true community label independently of the + /// fetch query's WHERE clause. That independence is what makes the + /// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous — + /// a mutation that dropped `community_id = $X` from the fetch query + /// would still let this helper return the row's true label, and the + /// checker would see the mismatch. + /// + /// Channels missing from the result map (deleted or never existed) + /// are intentionally not present rather than mapped to a default — + /// callers MUST treat "channel-id not in map" as a coverage breach, + /// never as "use the resolved community". + #[datastore_span(name = "communities_of_channels", system = "postgresql")] + pub async fn communities_of_channels( + &self, + channel_ids: &[Uuid], + ) -> Result> { + if channel_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let rows = sqlx::query( + r#" + SELECT id, community_id + FROM channels + WHERE id = ANY($1) + AND deleted_at IS NULL + "#, + ) + .bind(channel_ids) + .fetch_all(&self.pool) + .await?; + + let mut out = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let ch: Uuid = row.try_get("id")?; + let cm: Uuid = row.try_get("community_id")?; + out.insert(ch, CommunityId::from_uuid(cm)); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + //! Pin the load-bearing contract for `Db::communities_of_channels`: + //! a channel id that does NOT exist MUST be absent from the result + //! map, never mapped to a default. The relay-side read-row emitter + //! relies on this — a missing entry triggers `MissingLookup → + //! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this + //! helper ever started returning a default/zero entry for unknown + //! channels, that fail-closed chain would go blind. + use super::*; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn setup_db() -> Db { + let database_url = + std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let pool = PgPool::connect(&database_url) + .await + .expect("connect to test DB"); + Db::from_pool(pool) + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { + let creator: Vec = vec![0u8; 32]; + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by) + VALUES + ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4) + "#, + ) + .bind(channel_id) + .bind(community_id) + .bind(format!("ch-{}", channel_id.simple())) + .bind(&creator) + .execute(pool) + .await + .expect("insert channel"); + } + + #[test] + fn community_implementation_tests_and_spans_have_single_owners() { + let community_source = include_str!("community.rs"); + let lib_source = include_str!("lib.rs"); + let operations = [ + "lookup_community_by_host", + "is_community_active", + "lookup_community_by_host_for_management", + "list_communities_owned_by", + "lookup_community_host", + "get_community_icon", + "set_community_icon", + "ensure_configured_community", + "create_community_with_owner", + "archive_community_owned_by", + "unarchive_community_owned_by", + "community_of_channel", + "communities_of_channels", + ]; + for operation in operations { + let method = format!("pub async fn {operation}("); + assert_eq!( + community_source.matches(&method).count(), + 1, + "{operation} implementation must live exactly once in community.rs", + ); + assert!( + !lib_source.contains(&method), + "{operation} implementation must not remain in lib.rs", + ); + + let span = format!("name = \"{operation}\""); + assert_eq!( + community_source.matches(&span).count(), + 1, + "{operation} must have exactly one datastore span", + ); + assert!( + !lib_source.contains(&span), + "{operation} datastore span must not remain in lib.rs", + ); + } + + let records = [ + "CommunityRecord", + "EnsuredCommunityRecord", + "CreatedCommunityRecord", + "OwnedCommunityRecord", + "ArchivedCommunityRecord", + "UnarchivedCommunityRecord", + ]; + for record in records { + let declaration = format!("pub struct {record}"); + assert_eq!(community_source.matches(&declaration).count(), 1); + assert!(!lib_source.contains(&declaration)); + } + let result_declaration = format!("pub {} {}", "enum", "CreateCommunityWithOwnerResult"); + assert_eq!(community_source.matches(&result_declaration).count(), 1); + assert!(!lib_source.contains(&result_declaration)); + + let moved_tests = [ + "lookup_community_by_host_matches_case_insensitive_host_index", + "create_community_with_owner_is_atomic_and_create_only", + "unarchive_community_owned_by_restores_admission_idempotently", + "create_community_with_owner_enforces_per_owner_limit", + "concurrent_same_owner_create_returns_the_winning_row_to_both_callers", + "ensure_configured_community_reports_insert_winner", + "list_communities_owned_by_returns_only_owner_rows", + "communities_of_channels_present_for_existing_absent_for_missing", + ]; + for test in moved_tests { + let declaration = format!("async fn {test}"); + assert_eq!(community_source.matches(&declaration).count(), 1); + assert!(!lib_source.contains(&declaration)); + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lookup_community_by_host_matches_case_insensitive_host_index() { + let db = setup_db().await; + let id = Uuid::new_v4(); + let lower_host = format!("lookup-community-{}.example", id.simple()); + let stored_host = lower_host.to_uppercase(); + + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&stored_host) + .execute(&db.pool) + .await + .expect("insert mixed-case community host"); + + let found = db + .lookup_community_by_host(&lower_host) + .await + .expect("lookup lower-case host") + .expect("community found by lower-case host"); + assert_eq!(found.id, CommunityId::from_uuid(id)); + assert_eq!(found.host, stored_host); + + let found = db + .lookup_community_by_host(&stored_host) + .await + .expect("lookup stored-case host") + .expect("community found by stored-case host"); + assert_eq!(found.id, CommunityId::from_uuid(id)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_is_atomic_and_create_only() { + let db = setup_db().await; + let host = format!("create-only-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let created = db + .create_community_with_owner(&host, owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + assert_eq!(created.host, host); + let owner_role: Option = sqlx::query_scalar( + "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", + ) + .bind(created.id.as_uuid()) + .bind(owner) + .fetch_optional(&db.pool) + .await + .expect("owner role"); + assert_eq!(owner_role.as_deref(), Some("owner")); + + let retry = db + .create_community_with_owner(&host.to_ascii_uppercase(), owner) + .await + .expect("same-owner retry"); + assert_eq!( + retry, + CreateCommunityWithOwnerResult::Created(created.clone()), + "retry returns the original row" + ); + + let collision = db + .create_community_with_owner(&host, other) + .await + .expect("collision result"); + assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); + let roles: Vec<(String, String)> = sqlx::query_as( + "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", + ) + .bind(created.id.as_uuid()) + .fetch_all(&db.pool) + .await + .expect("community roles"); + assert_eq!(roles, vec![(owner.to_string(), "owner".to_string())]); + + db.bootstrap_owner(created.id, other) + .await + .expect("rotate owner"); + let post_rotation_retry = db + .create_community_with_owner(&host, owner) + .await + .expect("post-rotation retry"); + assert_eq!( + post_rotation_retry, + CreateCommunityWithOwnerResult::HostExists + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn unarchive_community_owned_by_restores_admission_idempotently() { + let db = setup_db().await; + let host = format!("unarchive-{}.example", Uuid::new_v4().simple()); + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let created = db + .create_community_with_owner(&host, &owner) + .await + .expect("create community"); + let CreateCommunityWithOwnerResult::Created(created) = created else { + panic!("expected new community"); + }; + + let archived = db + .archive_community_owned_by(&host, &owner, "protected.example") + .await + .expect("archive community") + .expect("owned community"); + assert_eq!(archived.id, created.id); + assert!( + db.lookup_community_by_host(&host) + .await + .expect("active lookup") + .is_none(), + "archived communities must fail admission" + ); + assert!(db + .unarchive_community_owned_by(&host, &outsider) + .await + .expect("wrong-owner unarchive") + .is_none()); + assert!(db + .unarchive_community_owned_by("missing.example", &owner) + .await + .expect("unknown-host unarchive") + .is_none()); + + let restored = db + .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) + .await + .expect("unarchive community") + .expect("owned community"); + assert_eq!(restored.id, created.id); + assert_eq!(restored.host, host); + assert_eq!( + db.lookup_community_by_host(&host) + .await + .expect("restored lookup") + .expect("active community") + .id, + created.id + ); + assert_eq!( + db.get_relay_member(created.id, &owner) + .await + .expect("owner lookup") + .expect("owner remains") + .role, + "owner" + ); + + let retry = db + .unarchive_community_owned_by(&host, &owner) + .await + .expect("idempotent retry") + .expect("owned community"); + assert_eq!(retry, restored); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_community_with_owner_enforces_per_owner_limit() { + let db = setup_db().await; + let owner = format!("{:064x}", Uuid::new_v4().as_u128()); + + // Create 3 communities for this owner (the max). + for i in 0..3 { + let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); + assert!(matches!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community"), + CreateCommunityWithOwnerResult::Created(_) + )); + } + + let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + assert_eq!( + db.create_community_with_owner(&host, &owner) + .await + .expect("create community call"), + CreateCommunityWithOwnerResult::LimitReached + ); + assert!( + db.lookup_community_by_host(&host) + .await + .expect("look up rolled-back fresh host") + .is_none(), + "limit rejection must roll back the fresh community row" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { + let db = setup_db().await; + let host = format!("concurrent-create-{}.example", Uuid::new_v4().simple()); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + let (first, second) = tokio::join!( + db.create_community_with_owner(&host, owner), + db.create_community_with_owner(&host, owner), + ); + let first = first.expect("first concurrent create"); + let second = second.expect("second concurrent create"); + + assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); + assert_eq!(first, second, "conflict loser re-reads the winning row"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_configured_community_reports_insert_winner() { + let db = setup_db().await; + let host = format!("ensure-community-{}.example", Uuid::new_v4().simple()); + + let first = db + .ensure_configured_community(&host) + .await + .expect("first ensure"); + assert!(first.created, "first ensure should report created"); + assert_eq!(first.host, host); + + let second = db + .ensure_configured_community(&host) + .await + .expect("second ensure"); + assert!(!second.created, "second ensure should report existed"); + assert_eq!(second.id, first.id); + assert_eq!(second.host, host); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_communities_owned_by_returns_only_owner_rows() { + let db = setup_db().await; + let community_a = CommunityId::from_uuid(make_community(&db.pool).await); + let community_b = CommunityId::from_uuid(make_community(&db.pool).await); + let community_c = CommunityId::from_uuid(make_community(&db.pool).await); + // Unique per run: `list_communities_owned_by` is keyed only by pubkey, + // so a shared fixed pubkey picks up communities leaked by sibling + // ignored tests running against the same database. + let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let owner = owner.as_str(); + let other = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let other = other.as_str(); + + db.bootstrap_owner(community_a, owner) + .await + .expect("owner A"); + db.bootstrap_owner(community_b, other) + .await + .expect("other owner B"); + db.add_relay_member(community_c, owner, "admin", None) + .await + .expect("admin C"); + + let owned = db + .list_communities_owned_by(owner) + .await + .expect("list owned communities"); + + assert_eq!(owned.len(), 1); + assert_eq!(owned[0].id, community_a); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn communities_of_channels_present_for_existing_absent_for_missing() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let existing = Uuid::new_v4(); + insert_channel(&db.pool, community, existing).await; + + // Channel that is NOT inserted — the load-bearing case. + let missing = Uuid::new_v4(); + + let result = db + .communities_of_channels(&[existing, missing]) + .await + .expect("communities_of_channels"); + + // (1) Existing channel → present with its true community. + assert_eq!( + result.get(&existing).copied(), + Some(CommunityId::from_uuid(community)), + "existing channel must map to its true community", + ); + + // (2) Missing channel → ABSENT from the map (never defaulted). + // This is the contract the relay-side `MissingLookup → ImplBug` + // fail-closed guard-rail depends on. If this assertion ever + // weakens to `result.get(&missing) != Some(community)`, the + // mutate-bite below stops biting. + assert!( + !result.contains_key(&missing), + "missing channel must be absent from the result map, got {:?}", + result.get(&missing), + ); + + // (3) Map size matches: exactly one entry, the existing one. + assert_eq!( + result.len(), + 1, + "result map must contain only existing channels" + ); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index cbce6d3753..a5c03a256e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -32,6 +32,8 @@ pub mod api_token; pub mod archived_identities; /// Channel and membership persistence. pub mod channel; +/// Community lifecycle and host-map persistence. +pub mod community; /// Durable whole-community deletion lifecycle and PostgreSQL adapter. pub mod deletion; /// Direct message channel persistence. @@ -73,6 +75,11 @@ pub mod user; /// Workflow, run, and approval persistence. pub mod workflow; +pub use community::{ + ArchivedCommunityRecord, CommunityRecord, CreateCommunityWithOwnerResult, + CreatedCommunityRecord, EnsuredCommunityRecord, OwnedCommunityRecord, + UnarchivedCommunityRecord, +}; pub use error::{DbError, Result}; pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; @@ -566,79 +573,6 @@ impl Default for DbConfig { } } -/// Community host-map row returned by [`Db::lookup_community_by_host`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host that maps to this community. - pub host: String, -} - -/// Community row returned by idempotent community ensure/create operations. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EnsuredCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host that maps to this community. - pub host: String, - /// True only when this call inserted the `communities` row. - pub created: bool, -} - -/// Community row returned by an atomic create-with-owner operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CreatedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host stored for the community. - pub host: String, -} - -/// Result of atomically creating a community with its initial owner. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum CreateCommunityWithOwnerResult { - /// The community was created, or an identical retried create found it. - Created(CreatedCommunityRecord), - /// The host already belongs to another owner. - HostExists, - /// The intended owner already owns the maximum number of communities. - LimitReached, -} - -/// Community row returned by operator-plane ownership reads. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OwnedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Normalized host that maps to this community. - pub host: String, - /// When the community row was created. - pub created_at: DateTime, - /// When the community was archived; absent while active. - pub archived_at: Option>, -} - -/// Community row returned by an owner-authorized archive operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ArchivedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Reserved canonical host. - pub host: String, - /// Durable first-archive timestamp. - pub archived_at: DateTime, -} - -/// Community row returned by an owner-authorized unarchive operation. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UnarchivedCommunityRecord { - /// Stable server-resolved community id. - pub id: CommunityId, - /// Reserved canonical host restored to active admission. - pub host: String, -} - /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -1246,465 +1180,6 @@ impl Db { self.pool.begin().await.map_err(Into::into) } - /// Returns the community mapped to a normalized request host, if one exists. - /// - /// The caller owns host normalization and turns `None` into the fail-closed - /// request/connection error. buzz-db only reads the durable host map. - #[datastore_span(name = "lookup_community_by_host", system = "postgresql")] - pub async fn lookup_community_by_host( - &self, - normalized_host: &str, - ) -> Result> { - let row = sqlx::query( - r#" - SELECT id, host - FROM communities - WHERE lower(host) = lower($1) - AND archived_at IS NULL - AND deleted_at IS NULL - AND deletion_state = 'active' - "#, - ) - .bind(normalized_host) - .fetch_optional(&self.pool) - .await?; - - row.map(|row| { - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - - Ok(CommunityRecord { - id: CommunityId::from_uuid(id), - host, - }) - }) - .transpose() - } - - /// Returns whether a community id still exists in the active lifecycle state. - #[datastore_span(name = "is_community_active", system = "postgresql")] - pub async fn is_community_active(&self, community_id: CommunityId) -> Result { - let active = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1 AND archived_at IS NULL AND deleted_at IS NULL AND deletion_state = 'active')", - ) - .bind(community_id.as_uuid()) - .fetch_one(&self.pool) - .await?; - Ok(active) - } - - /// Returns a community by host regardless of lifecycle state. Operator-plane only. - #[datastore_span( - name = "lookup_community_by_host_for_management", - system = "postgresql" - )] - pub async fn lookup_community_by_host_for_management( - &self, - normalized_host: &str, - ) -> Result> { - let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") - .bind(normalized_host) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(CommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - }) - }) - .transpose() - } - - /// Lists communities where `owner_pubkey` currently holds the `owner` role. - /// - /// This is an operator-plane helper, not a tenant-scoped data-plane read: - /// callers must gate it on deployment-level operator auth before exposing it. - #[datastore_span(name = "list_communities_owned_by", system = "postgresql")] - pub async fn list_communities_owned_by( - &self, - owner_pubkey: &str, - ) -> Result> { - let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let rows = sqlx::query( - r#" - SELECT c.id, c.host, c.created_at, c.archived_at - FROM communities c - JOIN relay_members rm ON rm.community_id = c.id - WHERE rm.pubkey = $1 - AND rm.role = 'owner' - ORDER BY c.created_at ASC, c.host ASC - "#, - ) - .bind(owner_pubkey) - .fetch_all(&self.pool) - .await?; - - rows.into_iter() - .map(|row| { - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - let created_at: DateTime = row.try_get("created_at")?; - let archived_at: Option> = row.try_get("archived_at")?; - Ok(OwnedCommunityRecord { - id: CommunityId::from_uuid(id), - host, - created_at, - archived_at, - }) - }) - .collect() - } - - /// Returns the normalized host mapped to a community id, if the community - /// exists. - /// - /// The reverse of [`lookup_community_by_host`]: used by side-effect - /// producers that already hold a server-resolved `CommunityId` (e.g. the - /// workflow action sink running a run owned by some community) and need a - /// fully-formed [`buzz_core::tenant::TenantContext`] — host included — to - /// fan out under *that* community rather than the deployment default. The - /// community is authoritative; the host is read back for labelling only and - /// is never used to re-derive the community. - #[datastore_span(name = "lookup_community_host", system = "postgresql")] - pub async fn lookup_community_host(&self, community_id: CommunityId) -> Result> { - let row = sqlx::query( - r#" - SELECT host - FROM communities - WHERE id = $1 - AND archived_at IS NULL - AND deleted_at IS NULL - AND deletion_state = 'active' - "#, - ) - .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) - .await?; - - row.map(|row| { - let host: String = row.try_get("host")?; - Ok(host) - }) - .transpose() - } - - /// Returns the community's workspace icon (NIP-11 `icon`), if set. - /// - /// Set by relay admins/owners via the kind:9033 command; the value is - /// validated and size-capped at that write path. - #[datastore_span(name = "get_community_icon", system = "postgresql")] - pub async fn get_community_icon(&self, community_id: CommunityId) -> Result> { - let row = sqlx::query( - r#" - SELECT icon - FROM communities - WHERE id = $1 - "#, - ) - .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) - .await?; - - Ok(row - .map(|row| row.try_get::, _>("icon")) - .transpose()? - .flatten() - .filter(|icon| !icon.is_empty())) - } - - /// Sets or clears (`None`) the community's workspace icon. - #[datastore_span(name = "set_community_icon", system = "postgresql")] - pub async fn set_community_icon( - &self, - community_id: CommunityId, - icon: Option<&str>, - ) -> Result<()> { - sqlx::query( - r#" - UPDATE communities - SET icon = $2 - WHERE id = $1 - "#, - ) - .bind(community_id.as_uuid()) - .bind(icon) - .execute(&self.pool) - .await?; - Ok(()) - } - - /// Ensure a configured community host exists and return its row. - /// - /// This is the startup/config seeding path for N=1 deployments. Migrations - /// create the schema only; deployment-specific hosts are not hardcoded into - /// schema history. - #[datastore_span(name = "ensure_configured_community", system = "postgresql")] - pub async fn ensure_configured_community( - &self, - normalized_host: &str, - ) -> Result { - let row = sqlx::query( - r#" - INSERT INTO communities (host) - VALUES ($1) - ON CONFLICT (lower(host)) DO UPDATE SET host = communities.host - WHERE communities.deletion_state = 'active' - AND communities.deleted_at IS NULL - RETURNING id, host, (xmax = 0) AS created - "#, - ) - .bind(normalized_host) - .fetch_optional(&self.pool) - .await? - .ok_or_else(|| { - DbError::AccessDenied(format!( - "community host {normalized_host:?} is permanently tombstoned" - )) - })?; - - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - let created: bool = row.try_get("created")?; - - Ok(EnsuredCommunityRecord { - id: CommunityId::from_uuid(id), - host, - created, - }) - } - - /// Atomically creates a community and its initial owner. - /// - /// Holds a per-owner advisory lock while enforcing the ownership limit. - /// Identical create retries return the original record; host collisions and - /// limit failures remain distinguishable to the operator API. - #[datastore_span(name = "create_community_with_owner", system = "postgresql")] - pub async fn create_community_with_owner( - &self, - normalized_host: &str, - owner_pubkey: &str, - ) -> Result { - let owner_pubkey = owner_pubkey.to_ascii_lowercase(); - let mut tx = self.pool.begin().await?; - - // Serialize on the owner pubkey so concurrent creates to the same - // owner cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) - .execute(&mut *tx) - .await?; - - let row = sqlx::query( - r#" - INSERT INTO communities (host) - VALUES ($1) - ON CONFLICT (lower(host)) DO NOTHING - RETURNING id, host - "#, - ) - .bind(normalized_host) - .fetch_optional(&mut *tx) - .await?; - - let (id, host) = if let Some(row) = row { - let id: Uuid = row.try_get("id")?; - let host: String = row.try_get("host")?; - - // Enforce the limit before inserting the new owner row. - let owned_count: i64 = sqlx::query_scalar( - "SELECT count(*) FROM relay_members WHERE pubkey = $1 AND role = 'owner'", - ) - .bind(&owner_pubkey) - .fetch_one(&mut *tx) - .await?; - - if owned_count >= relay_members::max_communities_per_owner() { - tx.rollback().await?; - return Ok(CreateCommunityWithOwnerResult::LimitReached); - } - - sqlx::query( - "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", - ) - .bind(id) - .bind(&owner_pubkey) - .execute(&mut *tx) - .await?; - (id, host) - } else { - let existing = sqlx::query( - r#" - SELECT c.id, c.host - FROM communities c - JOIN relay_members rm ON rm.community_id = c.id - WHERE lower(c.host) = lower($1) - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND c.archived_at IS NULL - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - "#, - ) - .bind(normalized_host) - .bind(&owner_pubkey) - .fetch_optional(&mut *tx) - .await?; - let Some(existing) = existing else { - tx.rollback().await?; - return Ok(CreateCommunityWithOwnerResult::HostExists); - }; - (existing.try_get("id")?, existing.try_get("host")?) - }; - - tx.commit().await?; - Ok(CreateCommunityWithOwnerResult::Created( - CreatedCommunityRecord { - id: CommunityId::from_uuid(id), - host, - }, - )) - } - - /// Idempotently archives a community when the asserted pubkey is its current owner. - #[datastore_span(name = "archive_community_owned_by", system = "postgresql")] - pub async fn archive_community_owned_by( - &self, - normalized_host: &str, - owner_pubkey: &str, - protected_deployment_host: &str, - ) -> Result> { - let row = sqlx::query( - r#"UPDATE communities c - SET archived_at = COALESCE(c.archived_at, now()) - FROM relay_members rm - WHERE lower(c.host) = lower($1) - AND rm.community_id = c.id - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND lower(c.host) <> lower($3) - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - RETURNING c.id, c.host, c.archived_at"#, - ) - .bind(normalized_host) - .bind(owner_pubkey) - .bind(protected_deployment_host) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(ArchivedCommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - archived_at: row.try_get("archived_at")?, - }) - }) - .transpose() - } - - /// Idempotently restores a community when the asserted pubkey is its current owner. - #[datastore_span(name = "unarchive_community_owned_by", system = "postgresql")] - pub async fn unarchive_community_owned_by( - &self, - normalized_host: &str, - owner_pubkey: &str, - ) -> Result> { - let row = sqlx::query( - r#"UPDATE communities c - SET archived_at = NULL - FROM relay_members rm - WHERE lower(c.host) = lower($1) - AND rm.community_id = c.id - AND lower(rm.pubkey) = lower($2) - AND rm.role = 'owner' - AND c.deletion_state = 'active' - AND c.deleted_at IS NULL - RETURNING c.id, c.host"#, - ) - .bind(normalized_host) - .bind(owner_pubkey) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - Ok(UnarchivedCommunityRecord { - id: CommunityId::from_uuid(row.try_get("id")?), - host: row.try_get("host")?, - }) - }) - .transpose() - } - - /// Returns the community that owns a channel, if the channel exists. - /// - /// Internal relay producers use this to derive tenant context from the row - /// they are acting on, rather than falling back to an implicit default. - #[datastore_span(name = "community_of_channel", system = "postgresql")] - pub async fn community_of_channel(&self, channel_id: Uuid) -> Result> { - let row = sqlx::query( - r#" - SELECT community_id - FROM channels - WHERE id = $1 - AND deleted_at IS NULL - "#, - ) - .bind(channel_id) - .fetch_optional(&self.pool) - .await?; - - row.map(|row| { - let id: Uuid = row.try_get("community_id")?; - Ok(CommunityId::from_uuid(id)) - }) - .transpose() - } - - /// Batched version of [`Self::community_of_channel`]: given a list of - /// channel UUIDs, returns a map from channel id → owning community - /// for every channel that exists (soft-deletes excluded). - /// - /// Used by the runtime conformance read-seam emitters in `buzz-relay`: - /// after a `query_events`/`get_events_by_ids` returns N rows, the - /// emitter collects distinct `channel_id`s, calls this once, then - /// projects each row's true community label independently of the - /// fetch query's WHERE clause. That independence is what makes the - /// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous — - /// a mutation that dropped `community_id = $X` from the fetch query - /// would still let this helper return the row's true label, and the - /// checker would see the mismatch. - /// - /// Channels missing from the result map (deleted or never existed) - /// are intentionally not present rather than mapped to a default — - /// callers MUST treat "channel-id not in map" as a coverage breach, - /// never as "use the resolved community". - #[datastore_span(name = "communities_of_channels", system = "postgresql")] - pub async fn communities_of_channels( - &self, - channel_ids: &[Uuid], - ) -> Result> { - if channel_ids.is_empty() { - return Ok(std::collections::HashMap::new()); - } - let rows = sqlx::query( - r#" - SELECT id, community_id - FROM channels - WHERE id = ANY($1) - AND deleted_at IS NULL - "#, - ) - .bind(channel_ids) - .fetch_all(&self.pool) - .await?; - - let mut out = std::collections::HashMap::with_capacity(rows.len()); - for row in rows { - let ch: Uuid = row.try_get("id")?; - let cm: Uuid = row.try_get("community_id")?; - out.insert(ch, CommunityId::from_uuid(cm)); - } - Ok(out) - } - /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. #[datastore_span(name = "insert_event", system = "postgresql")] pub async fn insert_event( @@ -5239,13 +4714,6 @@ fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { #[cfg(test)] mod tests { - //! Pin the load-bearing contract for `Db::communities_of_channels`: - //! a channel id that does NOT exist MUST be absent from the result - //! map, never mapped to a default. The relay-side read-row emitter - //! relies on this — a missing entry triggers `MissingLookup → - //! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this - //! helper ever started returning a default/zero entry for unknown - //! channels, that fail-closed chain would go blind. use super::*; use buzz_core::CommunityId; use sqlx::postgres::PgPoolOptions; @@ -6962,297 +6430,6 @@ mod tests { drop_scratch_db(&admin, pool, &scratch_name).await; } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn lookup_community_by_host_matches_case_insensitive_host_index() { - let db = setup_db().await; - let id = Uuid::new_v4(); - let lower_host = format!("lookup-community-{}.example", id.simple()); - let stored_host = lower_host.to_uppercase(); - - sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") - .bind(id) - .bind(&stored_host) - .execute(&db.pool) - .await - .expect("insert mixed-case community host"); - - let found = db - .lookup_community_by_host(&lower_host) - .await - .expect("lookup lower-case host") - .expect("community found by lower-case host"); - assert_eq!(found.id, CommunityId::from_uuid(id)); - assert_eq!(found.host, stored_host); - - let found = db - .lookup_community_by_host(&stored_host) - .await - .expect("lookup stored-case host") - .expect("community found by stored-case host"); - assert_eq!(found.id, CommunityId::from_uuid(id)); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn create_community_with_owner_is_atomic_and_create_only() { - let db = setup_db().await; - let host = format!("create-only-{}.example", Uuid::new_v4().simple()); - let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - - let created = db - .create_community_with_owner(&host, owner) - .await - .expect("create community"); - let CreateCommunityWithOwnerResult::Created(created) = created else { - panic!("expected new community"); - }; - assert_eq!(created.host, host); - let owner_role: Option = sqlx::query_scalar( - "SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2", - ) - .bind(created.id.as_uuid()) - .bind(owner) - .fetch_optional(&db.pool) - .await - .expect("owner role"); - assert_eq!(owner_role.as_deref(), Some("owner")); - - let retry = db - .create_community_with_owner(&host.to_ascii_uppercase(), owner) - .await - .expect("same-owner retry"); - assert_eq!( - retry, - CreateCommunityWithOwnerResult::Created(created.clone()), - "retry returns the original row" - ); - - let collision = db - .create_community_with_owner(&host, other) - .await - .expect("collision result"); - assert_eq!(collision, CreateCommunityWithOwnerResult::HostExists); - let roles: Vec<(String, String)> = sqlx::query_as( - "SELECT pubkey, role FROM relay_members WHERE community_id = $1 ORDER BY pubkey", - ) - .bind(created.id.as_uuid()) - .fetch_all(&db.pool) - .await - .expect("community roles"); - assert_eq!(roles, vec![(owner.to_string(), "owner".to_string())]); - - db.bootstrap_owner(created.id, other) - .await - .expect("rotate owner"); - let post_rotation_retry = db - .create_community_with_owner(&host, owner) - .await - .expect("post-rotation retry"); - assert_eq!( - post_rotation_retry, - CreateCommunityWithOwnerResult::HostExists - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn unarchive_community_owned_by_restores_admission_idempotently() { - let db = setup_db().await; - let host = format!("unarchive-{}.example", Uuid::new_v4().simple()); - let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let outsider = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let created = db - .create_community_with_owner(&host, &owner) - .await - .expect("create community"); - let CreateCommunityWithOwnerResult::Created(created) = created else { - panic!("expected new community"); - }; - - let archived = db - .archive_community_owned_by(&host, &owner, "protected.example") - .await - .expect("archive community") - .expect("owned community"); - assert_eq!(archived.id, created.id); - assert!( - db.lookup_community_by_host(&host) - .await - .expect("active lookup") - .is_none(), - "archived communities must fail admission" - ); - assert!(db - .unarchive_community_owned_by(&host, &outsider) - .await - .expect("wrong-owner unarchive") - .is_none()); - assert!(db - .unarchive_community_owned_by("missing.example", &owner) - .await - .expect("unknown-host unarchive") - .is_none()); - - let restored = db - .unarchive_community_owned_by(&host.to_ascii_uppercase(), &owner) - .await - .expect("unarchive community") - .expect("owned community"); - assert_eq!(restored.id, created.id); - assert_eq!(restored.host, host); - assert_eq!( - db.lookup_community_by_host(&host) - .await - .expect("restored lookup") - .expect("active community") - .id, - created.id - ); - assert_eq!( - db.get_relay_member(created.id, &owner) - .await - .expect("owner lookup") - .expect("owner remains") - .role, - "owner" - ); - - let retry = db - .unarchive_community_owned_by(&host, &owner) - .await - .expect("idempotent retry") - .expect("owned community"); - assert_eq!(retry, restored); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn create_community_with_owner_enforces_per_owner_limit() { - let db = setup_db().await; - let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - - // Create 3 communities for this owner (the max). - for i in 0..3 { - let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); - assert!(matches!( - db.create_community_with_owner(&host, &owner) - .await - .expect("create community"), - CreateCommunityWithOwnerResult::Created(_) - )); - } - - let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); - assert_eq!( - db.create_community_with_owner(&host, &owner) - .await - .expect("create community call"), - CreateCommunityWithOwnerResult::LimitReached - ); - assert!( - db.lookup_community_by_host(&host) - .await - .expect("look up rolled-back fresh host") - .is_none(), - "limit rejection must roll back the fresh community row" - ); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn concurrent_same_owner_create_returns_the_winning_row_to_both_callers() { - let db = setup_db().await; - let host = format!("concurrent-create-{}.example", Uuid::new_v4().simple()); - let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - - let (first, second) = tokio::join!( - db.create_community_with_owner(&host, owner), - db.create_community_with_owner(&host, owner), - ); - let first = first.expect("first concurrent create"); - let second = second.expect("second concurrent create"); - - assert!(matches!(first, CreateCommunityWithOwnerResult::Created(_))); - assert_eq!(first, second, "conflict loser re-reads the winning row"); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn ensure_configured_community_reports_insert_winner() { - let db = setup_db().await; - let host = format!("ensure-community-{}.example", Uuid::new_v4().simple()); - - let first = db - .ensure_configured_community(&host) - .await - .expect("first ensure"); - assert!(first.created, "first ensure should report created"); - assert_eq!(first.host, host); - - let second = db - .ensure_configured_community(&host) - .await - .expect("second ensure"); - assert!(!second.created, "second ensure should report existed"); - assert_eq!(second.id, first.id); - assert_eq!(second.host, host); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn list_communities_owned_by_returns_only_owner_rows() { - let db = setup_db().await; - let community_a = CommunityId::from_uuid(make_community(&db.pool).await); - let community_b = CommunityId::from_uuid(make_community(&db.pool).await); - let community_c = CommunityId::from_uuid(make_community(&db.pool).await); - // Unique per run: `list_communities_owned_by` is keyed only by pubkey, - // so a shared fixed pubkey picks up communities leaked by sibling - // ignored tests running against the same database. - let owner = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let owner = owner.as_str(); - let other = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); - let other = other.as_str(); - - db.bootstrap_owner(community_a, owner) - .await - .expect("owner A"); - db.bootstrap_owner(community_b, other) - .await - .expect("other owner B"); - db.add_relay_member(community_c, owner, "admin", None) - .await - .expect("admin C"); - - let owned = db - .list_communities_owned_by(owner) - .await - .expect("list owned communities"); - - assert_eq!(owned.len(), 1); - assert_eq!(owned[0].id, community_a); - } - - async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { - let creator: Vec = vec![0u8; 32]; - sqlx::query( - r#" - INSERT INTO channels - (id, community_id, name, channel_type, visibility, created_by) - VALUES - ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4) - "#, - ) - .bind(channel_id) - .bind(community_id) - .bind(format!("ch-{}", channel_id.simple())) - .bind(&creator) - .execute(pool) - .await - .expect("insert channel"); - } - #[tokio::test] #[ignore = "requires Postgres"] async fn allowlist_is_scoped_to_community() { @@ -7319,48 +6496,6 @@ mod tests { .expect("A not allowed after remove")); } - #[tokio::test] - #[ignore = "requires Postgres"] - async fn communities_of_channels_present_for_existing_absent_for_missing() { - let db = setup_db().await; - let community = make_community(&db.pool).await; - let existing = Uuid::new_v4(); - insert_channel(&db.pool, community, existing).await; - - // Channel that is NOT inserted — the load-bearing case. - let missing = Uuid::new_v4(); - - let result = db - .communities_of_channels(&[existing, missing]) - .await - .expect("communities_of_channels"); - - // (1) Existing channel → present with its true community. - assert_eq!( - result.get(&existing).copied(), - Some(CommunityId::from_uuid(community)), - "existing channel must map to its true community", - ); - - // (2) Missing channel → ABSENT from the map (never defaulted). - // This is the contract the relay-side `MissingLookup → ImplBug` - // fail-closed guard-rail depends on. If this assertion ever - // weakens to `result.get(&missing) != Some(community)`, the - // mutate-bite below stops biting. - assert!( - !result.contains_key(&missing), - "missing channel must be absent from the result map, got {:?}", - result.get(&missing), - ); - - // (3) Map size matches: exactly one entry, the existing one. - assert_eq!( - result.len(), - 1, - "result map must contain only existing channels" - ); - } - /// BUG-5 regression: the `reactions` table is community-scoped /// (`PK (community_id, event_created_at, event_id, pubkey, emoji)`), so a /// reaction added under community A must be invisible and unremovable from From 931747c9c42df14d5c23c87fe57e30b995321ae3 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 25 Aug 2026 11:23:17 -0400 Subject: [PATCH 05/24] Add staging dev relay image workflow (#6709) ## Summary Adds a manual, collaborator-triggered workflow for publishing pre-merge Buzz relay runtime images for bb-block staging. - Resolves a canonical `block/buzz` branch or tag to an immutable commit SHA before checkout. - Builds the relay runtime image for `linux/amd64` and `linux/arm64` and publishes a single OCI index. - Tags each publication uniquely as `dev-sha--run--` so no tag is ever reused against the immutable-tag utility ECR pull-through cache (rebuilds of the same SHA aren't byte-identical, given mutable base tags and `apt-get update`). - Uses the staging-only `ghcr.io/block/buzz-staging-dev` namespace, which maps to a distinct utility ECR pull-through path. - Restricts dispatch to `block/buzz` on `refs/heads/main`; job permissions are narrowly scoped to `contents: read` plus `packages: write`. - Emits a deployment summary with the exact BPCI `repository` and unique `tag` values, plus the merged manifest digest. - Keeps the existing production/release image workflow unchanged. The first real workflow run must confirm GHCR package creation/access and utility ECR pull-through import for the new package. --------- Signed-off-by: Brad Seiler Signed-off-by: Will Pfleger Signed-off-by: tornquist Co-authored-by: coder 0 Co-authored-by: Duncan Co-authored-by: Will Pfleger Co-authored-by: tornquist --- .github/workflows/staging-dev-relay-image.yml | 276 ++++++++++++++++++ docs/staging-dev-relay-images.md | 26 ++ 2 files changed, 302 insertions(+) create mode 100644 .github/workflows/staging-dev-relay-image.yml create mode 100644 docs/staging-dev-relay-images.md diff --git a/.github/workflows/staging-dev-relay-image.yml b/.github/workflows/staging-dev-relay-image.yml new file mode 100644 index 0000000000..c8a746a0b9 --- /dev/null +++ b/.github/workflows/staging-dev-relay-image.yml @@ -0,0 +1,276 @@ +name: Staging dev relay image + +# Publishes pre-merge relay runtime images for bb-block staging only. +# +# Operators run this workflow from the default branch and provide a target ref in +# this repository. GitHub's workflow_dispatch permission model limits triggering +# to collaborators who can run repository workflows; no actor allowlist is kept +# here. The target ref is resolved to an immutable commit SHA before checkout, +# and the published tag is derived from that full SHA. + +on: + workflow_dispatch: + inputs: + target_ref: + description: "Branch, tag, refs/heads/*, or refs/tags/* in block/buzz to publish for bb-block staging" + required: true + type: string + +permissions: {} + +env: + IMAGE_NAME: ghcr.io/block/buzz-staging-dev + ECR_REPOSITORY: 929862310821.dkr.ecr.us-west-2.amazonaws.com/ghcr.io/block/buzz-staging-dev + +jobs: + resolve: + name: Resolve target ref + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + target_sha: ${{ steps.resolve.outputs.target_sha }} + image_tag: ${{ steps.resolve.outputs.image_tag }} + steps: + - name: Require reviewed workflow from main in canonical repository + env: + DISPATCH_REF: ${{ github.ref }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + if [ "$REPOSITORY" != "block/buzz" ]; then + echo "::error::Staging dev relay image publication is restricted to block/buzz" + exit 1 + fi + + if [ "$DISPATCH_REF" != "refs/heads/main" ]; then + echo "::error::Dispatch this workflow from main, not $DISPATCH_REF" + exit 1 + fi + + - name: Resolve target ref in this repository + id: resolve + env: + TARGET_REF_INPUT: ${{ inputs.target_ref }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + + target_ref=$(printf '%s' "$TARGET_REF_INPUT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') + + if [ -z "$target_ref" ]; then + echo "target_ref must not be empty" >&2 + exit 1 + fi + + if [ ${#target_ref} -gt 255 ]; then + echo "target_ref is too long" >&2 + exit 1 + fi + + if [[ ! "$target_ref" =~ ^[A-Za-z0-9._/@-]+$ ]]; then + echo "target_ref contains unsupported characters; use a branch, tag, or full refs/heads/* or refs/tags/* name" >&2 + exit 1 + fi + + case "$target_ref" in + -*|*..*|*.lock|refs/pull/*|pull/*|*/pull/*) + echo "target_ref is not an allowed repository branch/tag/ref" >&2 + exit 1 + ;; + esac + + workdir=$(mktemp -d) + trap 'rm -rf "$workdir"' EXIT + git -C "$workdir" init --quiet + git -C "$workdir" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" + + fetch_commit() { + local ref=$1 + git -C "$workdir" fetch --no-tags --depth=1 origin "$ref" >/dev/null 2>&1 + git -C "$workdir" rev-parse --verify "FETCH_HEAD^{commit}" + } + + target_sha="" + if [[ "$target_ref" == refs/heads/* || "$target_ref" == refs/tags/* ]]; then + target_sha=$(fetch_commit "$target_ref") || { + echo "target_ref did not resolve in ${GITHUB_REPOSITORY}: $target_ref" >&2 + exit 1 + } + else + if target_sha=$(fetch_commit "refs/heads/${target_ref}"); then + : + elif target_sha=$(fetch_commit "refs/tags/${target_ref}"); then + : + else + echo "target_ref did not resolve as a branch or tag in ${GITHUB_REPOSITORY}: $target_ref" >&2 + exit 1 + fi + fi + + target_sha=$(printf '%s' "$target_sha" | tr '[:upper:]' '[:lower:]') + if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "resolved target SHA is invalid: $target_sha" >&2 + exit 1 + fi + + image_tag="dev-sha-${target_sha}-run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + { + echo "target_sha=${target_sha}" + echo "image_tag=${image_tag}" + } >> "$GITHUB_OUTPUT" + + printf "Resolved \`%s\` to \`%s\`; image tag \`%s\`.\n" "$target_ref" "$target_sha" "$image_tag" >> "$GITHUB_STEP_SUMMARY" + + build: + name: Build staging relay runtime (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + needs: resolve + timeout-minutes: 60 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + steps: + - name: Checkout resolved target + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve.outputs.target_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.title=Buzz staging dev relay + org.opencontainers.image.description=Pre-merge Buzz relay runtime image for bb-block staging only + org.opencontainers.image.licenses=Apache-2.0 + org.opencontainers.image.revision=${{ needs.resolve.outputs.target_sha }} + + - name: Build and push runtime image by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile + target: runtime + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + cache-to: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }},mode=max,compression=zstd + + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + mkdir -p /tmp/digests-release + touch "/tmp/digests-release/${DIGEST#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: staging-dev-relay-digests-${{ matrix.arch }} + path: /tmp/digests-release/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Publish staging relay runtime manifest + runs-on: ubuntu-24.04 + needs: + - resolve + - build + timeout-minutes: 15 + permissions: + contents: read + packages: write + steps: + - name: Download per-arch digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: staging-dev-relay-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + id: manifest + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + IMAGE_TAG: ${{ needs.resolve.outputs.image_tag }} + run: | + set -euo pipefail + digests=() + for digest in *; do + digests+=("${IMAGE_NAME}@sha256:${digest}") + done + + docker buildx imagetools create -t "${IMAGE_NAME}:${IMAGE_TAG}" "${digests[@]}" + merged_digest=$(docker buildx imagetools inspect "${IMAGE_NAME}:${IMAGE_TAG}" \ + --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT" + + - name: Deployment summary + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }} + IMAGE_TAG: ${{ needs.resolve.outputs.image_tag }} + TARGET_SHA: ${{ needs.resolve.outputs.target_sha }} + MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + { + echo "### Published bb-block staging dev relay image" + echo + echo "**Source commit:** \`${TARGET_SHA}\`" + echo "**GHCR image:** \`${IMAGE_NAME}:${IMAGE_TAG}\`" + echo "**Manifest digest:** \`${MERGED_DIGEST}\`" + echo + echo "Set bb-block staging BPCI values to:" + echo '```yaml' + echo "buzz:" + echo " image:" + echo " repository: ${ECR_REPOSITORY}" + echo " tag: ${IMAGE_TAG}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/staging-dev-relay-images.md b/docs/staging-dev-relay-images.md new file mode 100644 index 0000000000..1fdfdd7e02 --- /dev/null +++ b/docs/staging-dev-relay-images.md @@ -0,0 +1,26 @@ +# Staging dev relay images + +Use the **Staging dev relay image** GitHub Actions workflow to publish a pre-merge Buzz relay runtime image for bb-block staging. + +1. Run `.github/workflows/staging-dev-relay-image.yml` from the default branch. +2. Enter a `target_ref` from `block/buzz` (`my-branch`, `refs/heads/my-branch`, `my-tag`, or `refs/tags/my-tag`). +3. Wait for the workflow summary. It resolves that ref to a commit and publishes only the relay `runtime` image to: + + ```text + ghcr.io/block/buzz-staging-dev:dev-sha-<40-character-commit-sha>-run-- + ``` + +4. In `squareup/builderbot-platform-core-infrastructure`, set bb-block staging values to the distinct pull-through ECR path and immutable tag from the workflow summary: + + ```yaml + buzz: + image: + repository: 929862310821.dkr.ecr.us-west-2.amazonaws.com/ghcr.io/block/buzz-staging-dev + tag: dev-sha-<40-character-commit-sha>-run-- + ``` + +This path is intentionally separate from the production/main relay image path (`ghcr.io/block/buzz`) so a staging-only branch deployment is obvious in BPCI. + +These images are for manual, pre-merge staging evaluation only. They are not release-qualified and must not be promoted to production or used by the canonical Kargo promotion path. + +While bb-block remains a shared staging environment, do not deploy a target ref through this runtime-only path if it changes `migrations/` or requires changes to `deploy/charts/buzz/`. Backwards-compatible migrations can still leave the shared database ahead of the restored `main` image. Use an isolated environment or deployment-time enforcement for those changes. From a1219070fa6c3263c8a29637c70b7a317d4ecd9d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 11:49:07 -0400 Subject: [PATCH 06/24] feat(buzz-agent): gate LLM tool calls on session/request_permission (#5712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary buzz-agent now authorizes every LLM-issued MCP tool call through `session/request_permission` before it executes, instead of running tools unconditionally. The agent **always asks**; the client applies `BUZZ_ACP_PERMISSION_POLICY` and answers. buzz-agent never reads the policy — this keeps the policy decision on the client side, matching the layering of the other ACP harnesses, and avoids duplicating policy logic that would drift. ### Broker A crate-local `PermissionBroker` (`crates/buzz-agent/src/permission.rs`), owned by `App` for the connection lifetime, owns the full request-correlation lifecycle: - **Process-wide admission.** A global `Semaphore` (`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`, default 32, validated `>= 1`) is acquired *before* any correlation entry is inserted. This is the security bound: the per-turn tool semaphore is constructed fresh per turn and `max_sessions` is unbounded by default, so neither bounds simultaneously-outstanding asks process-wide. - **Abort-safe cleanup.** A successful admission returns a `PendingPermission` lease that owns the admission permit and the correlation id. Its `Drop` synchronously removes the still-pending entry and releases the slot, covering task abort/panic that bypasses the normal `run_prompt` tail. - **At-most-once resolution.** `deliver` claims (removes) the entry *before* waking the waiter, so each id resolves once and a later lease `Drop` is a no-op. Unknown/late ids are logged and dropped; only ids the broker minted (`perm-`) are recognized. - **Single absolute deadline.** Admission, request enqueue, and response wait all share one deadline (`BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`, default 330s, validated `>= 1`), so a saturated call cannot outlive one timeout window even when a stalled writer blocks the enqueue. Cancellation races inside every wait — resolution never depends on the outer abort drain. A writer that dies mid-connection (stdout closed, or a blocking write that only surfaces its error at flush) is connection-fatal: it cancels all sessions, which resolves any ask waiting on a reply that can never be written. ### Wire `request_permission_params` (`crates/buzz-agent/src/wire.rs`) is version-aware, keyed on the protocol version negotiated at `initialize` and stored on `App` for the connection lifetime (never derived from a later mutable session field). v2 nests the tool call under `subject: {type: "tool_call", toolCall}` with top-level `title`/`options`; v1 uses the legacy top-level `toolCall`. No hybrid shape. Both offered options (`allow_once`, `reject_once`) carry `optionId == kind`, so the client's `kind`-based selection and this side's `optionId`-based predicate agree without a lookup table. ### Gate In each spawned tool task (`crates/buzz-agent/src/agent.rs`) the sequence is: acquire per-turn permit → argument-shape validation → broker admission + request + wait → cancellation recheck → `emit_in_progress` → `mcp.call`. Argument-shape validation is hoisted out of `mcp.rs::do_call` into `validate_arg_shape` so a malformed non-object argument is rejected locally without prompting for a call that could never execute. Authorization is fail-closed, stated once in `evaluate`: execute IFF `outcome.outcome == "selected"` **and** the selected `optionId` equals the offered allow option. Every other shape (reject, cancelled, JSON-RPC error, malformed, unknown outcome, wrong/unknown option, timeout, wire-channel closure) denies with a synthetic tool error, and the turn continues. ### Scope Only LLM-issued MCP calls are gated. The built-in `load_skill` tool and `call_hooks` lifecycle calls (`_Stop`, `_PostCompact`) are exempt — they are not model-issued. `readOnlyHint` is never treated as a security boundary. First cut ships `allow_once`/`reject_once` only; session-scoped grants are deliberately out of scope. ### Related issue Part of #4938. This PR and [#5106](https://github.com/block/buzz/pull/5106) jointly implement the feature: #5106 is the client-side policy engine and permission cards; this PR is buzz-agent's asking side (`session/request_permission`). Neither closes #4938 alone. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Co-authored-by: Duncan --- crates/buzz-agent/src/agent.rs | 44 +- crates/buzz-agent/src/config.rs | 25 + crates/buzz-agent/src/lib.rs | 77 +- crates/buzz-agent/src/llm.rs | 2 + crates/buzz-agent/src/mcp.rs | 33 +- crates/buzz-agent/src/permission.rs | 1047 +++++++++++++++++ crates/buzz-agent/src/wire.rs | 327 ++++- crates/buzz-agent/tests/bin/fake_mcp.rs | 43 + crates/buzz-agent/tests/common/mod.rs | 279 +++++ crates/buzz-agent/tests/fake_llm.rs | 10 +- .../buzz-agent/tests/permission_boundary.rs | 631 ++++++++++ crates/buzz-agent/tests/regressions.rs | 275 +---- 12 files changed, 2494 insertions(+), 299 deletions(-) create mode 100644 crates/buzz-agent/src/permission.rs create mode 100644 crates/buzz-agent/tests/common/mod.rs create mode 100644 crates/buzz-agent/tests/permission_boundary.rs diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 9258ce449f..0743a75667 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -14,6 +14,7 @@ use crate::hints::SkillEntry; use crate::llm::Llm; use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; +use crate::permission::PermissionDecision; use crate::types::{ AgentError, CacheTotalState, ContentBlock, HistoryItem, PricingIdentity, ProviderStop, @@ -142,6 +143,14 @@ pub struct RunCtx<'a> { pub system_prompt: &'a str, pub llm: &'a Llm, pub mcp: &'a Arc, + /// Process-wide permission broker (owned by `App`). Every LLM-issued MCP + /// tool call asks the client to authorize it through this broker before + /// executing. Shared across all sessions so the global admission cap bounds + /// simultaneously-outstanding asks process-wide. + pub permissions: &'a Arc, + /// ACP protocol version negotiated at `initialize`, fixed for the + /// connection. Selects the `session/request_permission` wire shape. + pub protocol_version: u32, /// Skills discovered at session creation; used by the built-in `load_skill` tool. pub skills: &'a [SkillEntry], pub wire: &'a WireSender, @@ -882,8 +891,10 @@ impl RunCtx<'_> { total: MAX_TOOL_RESULT_BYTES, text: self.cfg.max_tool_result_text_bytes, }; - let cancel = self.cancel.clone(); + let mut cancel = self.cancel.clone(); let sem = Arc::clone(&sem); + let permissions = Arc::clone(self.permissions); + let protocol_version = self.protocol_version; set.spawn(async move { // Acquire a permit; if the semaphore is closed (cancel), // emit a terminal wire update and skip the call. @@ -894,6 +905,37 @@ impl RunCtx<'_> { return (i, InvokeOutcome::Failed("cancelled".into())); } }; + // Argument-shape validation BEFORE the ask: a malformed + // non-object argument can never execute, so reject it locally + // without prompting the user to approve a doomed call. + if let Err(e) = crate::mcp::validate_arg_shape(&call.name, &call.arguments) { + let msg = e.to_string(); + emit_failed(&wire, &session_id, &call, &msg).await; + return (i, InvokeOutcome::Failed(msg)); + } + // Ask the client to authorize this call. The broker owns the + // full correlation lifecycle and races cancellation internally; + // every non-authorizing outcome fails closed. + match permissions + .request_permission(&wire, protocol_version, &session_id, &call, &mut cancel) + .await + { + PermissionDecision::Allowed => {} + PermissionDecision::Denied(msg) => { + emit_failed(&wire, &session_id, &call, msg).await; + return (i, InvokeOutcome::Failed(msg.into())); + } + PermissionDecision::Cancelled => { + emit_failed(&wire, &session_id, &call, "cancelled").await; + return (i, InvokeOutcome::Failed("cancelled".into())); + } + } + // Cancellation recheck: a cancel may have landed while we + // waited for approval. Do not start the call in that case. + if *cancel.borrow() { + emit_failed(&wire, &session_id, &call, "cancelled").await; + return (i, InvokeOutcome::Failed("cancelled".into())); + } emit_in_progress(&wire, &session_id, &call).await; let outcome = invoke_tool_inner(&mcp, &call, timeout, budget, cancel).await; match &outcome { diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 67d7c593b5..abf17004e8 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -479,6 +479,18 @@ pub struct Config { /// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10. pub max_handoffs: usize, pub max_parallel_tools: usize, + /// Process-wide cap on simultaneously-outstanding `session/request_permission` + /// asks. Bounds the [`PermissionBroker`](crate::permission::PermissionBroker) + /// correlation map independently of the per-turn tool semaphore (which is + /// fresh per turn) and of `max_sessions` (unbounded by default). Default 32. + /// Set via `BUZZ_AGENT_MAX_PENDING_PERMISSIONS`; validated `>= 1`. + pub max_pending_permissions: usize, + /// Single absolute deadline for a permission ask — shared by broker + /// admission and the response wait, so a saturated call cannot live for two + /// full timeout windows. Default 330s, chosen to outlast the client's 300s + /// auto-deny so the answer (or auto-deny) lands first. Set via + /// `BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`; validated `>= 1`. + pub permission_timeout: Duration, pub hook_timeout: Duration, /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to /// disable `_Stop` hooks entirely (agent always honors end_turn). @@ -622,6 +634,11 @@ impl Config { max_context_tokens: parse_env("BUZZ_AGENT_MAX_CONTEXT_TOKENS", 200_000u64)?, max_handoffs: parse_env("BUZZ_AGENT_MAX_HANDOFFS", 10)?, max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, + max_pending_permissions: parse_env("BUZZ_AGENT_MAX_PENDING_PERMISSIONS", 32usize)?, + permission_timeout: Duration::from_secs(parse_env( + "BUZZ_AGENT_PERMISSION_TIMEOUT_SECS", + 330u64, + )?), hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, @@ -668,6 +685,8 @@ impl Config { max_context_tokens: 200_001, max_handoffs: 0, max_parallel_tools: 1, + max_pending_permissions: 32, + permission_timeout: Duration::from_secs(330), hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, require_reply: false, @@ -729,6 +748,12 @@ impl Config { if self.max_parallel_tools < 1 { return Err("config: BUZZ_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into()); } + if self.max_pending_permissions < 1 { + return Err("config: BUZZ_AGENT_MAX_PENDING_PERMISSIONS must be >= 1".into()); + } + if self.permission_timeout < MIN_TIMEOUT { + return Err("config: BUZZ_AGENT_PERMISSION_TIMEOUT_SECS must be >= 1".into()); + } if self.mcp_max_restart_attempts < 1 { return Err("config: BUZZ_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into()); } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 98fa99ca5b..208acc3469 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -9,6 +9,7 @@ mod hints; mod llm; mod mcp; pub mod model_capabilities; +mod permission; pub mod types; mod wire; @@ -32,6 +33,7 @@ pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[ use std::collections::HashMap; use std::path::Path; +use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::Arc; use serde_json::{json, Value}; @@ -54,6 +56,17 @@ struct App { cfg: Config, llm: Arc, sessions: Mutex>, + /// ACP protocol version negotiated at `initialize`, stored for the whole + /// connection lifetime. The `session/request_permission` wire shape derives + /// from this value — never from a later mutable session field — so a strict + /// client always receives exactly the shape it negotiated. Defaults to + /// [`PROTOCOL_VERSION`] before `initialize`; no prompt (and thus no + /// permission ask) can run before then. + negotiated_version: AtomicU32, + /// Owns the entire `session/request_permission` correlation lifecycle: + /// process-wide admission, id allocation, response delivery, and abort-safe + /// cleanup. See [`permission::PermissionBroker`]. + permissions: Arc, /// Cached model catalog for Databricks providers. Populated lazily on the /// first successful `session/new` discovery call. Failed discovery is never /// cached: static-token authentication errors reject session creation, while @@ -181,28 +194,53 @@ async fn async_main() { let cfg = Config::from_env().unwrap_or_else(|e| die(e)); let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string()))); let max_line = cfg.max_line_bytes; + let permissions = Arc::new(permission::PermissionBroker::new( + cfg.max_pending_permissions, + cfg.permission_timeout, + )); let app = Arc::new(App { cfg, llm, sessions: Mutex::new(HashMap::new()), + negotiated_version: AtomicU32::new(PROTOCOL_VERSION), + permissions, models_cache: tokio::sync::OnceCell::new(), }); let (wire_tx, wire_rx) = mpsc::channel::(64); - let writer = tokio::spawn(wire::writer_task(wire_rx)); - if let Err(e) = read_loop( - BufReader::new(tokio::io::stdin()), - app.clone(), - wire_tx, - max_line, - ) - .await - { - tracing::error!("io: reader: {e}"); + let mut writer = tokio::spawn(wire::writer_task(wire_rx)); + // Whichever ends first drives shutdown. The reader ending is the normal + // path (stdin EOF/error). The writer ending while the reader still runs + // means stdout is closed/broken: no reply can ever be written, so we must + // stop reading and cancel every session rather than leave the process + // reading input while outstanding permission asks wait out their full + // deadline for a response that can never arrive. + tokio::select! { + r = read_loop( + BufReader::new(tokio::io::stdin()), + app.clone(), + wire_tx, + max_line, + ) => { + if let Err(e) = r { + tracing::error!("io: reader: {e}"); + } + cancel_all_sessions(&app).await; + let _ = writer.await; + } + _ = &mut writer => { + tracing::error!("io: writer exited (stdout closed); shutting down connection"); + cancel_all_sessions(&app).await; + } } +} + +/// Signal every live session to cancel. Run on connection teardown so in-flight +/// prompts — including any waiting on a `session/request_permission` response — +/// resolve promptly instead of waiting out their deadline. +async fn cancel_all_sessions(app: &Arc) { for session in app.sessions.lock().await.values() { let _ = session.cancel_tx.send(true); } - let _ = writer.await; } async fn read_loop( @@ -235,7 +273,10 @@ async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { handle_request(app, id, method, params, wire_tx).await } Inbound::Notification { method, params } => handle_notification(app, &method, params).await, - Inbound::Ignored => {} + // Client's answer to a `session/request_permission` we issued. The + // broker matches it to a live correlation id (waking that waiter) or + // ignores an unknown/late id. + Inbound::Response { id, result } => app.permissions.deliver(&id, result), Inbound::Invalid { id, code, message } => { wire::send(wire_tx, wire::err(id, code, &message)).await } @@ -250,7 +291,7 @@ async fn handle_request( wire_tx: &WireSender, ) { match method.as_str() { - "initialize" => initialize(id, params, wire_tx).await, + "initialize" => initialize(app, id, params, wire_tx).await, "session/new" => { let app = app.clone(); let wire_tx = wire_tx.clone(); @@ -291,7 +332,7 @@ async fn handle_notification(app: &Arc, method: &str, params: Value) { } } -async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { +async fn initialize(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { let p: InitializeParams = match decode(params, "initialize") { Ok(p) => p, Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, @@ -303,6 +344,12 @@ async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { // RFD. Revisit when that RFD merges; otherwise a genuine upstream-v2 agent // would silently lose `[Base]`. let negotiated_version = p.protocol_version.min(PROTOCOL_VERSION); + // Store the negotiated version for the connection lifetime: the + // `session/request_permission` wire shape derives from this value, never + // from a later mutable session field, so a strict client always receives + // exactly the shape it negotiated at `initialize`. + app.negotiated_version + .store(negotiated_version, Ordering::Relaxed); wire::send( wire_tx, wire::ok( @@ -734,6 +781,8 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender system_prompt: &effective_system_prompt, llm: &app.llm, mcp: &mcp, + permissions: &app.permissions, + protocol_version: app.negotiated_version.load(Ordering::Relaxed), skills: &skills, wire: &wire_tx, cancel: &mut cancel_rx, diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 83f642c123..599954292a 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2609,6 +2609,8 @@ mod tests { max_context_tokens: 200_000, max_handoffs: 1, max_parallel_tools: 1, + max_pending_permissions: 32, + permission_timeout: Duration::from_secs(330), hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, require_reply: false, diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 9ae125a0b7..42c9cc4878 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -594,15 +594,7 @@ impl McpRegistry { budget: ResultBudget, cancel: &mut watch::Receiver, ) -> Result { - let arg_obj = match arguments { - Value::Object(m) => Some(m.clone()), - Value::Null => None, - _ => { - return Err(AgentError::Mcp(format!( - "tool {qname} arguments must be a JSON object" - ))) - } - }; + let arg_obj = validate_arg_shape(qname, arguments)?; let mut params = CallToolRequestParams::default(); params.name = bare.to_owned().into(); params.arguments = arg_obj; @@ -812,6 +804,29 @@ async fn spawn_one( Ok((client, pgid, names, tools)) } +/// Validate that tool-call arguments are a shape the MCP transport can carry: +/// a JSON object (`Some(map)`) or absent (`None`). Any other JSON type is a +/// malformed call that the transport would reject. +/// +/// Hoisted out of `do_call` so the permission gate can run it *before* asking +/// the user: a malformed non-object argument is rejected locally without +/// prompting for approval of a call that could never execute. `do_call` runs +/// it again as the single authoritative shape check — the duplicate is a cheap +/// idempotent match, and keeping it here means no code path can reach the +/// transport with an unvalidated shape. +pub fn validate_arg_shape( + qname: &str, + arguments: &Value, +) -> Result>, AgentError> { + match arguments { + Value::Object(m) => Ok(Some(m.clone())), + Value::Null => Ok(None), + _ => Err(AgentError::Mcp(format!( + "tool {qname} arguments must be a JSON object" + ))), + } +} + /// Send `notifications/cancelled` to the MCP server, fire-and-forget. /// Per MCP spec, cancellation notifications are best-effort; we never /// block the agent on slow server stdio. diff --git a/crates/buzz-agent/src/permission.rs b/crates/buzz-agent/src/permission.rs new file mode 100644 index 0000000000..01dea078c4 --- /dev/null +++ b/crates/buzz-agent/src/permission.rs @@ -0,0 +1,1047 @@ +//! `session/request_permission` broker. +//! +//! buzz-agent asks the client to authorize every LLM-issued MCP tool call +//! *before* executing it; the client applies `BUZZ_ACP_PERMISSION_POLICY` and +//! answers. The agent never reads the policy — it always asks, matching the +//! layering of every other ACP harness. This module owns the whole request +//! correlation lifecycle so the rest of the agent only sees a single +//! `Allowed`/`Denied`/`Cancelled` decision. +//! +//! ## Invariants +//! +//! - **Process-wide admission.** The broker owns a global [`Semaphore`] +//! (`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`) acquired *before* any correlation +//! entry is inserted. The per-turn `execute_parallel` semaphore is fresh per +//! turn and sessions are unbounded by default, so only this global cap bounds +//! simultaneously outstanding asks process-wide. +//! - **Abort-safe cleanup.** A successful admission returns a +//! [`PendingPermission`] lease that owns the admission permit and the +//! correlation id. Its `Drop` synchronously removes the still-pending entry +//! and releases the slot, covering task abort/panic that bypasses the normal +//! `run_prompt` tail. +//! - **Claim-before-wake / at-most-once.** [`PermissionBroker::deliver`] removes +//! the entry *before* waking the waiter, so each id resolves at most once and +//! a later lease `Drop` is a harmless no-op. +//! - **Unknown/late ids ignored.** A response whose id is not a live entry is +//! logged and dropped. +//! - **Undeliverable asks are terminal.** If the output wire is closed when the +//! request is enqueued, [`PermissionBroker::request_permission`] fails closed +//! immediately (dropping the lease removes the entry and releases the permit) +//! rather than leaving a resident waiter to time out — a closed wire can never +//! carry the reply. +//! - **Single absolute deadline.** Admission wait and response wait share one +//! absolute deadline computed at gate entry, so a saturated call cannot live +//! for two full timeout windows. +//! - **Cancellation races inside the wait.** The waiter selects on the turn's +//! cancel receiver directly; resolution never depends on the outer abort +//! drain. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::Value; +use tokio::sync::{oneshot, watch, OwnedSemaphorePermit, Semaphore}; +use tokio::time::Instant; + +use crate::types::ToolCall; +use crate::wire::{self, WireSender, ALLOW_OPTION_ID}; + +/// Model-visible tool error when a call is not authorized. Rides the normal +/// tool-failure path so the turn continues, matching every other tool error. +pub const PERMISSION_DENIED_MSG: &str = "permission denied: the tool call was not authorized"; + +/// Model-visible tool error when the client never answers within the deadline. +pub const PERMISSION_TIMEOUT_MSG: &str = + "permission request timed out: the tool call was not authorized"; + +/// Model-visible tool error when the permission request cannot be delivered +/// because the output wire is closed. Terminal and immediate — no waiter is +/// left resident, since a closed wire can never carry a reply. +pub const PERMISSION_WIRE_CLOSED_MSG: &str = + "permission request undeliverable: the tool call was not authorized"; + +/// Outcome of asking the client to authorize one tool call. +#[derive(Debug, PartialEq, Eq)] +pub enum PermissionDecision { + /// The client selected the offered allow option — execute the tool. + Allowed, + /// Every non-authorizing shape (reject, cancelled outcome, JSON-RPC error, + /// malformed response, unknown outcome, wrong/unknown optionId, timeout, + /// wire-channel closure). Fails closed with the given model-visible reason; + /// the turn continues. + Denied(&'static str), + /// The turn was cancelled while admitting or waiting. No tool runs and the + /// caller propagates cancellation exactly as the existing cancel path does. + Cancelled, +} + +/// Broker owned by `App` for the connection lifetime. +pub struct PermissionBroker { + /// Global admission cap. Acquired before any entry is inserted. + sem: Arc, + /// Live correlation entries: outbound request id -> response sender. + pending: Arc>>>, + /// Monotonic id allocator. Never reused within a process lifetime, so a + /// late response for a removed id can never collide with a fresh request. + next_id: AtomicU64, + /// Absolute deadline budget shared by admission + response wait. + timeout: Duration, + /// Test-only: invoked by the waiter the instant it observes a delivered + /// response, with `true` iff the entry was already claimed (removed) from + /// `pending` before the wake. Makes claim-before-wake ordering + /// mutation-sensitive — a wake-before-claim mutant reports `false`, which a + /// purely behavioral test cannot detect (the waiter reads the oneshot once + /// either way). + #[cfg(test)] + wake_observer: Mutex>, +} + +/// Test-only wake-boundary observer; see [`PermissionBroker::wake_observer`]. +#[cfg(test)] +type WakeObserver = Arc; + +impl PermissionBroker { + /// `max_pending` is validated `>= 1` by config; `timeout` is injectable so + /// broker unit tests exercise the timeout/abort paths without a 330s wait. + pub fn new(max_pending: usize, timeout: Duration) -> Self { + Self { + sem: Arc::new(Semaphore::new(max_pending.max(1))), + pending: Arc::new(Mutex::new(HashMap::new())), + next_id: AtomicU64::new(0), + timeout, + #[cfg(test)] + wake_observer: Mutex::new(None), + } + } + + /// Test-only: register a callback the waiter fires the instant it observes a + /// delivered response, with `true` iff the correlation entry was already + /// claimed (removed) before the wake. Used to prove claim-before-wake + /// ordering in a way a wake-before-claim mutant cannot satisfy. + #[cfg(test)] + pub fn set_wake_observer(&self, observer: WakeObserver) { + *self.wake_observer.lock().unwrap() = Some(observer); + } + + /// Test-only: fire the wake observer (if any) with the claimed-before-wake + /// status of `id`. Called synchronously by the waiter the moment it receives + /// its response, so the observed `pending` state is exactly the state at the + /// wake — deterministic in production (removal happens-before the send) and + /// violated by a wake-before-claim mutant. + #[cfg(test)] + fn observe_wake(&self, id: u64) { + let claimed = !self.pending.lock().unwrap().contains_key(&id); + let observer = self.wake_observer.lock().unwrap().clone(); + if let Some(observer) = observer { + observer(claimed); + } + } + + /// Number of live (unresolved, un-dropped) correlation entries. Test-only + /// observability for the drop-guard and delivery invariants. + #[cfg(test)] + pub fn pending_count(&self) -> usize { + self.pending.lock().unwrap().len() + } + + /// Free admission slots. Test-only, so a test can prove a terminal path + /// (delivery, timeout, cancel, drop) actually released the capacity it + /// held rather than leaking it. + #[cfg(test)] + pub fn available_permits(&self) -> usize { + self.sem.available_permits() + } + + /// Deliver a client response to its waiter. Claims (removes) the entry + /// before waking so the id resolves at most once; unknown/late ids are + /// logged and ignored. `result` is the JSON-RPC `result` field (or + /// `Value::Null` for an error/malformed response — every such shape fails + /// the authorization predicate and denies). + pub fn deliver(&self, id: &Value, result: Value) { + let Some(key) = parse_id(id) else { + tracing::debug!(target: "permission", "ignoring response with unrecognized id {id}"); + return; + }; + // Claim before wake: remove first, then send into the removed sender. + let sender = self.pending.lock().unwrap().remove(&key); + match sender { + Some(tx) => { + // The receiver may already be gone (waiter cancelled/timed out + // and dropped the lease); a failed send is a harmless no-op. + let _ = tx.send(result); + } + None => { + tracing::debug!(target: "permission", "ignoring unknown/late permission id {id}"); + } + } + } + + /// Ask the client to authorize `call`, returning the decision. + /// + /// Sequence: acquire global admission (racing cancel + deadline) → insert + /// correlation entry (held by an abort-safe lease) → send the version-aware + /// request → wait for the response (racing cancel + deadline). One absolute + /// deadline bounds both waits. + pub async fn request_permission( + &self, + wire: &WireSender, + version: u32, + session_id: &str, + call: &ToolCall, + cancel: &mut watch::Receiver, + ) -> PermissionDecision { + let deadline = Instant::now() + self.timeout; + + // ── Admission ────────────────────────────────────────────────────── + // Early cancel check: watch::changed() only fires on NEW writes. + if *cancel.borrow() { + return PermissionDecision::Cancelled; + } + let permit = tokio::select! { + biased; + _ = cancel.changed() => return PermissionDecision::Cancelled, + _ = tokio::time::sleep_until(deadline) => { + return PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG); + } + p = Arc::clone(&self.sem).acquire_owned() => match p { + Ok(p) => p, + // Semaphore is never closed in production; treat as fail-closed. + Err(_) => return PermissionDecision::Denied(PERMISSION_DENIED_MSG), + }, + }; + + // Insert the correlation entry under the owned permit. The lease's Drop + // removes the entry + releases the slot on every exit path below, + // including task abort. + let mut lease = self.register(permit); + + // ── Send the version-aware request ───────────────────────────────── + let params = wire::request_permission_params( + version, + session_id, + &call.provider_id, + &call.name, + &call.arguments, + ); + // ── Send (deadline- and cancel-governed) ─────────────────────────── + // Enqueue is the third phase under the single absolute deadline. A + // full-but-live channel makes `send_checked` wait for capacity; racing + // it against cancel + the deadline means a stalled writer cannot hold + // the ask (and its global permit) past the advertised deadline, and + // `session/cancel` resolves it promptly. On send error the wire is + // closed: fail closed at once — dropping `lease` removes the entry and + // releases the permit synchronously. + let request = wire::request_permission(lease.id_value.clone(), params); + tokio::select! { + biased; + _ = cancel.changed() => return PermissionDecision::Cancelled, + _ = tokio::time::sleep_until(deadline) => { + return PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG); + } + r = wire::send_checked(wire, request) => { + if r.is_err() { + return PermissionDecision::Denied(PERMISSION_WIRE_CLOSED_MSG); + } + } + } + + // ── Response wait ────────────────────────────────────────────────── + if *cancel.borrow() { + return PermissionDecision::Cancelled; + } + #[cfg(test)] + let id = lease.id; + tokio::select! { + biased; + _ = cancel.changed() => PermissionDecision::Cancelled, + r = &mut lease.rx => match r { + Ok(result) => { + // The waiter observes delivery here. At this instant the + // entry must already be claimed (removed) — delivery removes + // before it sends. The observer is test-only and a no-op in + // production. + #[cfg(test)] + self.observe_wake(id); + evaluate(&result) + } + // Sender dropped without sending — should not happen (delivery + // always sends before drop); fail closed. + Err(_) => PermissionDecision::Denied(PERMISSION_DENIED_MSG), + }, + _ = tokio::time::sleep_until(deadline) => { + PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG) + } + } + // `lease` drops here: entry removed (no-op if delivered) + slot released. + } + + /// Allocate an id, insert its response sender, and return the abort-safe + /// lease holding the receiver + owned permit. + fn register(&self, permit: OwnedSemaphorePermit) -> PendingPermission { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + self.pending.lock().unwrap().insert(id, tx); + PendingPermission { + id, + id_value: Value::String(format!("perm-{id}")), + rx, + pending: Arc::clone(&self.pending), + _permit: permit, + } + } +} + +/// Abort-safe correlation lease. Owns the admission permit and the correlation +/// id; its `Drop` synchronously removes the still-pending entry and releases +/// the slot. Delivery removes the entry first, so a later drop is a no-op. +struct PendingPermission { + id: u64, + id_value: Value, + rx: oneshot::Receiver, + pending: Arc>>>, + _permit: OwnedSemaphorePermit, +} + +impl Drop for PendingPermission { + fn drop(&mut self) { + // Synchronous, non-async removal — safe from a Drop and required for + // abort/panic paths. No-op if delivery already claimed the entry. + self.pending.lock().unwrap().remove(&self.id); + // `_permit` drops → global admission slot released. + } +} + +/// The authorization predicate, stated once: execute IFF the client selected an +/// option AND the selected `optionId` equals exactly this request's offered +/// allow-option id. Every other shape fails closed. +fn evaluate(result: &Value) -> PermissionDecision { + let outcome = &result["outcome"]; + if outcome["outcome"] == "selected" && outcome["optionId"].as_str() == Some(ALLOW_OPTION_ID) { + PermissionDecision::Allowed + } else { + PermissionDecision::Denied(PERMISSION_DENIED_MSG) + } +} + +/// Recover the correlation key from an outbound request id echoed by the +/// client. Only ids we minted (`perm-`, canonical decimal) are ours; a +/// noncanonical alias (`perm-01`, `perm-+0`, `perm-00`) or any other string is +/// a foreign/stale id and is ignored. Requiring an exact round-trip means only +/// the string the broker actually minted correlates — no alias is ever live. +fn parse_id(id: &Value) -> Option { + let s = id.as_str()?; + let n: u64 = s.strip_prefix("perm-")?.parse().ok()?; + (format!("perm-{n}") == s).then_some(n) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io; + use std::pin::Pin; + use std::task::{Context, Poll}; + use tokio::io::AsyncWrite; + use tokio::sync::mpsc; + + const LONG: Duration = Duration::from_secs(30); + const SHORT: Duration = Duration::from_millis(60); + + fn tool_call() -> ToolCall { + ToolCall { + provider_id: "fake".into(), + name: "fake__shell".into(), + arguments: json!({ "command": "ls" }), + provider_extra: serde_json::Map::new(), + } + } + + fn selected(option_id: &str) -> Value { + json!({ "outcome": { "outcome": "selected", "optionId": option_id } }) + } + + /// Pull the next outbound frame off the wire and return its JSON-RPC `id`. + /// Reading it also proves the request was registered and sent (delivery + /// only happens after `register`). + async fn next_request_id(rx: &mut mpsc::Receiver) -> Value { + let wire::WireMsg::Notify(v) = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("a request frame") + .expect("wire open"); + assert_eq!(v["method"], "session/request_permission"); + v["id"].clone() + } + + /// An `AsyncWrite` that accepts every write but fails on `flush`, modelling + /// Tokio's blocking stdout when the pipe has broken: `write_all` reports + /// `Ok` (the underlying blocking write is only scheduled) and the real + /// error surfaces at `flush`. Used to prove the writer treats flush failure + /// as connection-fatal. + struct FlushFailSink; + + impl AsyncWrite for FlushFailSink { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(io::Error::from(io::ErrorKind::BrokenPipe))) + } + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + // ── Authorization predicate (fail-closed) ──────────────────────────────── + + #[test] + fn test_selected_allow_option_authorizes() { + assert_eq!( + evaluate(&selected(ALLOW_OPTION_ID)), + PermissionDecision::Allowed + ); + } + + #[test] + fn test_every_non_allow_shape_denies() { + // reject, wrong/unknown option id, unknown outcome, missing fields, + // empty object — the full adversarial set the predicate must reject. + let denied = [ + selected("reject_once"), + selected("some_unknown_option"), + json!({ "outcome": { "outcome": "cancelled" } }), + json!({ "outcome": { "outcome": "selected" } }), // no optionId + json!({ "outcome": { "outcome": "banana", "optionId": ALLOW_OPTION_ID } }), + json!({ "outcome": {} }), + json!({}), + Value::Null, + ]; + for shape in denied { + assert_eq!( + evaluate(&shape), + PermissionDecision::Denied(PERMISSION_DENIED_MSG), + "shape must fail closed: {shape}" + ); + } + } + + // ── Id correlation ─────────────────────────────────────────────────────── + + #[test] + fn test_parse_id_accepts_only_minted_ids() { + assert_eq!(parse_id(&json!("perm-0")), Some(0)); + assert_eq!(parse_id(&json!("perm-42")), Some(42)); + assert_eq!(parse_id(&json!("perm-x")), None); + assert_eq!(parse_id(&json!("42")), None); // foreign numeric-string id + assert_eq!(parse_id(&json!(42)), None); // foreign numeric id + assert_eq!(parse_id(&Value::Null), None); + } + + /// Noncanonical strings that `u64::parse` would otherwise accept as aliases + /// of a minted id must NOT correlate. Only the exact string the broker + /// minted (`format!("perm-{n}")`) is live; leading zeros, a sign, or + /// whitespace make the id foreign and it is ignored. Without the exact + /// round-trip check these would resolve live asks under ids the broker + /// never issued. + #[test] + fn test_parse_id_rejects_noncanonical_aliases() { + for alias in [ + "perm-00", // extra leading zero + "perm-01", // leading zero + "perm-+0", // explicit sign + "perm-0x1", // hex + "perm- 1", // leading space + "perm-1 ", // trailing space + "perm-1_000", // digit separator + ] { + assert_eq!( + parse_id(&json!(alias)), + None, + "alias must be foreign: {alias}" + ); + } + } + + // ── Delivery: exact allow / deny ────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_deliver_allow_authorizes_and_frees_slot() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 1); + assert_eq!(broker.available_permits(), 3); + + broker.deliver(&id, selected(ALLOW_OPTION_ID)); + assert_eq!(task.await.unwrap(), PermissionDecision::Allowed); + assert_eq!(broker.pending_count(), 0, "entry claimed on delivery"); + assert_eq!( + broker.available_permits(), + 4, + "slot released after decision" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_deliver_reject_denies_and_frees_slot() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + broker.deliver(&id, selected("reject_once")); + assert_eq!( + task.await.unwrap(), + PermissionDecision::Denied(PERMISSION_DENIED_MSG) + ); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + // ── Malformed response frames deny (Carl's review) ──────────────────────── + + /// Route Carl's frame through the real `classify` → `deliver` path against a + /// live waiter and assert the tool is denied. Delivers on the exact id the + /// broker minted, so the only reason the waiter denies is that `classify` + /// refused to forward the ambiguous/malformed `result`. `provider_id` + /// carries which shape is under test so a failure names the mutant. + async fn assert_malformed_frame_denies(provider_id: &str, frame: Value) { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + // Stamp the broker's minted id onto Carl's frame, then classify it + // exactly as the dispatch loop would before handing `result` to deliver. + let mut frame = frame; + frame["id"] = id.clone(); + match crate::wire::classify(&frame) { + crate::wire::Inbound::Response { id, result } => broker.deliver(&id, result), + other => panic!("[{provider_id}] expected Response, got {other:?}"), + } + + assert_eq!( + task.await.unwrap(), + PermissionDecision::Denied(PERMISSION_DENIED_MSG), + "[{provider_id}] malformed frame must deny, not authorize the tool", + ); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + /// Carl frame #1: `result` (well-formed `selected`/`allow_once`) AND `error` + /// both present. The tool must not run — the ambiguous frame denies. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_frame_with_result_and_error_denies_tool() { + assert_malformed_frame_denies( + "result+error", + json!({ + "jsonrpc": "2.0", + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + "error": { "code": -32603, "message": "internal" }, + }), + ) + .await; + } + + /// Carl frame #2: present non-string `method: 7` alongside a well-formed + /// `selected` `result`. It is not a valid response — the tool must not run. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_frame_with_non_string_method_denies_tool() { + assert_malformed_frame_denies( + "non-string-method", + json!({ + "jsonrpc": "2.0", + "method": 7, + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + }), + ) + .await; + } + + // ── Stale / unknown id ignored ──────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_unknown_id_does_not_unblock_waiter() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let real_id = next_request_id(&mut rx).await; + // A stale/foreign id is dropped; the live entry survives. + broker.deliver(&json!("perm-999"), selected(ALLOW_OPTION_ID)); + broker.deliver(&json!(1), selected(ALLOW_OPTION_ID)); + broker.deliver(&Value::Null, selected(ALLOW_OPTION_ID)); + assert_eq!(broker.pending_count(), 1, "waiter still pending"); + + // The correct id resolves it exactly once. + broker.deliver(&real_id, selected(ALLOW_OPTION_ID)); + assert_eq!(task.await.unwrap(), PermissionDecision::Allowed); + assert_eq!(broker.pending_count(), 0); + } + + // ── Timeout ─────────────────────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_timeout_denies_and_removes_state() { + let broker = Arc::new(PermissionBroker::new(4, SHORT)); + let (tx, _rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + // No delivery ever arrives: the shared deadline denies. + let decision = broker + .request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await; + assert_eq!(decision, PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG)); + assert_eq!( + broker.pending_count(), + 0, + "timeout removes correlation state" + ); + assert_eq!(broker.available_permits(), 4, "timeout releases the slot"); + } + + // ── Undeliverable ask (closed wire) is terminal ─────────────────────────── + + /// When the output wire is closed, the ask can never be written and no + /// reply can ever arrive. `request_permission` must fail closed + /// *immediately* — denying with the wire-closed reason and leaving zero + /// pending entries and zero held permits — rather than registering an entry + /// that waits out the full deadline. Uses a LONG timeout so a wrong + /// implementation that waits the deadline would visibly hang the test far + /// past its own assertions. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_closed_wire_denies_immediately_without_leaking_state() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + // Drop the receiver so every send fails: the writer is gone. + let (tx, rx) = mpsc::channel(8); + drop(rx); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + // Bound the whole call: correct behavior returns at once; a regression + // that waits the deadline blows this timeout instead of hanging LONG. + let decision = tokio::time::timeout( + Duration::from_secs(2), + broker.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx), + ) + .await + .expect("closed wire must deny immediately, not wait the deadline"); + + assert_eq!( + decision, + PermissionDecision::Denied(PERMISSION_WIRE_CLOSED_MSG) + ); + assert_eq!( + broker.pending_count(), + 0, + "undeliverable ask leaves no resident entry" + ); + assert_eq!( + broker.available_permits(), + 4, + "undeliverable ask releases its admission slot" + ); + } + + // ── Writer flush failure is connection-fatal ────────────────────────────── + + /// A blocking stdout can report `Ok` from `write_all` (the underlying + /// blocking write is only scheduled) and surface the real error at `flush`. + /// The writer must therefore treat flush failure exactly like write failure + /// — return, dropping its receiver — so the connection supervisor observes + /// writer death and cancels every session (which resolves any waiting ask). + /// Modelled here: `write_frames` fed one frame over a sink that accepts the + /// write but fails flush must terminate promptly; a cancellation wired to + /// that termination (as `async_main`'s writer arm does) then unblocks a + /// registered permission waiter, leaving zero pending entries and all + /// permits free — not after the injected deadline. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_flush_failure_kills_writer_and_resolves_waiter() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + // A live output channel; its frames are drained by `write_frames` into + // the flush-failing sink, modelling the real writer over a broken pipe. + let (wire_tx, wire_rx) = mpsc::channel(8); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + + // Supervisor: run the writer over the failing sink; when it returns + // (flush error → connection-fatal), propagate cancellation exactly like + // `async_main`'s writer-death arm. + let writer = tokio::spawn(async move { + wire::write_frames(wire_rx, FlushFailSink).await; + let _ = cancel_tx.send(true); + }); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&wire_tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // The ask registers and enqueues its frame; the writer accepts the + // write, fails the flush, returns, and the supervisor cancels. The + // waiter must resolve via that cancellation, not the LONG deadline. + let decision = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("flush failure must cancel the waiter, not wait the deadline") + .unwrap(); + + writer.await.unwrap(); + assert_eq!(decision, PermissionDecision::Cancelled); + assert_eq!( + broker.pending_count(), + 0, + "writer death resolves the waiter and leaves no resident entry" + ); + assert_eq!( + broker.available_permits(), + 4, + "writer death releases the held admission slot" + ); + } + + // ── Enqueue backpressure is deadline- and cancel-governed ───────────────── + + /// A full-but-live output channel makes `send_checked` wait for capacity. + /// The send phase races the single absolute deadline, so a stalled writer + /// cannot hold the ask (and its global permit) past the advertised + /// deadline: `request_permission` must return the timeout deny within the + /// deadline and leave zero pending entries and zero held permits. Uses a + /// SHORT deadline bounded by a longer outer timeout, so a regression that + /// waits forever on capacity blows the outer bound. This is a distinct seam + /// from the dropped-receiver test: here the receiver is alive but never + /// drains. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_full_channel_send_is_bounded_by_the_deadline() { + let broker = Arc::new(PermissionBroker::new(4, SHORT)); + // Capacity-1 channel, prefilled and never drained: the next send blocks + // on capacity while the receiver stays alive (writer present, stalled). + let (tx, _rx) = mpsc::channel(1); + tx.send(wire::WireMsg::Notify(json!({ "fill": 1 }))) + .await + .unwrap(); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + let decision = tokio::time::timeout( + Duration::from_secs(2), + broker.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx), + ) + .await + .expect("a stalled writer must not hold the send past the deadline"); + + assert_eq!( + decision, + PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG), + "a full-but-live channel resolves via the deadline, not the wire-closed path" + ); + assert_eq!( + broker.pending_count(), + 0, + "a timed-out enqueue leaves no resident entry" + ); + assert_eq!( + broker.available_permits(), + 4, + "a timed-out enqueue releases its admission slot" + ); + } + + /// Cancellation must also resolve an ask stuck enqueueing on a stalled + /// writer: with a full-but-live channel and a LONG deadline, a + /// `session/cancel` returns `Cancelled` promptly rather than waiting the + /// deadline out. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cancel_resolves_a_blocked_enqueue() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, _rx) = mpsc::channel(1); + tx.send(wire::WireMsg::Notify(json!({ "fill": 1 }))) + .await + .unwrap(); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // Give the task time to admit + block on the full channel, then cancel. + tokio::time::sleep(Duration::from_millis(50)).await; + cancel_tx.send(true).unwrap(); + let decision = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("cancel must resolve a blocked enqueue promptly") + .unwrap(); + assert_eq!(decision, PermissionDecision::Cancelled); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + // ── Claim-before-wake ordering (mutation-sensitive) ─────────────────────── + + /// The waiter must observe the correlation entry already *claimed* (removed + /// from `pending`) at the instant it wakes with the delivered response — + /// `deliver` removes before it sends. The wake observer fires synchronously + /// inside the waiter's response arm, so it captures the exact `pending` + /// state at the wake. A wake-before-claim mutant (send first, remove after) + /// makes the observed state `false` and fails this assertion; the behavioral + /// delivery tests cannot detect that mutant because the waiter reads the + /// oneshot exactly once either way. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_waiter_observes_entry_claimed_before_wake() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let claimed_at_wake = Arc::new(Mutex::new(None::)); + let sink = Arc::clone(&claimed_at_wake); + broker.set_wake_observer(Arc::new(move |claimed| { + *sink.lock().unwrap() = Some(claimed); + })); + + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let id = next_request_id(&mut rx).await; + broker.deliver(&id, selected(ALLOW_OPTION_ID)); + assert_eq!(task.await.unwrap(), PermissionDecision::Allowed); + assert_eq!( + *claimed_at_wake.lock().unwrap(), + Some(true), + "entry must be claimed (removed) before the waiter is woken" + ); + } + + // ── Cancellation while waiting ─────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cancel_while_waiting_returns_cancelled_and_removes_state() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + let _id = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 1); + cancel_tx.send(true).unwrap(); + assert_eq!(task.await.unwrap(), PermissionDecision::Cancelled); + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 4); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_precancelled_turn_never_sends_request() { + let broker = Arc::new(PermissionBroker::new(4, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(true); // already cancelled + let call = tool_call(); + + let decision = broker + .request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await; + assert_eq!(decision, PermissionDecision::Cancelled); + assert_eq!(broker.pending_count(), 0, "no entry inserted"); + assert_eq!(broker.available_permits(), 4); + assert!(rx.try_recv().is_err(), "no request frame emitted"); + } + + // ── Abort-safe drop guard ───────────────────────────────────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_abort_while_waiting_leaves_zero_pending_and_reusable_slot() { + let broker = Arc::new(PermissionBroker::new(1, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // Registered + sent → then hard-abort the task (bypasses every normal + // exit path). The lease's Drop must still run. + let _id = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 1); + assert_eq!(broker.available_permits(), 0); + task.abort(); + let _ = task.await; + assert_eq!( + broker.pending_count(), + 0, + "drop guard removed the entry on abort" + ); + assert_eq!( + broker.available_permits(), + 1, + "drop guard released the slot on abort" + ); + } + + // ── Process-wide admission cap across multiple sessions ─────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_admission_cap_bounds_entries_across_sessions() { + // Capacity 2, shared by all sessions. Three distinct sessions ask at + // once; only two can register/send while the cap is saturated. The + // third is admitted only after a slot frees. Frame ids arrive in + // nondeterministic order across tasks, so the test never maps a task + // handle to a specific id — it proves the bound structurally (frame + // count + pending_count) and that every task ultimately resolves. + let broker = Arc::new(PermissionBroker::new(2, LONG)); + let (tx, mut rx) = mpsc::channel(8); + let (_c, cancel_rx) = watch::channel(false); + + let spawn_req = |session: &'static str| { + let b = Arc::clone(&broker); + let tx = tx.clone(); + let mut cancel = cancel_rx.clone(); + let call = tool_call(); + tokio::spawn(async move { + b.request_permission(&tx, 2, session, &call, &mut cancel) + .await + }) + }; + + let tasks = [spawn_req("ses_a"), spawn_req("ses_b"), spawn_req("ses_c")]; + + // Only two frames appear while the cap is 2; the third is blocked in + // admission with no entry and no frame. + let id1 = next_request_id(&mut rx).await; + let id2 = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 2); + assert_eq!(broker.available_permits(), 0); + assert!( + tokio::time::timeout(SHORT, rx.recv()).await.is_err(), + "third session must not send a request while the cap is saturated" + ); + assert_eq!( + broker.pending_count(), + 2, + "cap holds: no third entry inserted" + ); + + // Free one slot → the third session is admitted and sends its frame. + broker.deliver(&id1, selected(ALLOW_OPTION_ID)); + let id3 = next_request_id(&mut rx).await; + assert_eq!(broker.pending_count(), 2, "still bounded after churn"); + + // Resolve the two remaining live entries. + broker.deliver(&id2, selected(ALLOW_OPTION_ID)); + broker.deliver(&id3, selected(ALLOW_OPTION_ID)); + + // Every session resolved to Allowed — none stranded or timed out. + for t in tasks { + assert_eq!(t.await.unwrap(), PermissionDecision::Allowed); + } + assert_eq!(broker.pending_count(), 0); + assert_eq!(broker.available_permits(), 2); + } + + // ── Admission-phase cancel: fail-closed, zero entries ───────────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_cancel_during_admission_inserts_no_entry() { + // Saturate the single slot directly (test-module access to `sem`) so the + // request under test blocks in the admission phase, before any insert. + let broker = Arc::new(PermissionBroker::new(1, LONG)); + let held = Arc::clone(&broker.sem).acquire_owned().await.unwrap(); + assert_eq!(broker.available_permits(), 0); + + let (tx, mut rx) = mpsc::channel(8); + let (cancel_tx, mut cancel_rx) = watch::channel(false); + let b = Arc::clone(&broker); + let call = tool_call(); + let task = tokio::spawn(async move { + b.request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await + }); + + // It cannot proceed past admission: no entry, no frame. + assert!(tokio::time::timeout(SHORT, rx.recv()).await.is_err()); + assert_eq!(broker.pending_count(), 0, "blocked before insert"); + + cancel_tx.send(true).unwrap(); + assert_eq!(task.await.unwrap(), PermissionDecision::Cancelled); + assert_eq!( + broker.pending_count(), + 0, + "cancel during admission inserts nothing" + ); + drop(held); + assert_eq!(broker.available_permits(), 1); + } + + // ── Admission-phase deadline: fail-closed deny, zero entries ────────────── + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn test_deadline_during_admission_denies_with_no_entry() { + let broker = Arc::new(PermissionBroker::new(1, SHORT)); + let held = Arc::clone(&broker.sem).acquire_owned().await.unwrap(); + + let (tx, mut rx) = mpsc::channel(8); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + let call = tool_call(); + + // Slot never frees within the deadline → admission times out. + let decision = broker + .request_permission(&tx, 2, "ses_a", &call, &mut cancel_rx) + .await; + assert_eq!(decision, PermissionDecision::Denied(PERMISSION_TIMEOUT_MSG)); + assert_eq!( + broker.pending_count(), + 0, + "no entry inserted on admission timeout" + ); + assert!(rx.try_recv().is_err(), "no request frame emitted"); + drop(held); + } +} diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs index b4c876e0fe..e6fe89e07c 100644 --- a/crates/buzz-agent/src/wire.rs +++ b/crates/buzz-agent/src/wire.rs @@ -1,6 +1,6 @@ use serde::Deserialize; use serde_json::{json, Value}; -use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt}; use tokio::sync::mpsc; use crate::types::{ContentBlock, McpServerStdio}; @@ -27,7 +27,19 @@ pub enum Inbound { method: String, params: Value, }, - Ignored, + /// A bare JSON-RPC response (id present, no method) — the client's answer + /// to a request buzz-agent issued. Today the only such request is + /// `session/request_permission`. `result` carries the JSON-RPC `result` + /// field ONLY when the frame is a structurally valid response — no `method` + /// member and exactly one of `result`/`error`. Any malformed shape (present + /// non-string `method`, both `result` and `error`, or neither) is normalized + /// to `Null` so a possibly-`selected` payload is never laundered into an + /// approval; every non-`selected` shape fails the broker's authorization + /// predicate and denies. + Response { + id: Value, + result: Value, + }, Invalid { id: Value, code: i32, @@ -109,9 +121,27 @@ pub fn classify(msg: &Value) -> Inbound { params, }, (Some(m), None) => Inbound::Notification { method: m, params }, - // Bare responses (id present, no method) are unexpected — buzz-agent - // does not issue requests to the client. Ignore silently. - (None, Some(_)) => Inbound::Ignored, + // Bare responses (id present, no method) answer a request buzz-agent + // issued — today only `session/request_permission`. Route to the + // permission broker, which matches a live correlation id or ignores an + // unknown one. Forward the `result` ONLY when the frame is a + // structurally valid response — the exactly-one-of invariant: no + // `method` member at all, and `result` present with `error` absent. A + // present non-string `method` (which `as_str` above collapsed to + // `None`), both `result` and `error`, or neither is malformed; forward + // `Null` so the broker fails closed (deny) rather than laundering a + // possibly-`selected` payload into an approval. + (None, Some(id)) => { + let well_formed = msg.get("method").is_none() + && msg.get("result").is_some() + && msg.get("error").is_none(); + let result = if well_formed { + msg.get("result").cloned().unwrap_or(Value::Null) + } else { + Value::Null + }; + Inbound::Response { id, result } + } (None, None) => Inbound::Invalid { id: Value::Null, code: INVALID_REQUEST, @@ -120,6 +150,79 @@ pub fn classify(msg: &Value) -> Inbound { } } +/// `optionId`/`kind` of the single allow option offered on every +/// `session/request_permission`. buzz-acp's answering side selects the option +/// whose `kind == "allow_once"` (never by hardcoded `optionId`), and the +/// authorization predicate on this side requires the returned `optionId` to +/// equal exactly this value. Keeping option id and kind identical means both +/// sides agree without a separate lookup table. +pub const ALLOW_OPTION_ID: &str = "allow_once"; + +/// The two options offered on every permission request: allow-once and +/// reject-once. First cut ships only these (no session-scoped grant), so every +/// offered option is already in the desktop card's exact actionable allowlist. +fn permission_options() -> Value { + json!([ + { "optionId": ALLOW_OPTION_ID, "name": "Allow", "kind": ALLOW_OPTION_ID }, + { "optionId": "reject_once", "name": "Deny", "kind": "reject_once" }, + ]) +} + +/// Build `session/request_permission` params for the negotiated protocol +/// version. No hybrid shapes — the request must match exactly what the client +/// negotiated at `initialize`, or a strict client can reject it before policy +/// is applied. +/// +/// - **v2** (what buzz-agent negotiates with current buzz-acp): tool context +/// lives under `subject: {type: "tool_call", toolCall}` with top-level +/// `title` and `options`. +/// - **v1** (still negotiated when a client requests it): the legacy shape with +/// `toolCall` (carrying `kind`) directly at the params level. +pub fn request_permission_params( + version: u32, + session_id: &str, + tool_call_id: &str, + title: &str, + raw_input: &Value, +) -> Value { + if version >= 2 { + json!({ + "sessionId": session_id, + "title": title, + "subject": { + "type": "tool_call", + "toolCall": { + "toolCallId": tool_call_id, + "title": title, + "rawInput": raw_input, + }, + }, + "options": permission_options(), + }) + } else { + json!({ + "sessionId": session_id, + "toolCall": { + "toolCallId": tool_call_id, + "title": title, + "kind": "other", + "rawInput": raw_input, + }, + "options": permission_options(), + }) + } +} + +/// Build an outbound JSON-RPC request `session/request_permission` frame. +pub fn request_permission(id: Value, params: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "session/request_permission", + "params": params, + }) +} + pub fn ok(id: Value, result: Value) -> Value { json!({ "jsonrpc": "2.0", "id": id, "result": result }) } @@ -267,7 +370,18 @@ pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Valu } pub async fn send(wire: &WireSender, msg: Value) { - let _ = wire.send(WireMsg::Notify(msg)).await; + let _ = send_checked(wire, msg).await; +} + +/// Enqueue a frame, reporting whether the writer accepted it. Unlike mpsc's +/// non-blocking `try_send`, this awaits channel capacity; it fails only when +/// the writer task has dropped its receiver, which happens exactly when the +/// writer has exited because stdout is closed/broken. A frame that fails here +/// will never be written, so callers that correlate a response — the +/// permission broker — must fail closed immediately rather than wait out a +/// deadline for a reply that can never arrive. +pub async fn send_checked(wire: &WireSender, msg: Value) -> Result<(), ()> { + wire.send(WireMsg::Notify(msg)).await.map_err(|_| ()) } pub async fn read_bounded_line( @@ -316,8 +430,25 @@ pub async fn read_bounded_line( } } -pub async fn writer_task(mut rx: mpsc::Receiver) { - let mut stdout = tokio::io::stdout(); +pub async fn writer_task(rx: mpsc::Receiver) { + write_frames(rx, tokio::io::stdout()).await; +} + +/// Drain `rx`, writing each frame to `out` as a newline-terminated JSON line. +/// Generic over the sink so tests can inject an `AsyncWrite` that fails on +/// flush; production passes stdout. +/// +/// Both `write_all` and `flush` failure are connection-fatal: they return, +/// dropping `rx` so `async_main`'s writer-death arm cancels every session. +/// Flush must be fatal too — a blocking stdout can report `Ok` from +/// `write_all` when it only schedules the underlying write and surface the +/// real error at `flush`, so ignoring flush failure would leave a dead stdout +/// undetected and strand any correlated ask waiting for a reply that can never +/// be written. +pub(crate) async fn write_frames( + mut rx: mpsc::Receiver, + mut out: W, +) { while let Some(msg) = rx.recv().await { let WireMsg::Notify(v) = msg; let mut s = match serde_json::to_string(&v) { @@ -328,10 +459,9 @@ pub async fn writer_task(mut rx: mpsc::Receiver) { } }; s.push('\n'); - if stdout.write_all(s.as_bytes()).await.is_err() { + if out.write_all(s.as_bytes()).await.is_err() || out.flush().await.is_err() { return; } - let _ = stdout.flush().await; } } @@ -534,4 +664,181 @@ mod tests { assert_eq!(payload["accumulatedInputTokens"], serde_json::json!(1000)); assert_eq!(payload["accumulatedOutputTokens"], serde_json::json!(200)); } + + // ── request_permission_params: version-aware wire shape ────────────────── + + /// v2 (what buzz-agent negotiates with current buzz-acp): tool context is + /// nested under `subject: {type: "tool_call", toolCall}` with top-level + /// `title` and `options`, matching the ACP v2 `RequestPermissionRequest`. + #[test] + fn request_permission_params_v2_nests_tool_call_under_subject() { + let raw = json!({ "command": "ls" }); + let p = request_permission_params(2, "ses_1", "fake__shell", "fake__shell", &raw); + + assert_eq!(p["sessionId"], "ses_1"); + assert_eq!(p["title"], "fake__shell"); + assert_eq!(p["subject"]["type"], "tool_call"); + assert_eq!(p["subject"]["toolCall"]["toolCallId"], "fake__shell"); + assert_eq!(p["subject"]["toolCall"]["title"], "fake__shell"); + assert_eq!(p["subject"]["toolCall"]["rawInput"], raw); + // No hybrid: v2 must NOT carry a top-level `toolCall`. + assert!(p.get("toolCall").is_none(), "v2 must not use the v1 shape"); + assert_options(&p["options"]); + } + + /// v1 (still negotiated when a client requests it): the legacy shape with + /// `toolCall` (carrying `kind`) directly at the params level, no `subject`. + #[test] + fn request_permission_params_v1_uses_legacy_top_level_tool_call() { + let raw = json!({ "command": "ls" }); + let p = request_permission_params(1, "ses_1", "fake__shell", "fake__shell", &raw); + + assert_eq!(p["sessionId"], "ses_1"); + assert_eq!(p["toolCall"]["toolCallId"], "fake__shell"); + assert_eq!(p["toolCall"]["title"], "fake__shell"); + assert_eq!(p["toolCall"]["kind"], "other"); + assert_eq!(p["toolCall"]["rawInput"], raw); + // No hybrid: v1 must NOT carry the v2 `subject` or top-level `title`. + assert!(p.get("subject").is_none(), "v1 must not use the v2 shape"); + assert!(p.get("title").is_none(), "v1 has no top-level title"); + assert_options(&p["options"]); + } + + /// Both offered options are exactly allow-once and reject-once, with + /// `optionId == kind` so buzz-acp's `kind`-based selector and this side's + /// `optionId`-based predicate agree without a lookup table. + fn assert_options(options: &Value) { + let opts = options.as_array().expect("options is an array"); + assert_eq!(opts.len(), 2, "first cut offers exactly two options"); + assert_eq!(opts[0]["optionId"], ALLOW_OPTION_ID); + assert_eq!(opts[0]["kind"], ALLOW_OPTION_ID); + assert_eq!(opts[0]["name"], "Allow"); + assert_eq!(opts[1]["optionId"], "reject_once"); + assert_eq!(opts[1]["kind"], "reject_once"); + assert_eq!(opts[1]["name"], "Deny"); + } + + /// The outbound frame wraps params in a JSON-RPC request whose id echoes + /// back verbatim so the broker can correlate the response. + #[test] + fn request_permission_frame_is_a_correlatable_jsonrpc_request() { + let params = request_permission_params(2, "ses_1", "t", "t", &json!({})); + let frame = request_permission(json!("perm-7"), params); + assert_eq!(frame["jsonrpc"], "2.0"); + assert_eq!(frame["id"], "perm-7"); + assert_eq!(frame["method"], "session/request_permission"); + assert_eq!(frame["params"]["sessionId"], "ses_1"); + } + + // ── classify: bare responses route to the broker ───────────────────────── + + /// A bare JSON-RPC response (id, no method) is the client's answer to a + /// request buzz-agent issued; it routes to the broker with its `result`. + #[test] + fn classify_bare_response_routes_to_broker() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!(result["outcome"]["outcome"], "selected"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// A JSON-RPC error response (id, `error`, no `result`) still routes to the + /// broker but with `result == Null`, which the authorization predicate + /// fails closed. buzz-agent never leaves the waiter hanging on an error. + #[test] + fn classify_error_response_routes_with_null_result() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "error": { "code": -32601, "message": "method not found" }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!(result, Value::Null, "error/absent result → Null → deny"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// Carl's frame #1: a response carrying BOTH `result` and `error` is + /// structurally ambiguous and must NOT deliver the `result`, even when that + /// `result` is a well-formed `selected`/`allow_once` payload. The wire layer + /// normalizes it to `Null` so the broker denies instead of the frame + /// laundering an approval upstream of every fail-closed check. + #[test] + fn classify_response_with_both_result_and_error_denies() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + "error": { "code": -32603, "message": "internal" }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!( + result, + Value::Null, + "result+error is malformed → Null → deny, never forward the allow payload" + ); + } + other => panic!("expected Response, got {other:?}"), + } + } + + /// Carl's frame #2: a present but non-string `method` is NOT "method + /// absent". `as_str` collapses `method: 7` to `None`, which lands the frame + /// in the response arm, but it is not a valid response and must not forward + /// its `result` (a well-formed `selected` payload here). The structural + /// check sees the present `method` member and normalizes to `Null` → deny. + #[test] + fn classify_response_with_non_string_method_denies() { + let msg = json!({ + "jsonrpc": "2.0", + "id": "perm-3", + "method": 7, + "result": { "outcome": { "outcome": "selected", "optionId": ALLOW_OPTION_ID } }, + }); + match classify(&msg) { + Inbound::Response { id, result } => { + assert_eq!(id, json!("perm-3")); + assert_eq!( + result, + Value::Null, + "present non-string method → not a valid response → Null → deny" + ); + } + other => panic!("expected Response, got {other:?}"), + } + } + + // ── send_checked: observable wire closure ──────────────────────────────── + + /// `send_checked` reports `Ok` while the writer's receiver is alive and + /// `Err` once it is gone (writer task exited on closed/broken stdout). This + /// is the contract the permission broker relies on to fail an undeliverable + /// ask closed immediately instead of waiting out its deadline for a reply + /// that can never be written. + #[tokio::test] + async fn send_checked_reports_closure_when_writer_gone() { + let (tx, rx) = mpsc::channel::(4); + assert!( + send_checked(&tx, json!({ "ok": 1 })).await.is_ok(), + "send succeeds while the writer receiver is alive" + ); + drop(rx); // writer exited → receiver dropped + assert!( + send_checked(&tx, json!({ "ok": 2 })).await.is_err(), + "send reports failure once the writer is gone" + ); + } } diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 1b7f346162..8d96779bba 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -23,6 +23,12 @@ //! tree dies on timeout. //! FAKE_MCP_GRANDCHILD_PID_FILE=path //! — path to write the grandchild PID to. +//! FAKE_MCP_CANCEL_LOG=path — append each `notifications/cancelled` frame to +//! `path` (one JSON line per notification). +//! FAKE_MCP_CALL_LOG=path — append the tool name of each `tools/call` to +//! `path` (one name per line). Lets a test assert +//! a tool was invoked exactly once, or never — the +//! permission gate's core proof. //! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool //! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") //! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying @@ -39,6 +45,11 @@ //! `command` string. Lets a test drive the //! reply guard's recognition of a real, //! registered shell tool. +//! FAKE_MCP_NAMED_TOOLS=a,b — expose one no-arg tool per comma-separated bare +//! name (each registered as `__`), in +//! addition to any `FAKE_MCP_TOOL_COUNT` tools. Lets +//! a test issue parallel calls to distinctly named +//! tools and tell them apart in `FAKE_MCP_CALL_LOG`. use std::io::{BufRead, Write}; @@ -83,6 +94,7 @@ fn make_tools( include_stop_hook: bool, include_post_compact_hook: bool, include_shell_tool: bool, + named_tools: &[String], ) -> Vec { let mut tools: Vec = (0..count) .map(|i| { @@ -93,6 +105,13 @@ fn make_tools( }) }) .collect(); + for name in named_tools { + tools.push(json!({ + "name": name, + "description": "named test tool", + "inputSchema": { "type": "object", "properties": {} }, + })); + } if include_stop_hook { tools.push(json!({ "name": "_Stop", @@ -156,6 +175,14 @@ fn main() { let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL"); let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); + // One extra no-arg tool per comma-separated bare name. + let named_tools: Vec = std::env::var("FAKE_MCP_NAMED_TOOLS") + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); // Use a channel-based stdin reader so notifications (which carry no id) // are captured even while the main thread is sleeping during a tool call. @@ -231,6 +258,7 @@ fn main() { stop_hook, post_compact_hook, shell_tool, + &named_tools, ) }), ); @@ -248,6 +276,21 @@ fn main() { .and_then(|p| p.get("name")) .and_then(Value::as_str) .unwrap_or(""); + // Append every invoked tool name so a test can prove a call + // reached the server exactly once (or never). This fires for + // ALL tools/call, including `_Stop`/`_PostCompact` hooks, so a + // test can also prove hooks are NOT permission-gated by + // observing they still reach the server without an ask. + if let Ok(path) = std::env::var("FAKE_MCP_CALL_LOG") { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(f, "{called_name}"); + } + } // Optionally spawn a long-sleeping grandchild so the test // can verify process-group killing reaches the whole tree. if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") { diff --git a/crates/buzz-agent/tests/common/mod.rs b/crates/buzz-agent/tests/common/mod.rs new file mode 100644 index 0000000000..02bdc3bc0e --- /dev/null +++ b/crates/buzz-agent/tests/common/mod.rs @@ -0,0 +1,279 @@ +//! Shared subprocess test harness for the buzz-agent ACP integration suites. +//! +//! Every integration test file is its own crate, so this module is included +//! with `mod common;` in each and compiles once per binary — each binary uses +//! only the subset it needs, hence the module-wide `dead_code` allow. +//! +//! It drives a real `buzz-agent` child over the ACP wire against a fake LLM +//! (`CapturingLlm`, which records each request body) and answers the +//! `session/request_permission` surface (`approve_permission`, selecting the +//! offered `allow_once` option by `kind`, never a hardcoded `optionId`). + +#![allow(dead_code)] + +use std::collections::VecDeque; +use std::process::Stdio; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +pub struct CapturingLlm { + pub url: String, + pub captured: Arc>>, +} + +pub async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { + spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await +} + +/// Like `spawn_capturing_llm` but each canned response carries its own HTTP +/// status, so a test can serve a real provider rejection (e.g. a context-window +/// 400) instead of only success bodies. +pub async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let queue = Arc::new(Mutex::new(VecDeque::from(responses))); + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap2 = captured.clone(); + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let queue = queue.clone(); + let captured = cap2.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + // Read until headers complete. + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + if buf.len() > 4_000_000 { + return; + } + } + // Parse Content-Length and read body. + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + let headers = &buf[..header_end]; + let mut body_len = 0usize; + for line in headers.split(|b| *b == b'\n') { + let line = std::str::from_utf8(line).unwrap_or(""); + if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { + body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); + } + } + while buf.len() < header_end + body_len { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + } + if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { + captured.lock().await.push(req); + } + let (status, body) = queue + .lock() + .await + .pop_front() + .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); + let body_s = serde_json::to_string(&body).unwrap(); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + _ => "Error", + }; + let resp = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body_s.len(), body_s, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + CapturingLlm { url, captured } +} + +pub struct Harness { + child: tokio::process::Child, + stdin: tokio::process::ChildStdin, + stdout: BufReader, + stderr: Arc>, + next_id: i64, +} + +impl Harness { + pub async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { + let bin = env!("CARGO_BIN_EXE_buzz-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") + .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") + .env("BUZZ_AGENT_MAX_ROUNDS", "8") + .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); + for (k, v) in extra { + cmd.env(k, v); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn().expect("spawn buzz-agent"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let stderr = child.stderr.take().unwrap(); + let stderr_buf = Arc::new(StdMutex::new(String::new())); + let stderr_out = Arc::clone(&stderr_buf); + tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + let n = match reader.read_line(&mut line).await { + Ok(n) => n, + Err(_) => break, + }; + if n == 0 { + break; + } + if let Ok(mut out) = stderr_out.lock() { + out.push_str(&line); + } + } + }); + Self { + child, + stdin, + stdout, + stderr: stderr_buf, + next_id: 1, + } + } + + pub async fn spawn(base_url: &str) -> Self { + Self::spawn_with_env(base_url, &[]).await + } + + pub async fn send(&mut self, method: &str, params: Value) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + id + } + + pub async fn notify(&mut self, method: &str, params: Value) { + self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params })) + .await; + } + + pub async fn write(&mut self, msg: Value) { + let mut s = serde_json::to_string(&msg).unwrap(); + s.push('\n'); + self.stdin.write_all(s.as_bytes()).await.unwrap(); + self.stdin.flush().await.unwrap(); + } + + pub async fn recv(&mut self) -> Value { + let mut line = String::new(); + let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) + .await + .expect("recv timeout") + .expect("read line"); + assert!(n > 0, "agent EOF; stderr={}", self.stderr_text()); + serde_json::from_str(&line).expect("non-JSON line") + } + + pub async fn recv_until bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if pred(&v) { + return v; + } + } + } + + /// Like `recv_until`, but auto-approves any `session/request_permission` + /// seen while waiting. Tests that exercise tool execution, not the + /// permission boundary (that lives in `permission_boundary.rs`), must + /// approve a model-issued tool call so it reaches the server. + pub async fn recv_until_approving bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let resp = approve_permission(&v); + self.write(resp).await; + continue; + } + if pred(&v) { + return v; + } + } + } + + pub async fn shutdown(mut self) { + drop(self.stdin); + let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; + let _ = self.child.start_kill(); + } + + pub fn stderr_text(&self) -> String { + self.stderr.lock().map(|s| s.clone()).unwrap_or_default() + } +} + +pub fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +pub fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { + json!({ + "id": "cc-2", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", "content": null, + "tool_calls": [{ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }], + }, + "finish_reason": "tool_calls", + }], + }) +} + +/// Select the offered option whose `kind == "allow_once"` and return the +/// `session/request_permission` response. Mirrors buzz-acp's answering side, +/// which selects by `kind`, never by a hardcoded `optionId`. Centralizing this +/// means a future option-id rename can't silently turn allow into a denial. +pub fn approve_permission(request: &Value) -> Value { + let option_id = request["params"]["options"] + .as_array() + .and_then(|opts| opts.iter().find(|o| o["kind"] == "allow_once")) + .and_then(|o| o["optionId"].as_str()) + .expect("request must offer an allow_once option"); + json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": { "outcome": { "outcome": "selected", "optionId": option_id } }, + }) +} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 4253ef329c..fefd5a24c5 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -16,6 +16,9 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; use tokio::sync::Mutex; +mod common; +use common::approve_permission; + async fn spawn_fake_llm(responses: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); @@ -396,12 +399,7 @@ async fn unsupported_image_response_recovers_without_replaying_image() { loop { let message = h.recv().await; if message.get("method") == Some(&json!("session/request_permission")) { - h.write(json!({ - "jsonrpc": "2.0", - "id": message["id"], - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&message)).await; } else if message["id"] == json!(prompt_id) { assert_eq!(message["result"]["stopReason"], "end_turn"); break; diff --git a/crates/buzz-agent/tests/permission_boundary.rs b/crates/buzz-agent/tests/permission_boundary.rs new file mode 100644 index 0000000000..2873ba14d6 --- /dev/null +++ b/crates/buzz-agent/tests/permission_boundary.rs @@ -0,0 +1,631 @@ +//! Production authorization-boundary tests for the `session/request_permission` +//! surface. +//! +//! These drive a real `buzz-agent` subprocess against a fake MCP server and a +//! capturing LLM, and prove the security invariant end to end: an LLM-issued +//! MCP tool call reaches the server IFF the client selected the offered +//! allow-once option, and every other outcome fails closed without invoking the +//! tool. `fake_mcp` appends each invoked *bare* tool name to `FAKE_MCP_CALL_LOG` +//! (fired for `_Stop`/`_PostCompact` too), so "reached the tool exactly once" +//! and "never reached the tool" are both directly observable from disk. +//! +//! Timeout/abort/multi-session-cap state invariants live in the broker-seam unit +//! tests (`src/permission.rs`), which use an injectable deadline and inspect the +//! private correlation map — neither of which a subprocess can do. +//! +//! The subprocess `Harness`, capturing LLM, and `approve_permission` helper are +//! shared with the other integration suites via `mod common`. + +use std::time::Duration; + +use serde_json::{json, Value}; + +mod common; +use common::{approve_permission, spawn_capturing_llm, Harness}; + +// ───────────────────────────────────────────────────────────────────────────── +// LLM response builders +// ───────────────────────────────────────────────────────────────────────────── + +fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +/// One assistant turn issuing `calls`, each `(id, qualified_name, arguments)`. +fn openai_tool_calls(calls: &[(&str, &str, Value)]) -> Value { + let tool_calls: Vec = calls + .iter() + .map(|(id, name, args)| { + json!({ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }) + }) + .collect(); + json!({ + "id": "cc-tc", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": tool_calls }, + "finish_reason": "tool_calls", + }], + }) +} + +fn shell_call(id: &str) -> Value { + openai_tool_calls(&[(id, "fake__shell", json!({ "command": "ls" }))]) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Permission-response builders + drivers +// ───────────────────────────────────────────────────────────────────────────── + +/// Bare JSON-RPC response selecting `option_id`. +fn resp_selected(id: &Value, option_id: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id.clone(), + "result": { "outcome": { "outcome": "selected", "optionId": option_id } }, + }) +} + +/// Bare JSON-RPC response carrying an arbitrary `result` shape. +fn resp_result(id: &Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id.clone(), "result": result }) +} + +/// Bare JSON-RPC *error* response (id present, `error`, no `result`). +fn resp_error(id: &Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id.clone(), + "error": { "code": -32601, "message": "method not found" }, + }) +} + +/// The tool title carried by a permission request, across both wire shapes. +fn perm_title(req: &Value) -> String { + let p = &req["params"]; + p["title"] + .as_str() + .or_else(|| p["toolCall"]["title"].as_str()) + .or_else(|| p["subject"]["toolCall"]["title"].as_str()) + .unwrap_or("") + .to_owned() +} + +/// Drive one prompt to completion. For each `session/request_permission`, +/// `decide(&req)` returns `Some(response)` to answer or `None` to leave it +/// unanswered. Returns the final prompt response and every request seen. +async fn drive( + h: &mut Harness, + sid: &str, + prompt: &str, + mut decide: impl FnMut(&Value) -> Option, +) -> (Value, Vec) { + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": prompt }] }), + ) + .await; + let mut requests: Vec = Vec::new(); + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + requests.push(v.clone()); + if let Some(resp) = decide(&v) { + h.write(resp).await; + } + continue; + } + if v["id"] == json!(p) { + return (v, requests); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Session init + call-log helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn call_log_path(tag: &str) -> String { + let p = std::env::temp_dir().join(format!( + "buzz_perm_calllog_{tag}_{}_{:x}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + let s = p.to_string_lossy().to_string(); + let _ = std::fs::remove_file(&s); + s +} + +/// Invoked tool names recorded by fake_mcp (bare names, one per `tools/call`). +/// A missing file means zero invocations. +fn call_log_lines(path: &str) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_owned) + .collect() +} + +/// Initialize + create a session with a single fake MCP server named `fake`, +/// negotiating `protocol_version` and passing `mcp_env` to the server. `cwd` +/// controls skill discovery (`.agents/skills`). +async fn init( + h: &mut Harness, + protocol_version: u32, + cwd: &str, + mcp_env: &[(&str, &str)], +) -> String { + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let env: Vec = mcp_env + .iter() + .map(|(k, v)| json!({ "name": k, "value": v })) + .collect(); + h.send( + "initialize", + json!({ "protocolVersion": protocol_version, "clientCapabilities": {} }), + ) + .await; + let _ = h.recv().await; + let servers = if mcp_env.is_empty() { + json!([]) + } else { + json!([{ "name": "fake", "command": fake_mcp, "args": [], "env": env }]) + }; + h.send("session/new", json!({ "cwd": cwd, "mcpServers": servers })) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + r["result"]["sessionId"] + .as_str() + .unwrap_or_else(|| panic!("session/new failed: {r}, stderr={}", h.stderr_text())) + .to_owned() +} + +fn stop_reason(resp: &Value) -> String { + resp["result"]["stopReason"] + .as_str() + .unwrap_or("") + .to_owned() +} + +// ═════════════════════════════════════════════════════════════════════════════ +// The authorization boundary +// ═════════════════════════════════════════════════════════════════════════════ + +/// Exact allow reaches the tool exactly once — and never *before* approval. +/// The pre-approval check proves the gate precedes the MCP call, not just that +/// the tally ends at one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_allow_reaches_tool_exactly_once_and_not_before_approval() { + let log = call_log_path("allow"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let log_for_check = log.clone(); + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + // Before answering, the tool must not have run. + assert!( + call_log_lines(&log_for_check).is_empty(), + "tool invoked BEFORE approval" + ); + Some(approve_permission(req)) + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 1, "exactly one call → exactly one ask"); + assert_eq!( + call_log_lines(&log), + vec!["shell"], + "approved tool reached MCP exactly once" + ); + h.shutdown().await; +} + +/// Selecting the offered reject option never invokes the tool, and the model +/// sees a permission-denied tool error (the turn continues to `end_turn`). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_reject_option_never_invokes_tool() { + let log = call_log_path("reject"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("understood")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + Some(resp_selected(&req["id"], "reject_once")) + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 1); + assert!( + call_log_lines(&log).is_empty(), + "rejected tool must never reach MCP" + ); + h.shutdown().await; +} + +/// Every non-authorizing response shape fails closed: the tool never runs. +/// One subprocess per shape keeps the failure attributable. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_adversarial_outcomes_never_invoke_tool() { + // (tag, response-for-request builder) — the full fail-closed matrix. + type Shape = (&'static str, fn(&Value) -> Value); + let shapes: Vec = vec![ + ("cancelled", |req| { + resp_result(&req["id"], json!({ "outcome": { "outcome": "cancelled" } })) + }), + ("jsonrpc_error", |req| resp_error(&req["id"])), + ("missing_outcome", |req| resp_result(&req["id"], json!({}))), + ("empty_outcome", |req| { + resp_result(&req["id"], json!({ "outcome": {} })) + }), + ("selected_no_option", |req| { + resp_result(&req["id"], json!({ "outcome": { "outcome": "selected" } })) + }), + ("unknown_outcome", |req| { + resp_result( + &req["id"], + json!({ "outcome": { "outcome": "banana", "optionId": "allow_once" } }), + ) + }), + ("wrong_option_id", |req| { + resp_selected(&req["id"], "not_an_offered_option") + }), + // Structurally malformed *frames* that each still carry a well-formed + // `selected`/`allow_once` result — the payload would authorize, so only + // the frame-structure check (wire::classify) stands between them and the + // tool. These mirror the two broker-seam unit tests + // (`src/permission.rs::test_frame_with_{result_and_error,non_string_method}_denies_tool`) + // at the live MCP-log seam, proving the frame gate denies end to end. + ("result_and_error", |req| { + let mut frame = approve_permission(req); + frame["error"] = json!({ "code": -32603, "message": "internal" }); + frame + }), + ("non_string_method", |req| { + let mut frame = approve_permission(req); + frame["method"] = json!(7); + frame + }), + ]; + + for (tag, build) in shapes { + let log = call_log_path(tag); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("ok")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |req| Some(build(req))).await; + + assert_eq!( + stop_reason(&resp), + "end_turn", + "shape {tag}: turn should continue" + ); + assert_eq!(requests.len(), 1, "shape {tag}: exactly one ask"); + assert!( + call_log_lines(&log).is_empty(), + "shape {tag}: non-authorizing outcome must never reach MCP" + ); + h.shutdown().await; + } +} + +/// A stale/unknown/foreign response id is ignored and does not unblock the live +/// waiter; the correct id then authorizes exactly once. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stale_id_ignored_then_real_id_authorizes() { + let log = call_log_path("staleid"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "go" }] }), + ) + .await; + + // Wait for the ask. + let req = h + .recv_until(|v| v.get("method") == Some(&json!("session/request_permission"))) + .await; + + // Feed several ignorable responses first: a minted-but-never-issued id, a + // foreign numeric id, and a null id. None may unblock the real waiter. + h.write(resp_selected(&json!("perm-9999"), "allow_once")) + .await; + h.write(resp_selected(&json!(7), "allow_once")).await; + h.write(resp_selected(&Value::Null, "allow_once")).await; + // Give the agent a beat to (wrongly) act on any of them. + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + call_log_lines(&log).is_empty(), + "stale/foreign ids must not authorize the pending call" + ); + + // The real id resolves it exactly once. + h.write(approve_permission(&req)).await; + let resp = h.recv_until(|v| v["id"] == json!(p)).await; + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!( + call_log_lines(&log), + vec!["shell"], + "only the correct id authorizes, exactly once" + ); + h.shutdown().await; +} + +/// `session/cancel` while a permission ask is outstanding terminates the turn +/// promptly, executes nothing, and needs no `cancelled` permission response — +/// a Buzz client always answers, but a non-Buzz ACP client may violate the spec +/// by staying silent, and cancellation must not depend on that answer. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_cancel_while_waiting_executes_nothing_without_client_answer() { + let log = call_log_path("cancelwait"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{ "type": "text", "text": "go" }] }), + ) + .await; + + // Wait for the ask, then cancel WITHOUT ever answering the permission. + let _req = h + .recv_until(|v| v.get("method") == Some(&json!("session/request_permission"))) + .await; + h.notify("session/cancel", json!({ "sessionId": sid })) + .await; + + let resp = h.recv_until(|v| v["id"] == json!(p)).await; + assert_eq!( + stop_reason(&resp), + "cancelled", + "cancel resolves the turn without a client permission answer" + ); + assert!( + call_log_lines(&log).is_empty(), + "a cancelled ask must never reach the tool" + ); + h.shutdown().await; +} + +/// Two parallel calls each get their own ask (distinct ids); crossed decisions — +/// deny the first-asked, allow the second-asked — authorize only the allowed +/// call. Serial admission (`max_parallel_tools=1`) serializes the asks: the +/// second ask fires only after the first resolves. Which tool is admitted first +/// is a tokio scheduling detail, so the test denies whichever is asked first and +/// proves only the allowed (second) call reached MCP — order-agnostic. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_crossed_parallel_decisions_authorize_only_matching_call() { + let log = call_log_path("crossed"); + // Two distinct registered tools so the call log distinguishes them by name. + let llm = spawn_capturing_llm(vec![ + openai_tool_calls(&[ + ("tc-alpha", "fake__alpha", json!({})), + ("tc-bravo", "fake__bravo", json!({})), + ]), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_MAX_PARALLEL_TOOLS", "1")]).await; + let sid = init( + &mut h, + 1, + "/tmp", + &[ + ("FAKE_MCP_NAMED_TOOLS", "alpha,bravo"), + ("FAKE_MCP_CALL_LOG", &log), + ], + ) + .await; + + // Deny whichever tool is asked first; allow the second. Ids are distinct. + let mut seen_ids: Vec = Vec::new(); + let mut allowed_title = String::new(); + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + assert!( + !seen_ids.contains(&req["id"]), + "each parallel call must get a distinct request id" + ); + seen_ids.push(req["id"].clone()); + if seen_ids.len() == 1 { + Some(resp_selected(&req["id"], "reject_once")) // deny the first-asked + } else { + allowed_title = perm_title(req); + Some(approve_permission(req)) // allow the second-asked + } + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 2, "one ask per parallel call"); + // The call log records bare tool names; the title carries the qualified + // `__`. Only the allowed (second-asked) call reached MCP. + let allowed_bare = allowed_title + .strip_prefix("fake__") + .expect("qualified title") + .to_owned(); + assert_eq!( + call_log_lines(&log), + vec![allowed_bare], + "only the allowed (second-asked) call reached MCP; the denied one did not" + ); + h.shutdown().await; +} + +/// The built-in `load_skill` tool is not an MCP call and is exempt from the +/// permission boundary: it executes with no `session/request_permission`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_load_skill_emits_no_permission_request() { + let tmp = tempfile::TempDir::new().unwrap(); + let cwd = tmp.path(); + let skill_dir = cwd.join(".agents/skills/my-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nSKILL_BODY_77\n", + ) + .unwrap(); + + let llm = spawn_capturing_llm(vec![ + openai_tool_calls(&[("tc-ls", "load_skill", json!({ "name": "my-skill" }))]), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn(&llm.url).await; + // No MCP server: `load_skill` is a built-in, and skills come from `cwd`. + let sid = init(&mut h, 1, cwd.to_str().unwrap(), &[]).await; + + let (resp, requests) = drive(&mut h, &sid, "use my-skill", |_| { + panic!("load_skill must not trigger a permission request") + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert!(requests.is_empty(), "built-in load_skill is exempt"); + h.shutdown().await; +} + +/// Lifecycle hooks (`_Stop`, `_PostCompact`) invoke MCP through `call_hooks`, +/// not the model-issued tool path, so they are exempt: the hook reaches the +/// server (call log records it) with no permission ask. Here the `_Stop` hook +/// objects once, forcing a hook invocation the test can observe. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_stop_hook_reaches_mcp_without_permission_ask() { + let log = call_log_path("stophook"); + // Text turn triggers the _Stop gate → hook objects once → agent loops → + // second text turn, hook silent → end_turn. No model-issued tool call. + let llm = spawn_capturing_llm(vec![ + openai_text("premature"), + openai_text("really done"), + openai_text("unexpected"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init( + &mut h, + 1, + "/tmp", + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open work"), + ("FAKE_MCP_STOP_COUNT", "1"), + ("FAKE_MCP_CALL_LOG", &log), + ], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |_| { + panic!("a lifecycle hook must not trigger a permission request") + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert!(requests.is_empty(), "_Stop hook is exempt from the ask"); + assert!( + call_log_lines(&log).contains(&"_Stop".to_owned()), + "the _Stop hook still reached MCP without an ask; log={:?}", + call_log_lines(&log) + ); + h.shutdown().await; +} + +/// Under a v2-negotiated connection, the emitted `session/request_permission` +/// carries the v2 shape (`subject.toolCall`, top-level `title`), never the v1 +/// legacy top-level `toolCall`. Complements the pure v1/v2 builder unit tests +/// with an end-to-end proof that the negotiated version reaches the wire. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_v2_negotiation_emits_v2_request_shape() { + let log = call_log_path("v2shape"); + let llm = spawn_capturing_llm(vec![shell_call("tc1"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init( + &mut h, + 2, // negotiate v2 + "/tmp", + &[("FAKE_MCP_SHELL_TOOL", "1"), ("FAKE_MCP_CALL_LOG", &log)], + ) + .await; + + let (resp, requests) = drive(&mut h, &sid, "go", |req| { + let params = &req["params"]; + // v2: tool context under `subject.toolCall`, with top-level `title`. + assert_eq!(params["subject"]["type"], "tool_call", "v2 uses subject"); + assert_eq!(params["subject"]["toolCall"]["title"], "fake__shell"); + assert_eq!(params["title"], "fake__shell", "v2 has top-level title"); + assert!( + params.get("toolCall").is_none(), + "v2 must not carry the v1 top-level toolCall" + ); + Some(approve_permission(req)) + }) + .await; + + assert_eq!(stop_reason(&resp), "end_turn"); + assert_eq!(requests.len(), 1); + assert_eq!(call_log_lines(&log), vec!["shell"]); + h.shutdown().await; +} diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 6a4f347f6b..edee9090d8 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -5,220 +5,16 @@ //! - cancellation leaves history valid for the next prompt //! - empty-content assistant turn doesn't poison OpenAI history -use std::collections::VecDeque; use std::process::Stdio; -use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; use serde_json::{json, Value}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; -use tokio::sync::Mutex; -struct CapturingLlm { - url: String, - captured: Arc>>, -} - -async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { - spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await -} - -/// Like `spawn_capturing_llm` but each canned response carries its own HTTP -/// status, so a test can serve a real provider rejection (e.g. a context-window -/// 400) instead of only success bodies. -async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); - let cap2 = captured.clone(); - tokio::spawn(async move { - loop { - let (mut sock, _) = match listener.accept().await { - Ok(p) => p, - Err(_) => return, - }; - let queue = queue.clone(); - let captured = cap2.clone(); - tokio::spawn(async move { - let mut buf = Vec::new(); - let mut tmp = [0u8; 8192]; - // Read until headers complete. - while !buf.windows(4).any(|w| w == b"\r\n\r\n") { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - if buf.len() > 4_000_000 { - return; - } - } - // Parse Content-Length and read body. - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - let headers = &buf[..header_end]; - let mut body_len = 0usize; - for line in headers.split(|b| *b == b'\n') { - let line = std::str::from_utf8(line).unwrap_or(""); - if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { - body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); - } - } - while buf.len() < header_end + body_len { - match sock.read(&mut tmp).await { - Ok(0) | Err(_) => return, - Ok(n) => buf.extend_from_slice(&tmp[..n]), - } - } - if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { - captured.lock().await.push(req); - } - let (status, body) = queue - .lock() - .await - .pop_front() - .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); - let body_s = serde_json::to_string(&body).unwrap(); - let reason = match status { - 200 => "OK", - 400 => "Bad Request", - _ => "Error", - }; - let resp = format!( - "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body_s.len(), body_s, - ); - let _ = sock.write_all(resp.as_bytes()).await; - let _ = sock.shutdown().await; - }); - } - }); - CapturingLlm { url, captured } -} - -struct Harness { - child: tokio::process::Child, - stdin: tokio::process::ChildStdin, - stdout: BufReader, - stderr: Arc>, - next_id: i64, -} - -impl Harness { - async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { - let bin = env!("CARGO_BIN_EXE_buzz-agent"); - let mut cmd = tokio::process::Command::new(bin); - cmd.env("BUZZ_AGENT_PROVIDER", "openai") - .env("OPENAI_COMPAT_API_KEY", "test") - .env("OPENAI_COMPAT_MODEL", "fake-model") - .env("OPENAI_COMPAT_BASE_URL", base_url) - .env("BUZZ_AGENT_LLM_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", "5") - .env("BUZZ_AGENT_MAX_ROUNDS", "8") - .env("BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); - for (k, v) in extra { - cmd.env(k, v); - } - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - let mut child = cmd.spawn().expect("spawn buzz-agent"); - let stdin = child.stdin.take().unwrap(); - let stdout = BufReader::new(child.stdout.take().unwrap()); - let stderr = child.stderr.take().unwrap(); - let stderr_buf = Arc::new(StdMutex::new(String::new())); - let stderr_out = Arc::clone(&stderr_buf); - tokio::spawn(async move { - let mut reader = BufReader::new(stderr); - let mut line = String::new(); - loop { - line.clear(); - let n = match reader.read_line(&mut line).await { - Ok(n) => n, - Err(_) => break, - }; - if n == 0 { - break; - } - if let Ok(mut out) = stderr_out.lock() { - out.push_str(&line); - } - } - }); - Self { - child, - stdin, - stdout, - stderr: stderr_buf, - next_id: 1, - } - } - - async fn spawn(base_url: &str) -> Self { - Self::spawn_with_env(base_url, &[]).await - } - - async fn send(&mut self, method: &str, params: Value) -> i64 { - let id = self.next_id; - self.next_id += 1; - self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) - .await; - id - } - - async fn notify(&mut self, method: &str, params: Value) { - self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params })) - .await; - } - - async fn write(&mut self, msg: Value) { - let mut s = serde_json::to_string(&msg).unwrap(); - s.push('\n'); - self.stdin.write_all(s.as_bytes()).await.unwrap(); - self.stdin.flush().await.unwrap(); - } - - async fn recv(&mut self) -> Value { - let mut line = String::new(); - let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) - .await - .expect("recv timeout") - .expect("read line"); - assert!(n > 0, "agent EOF"); - serde_json::from_str(&line).expect("non-JSON line") - } - - async fn recv_until bool>(&mut self, mut pred: F) -> Value { - loop { - let v = self.recv().await; - if pred(&v) { - return v; - } - } - } - - async fn shutdown(mut self) { - drop(self.stdin); - let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; - let _ = self.child.start_kill(); - } - - fn stderr_text(&self) -> String { - self.stderr.lock().map(|s| s.clone()).unwrap_or_default() - } -} - -fn openai_text(content: &str) -> Value { - json!({ - "id": "cc-1", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { "role": "assistant", "content": content }, - "finish_reason": "stop", - }], - }) -} +mod common; +use common::{ + approve_permission, openai_text, openai_tool_call, spawn_capturing_llm, + spawn_capturing_llm_with_status, Harness, +}; /// Like [`openai_text`] but attaches a `usage` block so tests can drive the /// token-based handoff gate. `prompt_tokens` is the input-token count the @@ -253,23 +49,6 @@ fn openai_max_tokens(content: &str, tool_calls: Value) -> Value { }) } -fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { - json!({ - "id": "cc-2", "object": "chat.completion", "model": "fake-model", - "choices": [{ - "index": 0, - "message": { - "role": "assistant", "content": null, - "tool_calls": [{ - "id": id, "type": "function", - "function": { "name": name, "arguments": args.to_string() }, - }], - }, - "finish_reason": "tool_calls", - }], - }) -} - async fn init_session(h: &mut Harness, mcp_servers: Value) -> String { h.send( "initialize", @@ -676,13 +455,7 @@ async fn per_turn_tool_call_cap_enforced() { loop { let v = h.recv().await; if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v.get("method") == Some(&json!("session/update")) @@ -858,7 +631,7 @@ async fn hook_stop_blocks_premature_end() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; + let r = h.recv_until_approving(|v| v["id"] == json!(p)).await; assert!(r.get("result").is_some(), "errored: {r}"); assert_eq!(r["result"]["stopReason"], "end_turn"); @@ -936,7 +709,7 @@ async fn hook_stop_budget_exhausted() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let r = h.recv_until(|v| v["id"] == json!(p)).await; + let r = h.recv_until_approving(|v| v["id"] == json!(p)).await; assert!(r.get("result").is_some(), "errored: {r}"); assert_eq!(r["result"]["stopReason"], "end_turn"); @@ -1517,7 +1290,7 @@ async fn stale_usage_plus_history_growth_triggers_handoff() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let _ = h.recv_until(|v| v["id"] == json!(p)).await; + let _ = h.recv_until_approving(|v| v["id"] == json!(p)).await; // req1 (tool_call) + summarize (handoff) + req2 (done) = 3. Without the // growth estimate we'd see only 2 (stale 8500 < 9000, no handoff). let captured = llm.captured.lock().await.len(); @@ -1788,7 +1561,7 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { .await; // Wait for tool call to be in-progress. - h.recv_until(|v| { + h.recv_until_approving(|v| { v.get("params") .and_then(|p| p.get("update")) .and_then(|u| u.get("status")) @@ -1903,13 +1676,7 @@ async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value { loop { let v = h.recv().await; if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v["id"] == json!(p) { @@ -2664,7 +2431,9 @@ async fn max_tokens_recovery_can_proceed_to_tool_call() { json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), ) .await; - let reply = h.recv_until(|v| v["id"] == json!(prompt_id)).await; + let reply = h + .recv_until_approving(|v| v["id"] == json!(prompt_id)) + .await; assert_eq!(reply["result"]["stopReason"], "end_turn", "{reply}"); let requests = llm.captured.lock().await; assert_eq!(requests.len(), 3); @@ -3642,13 +3411,7 @@ async fn handoff_cap_binds_within_a_single_turn() { } if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v["id"] == json!(p2) { @@ -3795,13 +3558,7 @@ async fn failed_summarize_burns_handoff_attempt_budget() { loop { let v = h.recv().await; if v.get("method") == Some(&json!("session/request_permission")) { - let id = v["id"].clone(); - h.write(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, - })) - .await; + h.write(approve_permission(&v)).await; continue; } if v["id"] == json!(p2) { From bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 25 Aug 2026 16:50:21 +0100 Subject: [PATCH 07/24] Fix TipTap editor mount race (#6779) ## Summary - wait for TipTap's mount lifecycle before reading the selection formatting tray's editor DOM - detach and reattach DOM listeners across editor view unmounts and remounts - cover mounted and pre-mount editor view access ## Testing - `just ci` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/composer-selection-formatting.spec.ts --project=smoke --grep "right-clicking selected composer text"` - Native Builderlab staging: reproduced the pre-mount crash on main, relaunched with the fix, and verified the composer mounts without the TipTap error Signed-off-by: kenny lopez --- .../messages/ui/SelectionFormattingTray.tsx | 36 ++++++++++++++----- .../selectionFormattingTrayEditorDom.test.mjs | 28 +++++++++++++++ .../ui/selectionFormattingTrayEditorDom.ts | 10 ++++++ 3 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs create mode 100644 desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts diff --git a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx index 84934d1826..ab092b5756 100644 --- a/desktop/src/features/messages/ui/SelectionFormattingTray.tsx +++ b/desktop/src/features/messages/ui/SelectionFormattingTray.tsx @@ -4,6 +4,7 @@ import type { Editor } from "@tiptap/react"; import { cn } from "@/shared/lib/cn"; import { FormattingToolbar } from "./FormattingToolbar"; +import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom"; type SelectionFormattingTrayProps = { editor: Editor | null; @@ -129,6 +130,7 @@ export function SelectionFormattingTray({ if ( suppressRightClickUpdatesRef.current || !editor || + !getMountedEditorDom(editor) || disabled || !editor.isEditable || !editor.isFocused @@ -161,7 +163,7 @@ export function SelectionFormattingTray({ return; } - const editorDom = editor.view.dom; + let editorDom: HTMLElement | null = null; const hide = () => setPosition(null); const handleContextMenu = () => { suppressRightClickUpdatesRef.current = true; @@ -176,26 +178,44 @@ export function SelectionFormattingTray({ if (event.button === 0) clearSuppression(); }; - scheduleUpdate(); + const detachEditorDom = () => { + if (!editorDom) return; + editorDom.removeEventListener("contextmenu", handleContextMenu); + editorDom.removeEventListener("pointerdown", handlePointerDown); + editorDom.removeEventListener("keydown", clearSuppression); + editorDom = null; + }; + + const attachEditorDom = () => { + const nextEditorDom = getMountedEditorDom(editor); + if (!nextEditorDom || nextEditorDom === editorDom) return; + detachEditorDom(); + editorDom = nextEditorDom; + editorDom.addEventListener("contextmenu", handleContextMenu); + editorDom.addEventListener("pointerdown", handlePointerDown); + editorDom.addEventListener("keydown", clearSuppression); + scheduleUpdate(); + }; + + editor.on("mount", attachEditorDom); + editor.on("unmount", detachEditorDom); editor.on("selectionUpdate", scheduleUpdate); editor.on("transaction", scheduleUpdate); editor.on("focus", scheduleUpdate); editor.on("blur", hide); - editorDom.addEventListener("contextmenu", handleContextMenu); - editorDom.addEventListener("pointerdown", handlePointerDown); - editorDom.addEventListener("keydown", clearSuppression); window.addEventListener("resize", scheduleUpdate); window.addEventListener("scroll", scheduleUpdate, true); + attachEditorDom(); return () => { cancelScheduledUpdate(); + editor.off("mount", attachEditorDom); + editor.off("unmount", detachEditorDom); editor.off("selectionUpdate", scheduleUpdate); editor.off("transaction", scheduleUpdate); editor.off("focus", scheduleUpdate); editor.off("blur", hide); - editorDom.removeEventListener("contextmenu", handleContextMenu); - editorDom.removeEventListener("pointerdown", handlePointerDown); - editorDom.removeEventListener("keydown", clearSuppression); + detachEditorDom(); window.removeEventListener("resize", scheduleUpdate); window.removeEventListener("scroll", scheduleUpdate, true); }; diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs new file mode 100644 index 0000000000..433af4664d --- /dev/null +++ b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getMountedEditorDom } from "./selectionFormattingTrayEditorDom.ts"; + +test("returns null while the TipTap editor view is not mounted", () => { + const editor = { + get view() { + return new Proxy( + {}, + { + get(_target, key) { + throw new Error(`editor view cannot access ${String(key)}`); + }, + }, + ); + }, + }; + + assert.equal(getMountedEditorDom(editor), null); +}); + +test("returns the editor DOM after the TipTap view mounts", () => { + const dom = {}; + const editor = { view: { dom } }; + + assert.equal(getMountedEditorDom(editor), dom); +}); diff --git a/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts new file mode 100644 index 0000000000..25733239ba --- /dev/null +++ b/desktop/src/features/messages/ui/selectionFormattingTrayEditorDom.ts @@ -0,0 +1,10 @@ +import type { Editor } from "@tiptap/react"; + +/** Returns the editor DOM only after TipTap has mounted its view. */ +export function getMountedEditorDom(editor: Editor): HTMLElement | null { + try { + return editor.view.dom; + } catch { + return null; + } +} From d12dea4e67c5224a626d9c00f45e68d1def72d4c Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Tue, 25 Aug 2026 12:02:18 -0400 Subject: [PATCH 08/24] Support community deletion in versioned media buckets (#6738) --- Cargo.lock | 1 + crates/buzz-db/src/deletion.rs | 193 +++++- crates/buzz-deletion/src/lib.rs | 366 +++++++++-- crates/buzz-media/Cargo.toml | 1 + crates/buzz-media/src/lib.rs | 5 +- crates/buzz-media/src/storage.rs | 573 +++++++++++++++++- crates/buzz-media/tests/versioned_minio.rs | 363 +++++++++++ crates/buzz-relay/src/api/git/transport.rs | 98 +++ .../buzz-relay/src/handlers/side_effects.rs | 75 ++- deploy/charts/buzz/values.yaml | 9 + desktop/src-tauri/Cargo.lock | 1 + 11 files changed, 1584 insertions(+), 101 deletions(-) create mode 100644 crates/buzz-media/tests/versioned_minio.rs diff --git a/Cargo.lock b/Cargo.lock index 18c53c18ca..9ff8ccf618 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,6 +1136,7 @@ dependencies = [ "infer", "mp4", "nostr 0.44.7", + "quick-xml 0.38.4", "rust-s3", "serde", "serde_json", diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index fbe69f22a6..7db0df8ee3 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -310,11 +310,11 @@ pub struct StorageManifest { pub struct PrefixManifest { /// Exact community-scoped listing prefix. pub prefix: String, - /// Objects under the prefix at enumeration time. + /// Object versions and delete markers under the prefix at enumeration time. pub object_count: u64, - /// Total object bytes under the prefix at enumeration time. + /// Total object-version bytes under the prefix at enumeration time. pub total_bytes: u64, - /// Hex SHA-256 of the newline-terminated ascending key stream. + /// Hex SHA-256 of the newline-terminated ascending version-entry stream. pub keys_digest: String, } @@ -325,10 +325,88 @@ pub struct ManifestKeyChunk { pub chunk_no: i64, /// The tenant prefix every key in this chunk lives under. pub prefix: String, - /// Strictly ascending keys. + /// Strictly ascending serialized manifest entries. pub keys: Vec, } +/// One immutable object-store manifest entry. +/// +/// Version 5 storage manifests serialize entries as +/// `key\u{1f}version_id\u{1f}kind`, where kind is `object` or +/// `delete_marker`. Version 4 manifests used bare keys. Keeping the side-table +/// column name unchanged avoids a database migration while making the stream +/// explicitly version-aware. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StorageManifestEntry { + /// Object key. + pub key: String, + /// S3 version id. + pub version_id: String, + /// Either `object` or `delete_marker`. + pub kind: String, +} + +impl StorageManifestEntry { + /// Create a manifest entry. + pub fn new( + key: impl Into, + version_id: impl Into, + kind: impl Into, + ) -> Self { + Self { + key: key.into(), + version_id: version_id.into(), + kind: kind.into(), + } + } + + /// Serialize this entry into the chunk stream. + pub fn encode(&self) -> Result { + validate_manifest_component("key", &self.key)?; + validate_manifest_component("version id", &self.version_id)?; + validate_manifest_component("kind", &self.kind)?; + if self.kind != "object" && self.kind != "delete_marker" { + return Err(DbError::DeletionSafety(format!( + "unsupported storage manifest entry kind {}", + self.kind + ))); + } + Ok(format!( + "{}\u{1f}{}\u{1f}{}", + self.key, self.version_id, self.kind + )) + } + + /// Decode a manifest stream entry. + pub fn decode(value: &str) -> Result { + let mut parts = value.split('\u{1f}'); + let key = parts.next().unwrap_or_default(); + let version_id = parts.next().ok_or_else(|| { + DbError::DeletionSafety("storage manifest entry is missing version id".to_string()) + })?; + let kind = parts.next().ok_or_else(|| { + DbError::DeletionSafety("storage manifest entry is missing kind".to_string()) + })?; + if parts.next().is_some() { + return Err(DbError::DeletionSafety( + "storage manifest entry has too many fields".to_string(), + )); + } + let entry = Self::new(key, version_id, kind); + entry.encode()?; + Ok(entry) + } +} + +fn validate_manifest_component(name: &str, value: &str) -> Result<()> { + if value.is_empty() || value.contains(['\n', '\u{1f}']) { + return Err(DbError::DeletionSafety(format!( + "storage manifest {name} is empty or contains a reserved delimiter" + ))); + } + Ok(()) +} + /// One durable fleet-wide object-store taxonomy sweep record. #[derive(Debug, Clone, Serialize)] pub struct TaxonomySweep { @@ -358,11 +436,11 @@ type TaxonomySweepRow = ( i64, ); -/// Streaming SHA-256 over a strictly ascending key stream. +/// Streaming SHA-256 over a strictly ascending storage manifest stream. /// /// The executor's prefix enumeration and the destructive freeze's chunk -/// validation both fold keys through this, so "the chunk rows are exactly -/// the frozen enumeration" reduces to digest equality. Each key is hashed +/// validation both fold entries through this, so "the chunk rows are exactly +/// the frozen enumeration" reduces to digest equality. Each entry is hashed /// with a trailing newline so concatenation cannot alias two streams. pub struct KeyStreamDigest { hasher: Sha256, @@ -395,6 +473,17 @@ impl KeyStreamDigest { "storage key stream is not strictly ascending at {key}" ))); } + self.fold_unordered(key) + } + + /// Fold an already-canonical manifest entry whose source ordering is owned + /// by the object store, not by key lexicographic order. + /// + /// S3 `ListObjectVersions` sorts by key but orders multiple versions of one + /// key by recency with opaque version ids, so version-aware manifests cannot + /// require strictly ascending serialized entries. Digest equality still + /// binds the exact stream that was listed and chunked. + pub fn fold_unordered(&mut self, key: &str) -> Result<()> { self.hasher.update(key.as_bytes()); self.hasher.update(b"\n"); self.last = Some(key.to_owned()); @@ -2594,7 +2683,7 @@ async fn live_fenced_tables_on(conn: &mut PgConnection) -> Result Result<()> { - if manifest.version != 4 { + if !matches!(manifest.version, 4 | 5) { return Err(DbError::DeletionSafety(format!( "unsupported storage manifest version {}", manifest.version @@ -2682,12 +2771,21 @@ fn validate_manifest_key_chunks( )); } for key in &keys.0 { - if !key.starts_with(chunk_prefix.as_str()) { + let prefix_key = if manifest.version >= 5 { + StorageManifestEntry::decode(key)?.key + } else { + key.clone() + }; + if !prefix_key.starts_with(chunk_prefix.as_str()) { return Err(DbError::DeletionSafety(format!( - "frozen key {key} is outside its chunk prefix {chunk_prefix}" + "frozen key {prefix_key} is outside its chunk prefix {chunk_prefix}" ))); } - digest.fold(key)?; + if manifest.version >= 5 { + digest.fold_unordered(key)?; + } else { + digest.fold(key)?; + } } } if let Some(summary) = current { @@ -3039,6 +3137,36 @@ mod tests { assert!(validate_storage_manifest(&malformed_digest).is_err()); } + #[test] + fn frozen_inventory_digest_is_canonical_for_v5_manifest_entries() { + let entry = StorageManifestEntry::new("_meta/c/a.json", "null", "object") + .encode() + .expect("entry"); + let mut digest = KeyStreamDigest::new(); + digest.fold_unordered(&entry).expect("fold entry"); + let (keys_digest, object_count) = digest.finish(); + let inventory = FrozenInventory { + schema: SchemaManifest { + scoped_tables: vec!["events".to_string()], + row_counts: BTreeMap::from([("events".to_string(), 1)]), + fenced_tables: vec!["events".to_string()], + }, + storage: StorageManifest { + version: 5, + prefixes: vec![PrefixManifest { + prefix: "_meta/c/".to_string(), + object_count, + total_bytes: 4, + keys_digest, + }], + }, + }; + let digest = inventory.digest().unwrap(); + let round_tripped: FrozenInventory = + serde_json::from_slice(&serde_json::to_vec(&inventory).unwrap()).unwrap(); + assert_eq!(digest, round_tripped.digest().unwrap()); + } + #[test] fn key_stream_digest_requires_strict_order_and_is_chunking_invariant() { let keys = ["a/1", "a/2", "a/3"]; @@ -3100,6 +3228,49 @@ mod tests { assert!(validate_manifest_key_chunks(&storage_manifest(), &[]).is_ok()); } + #[test] + fn versioned_manifest_entries_decode_and_validate_chunks() { + let entries = vec![ + StorageManifestEntry::new("_meta/c/1", "v2", "object") + .encode() + .expect("entry 1"), + StorageManifestEntry::new("_meta/c/1", "v1", "delete_marker") + .encode() + .expect("entry 2"), + ]; + let mut digest = KeyStreamDigest::new(); + for entry in &entries { + digest.fold_unordered(entry).expect("fold version entry"); + } + let (hex_digest, count) = digest.finish(); + let mut manifest = storage_manifest(); + manifest.version = 5; + manifest.prefixes[0].object_count = count; + manifest.prefixes[0].keys_digest = hex_digest; + + let chunk = |entries: &[String]| { + vec![( + 0, + "_meta/c/".to_string(), + sqlx::types::Json(entries.to_vec()), + )] + }; + // v5 freeze validation is retry-stable: a retried freeze with the + // same canonical version-entry stream is accepted, while a drifted + // stream is rejected. + assert!(validate_manifest_key_chunks(&manifest, &chunk(&entries)).is_ok()); + assert!(validate_manifest_key_chunks(&manifest, &chunk(&entries)).is_ok()); + + let foreign = vec![StorageManifestEntry::new("_uploads/c/1", "v1", "object") + .encode() + .expect("foreign entry")]; + assert!(validate_manifest_key_chunks(&manifest, &chunk(&foreign)).is_err()); + assert!(StorageManifestEntry::decode("_meta/c/1").is_err()); + assert!(StorageManifestEntry::new("_meta/c/1", "v1", "unknown") + .encode() + .is_err()); + } + #[test] fn frozen_inventory_digest_is_stable() { let inventory = FrozenInventory { diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index ae3dbe4f39..f13b7d507a 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -2,6 +2,7 @@ #![warn(missing_docs)] //! Shared durable whole-community deletion engine and store adapters. +use std::future::Future; #[cfg(test)] use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -10,10 +11,14 @@ use std::time::Duration; use anyhow::{Context, Result}; use buzz_db::deletion::{ ClaimedDeletion, DeletionRequest, DeletionStage, DeletionStore, FrozenInventory, - KeyStreamDigest, LeaseToken, PrefixManifest, StorageManifest, DEFAULT_LEASE_DURATION, + KeyStreamDigest, LeaseToken, PrefixManifest, StorageManifest, StorageManifestEntry, + DEFAULT_LEASE_DURATION, }; use buzz_db::{Db, DbConfig}; -use buzz_media::{is_tenant_owned_key, tenant_prefixes, MediaStorage}; +use buzz_media::{ + is_tenant_owned_key, tenant_prefixes, BulkDeleteOutcome, MediaStorage, ObjectVersionKind, + ObjectVersionRef, +}; use clap::Subcommand; use serde::Serialize; use tokio_util::sync::CancellationToken; @@ -700,6 +705,26 @@ async fn flush_chunk(services: &Services, sink: &mut ChunkSink<'_>, prefix: &str Ok(()) } +fn manifest_kind_name(kind: ObjectVersionKind) -> &'static str { + match kind { + ObjectVersionKind::Object => "object", + ObjectVersionKind::DeleteMarker => "delete_marker", + } +} + +fn manifest_chunk_deleted_detail( + prefix: &str, + key_count: usize, + outcome: &buzz_media::BulkDeleteOutcome, +) -> serde_json::Value { + serde_json::json!({ + "prefix": prefix, + "keys": key_count, + "deleted": outcome.deleted, + "already_missing": outcome.already_missing, + }) +} + /// Enumerate the target's three tenant prefixes into per-prefix summaries. /// /// Cost is O(tenant objects) regardless of fleet size. Unknown shapes inside @@ -715,36 +740,44 @@ async fn enumerate_tenant_prefixes( heartbeat_lost: Option<&CancellationToken>, mut sink: Option<&mut ChunkSink<'_>>, ) -> Result { - if services.media.bucket_versioning_detected().await? { - return Err(permanent( - "bucket versioning detected; deletion cannot prove logical absence with delete markers", - )); - } let community = *request.community_id.as_uuid(); let chunk_keys = manifest_chunk_keys(); let mut prefixes = Vec::new(); for prefix in tenant_prefixes(community) { let mut digest = KeyStreamDigest::new(); let mut total_bytes: u64 = 0; - let mut continuation = None; + let mut key_marker = None; + let mut version_id_marker = None; loop { if heartbeat_lost.is_some_and(CancellationToken::is_cancelled) { return Err(DeletionLeaseLost.into()); } let page = services .media - .list_prefix_page(&prefix, continuation.take(), LIST_PAGE_SIZE) + .list_prefix_versions_page( + &prefix, + key_marker.take(), + version_id_marker.take(), + LIST_PAGE_SIZE, + ) .await?; - for (key, size) in page.objects { - if !is_tenant_owned_key(community, &key) { + for entry in page.entries { + if !is_tenant_owned_key(community, &entry.key) { return Err(permanent(format!( - "key under a tenant prefix is outside the exact writer taxonomy: {key}" + "key under a tenant prefix is outside the exact writer taxonomy: {}", + entry.key ))); } - digest.fold(&key)?; - total_bytes = total_bytes.saturating_add(size); + let encoded = StorageManifestEntry::new( + entry.key, + entry.version_id, + manifest_kind_name(entry.kind), + ) + .encode()?; + digest.fold_unordered(&encoded)?; + total_bytes = total_bytes.saturating_add(entry.size); if let Some(sink) = sink.as_deref_mut() { - sink.buffered.push(key); + sink.buffered.push(encoded); if sink.buffered.len() >= chunk_keys { flush_chunk(services, sink, &prefix).await?; } @@ -753,12 +786,12 @@ async fn enumerate_tenant_prefixes( if !page.is_truncated { break; } - continuation = page.next_continuation_token; - if continuation.is_none() { - return Err(transient( - "truncated tenant listing page has no continuation token", - )); - } + let (next_key_marker, next_version_id_marker) = require_truncated_version_markers( + page.next_key_marker, + page.next_version_id_marker, + )?; + key_marker = Some(next_key_marker); + version_id_marker = Some(next_version_id_marker); } if let Some(sink) = sink.as_deref_mut() { flush_chunk(services, sink, &prefix).await?; @@ -772,13 +805,113 @@ async fn enumerate_tenant_prefixes( }); } let manifest = StorageManifest { - version: 4, + version: 5, prefixes, }; buzz_db::deletion::validate_storage_manifest(&manifest)?; Ok(manifest) } +fn require_truncated_version_markers( + next_key_marker: Option, + next_version_id_marker: Option, +) -> Result<(String, String)> { + match (next_key_marker, next_version_id_marker) { + (Some(key_marker), Some(version_id_marker)) => Ok((key_marker, version_id_marker)), + (None, Some(_)) => Err(transient( + "truncated tenant version listing page has no key marker", + )), + (Some(_), None) => Err(transient( + "truncated tenant version listing page has no version id marker", + )), + (None, None) => Err(transient( + "truncated tenant version listing page has no key marker or version id marker", + )), + } +} + +async fn delete_manifest_chunk_with( + chunk: &buzz_db::deletion::ManifestKeyChunk, + storage_version: i32, + delete: F, +) -> Result +where + F: FnOnce(Vec) -> Fut, + Fut: Future>, +{ + let versions = object_versions_from_manifest_chunk(chunk, storage_version)?; + delete(versions).await +} + +fn object_versions_from_manifest_chunk( + chunk: &buzz_db::deletion::ManifestKeyChunk, + storage_version: i32, +) -> Result> { + if storage_version >= 5 { + chunk + .keys + .iter() + .map(|entry| { + let entry = StorageManifestEntry::decode(entry)?; + Ok(ObjectVersionRef { + key: entry.key, + version_id: entry.version_id, + }) + }) + .collect() + } else { + Ok(chunk + .keys + .iter() + .map(|key| ObjectVersionRef { + key: key.clone(), + version_id: String::new(), + }) + .collect()) + } +} + +fn manifest_chunk_deleted_checkpoint_detail( + chunk: &buzz_db::deletion::ManifestKeyChunk, + outcome: &BulkDeleteOutcome, +) -> Result { + validate_manifest_chunk_delete_outcome(chunk, outcome)?; + Ok(manifest_chunk_deleted_detail( + &chunk.prefix, + chunk.keys.len(), + outcome, + )) +} + +fn validate_manifest_chunk_delete_outcome( + chunk: &buzz_db::deletion::ManifestKeyChunk, + outcome: &BulkDeleteOutcome, +) -> Result<()> { + if !outcome.versioned_keys.is_empty() { + return Err(transient(format!( + "bulk delete returned version metadata for {} explicit versions: {}", + outcome.versioned_keys.len(), + outcome.versioned_keys.join(",") + ))); + } + if !outcome.failed.is_empty() { + let (key, code, message) = &outcome.failed[0]; + return Err(transient(format!( + "bulk delete failed for {} key(s); first: {key}: {code}: {message}", + outcome.failed.len() + ))); + } + let acknowledged = outcome.deleted.saturating_add(outcome.already_missing); + if acknowledged != chunk.keys.len() as u64 { + return Err(transient(format!( + "bulk delete acknowledged {acknowledged} of {} keys in chunk {}", + chunk.keys.len(), + chunk.chunk_no + ))); + } + Ok(()) +} + /// Freeze the post-fence, post-drain destructive enumeration: stream the /// tenant prefixes into side-table chunks, then bind the chunk stream to the /// request row's digests atomically. @@ -1071,6 +1204,35 @@ async fn execute_stage( } match request.stage { DeletionStage::Approved => { + // Fail closed on missing version-list permission before we take the + // durable write fence. Exact-version delete permission cannot be + // proven safely here: S3 has no dry-run DeleteObjectVersion, and a + // fabricated-version delete would still be a destructive API call + // while proving less than the real tenant-prefix operation. + run_guarded_external_step( + services, + &token, + DeletionStage::Approved, + heartbeat_lost, + || async { + for prefix in tenant_prefixes(*request.community_id.as_uuid()) { + services + .media + .preflight_version_listing(&prefix) + .await + .with_context(|| { + format!( + "S3 version-list preflight failed for prefix {prefix}; \ + verify s3:ListBucketVersions and s3:DeleteObjectVersion \ + on the relay bucket before fencing" + ) + })?; + } + Ok(()) + }, + ) + .await?; + // Approval binds immutable catalog + community-prefix ownership. // Live row counts and tenant binding keys are deliberately not // equality-bound until the durable fence closes all writers. @@ -1150,51 +1312,38 @@ async fn execute_stage( let mut removed: u64 = 0; let mut already_missing: u64 = 0; while let Some(chunk) = services.store.next_pending_manifest_chunk(&token).await? { + let chunk_no = chunk.chunk_no; let outcome = run_guarded_external_step( services, &token, DeletionStage::Drained, heartbeat_lost, - || async { Ok(services.media.delete_objects(&chunk.keys).await?) }, + || async { + delete_manifest_chunk_with(&chunk, storage.version, |versions| async { + if storage.version >= 5 { + Ok(services.media.delete_object_versions(&versions).await?) + } else { + let keys = versions + .into_iter() + .map(|version| version.key) + .collect::>(); + Ok(services.media.delete_objects(&keys).await?) + } + }) + .await + }, ) .await?; - if !outcome.versioned_keys.is_empty() { - return Err(permanent(format!( - "bulk delete produced version artifacts; bucket versioning blocks \ - deletion: {}", - outcome.versioned_keys.join(",") - ))); + if heartbeat_lost.is_cancelled() { + return Err(DeletionLeaseLost.into()); } - if !outcome.failed.is_empty() { - let (key, code, message) = &outcome.failed[0]; - return Err(transient(format!( - "bulk delete failed for {} key(s); first: {key}: {code}: {message}", - outcome.failed.len() - ))); - } - let acknowledged = outcome.deleted.saturating_add(outcome.already_missing); - if acknowledged != chunk.keys.len() as u64 { - return Err(transient(format!( - "bulk delete acknowledged {acknowledged} of {} keys in chunk {}", - chunk.keys.len(), - chunk.chunk_no - ))); - } - removed += outcome.deleted; - already_missing += outcome.already_missing; + let detail = manifest_chunk_deleted_checkpoint_detail(&chunk, &outcome)?; services .store - .mark_manifest_chunk_deleted( - &token, - chunk.chunk_no, - serde_json::json!({ - "prefix": chunk.prefix, - "keys": chunk.keys.len(), - "deleted": outcome.deleted, - "already_missing": outcome.already_missing, - }), - ) + .mark_manifest_chunk_deleted(&token, chunk_no, detail) .await?; + removed += outcome.deleted; + already_missing += outcome.already_missing; } let frozen_keys: u64 = storage .prefixes @@ -1277,10 +1426,16 @@ fn token_with_current_fence(token: &LeaseToken, request: &DeletionRequest) -> Le /// empty — O(1) requests per prefix, independent of fleet size. async fn verify_storage_absence(services: &Services, request: &DeletionRequest) -> Result<()> { for prefix in tenant_prefixes(*request.community_id.as_uuid()) { - let page = services.media.list_prefix_page(&prefix, None, 1).await?; - if let Some((key, _)) = page.objects.first() { + let page = services + .media + .list_prefix_versions_page(&prefix, None, None, 1) + .await?; + if let Some(entry) = page.entries.first() { return Err(transient(format!( - "logical verification found a live target object binding: {key}" + "logical verification found a retained target object version: {}@{} ({})", + entry.key, + entry.version_id, + manifest_kind_name(entry.kind) ))); } } @@ -1821,6 +1976,99 @@ mod tests { } } + #[test] + fn truncated_version_listing_requires_key_marker() { + let error = require_truncated_version_markers(None, Some("v1".to_string())) + .expect_err("missing key marker must fail closed"); + + assert!(format!("{error:#}").contains("no key marker")); + } + + #[test] + fn truncated_version_listing_requires_version_id_marker() { + let error = require_truncated_version_markers(Some("key".to_string()), None) + .expect_err("missing version id marker must fail closed"); + + assert!(format!("{error:#}").contains("no version id marker")); + } + + #[test] + fn legacy_v4_manifest_chunk_decodes_bare_keys_for_resume_delete() { + let chunk = buzz_db::deletion::ManifestKeyChunk { + chunk_no: 3, + prefix: "_meta/community/".to_string(), + keys: vec![ + "_meta/community/a.json".to_string(), + "_meta/community/b.json".to_string(), + ], + }; + + let versions = object_versions_from_manifest_chunk(&chunk, 4).expect("decode v4 chunk"); + assert_eq!( + versions, + vec![ + ObjectVersionRef { + key: "_meta/community/a.json".to_string(), + version_id: String::new(), + }, + ObjectVersionRef { + key: "_meta/community/b.json".to_string(), + version_id: String::new(), + }, + ] + ); + } + + #[tokio::test] + async fn partial_delete_ack_fails_before_checkpoint_detail() { + let chunk = buzz_db::deletion::ManifestKeyChunk { + chunk_no: 7, + prefix: "_meta/community/".to_string(), + keys: vec![ + StorageManifestEntry::new("_meta/community/a.json", "v1", "object") + .encode() + .expect("encode manifest entry"), + StorageManifestEntry::new("_meta/community/b.json", "v2", "object") + .encode() + .expect("encode manifest entry"), + ], + }; + let delete = delete_manifest_chunk_with(&chunk, 5, |versions| async move { + assert_eq!(versions.len(), 2); + Ok(BulkDeleteOutcome { + deleted: 1, + already_missing: 0, + versioned_keys: Vec::new(), + failed: Vec::new(), + }) + }) + .await + .expect("delete call returns partial acknowledgement"); + let checkpoint = manifest_chunk_deleted_checkpoint_detail(&chunk, &delete); + + let error = checkpoint.expect_err("partial acknowledgement must be transient"); + assert!(format!("{error:#}").contains("bulk delete acknowledged 1 of 2 keys in chunk 7")); + } + + #[test] + fn manifest_chunk_checkpoint_detail_records_partial_delete_response_counts() { + let detail = manifest_chunk_deleted_detail( + "_meta/community/", + 3, + &buzz_media::BulkDeleteOutcome { + deleted: 2, + already_missing: 1, + versioned_keys: Vec::new(), + failed: Vec::new(), + }, + ); + + assert_eq!(detail["prefix"], "_meta/community/"); + assert_eq!(detail["keys"], 3); + assert_eq!(detail["deleted"], 2); + assert_eq!(detail["already_missing"], 1); + } + #[test] fn permanent_failures_are_typed_not_string_classified() { let permanent_error = permanent("catalog drift"); diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index 530ce69c90..7808ecaff4 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -32,6 +32,7 @@ tempfile = "3" tokio-util = { version = "0.7", features = ["io"] } futures-util = "0.3" futures-core = "0.3" +quick-xml = { version = "0.38", features = ["serialize"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index b2ff12c16e..3198e1f830 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -20,7 +20,10 @@ pub use bucket_index::{ }; pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; -pub use storage::{BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage}; +pub use storage::{ + BlobHeadMeta, BlobMeta, BulkDeleteOutcome, ByteStream, MediaStorage, ObjectVersionEntry, + ObjectVersionKind, ObjectVersionRef, ObjectVersionsPage, +}; pub use types::BlobDescriptor; pub use upload::{process_file_upload, process_upload, process_video_upload}; pub use upload_record::{ diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 0f0aa7af62..abe6bdd40e 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -1,5 +1,6 @@ //! S3/MinIO storage client. +use std::collections::HashMap; use std::path::Path; use std::pin::Pin; @@ -8,13 +9,198 @@ use buzz_core::tenant::{CommunityId, TenantContext}; use crate::config::{MediaConfig, S3AddressingStyle}; use crate::error::MediaError; use bytes::Bytes; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; use s3::creds::Credentials; +use s3::request::Request as _; use s3::{Bucket, Region}; use serde::{Deserialize, Serialize}; /// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`. pub type ByteStream = Pin> + Send>>; +/// The kind of versioned S3 object-store entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ObjectVersionKind { + /// A concrete object version with bytes. + Object, + /// A delete-marker version hiding older bytes from live-object listing. + DeleteMarker, +} + +/// One S3 object version or delete marker under a tenant prefix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectVersionEntry { + /// Object key. + pub key: String, + /// Concrete S3 version id. + pub version_id: String, + /// Whether this entry is a byte-bearing object or delete marker. + pub kind: ObjectVersionKind, + /// Byte size for object versions; zero for delete markers. + pub size: u64, +} + +/// Exact version identifier used for permanent deletion. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ObjectVersionRef { + /// Object key. + pub key: String, + /// Concrete S3 version id. + pub version_id: String, +} + +/// One `ListObjectVersions` page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObjectVersionsPage { + /// Object versions and delete markers returned by this page. + pub entries: Vec, + /// Next key marker for truncated listings. + pub next_key_marker: Option, + /// Next version-id marker for truncated listings. + pub next_version_id_marker: Option, + /// Whether more pages remain. + pub is_truncated: bool, +} + +#[derive(Debug, Default)] +struct ListVersionFields { + key: Option, + version_id: Option, + size: Option, +} + +fn local_name(name: &[u8]) -> &[u8] { + name.rsplit(|byte| *byte == b':').next().unwrap_or(name) +} + +fn xml_error(error: impl std::fmt::Display) -> MediaError { + MediaError::StorageError(error.to_string()) +} + +fn read_element_text( + reader: &mut Reader<&[u8]>, + start: &BytesStart<'_>, +) -> Result { + reader + .read_text(start.to_end().name()) + .map(|text| text.into_owned()) + .map_err(xml_error) +} + +fn skip_element(reader: &mut Reader<&[u8]>, start: &BytesStart<'_>) -> Result<(), MediaError> { + reader + .read_to_end(start.to_end().name()) + .map_err(xml_error)?; + Ok(()) +} + +fn parse_list_version_entry( + reader: &mut Reader<&[u8]>, + start: &BytesStart<'_>, + kind: ObjectVersionKind, +) -> Result { + let mut fields = ListVersionFields::default(); + loop { + match reader.read_event().map_err(xml_error)? { + Event::Start(child) => match local_name(child.local_name().as_ref()) { + b"Key" => fields.key = Some(read_element_text(reader, &child)?), + b"VersionId" => fields.version_id = Some(read_element_text(reader, &child)?), + b"Size" => { + let size = read_element_text(reader, &child)?; + fields.size = Some(size.parse::().map_err(xml_error)?); + } + _ => skip_element(reader, &child)?, + }, + Event::Empty(child) => match local_name(child.local_name().as_ref()) { + b"Key" => fields.key = Some(String::new()), + b"VersionId" => fields.version_id = Some(String::new()), + b"Size" => fields.size = Some(0), + _ => {} + }, + Event::End(end) if end.name().as_ref() == start.to_end().name().as_ref() => { + let key = fields.key.ok_or_else(|| { + MediaError::StorageError("ListObjectVersions entry missing Key".to_string()) + })?; + let version_id = fields.version_id.ok_or_else(|| { + MediaError::StorageError( + "ListObjectVersions entry missing VersionId".to_string(), + ) + })?; + return Ok(ObjectVersionEntry { + key, + version_id, + kind, + size: if kind == ObjectVersionKind::Object { + fields.size.unwrap_or(0) + } else { + 0 + }, + }); + } + Event::Eof => { + return Err(MediaError::StorageError( + "unexpected EOF inside ListObjectVersions entry".to_string(), + )); + } + _ => {} + } + } +} + +fn parse_object_versions_page(xml: &[u8]) -> Result { + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(true); + let mut entries = Vec::new(); + let mut next_key_marker = None; + let mut next_version_id_marker = None; + let mut is_truncated = false; + + loop { + match reader.read_event().map_err(xml_error)? { + Event::Start(start) => match local_name(start.local_name().as_ref()) { + b"Version" => entries.push(parse_list_version_entry( + &mut reader, + &start, + ObjectVersionKind::Object, + )?), + b"DeleteMarker" => entries.push(parse_list_version_entry( + &mut reader, + &start, + ObjectVersionKind::DeleteMarker, + )?), + b"IsTruncated" => { + let value = read_element_text(&mut reader, &start)?; + is_truncated = value.eq_ignore_ascii_case("true"); + } + b"NextKeyMarker" => { + next_key_marker = Some(read_element_text(&mut reader, &start)?); + } + b"NextVersionIdMarker" => { + next_version_id_marker = Some(read_element_text(&mut reader, &start)?); + } + b"ListVersionsResult" => {} + _ => skip_element(&mut reader, &start)?, + }, + Event::Empty(start) => match local_name(start.local_name().as_ref()) { + b"NextKeyMarker" => next_key_marker = Some(String::new()), + b"NextVersionIdMarker" => next_version_id_marker = Some(String::new()), + _ => {} + }, + Event::Eof => break, + _ => {} + } + } + + Ok(ObjectVersionsPage { + entries, + next_key_marker, + next_version_id_marker, + is_truncated, + }) +} + /// S3-compatible object storage client. pub struct MediaStorage { bucket: Box, @@ -177,24 +363,6 @@ impl MediaStorage { } } - /// Detect whether the bucket has ever had versioning enabled. - /// - /// rust-s3 exposes no GetBucketVersioning, so this writes and inspects a - /// short-lived fleet probe object instead: versioning-enabled (and - /// versioning-suspended) buckets stamp new writes with a version id. - /// Deletion refuses versioned buckets because bulk deletes without a - /// VersionId would only insert delete markers, not prove logical absence. - pub async fn bucket_versioning_detected(&self) -> Result { - let key = format!("probe/deletion-versioning-{}", uuid::Uuid::new_v4()); - self.put(&key, b"buzz deletion versioning probe", "text/plain") - .await?; - let inspected = self.bucket.head_object(&key).await; - let removed = self.bucket.delete_object(&key).await; - let (head, _) = inspected.map_err(|e| MediaError::StorageError(e.to_string()))?; - removed.map_err(|e| MediaError::StorageError(e.to_string()))?; - Ok(head.version_id.is_some()) - } - /// Bulk-delete up to one manifest chunk of keys via S3 `DeleteObjects`. /// /// Never fails on per-key outcomes: they are folded into @@ -210,6 +378,57 @@ impl MediaStorage { .iter() .map(|key| s3::serde_types::ObjectIdentifier::new(key.clone())) .collect::>(); + self.delete_object_identifiers(identifiers).await + } + + /// Non-destructively verify that versioned bucket APIs are reachable. + /// + /// `ListObjectVersions` can be proven without mutation. S3 has no equivalent + /// dry-run for `DeleteObjectVersion`: `DeleteObjects` is always destructive, + /// even for exact versions, and deleting a fabricated version id does not + /// prove permission when policies can be prefix- or tag-constrained. + /// Operators must still provision `s3:DeleteObjectVersion`; the first exact + /// version deletion remains the destructive proof. + pub async fn preflight_version_listing(&self, prefix: &str) -> Result<(), MediaError> { + self.list_prefix_versions_page(prefix, None, None, 1) + .await + .map(|_| ()) + } + + /// Bulk-delete exact object versions via S3 `DeleteObjects`. + /// + /// Every identifier includes a version id, so this removes historical + /// versions and delete markers permanently instead of adding another + /// delete marker to a versioned bucket. + pub async fn delete_object_versions( + &self, + versions: &[ObjectVersionRef], + ) -> Result { + self.delete_object_versions_with_folding(versions, fold_version_delete_result) + .await + } + + async fn delete_object_versions_with_folding( + &self, + versions: &[ObjectVersionRef], + fold: fn(s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome, + ) -> Result { + if versions.is_empty() { + return Ok(BulkDeleteOutcome::default()); + } + let identifiers = object_version_identifiers(versions); + let result = self + .bucket + .delete_objects(identifiers) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + Ok(fold(result)) + } + + async fn delete_object_identifiers( + &self, + identifiers: Vec, + ) -> Result { let result = self .bucket .delete_objects(identifiers) @@ -330,6 +549,57 @@ impl MediaStorage { is_truncated: result.is_truncated, }) } + + /// One page of object versions and delete markers under a prefix. + /// + /// This uses S3 `ListObjectVersions` (`?versions`) instead of + /// `ListObjectsV2`: versioned buckets can be logically empty while still + /// retaining historical versions or delete markers, and permanent deletion + /// must enumerate both. Pagination must carry both `KeyMarker` and + /// `VersionIdMarker`; carrying only the key marker can skip siblings when a + /// key has multiple versions on a page boundary. + pub async fn list_prefix_versions_page( + &self, + prefix: &str, + key_marker: Option, + version_id_marker: Option, + max_keys: usize, + ) -> Result { + let mut query = HashMap::from([ + ("versions".to_string(), String::new()), + ("prefix".to_string(), prefix.to_string()), + ("max-keys".to_string(), max_keys.to_string()), + ]); + if let Some(marker) = key_marker { + query.insert("key-marker".to_string(), marker); + } + if let Some(marker) = version_id_marker { + query.insert("version-id-marker".to_string(), marker); + } + let bucket = self + .bucket + .with_extra_query(query) + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let request = s3::request::tokio_backend::ReqwestRequest::new( + &bucket, + "/", + s3::command::Command::GetObject, + ) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let response = request + .response_data(false) + .await + .map_err(|e| MediaError::StorageError(e.to_string()))?; + if response.status_code() >= 300 { + return Err(MediaError::StorageError(format!( + "list object versions failed with status {}: {}", + response.status_code(), + response.as_str().unwrap_or("") + ))); + } + parse_object_versions_page(response.as_slice()) + } } /// Per-key outcomes of one bulk `DeleteObjects` call. @@ -349,16 +619,49 @@ pub struct BulkDeleteOutcome { pub failed: Vec<(String, String, String)>, } +fn object_version_identifiers( + versions: &[ObjectVersionRef], +) -> Vec { + versions + .iter() + .map(|version| { + s3::serde_types::ObjectIdentifier::with_version( + version.key.clone(), + version.version_id.clone(), + ) + }) + .collect() +} + fn fold_bulk_delete_result(result: s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome { + fold_delete_result(result, DeleteMode::Unversioned) +} + +fn fold_version_delete_result(result: s3::serde_types::DeleteObjectsResult) -> BulkDeleteOutcome { + fold_delete_result(result, DeleteMode::ExplicitVersion) +} + +enum DeleteMode { + Unversioned, + ExplicitVersion, +} + +fn fold_delete_result( + result: s3::serde_types::DeleteObjectsResult, + mode: DeleteMode, +) -> BulkDeleteOutcome { let mut outcome = BulkDeleteOutcome::default(); for deleted in result.deleted { - if deleted.delete_marker == Some(true) + let has_version_artifact = deleted.delete_marker == Some(true) || deleted.delete_marker_version_id.is_some() - || deleted.version_id.is_some() - { - outcome.versioned_keys.push(deleted.key); - } else { - outcome.deleted += 1; + || deleted.version_id.is_some(); + match mode { + DeleteMode::Unversioned if has_version_artifact => { + outcome.versioned_keys.push(deleted.key); + } + DeleteMode::Unversioned | DeleteMode::ExplicitVersion => { + outcome.deleted += 1; + } } } for error in result.errors { @@ -419,6 +722,228 @@ mod tests { ); } + #[test] + fn version_delete_fold_counts_explicit_version_artifacts_as_deleted() { + use s3::serde_types::{DeleteError, DeleteObjectsResult, DeletedObject}; + let result = DeleteObjectsResult { + deleted: vec![DeletedObject { + key: "versioned".to_string(), + version_id: Some("v1".to_string()), + delete_marker: Some(true), + delete_marker_version_id: Some("v1".to_string()), + }], + errors: vec![ + DeleteError { + key: "retried-version".to_string(), + code: "NoSuchVersion".to_string(), + message: "already absent".to_string(), + version_id: Some("v-gone".to_string()), + }, + DeleteError { + key: "denied-version".to_string(), + code: "AccessDenied".to_string(), + message: "denied".to_string(), + version_id: Some("v-denied".to_string()), + }, + ], + }; + + let outcome = fold_version_delete_result(result); + assert_eq!(outcome.deleted, 1); + assert_eq!(outcome.already_missing, 1); + assert!(outcome.versioned_keys.is_empty()); + assert_eq!( + outcome.failed, + vec![( + "denied-version".to_string(), + "AccessDenied".to_string(), + "denied".to_string() + )] + ); + } + + #[test] + fn object_version_identifiers_include_explicit_version_ids() { + let identifiers = object_version_identifiers(&[ + ObjectVersionRef { + key: "_meta/tenant/a.json".to_string(), + version_id: "v-object".to_string(), + }, + ObjectVersionRef { + key: "uploads/tenant/event/blob".to_string(), + version_id: "v-delete-marker".to_string(), + }, + ]); + + assert_eq!(identifiers.len(), 2); + assert_eq!(identifiers[0].key, "_meta/tenant/a.json"); + assert_eq!(identifiers[0].version_id.as_deref(), Some("v-object")); + assert_eq!(identifiers[1].key, "uploads/tenant/event/blob"); + assert_eq!( + identifiers[1].version_id.as_deref(), + Some("v-delete-marker") + ); + } + + #[tokio::test] + async fn delete_object_versions_empty_input_short_circuits_before_folding() { + let storage = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret")) + .expect("static client"); + let outcome = storage + .delete_object_versions_with_folding(&[], |_| BulkDeleteOutcome { + deleted: 0, + already_missing: 0, + versioned_keys: vec!["wrong-fold".to_string()], + failed: Vec::new(), + }) + .await + .expect("empty delete short-circuits before fold"); + assert_eq!(outcome, BulkDeleteOutcome::default()); + } + + #[test] + fn parse_object_versions_page_includes_objects_delete_markers_and_dual_markers() { + let page = parse_object_versions_page( + br#" + + buzz-media + _meta/tenant/ + _meta/tenant/a.json + v-old + 2 + true + _meta/tenant/a.json + v-new + + _meta/tenant/a.json + v-delete + true + + + _meta/tenant/a.json + v-new + false + 42 + +"#, + ) + .expect("parse versions page"); + + assert!(page.is_truncated); + assert_eq!(page.next_key_marker.as_deref(), Some("_meta/tenant/a.json")); + assert_eq!(page.next_version_id_marker.as_deref(), Some("v-new")); + assert_eq!( + page.entries, + vec![ + ObjectVersionEntry { + key: "_meta/tenant/a.json".to_string(), + version_id: "v-delete".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "_meta/tenant/a.json".to_string(), + version_id: "v-new".to_string(), + kind: ObjectVersionKind::Object, + size: 42, + }, + ] + ); + } + + #[test] + fn parse_object_versions_page_preserves_repeated_interleaved_aws_ordering() { + let page = parse_object_versions_page( + br#" + k-av33 + k-av2 + k-av11 + k-bm2 + k-bm110 +"#, + ) + .expect("parse interleaved versions page"); + + assert_eq!( + page.entries, + vec![ + ObjectVersionEntry { + key: "k-a".to_string(), + version_id: "v3".to_string(), + kind: ObjectVersionKind::Object, + size: 3, + }, + ObjectVersionEntry { + key: "k-a".to_string(), + version_id: "v2".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-a".to_string(), + version_id: "v1".to_string(), + kind: ObjectVersionKind::Object, + size: 1, + }, + ObjectVersionEntry { + key: "k-b".to_string(), + version_id: "m2".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-b".to_string(), + version_id: "m1".to_string(), + kind: ObjectVersionKind::Object, + size: 10, + }, + ] + ); + } + + #[test] + fn parse_object_versions_page_handles_marker_only_key_before_versioned_key() { + let page = parse_object_versions_page( + br#" + k-marker-onlyd-only + k-versionedv220 + k-versionedd1 + k-versionedv110 +"#, + ) + .expect("parse marker-only and versioned keys"); + + assert_eq!( + page.entries, + vec![ + ObjectVersionEntry { + key: "k-marker-only".to_string(), + version_id: "d-only".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-versioned".to_string(), + version_id: "v2".to_string(), + kind: ObjectVersionKind::Object, + size: 20, + }, + ObjectVersionEntry { + key: "k-versioned".to_string(), + version_id: "d1".to_string(), + kind: ObjectVersionKind::DeleteMarker, + size: 0, + }, + ObjectVersionEntry { + key: "k-versioned".to_string(), + version_id: "v1".to_string(), + kind: ObjectVersionKind::Object, + size: 10, + }, + ] + ); + } + fn tenant(n: u128) -> TenantContext { TenantContext::resolved( CommunityId::from_uuid(uuid::Uuid::from_u128(n)), diff --git a/crates/buzz-media/tests/versioned_minio.rs b/crates/buzz-media/tests/versioned_minio.rs new file mode 100644 index 0000000000..1e0db4b481 --- /dev/null +++ b/crates/buzz-media/tests/versioned_minio.rs @@ -0,0 +1,363 @@ +//! Live destructive versioned-bucket deletion coverage against docker-compose MinIO. +//! +//! This exercises the S3-compatible path that community deletion relies on when +//! a bucket has versioning enabled: list object versions/delete markers with +//! dual markers, delete exact `(Key, VersionId)` identifiers, retry an already +//! deleted version, and prove final `ListObjectVersions` emptiness. +//! +//! Run it against the docker-compose MinIO (creds `buzz_dev`/`buzz_dev_secret`): +//! +//! ```bash +//! docker compose up -d minio minio-init +//! cargo test -p buzz-media --test versioned_minio -- --ignored --nocapture +//! ``` +//! +//! The test creates and removes its own bucket. The MinIO container name is +//! overridable with `BUZZ_MINIO_CONTAINER`; credentials/endpoint/region/addressing +//! use the same `BUZZ_S3_*` env vars as `static_creds_minio`. + +use std::process::Command; + +use buzz_media::config::MediaConfig; +use buzz_media::storage::{MediaStorage, ObjectVersionKind, ObjectVersionRef}; + +fn env_or(name: &str, default: &str) -> String { + std::env::var(name).unwrap_or_else(|_| default.to_string()) +} + +fn minio_config(bucket: String) -> MediaConfig { + MediaConfig { + s3_endpoint: env_or("BUZZ_S3_ENDPOINT", "http://localhost:9000"), + s3_access_key: env_or("BUZZ_S3_ACCESS_KEY", "buzz_dev"), + s3_secret_key: env_or("BUZZ_S3_SECRET_KEY", "buzz_dev_secret"), + s3_bucket: bucket, + s3_region: env_or("BUZZ_S3_REGION", "us-east-1"), + s3_addressing_style: env_or("BUZZ_S3_ADDRESSING_STYLE", "path") + .parse() + .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 524_288_000, + max_file_bytes: 104_857_600, + public_base_url: "http://localhost:3000/media".to_string(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + } +} + +fn run_mc(args: &[String]) -> Result<(), String> { + let container = env_or("BUZZ_MINIO_CONTAINER", "buzz-minio"); + let output = Command::new("docker") + .arg("exec") + .arg(container) + .arg("mc") + .args(args) + .output() + .map_err(|err| format!("failed to execute docker/mc: {err}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "mc {:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", + args, + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + } +} + +fn mc_alias(access_key: &str, secret_key: &str) -> Result<(), String> { + run_mc(&[ + "alias".to_string(), + "set".to_string(), + "local".to_string(), + "http://localhost:9000".to_string(), + access_key.to_string(), + secret_key.to_string(), + ]) +} + +async fn list_all_versions( + storage: &MediaStorage, + prefix: &str, + max_keys: usize, +) -> Vec { + let mut entries = Vec::new(); + let mut key_marker = None; + let mut version_id_marker = None; + loop { + let page = storage + .list_prefix_versions_page( + prefix, + key_marker.take(), + version_id_marker.take(), + max_keys, + ) + .await + .expect("list object versions page"); + if page.is_truncated { + assert!( + page.next_key_marker.is_some(), + "truncated ListObjectVersions page must include NextKeyMarker" + ); + assert!( + page.next_version_id_marker.is_some(), + "truncated ListObjectVersions page must include NextVersionIdMarker" + ); + } + entries.extend(page.entries); + if !page.is_truncated { + break; + } + key_marker = page.next_key_marker; + version_id_marker = page.next_version_id_marker; + } + entries +} + +fn refs_from(entries: &[buzz_media::storage::ObjectVersionEntry]) -> Vec { + entries + .iter() + .map(|entry| ObjectVersionRef { + key: entry.key.clone(), + version_id: entry.version_id.clone(), + }) + .collect() +} + +#[tokio::test] +#[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"] +async fn never_versioned_bucket_lists_null_versions_and_exact_delete_empties_listing() { + let bucket = format!("buzz-media-never-versioned-{}", std::process::id()); + let bucket_path = format!("local/{bucket}"); + let config = minio_config(bucket.clone()); + mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias"); + run_mc(&[ + "mb".to_string(), + "--ignore-existing".to_string(), + bucket_path.clone(), + ]) + .expect("create isolated never-versioned test bucket"); + + let storage = MediaStorage::new(&config).expect("static MinIO storage client"); + let prefix = format!("_test/never-versioned-{}/", uuid::Uuid::new_v4()); + let key = format!("{prefix}plain.bin"); + storage + .put(&key, b"plain", "application/octet-stream") + .await + .expect("put never-versioned object"); + + let listed = list_all_versions(&storage, &prefix, 2).await; + assert_eq!( + listed, + vec![buzz_media::storage::ObjectVersionEntry { + key: key.clone(), + version_id: "null".to_string(), + kind: ObjectVersionKind::Object, + size: 5, + }], + "never-versioned buckets must still enumerate exact null-version objects" + ); + + let delete = storage + .delete_object_versions(&refs_from(&listed)) + .await + .expect("delete exact null-version object"); + assert!(delete.failed.is_empty(), "{delete:?}"); + assert!(delete.versioned_keys.is_empty(), "{delete:?}"); + assert_eq!( + delete.deleted + delete.already_missing, + 1, + "exact null-version delete must account for the listed object" + ); + assert!( + list_all_versions(&storage, &prefix, 2).await.is_empty(), + "final ListObjectVersions must be empty after deleting the exact null version" + ); + + run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()]) + .expect("remove isolated never-versioned test bucket"); +} + +#[tokio::test] +#[ignore = "requires live docker-compose MinIO; permanently deletes exact test object versions"] +async fn versioned_bucket_exact_version_delete_reaches_final_list_versions_emptiness() { + let bucket = format!("buzz-media-versioned-{}", std::process::id()); + let bucket_path = format!("local/{bucket}"); + let config = minio_config(bucket.clone()); + mc_alias(&config.s3_access_key, &config.s3_secret_key).expect("configure mc alias"); + run_mc(&[ + "mb".to_string(), + "--ignore-existing".to_string(), + bucket_path.clone(), + ]) + .expect("create isolated versioned test bucket"); + run_mc(&[ + "version".to_string(), + "enable".to_string(), + bucket_path.clone(), + ]) + .expect("enable bucket versioning"); + + let storage = MediaStorage::new(&config).expect("static MinIO storage client"); + let prefix = format!("_test/versioned-{}/", uuid::Uuid::new_v4()); + let historical_key = format!("{prefix}historical.bin"); + let marker_only_key = format!("{prefix}marker-only.bin"); + let paginated_key = format!("{prefix}paginated.bin"); + + storage + .put(&historical_key, b"v1", "application/octet-stream") + .await + .expect("put historical v1"); + storage + .put(&historical_key, b"v2", "application/octet-stream") + .await + .expect("put historical v2"); + storage + .delete(&historical_key) + .await + .expect("delete historical current version creates delete marker"); + storage + .put(&marker_only_key, b"marker-base", "application/octet-stream") + .await + .expect("put marker-only base version"); + storage + .delete(&marker_only_key) + .await + .expect("delete current version creates marker-only delete marker"); + let marker_versions = list_all_versions(&storage, &marker_only_key, 2).await; + let marker_objects: Vec = marker_versions + .iter() + .filter(|entry| entry.kind == ObjectVersionKind::Object) + .map(|entry| ObjectVersionRef { + key: entry.key.clone(), + version_id: entry.version_id.clone(), + }) + .collect(); + assert_eq!( + marker_objects.len(), + 1, + "marker-only setup should have one object version: {marker_versions:?}" + ); + let marker_object_delete = storage + .delete_object_versions(&marker_objects) + .await + .expect("delete marker-only base object version"); + assert!( + marker_object_delete.failed.is_empty(), + "{marker_object_delete:?}" + ); + assert!( + marker_object_delete.versioned_keys.is_empty(), + "{marker_object_delete:?}" + ); + storage + .put(&paginated_key, b"page-a", "application/octet-stream") + .await + .expect("put paginated v1"); + storage + .put(&paginated_key, b"page-b", "application/octet-stream") + .await + .expect("put paginated v2"); + + let listed = list_all_versions(&storage, &prefix, 2).await; + assert!( + listed.len() >= 6, + "expected multiple versions/delete markers across small pages, got {listed:?}" + ); + assert!(listed.iter().any(|entry| { + entry.key == historical_key && entry.kind == ObjectVersionKind::Object && entry.size == 2 + })); + assert!(listed.iter().any(|entry| { + entry.key == historical_key && entry.kind == ObjectVersionKind::DeleteMarker + })); + assert!(listed.iter().any(|entry| { + entry.key == marker_only_key && entry.kind == ObjectVersionKind::DeleteMarker + })); + + let refs = refs_from(&listed); + let first_chunk = &refs[..2.min(refs.len())]; + let first_delete = storage + .delete_object_versions(first_chunk) + .await + .expect("delete first explicit version chunk"); + assert!(first_delete.failed.is_empty(), "{first_delete:?}"); + assert!(first_delete.versioned_keys.is_empty(), "{first_delete:?}"); + assert_eq!( + first_delete.deleted + first_delete.already_missing, + first_chunk.len() as u64, + "explicit version delete should account for every requested identifier" + ); + + let retry = storage + .delete_object_versions(&first_chunk[..1]) + .await + .expect("retry already-deleted explicit version"); + assert!(retry.failed.is_empty(), "{retry:?}"); + assert!(retry.versioned_keys.is_empty(), "{retry:?}"); + assert_eq!( + retry.deleted + retry.already_missing, + 1, + "retry should be idempotently accounted as deleted/already missing" + ); + + let rest_delete = storage + .delete_object_versions(&refs[first_chunk.len()..]) + .await + .expect("delete remaining explicit versions"); + assert!(rest_delete.failed.is_empty(), "{rest_delete:?}"); + assert!(rest_delete.versioned_keys.is_empty(), "{rest_delete:?}"); + assert_eq!( + rest_delete.deleted + rest_delete.already_missing, + (refs.len() - first_chunk.len()) as u64, + "remaining explicit version delete should account for every requested identifier" + ); + + let remaining = list_all_versions(&storage, &prefix, 2).await; + assert!( + remaining.is_empty(), + "final ListObjectVersions must be empty after exact-version deletion: {remaining:?}" + ); + + if run_mc(&[ + "version".to_string(), + "suspend".to_string(), + bucket_path.clone(), + ]) + .is_ok() + { + let suspended_key = format!("{prefix}suspended.bin"); + storage + .put(&suspended_key, b"suspended", "application/octet-stream") + .await + .expect("put suspended-versioning object"); + storage + .delete(&suspended_key) + .await + .expect("delete suspended-versioning object"); + let suspended_entries = list_all_versions(&storage, &prefix, 2).await; + assert!( + suspended_entries + .iter() + .any(|entry| entry.key == suspended_key), + "suspended-versioning write/delete should be visible to ListObjectVersions" + ); + let suspended_delete = storage + .delete_object_versions(&refs_from(&suspended_entries)) + .await + .expect("delete suspended-versioning entries by explicit version id"); + assert!(suspended_delete.failed.is_empty(), "{suspended_delete:?}"); + assert!( + suspended_delete.versioned_keys.is_empty(), + "{suspended_delete:?}" + ); + assert!(list_all_versions(&storage, &prefix, 2).await.is_empty()); + } else { + eprintln!("MinIO mc did not support version suspend; enabled-versioning coverage passed"); + } + + run_mc(&["rb".to_string(), "--force".to_string(), bucket_path.clone()]) + .expect("remove isolated versioned test bucket"); +} diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 3b2241046a..ec7af3aac6 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2424,6 +2424,104 @@ mod track_c_tests { } } + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { + let (state, pool) = finalize_test_state().await; + let host = format!( + "git-announce-lease-{}.example", + uuid::Uuid::new_v4().simple() + ); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("create test community") + .id; + let (request, claim) = approved_deletion(&state, &host).await; + let tenant = TenantContext::resolved(community, host.clone()); + let owner_keys = Keys::generate(); + let repo = format!("repo-{}", uuid::Uuid::new_v4().simple()); + let event = EventBuilder::new(Kind::Custom(30_617), "") + .tags([Tag::parse(["d", &repo]).expect("d tag")]) + .sign_with_keys(&owner_keys) + .expect("sign announcement"); + let gate = Arc::new(crate::handlers::side_effects::GitRepoAnnouncementGate::default()); + let hooks = crate::handlers::side_effects::GitRepoAnnouncementHooks { + post_lease_gate: Some(Arc::clone(&gate)), + }; + let announce_state = Arc::clone(&state); + let announce_tenant = tenant.clone(); + let announce = tokio::spawn(async move { + let result = crate::handlers::side_effects::handle_git_repo_announcement_inner( + &announce_tenant, + &event, + &announce_state, + &hooks, + ) + .await; + let owner_hex = hex::encode(owner_keys.public_key().to_bytes()); + (result, owner_hex) + }); + + gate.reached.notified().await; + state + .db + .deletion_store() + .begin_quiescing(&claim.lease) + .await + .expect("quiesce after announcement lease"); + let error = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect_err("announcement serving lease must block fence"); + assert!(matches!( + error, + buzz_db::DbError::ServingWritesNotDrained { .. } + )); + + gate.resume.notify_one(); + let (announce_result, owner_hex) = announce.await.expect("announcement task"); + announce_result.expect("announcement completes"); + let pointer_key = crate::api::git::manifest::pointer_key(community, &owner_hex, &repo); + assert!( + state + .git_store + .get_pointer(&pointer_key) + .await + .expect("read pointer") + .is_some(), + "announcement pointer must be durable before lease release" + ); + assert!(state + .db + .deletion_store() + .serving_writes_drained(community) + .await + .expect("serving lease released")); + let generation = state + .db + .deletion_store() + .fence(&claim.lease) + .await + .expect("fence after pointer seed"); + assert_eq!(generation, 1); + assert_eq!( + state + .db + .deletion_store() + .get(request.id) + .await + .expect("fenced request") + .stage, + buzz_db::deletion::DeletionStage::Fenced + ); + drop(state); + pool.close().await; + } + #[tokio::test] #[ignore = "requires Postgres and MinIO"] async fn finalize_push_holds_serving_lease_through_post_cas_publication() { diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 89595fbee1..d37e7b375e 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2578,6 +2578,31 @@ async fn handle_git_repo_announcement( event: &Event, state: &Arc, ) -> anyhow::Result<()> { + handle_git_repo_announcement_inner(tenant, event, state, &GitRepoAnnouncementHooks::default()) + .await +} + +#[derive(Default)] +pub(crate) struct GitRepoAnnouncementHooks { + #[cfg(test)] + pub(crate) post_lease_gate: Option>, +} + +#[cfg(test)] +#[derive(Default)] +pub(crate) struct GitRepoAnnouncementGate { + pub(crate) reached: tokio::sync::Notify, + pub(crate) resume: tokio::sync::Notify, +} + +pub(crate) async fn handle_git_repo_announcement_inner( + tenant: &TenantContext, + event: &Event, + state: &Arc, + hooks: &GitRepoAnnouncementHooks, +) -> anyhow::Result<()> { + #[cfg(not(test))] + let _ = hooks; // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). let repo_id = extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; @@ -2677,6 +2702,32 @@ async fn handle_git_repo_announcement( // other attempt already established. let reserved_by_this_attempt = matches!(outcome, ReserveOutcome::Reserved); + // The event row and name registry are ordinary database state: if deletion + // quiescing wins before they commit, the DB write fence rejects them; if + // they committed first, the destructive DB stage purges them. The manifest + // and pointer below are external S3 effects, so acquire the durable + // serving-write lease immediately before that sequence. Once acquired, + // deletion must drain this lease before it can freeze the final object list. + let serving_write = buzz_deletion::acquire_serving_write( + &state.db, + tenant.community(), + "git_repo_announcement", + ) + .await + .map_err(|e| anyhow::anyhow!("repo announcement rejected by community deletion fence: {e}"))?; + + #[cfg(test)] + if let Some(gate) = &hooks.post_lease_gate { + gate.reached.notify_one(); + gate.resume.notified().await; + } + + if let Err(error) = serving_write.verify().await { + return Err(anyhow::anyhow!( + "repo announcement lost community serving lease: {error}" + )); + } + // Establish/confirm the manifest pointer, keeping the invariant // "repo announced ⟺ pointer exists" so the read path can rely on // pointer-absent meaning never-announced (keeping `info_refs`'s fail-closed @@ -2691,10 +2742,16 @@ async fn handle_git_repo_announcement( // re-announce must accept it untouched; only an absent pointer is // repaired by seeding. Using the strict seed here would wrongly reject // every re-announce after the first push. - let pointer_result = if reserved_by_this_attempt { - seed_manifest_pointer(state, tenant, &owner_hex, &repo_id).await - } else { - ensure_manifest_pointer(state, tenant, &owner_hex, &repo_id).await + let pointer_operation = async { + if reserved_by_this_attempt { + seed_manifest_pointer(state, tenant, &owner_hex, &repo_id).await + } else { + ensure_manifest_pointer(state, tenant, &owner_hex, &repo_id).await + } + }; + let pointer_result = match serving_write.protect(pointer_operation).await { + Ok(result) => result, + Err(error) => Err(error), }; if let Err(pointer_err) = pointer_result { // A reserved name without a clone-able pointer is exactly the broken @@ -2743,7 +2800,9 @@ async fn handle_git_repo_announcement( // initial empty signal is a one-time seeding notification, not something a // re-announce should replay. if reserved_by_this_attempt { - if let Err(e) = emit_initial_ref_state(tenant, state, &owner_hex, &repo_id).await { + if let Err(e) = + emit_initial_ref_state(tenant, state, serving_write.lease(), &owner_hex, &repo_id).await + { // Non-fatal: the manifest is the source of truth; this is just the // derived notification. A failure here means subscribers miss the // "repo now exists" event, but clone/push still works. @@ -2756,6 +2815,9 @@ async fn handle_git_repo_announcement( } } + serving_write.finish().await.map_err(|e| { + anyhow::anyhow!("repo announcement lost community serving lease on release: {e}") + })?; Ok(()) } @@ -2897,6 +2959,7 @@ async fn ensure_manifest_pointer( async fn emit_initial_ref_state( tenant: &TenantContext, state: &Arc, + lease: &buzz_db::deletion::ServingWriteLease, owner_hex: &str, repo_id: &str, ) -> anyhow::Result<()> { @@ -2914,7 +2977,7 @@ async fn emit_initial_ref_state( .map_err(|e| anyhow::anyhow!("build_ref_state_event: {e}"))?; let (stored, was_inserted) = state .db - .insert_event(tenant.community(), &event, None) + .insert_event_with_serving_write_guard(lease, &event, None) .await .map_err(|e| anyhow::anyhow!("insert kind:30618: {e}"))?; if was_inserted { diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index ca3403a633..448e15ca97 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -333,6 +333,15 @@ externalRedis: # first sweep fails AccessDenied and buzz_storage_sweep_ok stays 0 — no other # media functionality is affected. Set BUZZ_STORAGE_METRICS=off to disable # the sweep entirely on a deployment that can't grant it. +# +# Whole-community deletion (`buzz-admin deletions ...`) permanently removes +# tenant-owned object versions through the v5 deletion path. In addition to the +# ordinary media permissions, every deployment that enables community deletion +# needs bucket-level `s3:ListBucketVersions` and object-level +# `s3:DeleteObjectVersion` on this bucket before exercising deletion — including +# never-versioned buckets, which S3 exposes through `ListObjectVersions` with the +# `null` version id. +# # Note: buzz_storage_sweep_failures is a process-local gauge — on leader # failover it resets to the new leader's local count, not a global total. # Note: on a failed sweep attempt, the next retry fires on the next usage tick diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fb60a35189..0933149f5b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1191,6 +1191,7 @@ dependencies = [ "infer", "mp4", "nostr 0.44.7", + "quick-xml 0.38.4", "rust-s3", "serde", "serde_json", From 9b6a637d014607760d116b90c57062f82ab27cf3 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 25 Aug 2026 09:09:13 -0700 Subject: [PATCH 09/24] fix(desktop): make lightbox zoom controls interactive (#6710) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace the decorative lightbox zoom icons with accessible Zoom out and Zoom in buttons. - Route button clicks through the existing 1x–3x, 5% stepped zoom state without dismissing the viewer, including clicks on SVG icon descendants. - Extract the zoom toolbar so `markdown.tsx` remains below the repository file-size threshold. ### Testing - `pnpm build:e2e` - `pnpm exec playwright test image-attachment-gallery.spec.ts --project=smoke` — 10 passed - `pnpm typecheck` - Full desktop unit suite — 5,397 passed - Pre-push checks — file-size gate, desktop checks, typecheck, and desktop tests passed - `git diff --check` ### UI evidence The rendered lightbox flow is covered by the gallery smoke test: Zoom out is disabled at 100%, Zoom in changes the value to 105%, Zoom out returns it to 100%, and the dialog remains open throughout. Existing gallery, keyboard, spoiler, context-menu, and close-path assertions also pass. --------- Signed-off-by: sanic Signed-off-by: Kalvin Chau Co-authored-by: sanic Co-authored-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- desktop/src/shared/ui/markdown.tsx | 118 +++++++++--------- .../ui/markdown/ImageLightboxZoomControls.tsx | 91 ++++++++++++++ .../src/shared/ui/markdown/imageLightbox.ts | 90 ++++++++++++- .../e2e/image-attachment-gallery.spec.ts | 62 +++++++++ 4 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index af3997f815..445a7c2aba 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1,13 +1,7 @@ import * as React from "react"; import { createPortal } from "react-dom"; import type { Components } from "react-markdown"; -import { - ChevronLeft, - ChevronRight, - Download, - ZoomIn, - ZoomOut, -} from "lucide-react"; +import { ChevronLeft, ChevronRight, Download } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { toast } from "sonner"; @@ -49,6 +43,7 @@ import { markdownPropsAreEqual, } from "./markdownUtils"; import { ImageMosaic } from "./markdown/ImageMosaic"; +import { ImageLightboxZoomControls } from "./markdown/ImageLightboxZoomControls"; import { CODE_BLOCK_CLASS, extractLanguage, @@ -74,7 +69,6 @@ import { } from "./markdown/MediaContextMenu"; import { isVideoMedia } from "./markdown/mediaEntry"; import { - clampImageLightboxZoom, type ImageGalleryDirection, type ImageGalleryItem, type ImageLightboxBox, @@ -90,13 +84,11 @@ import { IMAGE_LIGHTBOX_GALLERY_EASE, IMAGE_LIGHTBOX_GALLERY_SLIDE_DISTANCE_PX, IMAGE_LIGHTBOX_GALLERY_SLIDE_MS, - IMAGE_LIGHTBOX_MAX_ZOOM, IMAGE_LIGHTBOX_MIN_ZOOM, IMAGE_LIGHTBOX_REDUCED_MOTION_MS, IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS, IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA, IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED, - IMAGE_LIGHTBOX_ZOOM_STEP, IMAGE_LIGHTBOX_ZOOM_TRANSITION_MS, imageLightboxBasisBoxForItem, imageLightboxBoxFromRect, @@ -110,6 +102,8 @@ import { imageLightboxTargetBox, imageLightboxTransform, imageLightboxZoomBox, + imageLightboxZoomStateAtPoint, + imageLightboxZoomStateAtZoom, normalizedWheelDeltaY, visibleImageGalleryForTrigger, } from "./markdown/imageLightbox"; @@ -218,7 +212,10 @@ function ImageZoomOverlay({ const [returnBox, setReturnBox] = React.useState(sourceBox); const [returnCornerRadii, setReturnCornerRadii] = React.useState(sourceCornerRadii); - const [zoom, setZoom] = React.useState(IMAGE_LIGHTBOX_MIN_ZOOM); + const [{ zoom, zoomOffset }, setZoomState] = React.useState(() => ({ + zoom: IMAGE_LIGHTBOX_MIN_ZOOM, + zoomOffset: { x: 0, y: 0 }, + })); const controlPointerDownRef = React.useRef(false); const fadeTimerRef = React.useRef(null); const galleryTransitionTimerRef = React.useRef(null); @@ -269,7 +266,6 @@ function ImageZoomOverlay({ Date.now() + IMAGE_LIGHTBOX_CONTROL_SUPPRESS_CLOSE_MS; }, []); const closeMenu = React.useCallback(() => setMenu(null), []); - const finishZoomGestureSoon = React.useCallback(() => { if (zoomIdleTimerRef.current != null) { window.clearTimeout(zoomIdleTimerRef.current); @@ -280,13 +276,21 @@ function ImageZoomOverlay({ }, IMAGE_LIGHTBOX_TRACKPAD_ZOOM_IDLE_MS); }, []); - const setClampedZoom = React.useCallback((nextZoom: number) => { - setZoom(clampImageLightboxZoom(nextZoom)); - }, []); + const setClampedZoom = React.useCallback( + (nextZoom: number) => + setZoomState((current) => + imageLightboxZoomStateAtZoom(current, nextZoom), + ), + [], + ); - const updateZoom = React.useCallback((updater: (zoom: number) => number) => { - setZoom((currentZoom) => clampImageLightboxZoom(updater(currentZoom))); - }, []); + const updateZoom = React.useCallback( + (updater: (zoom: number) => number) => + setZoomState((current) => + imageLightboxZoomStateAtZoom(current, updater(current.zoom)), + ), + [], + ); const close = React.useCallback(() => { if (closeTimerRef.current != null) return; @@ -351,7 +355,10 @@ function ImageZoomOverlay({ galleryTransitionTimerRef.current = null; }, IMAGE_LIGHTBOX_GALLERY_SLIDE_MS); setIsAdjustingZoom(false); - setZoom(IMAGE_LIGHTBOX_MIN_ZOOM); + setZoomState({ + zoom: IMAGE_LIGHTBOX_MIN_ZOOM, + zoomOffset: { x: 0, y: 0 }, + }); setCurrentIndex(nextIndex); }, [currentIndex, items.length, markControlGesture, prefersReducedMotion], @@ -638,7 +645,7 @@ function ImageZoomOverlay({ const isClosing = phase === "closing"; const isOpen = phase === "open"; const isFading = phase === "fading"; - const displayBox = imageLightboxZoomBox(targetBox, zoom); + const displayBox = imageLightboxZoomBox(targetBox, zoom, zoomOffset); const frameBox = isReturning ? returnBox : targetBox; const frameCornerRadii = isReturning ? returnCornerRadii @@ -677,11 +684,25 @@ function ImageZoomOverlay({ : isFading ? IMAGE_LIGHTBOX_FADE_EXIT_MS : IMAGE_LIGHTBOX_FADE_ENTER_MS; - const zoomFillPercent = - ((zoom - IMAGE_LIGHTBOX_MIN_ZOOM) / - (IMAGE_LIGHTBOX_MAX_ZOOM - IMAGE_LIGHTBOX_MIN_ZOOM)) * - 100; const label = currentItem.alt?.trim() || "Image preview"; + const handleImageClick = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!isOpen || isReturning) { + return; + } + + setIsAdjustingZoom(false); + setZoomState((current) => + imageLightboxZoomStateAtPoint(targetBox, current, { + x: event.clientX, + y: event.clientY, + }), + ); + }, + [isOpen, isReturning, targetBox], + ); const handleImageContextMenu = React.useCallback( (event: React.MouseEvent) => { event.preventDefault(); @@ -716,7 +737,7 @@ function ImageZoomOverlay({ return; } if ( - event.target instanceof HTMLElement && + event.target instanceof Element && event.target.closest("[data-image-lightbox-controls]") ) { markControlGesture(); @@ -738,7 +759,7 @@ function ImageZoomOverlay({ }} onPointerDownCapture={(event) => { if ( - event.target instanceof HTMLElement && + event.target instanceof Element && event.target.closest("[data-image-lightbox-controls]") ) { controlPointerDownRef.current = true; @@ -756,7 +777,8 @@ function ImageZoomOverlay({ tabIndex={-1} >

- Full-size image preview. Press Escape or click to close. + Full-size image preview. Press Escape or click outside the image to + close. Click the image to zoom.

IMAGE_LIGHTBOX_MIN_ZOOM + ? "cursor-zoom-out" + : "cursor-zoom-in", )} custom={galleryDirection} exit="exit" @@ -843,6 +868,7 @@ function ImageZoomOverlay({ ease: IMAGE_LIGHTBOX_GALLERY_EASE, }} variants={galleryImageVariants} + onClick={handleImageClick} onContextMenuCapture={handleImageContextMenu} /> @@ -919,39 +945,13 @@ function ImageZoomOverlay({ aria-hidden="true" className="h-5 w-px shrink-0 bg-muted-foreground/15" /> -
{menu && canActOnCurrentImage ? ( diff --git a/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx b/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx new file mode 100644 index 0000000000..f92f4afc89 --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageLightboxZoomControls.tsx @@ -0,0 +1,91 @@ +import type { CSSProperties } from "react"; +import { ZoomIn, ZoomOut } from "lucide-react"; + +import { + IMAGE_LIGHTBOX_MAX_ZOOM, + IMAGE_LIGHTBOX_MIN_ZOOM, + IMAGE_LIGHTBOX_ZOOM_STEP, +} from "./imageLightbox"; + +type ImageLightboxZoomControlsProps = { + markControlGesture: () => void; + setClampedZoom: (nextZoom: number) => void; + setIsAdjustingZoom: (isAdjusting: boolean) => void; + updateZoom: (updater: (currentZoom: number) => number) => void; + zoom: number; +}; + +const ZOOM_BUTTON_CLASS_NAME = + "flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-muted-foreground/10 hover:text-foreground outline-hidden focus-visible:ring-2 focus-visible:ring-ring/70 disabled:pointer-events-none disabled:opacity-45"; + +/** Interactive zoom controls for the image lightbox toolbar. */ +export function ImageLightboxZoomControls({ + markControlGesture, + setClampedZoom, + setIsAdjustingZoom, + updateZoom, + zoom, +}: ImageLightboxZoomControlsProps) { + const zoomFillPercent = + ((zoom - IMAGE_LIGHTBOX_MIN_ZOOM) / + (IMAGE_LIGHTBOX_MAX_ZOOM - IMAGE_LIGHTBOX_MIN_ZOOM)) * + 100; + + return ( + <> + + setIsAdjustingZoom(false)} + onChange={(event) => { + markControlGesture(); + setClampedZoom(Number(event.target.value)); + }} + onPointerCancel={() => setIsAdjustingZoom(false)} + onPointerDown={() => { + markControlGesture(); + setIsAdjustingZoom(true); + }} + onPointerUp={() => { + markControlGesture(); + setIsAdjustingZoom(false); + }} + /> + + + {Math.round(zoom * 100)}% + + + ); +} diff --git a/desktop/src/shared/ui/markdown/imageLightbox.ts b/desktop/src/shared/ui/markdown/imageLightbox.ts index 4c0c01f93e..da29fc4971 100644 --- a/desktop/src/shared/ui/markdown/imageLightbox.ts +++ b/desktop/src/shared/ui/markdown/imageLightbox.ts @@ -38,6 +38,16 @@ export type ImageGalleryItem = { thumbnailCornerRadii?: ImageLightboxCornerRadii; }; +export type ImageLightboxZoomAnchor = { + x: number; + y: number; +}; + +export type ImageLightboxZoomState = { + zoom: number; + zoomOffset: ImageLightboxZoomAnchor; +}; + export const IMAGE_LIGHTBOX_ENTER_MS = 260; export const IMAGE_LIGHTBOX_EXIT_MS = 170; export const IMAGE_LIGHTBOX_FADE_ENTER_MS = 180; @@ -54,7 +64,8 @@ export const IMAGE_LIGHTBOX_WHEEL_ZOOM_SPEED = 0.002; export const IMAGE_LIGHTBOX_WHEEL_ZOOM_MAX_DELTA = 0.2; export const IMAGE_LIGHTBOX_MIN_ZOOM = 1; export const IMAGE_LIGHTBOX_MAX_ZOOM = 3; -export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.05; +export const IMAGE_LIGHTBOX_ZOOM_STEP = 0.15; +export const IMAGE_LIGHTBOX_CLICK_ZOOM = 1.75; export const IMAGE_LIGHTBOX_EASE_OUT = "cubic-bezier(0.23, 1, 0.32, 1)"; export const IMAGE_LIGHTBOX_EASE_IN_OUT = "cubic-bezier(0.77, 0, 0.175, 1)"; export const IMAGE_LIGHTBOX_EXPANDED_CORNER_RADIUS = "1rem"; @@ -151,18 +162,91 @@ export function imageLightboxTransform( export function imageLightboxZoomBox( targetBox: ImageLightboxBox, zoom: number, + offset: ImageLightboxZoomAnchor = { x: 0, y: 0 }, ): ImageLightboxBox { const width = targetBox.width * zoom; const height = targetBox.height * zoom; return { height, - left: targetBox.left + (targetBox.width - width) / 2, - top: targetBox.top + (targetBox.height - height) / 2, + left: targetBox.left + (targetBox.width - width) / 2 + offset.x, + top: targetBox.top + (targetBox.height - height) / 2 + offset.y, width, }; } +export function imageLightboxZoomBoxAtPoint( + targetBox: ImageLightboxBox, + currentBox: ImageLightboxBox, + nextZoom: number, + point: ImageLightboxZoomAnchor, +): ImageLightboxBox { + const relativeX = (point.x - currentBox.left) / Math.max(1, currentBox.width); + const relativeY = (point.y - currentBox.top) / Math.max(1, currentBox.height); + const nextBox = imageLightboxZoomBox(targetBox, nextZoom); + + return { + ...nextBox, + left: point.x - relativeX * nextBox.width, + top: point.y - relativeY * nextBox.height, + }; +} + +export function imageLightboxZoomStateAtZoom( + currentState: ImageLightboxZoomState, + nextZoom: number, +): ImageLightboxZoomState { + const zoom = clampImageLightboxZoom(nextZoom); + if (zoom === IMAGE_LIGHTBOX_MIN_ZOOM) { + return { zoom, zoomOffset: { x: 0, y: 0 } }; + } + + // Scale the stored offset by the zoom ratio so the image point currently + // at the frame center stays anchored there as the slider/wheel changes zoom. + const offsetScale = zoom / currentState.zoom; + return { + zoom, + zoomOffset: { + x: currentState.zoomOffset.x * offsetScale, + y: currentState.zoomOffset.y * offsetScale, + }, + }; +} + +export function imageLightboxZoomStateAtPoint( + targetBox: ImageLightboxBox, + currentState: ImageLightboxZoomState, + point: ImageLightboxZoomAnchor, +): ImageLightboxZoomState { + const nextZoom = + currentState.zoom === IMAGE_LIGHTBOX_MIN_ZOOM + ? IMAGE_LIGHTBOX_CLICK_ZOOM + : IMAGE_LIGHTBOX_MIN_ZOOM; + if (nextZoom === IMAGE_LIGHTBOX_MIN_ZOOM) { + return imageLightboxZoomStateAtZoom(currentState, nextZoom); + } + + const currentBox = imageLightboxZoomBox( + targetBox, + currentState.zoom, + currentState.zoomOffset, + ); + const nextBox = imageLightboxZoomBoxAtPoint( + targetBox, + currentBox, + nextZoom, + point, + ); + const centeredNextBox = imageLightboxZoomBox(targetBox, nextZoom); + return { + zoom: nextZoom, + zoomOffset: { + x: nextBox.left - centeredNextBox.left, + y: nextBox.top - centeredNextBox.top, + }, + }; +} + export function imageLightboxBasisBoxForItem( item: ImageGalleryItem, fallbackBox: ImageLightboxBox, diff --git a/desktop/tests/e2e/image-attachment-gallery.spec.ts b/desktop/tests/e2e/image-attachment-gallery.spec.ts index 8c36023655..38975b4a3a 100644 --- a/desktop/tests/e2e/image-attachment-gallery.spec.ts +++ b/desktop/tests/e2e/image-attachment-gallery.spec.ts @@ -175,6 +175,68 @@ test("image bundle lightbox navigates as a gallery", async ({ page }) => { .first(); await expectCornerRadiusPx(lightboxSurface, 16); await expectSmoothCorners(lightboxSurface); + await expect(page.getByRole("button", { name: "Zoom out" })).toBeDisabled(); + await expect(page.getByRole("button", { name: "Zoom in" })).toBeEnabled(); + + // Clicking the image zooms to the secondary level instead of dismissing the + // dialog, and the clicked image point remains under the cursor. + await waitForAnimations(page); + const lightboxImage = dialog.locator(`img[src*="${IMAGE_SHAS[0]}"]`); + const initialImageBox = await lightboxImage.boundingBox(); + if (!initialImageBox) { + throw new Error("Expected lightbox image to have a layout box"); + } + const clickPoint = { + x: initialImageBox.x + initialImageBox.width * 0.25, + y: initialImageBox.y + initialImageBox.height * 0.35, + }; + await page.mouse.click(clickPoint.x, clickPoint.y); + await expect(dialog).toBeVisible(); + await expect(page.getByText("175%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1.75", + ); + await waitForAnimations(page); + const zoomedImageBox = await lightboxImage.boundingBox(); + if (!zoomedImageBox) { + throw new Error("Expected zoomed lightbox image to have a layout box"); + } + expect(zoomedImageBox.width).toBeCloseTo(initialImageBox.width * 1.75, 0); + expect(zoomedImageBox.height).toBeCloseTo(initialImageBox.height * 1.75, 0); + expect( + Math.abs( + zoomedImageBox.x + initialImageBox.width * 0.25 * 1.75 - clickPoint.x, + ), + ).toBeLessThan(2); + expect( + Math.abs( + zoomedImageBox.y + initialImageBox.height * 0.35 * 1.75 - clickPoint.y, + ), + ).toBeLessThan(2); + + // A second image click toggles back to the centered 1× view. + await page.mouse.click(clickPoint.x, clickPoint.y); + await expect(dialog).toBeVisible(); + await expect(page.getByText("100%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1", + ); + + await page.getByRole("button", { name: "Zoom in" }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByText("115%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1.15", + ); + await expect( + page.getByRole("slider", { name: "Image zoom" }), + ).toHaveAttribute("step", "0.15"); + await page.getByRole("button", { name: "Zoom out" }).click(); + await expect(dialog).toBeVisible(); + await expect(page.getByText("100%", { exact: true })).toBeVisible(); + await expect(page.getByRole("slider", { name: "Image zoom" })).toHaveValue( + "1", + ); await expect( page.getByRole("button", { name: "Previous image" }), ).toHaveCount(0); From 29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 12:22:12 -0400 Subject: [PATCH 10/24] highlight search terms in results and messages (#6702) **Category:** fix **User Impact:** Search terms are now highlighted in yellow in Cmd+F results and in the message opened from a result. **Problem:** Search returned relevant messages, but users still had to reread each preview and destination message to discover where the query appeared. Search navigation could also lose or misapply highlighting during rapid typing, repeated navigation, thread opening, and forum navigation. **Solution:** Render match-focused previews with a shared yellow treatment, carry the result query through navigation, and apply it only to the clicked destination. The implementation binds highlights to the debounced result set, supports token and prefix matching, and handles channels, threads, forums, diffs, and wave messages consistently.
File changes **desktop/src/app/AppShell.tsx** Carries the active result query into search-hit navigation so the destination can preserve the user's context. **desktop/src/app/navigation/searchHitEventCache.ts** Adds a bounded, one-shot cache for result queries and navigation IDs alongside cached search-hit events. **desktop/src/app/navigation/searchHitNavigation.test.mjs** Covers query retention, one-shot consumption, repeated same-route activations, forum posts, forced routing, and cancellation. **desktop/src/app/navigation/searchHitNavigation.ts** Associates each search-result activation with a unique navigation ID and forwards it to channel or forum destinations. **desktop/src/app/navigation/useAppNavigation.ts** Extends channel and forum navigation to carry search navigation state without changing normal navigation behavior. **desktop/src/app/routes/ChannelRouteScreen.tsx** Consumes search highlight state after navigation and retains it for the selected message while clearing it on community context changes. **desktop/src/app/routes/channels.$channelId.posts.$postId.tsx** Validates and forwards forum search-navigation IDs. **desktop/src/app/routes/channels.$channelId.tsx** Validates and forwards channel search-navigation IDs. **desktop/src/features/channels/ui/ChannelPane.tsx** Passes the selected result ID and query into the main timeline and open thread panel. **desktop/src/features/channels/ui/ChannelPane.types.ts** Defines the destination-highlight contract for channel panes. **desktop/src/features/channels/ui/ChannelScreen.tsx** Routes destination highlighting to either forum content or the channel timeline. **desktop/src/features/channels/ui/ChannelScreen.types.ts** Defines route-level search-highlight inputs. **desktop/src/features/channels/ui/ForumChannelContent.tsx** Forwards search context into expanded forum threads. **desktop/src/features/forum/ui/ForumThreadPanel.tsx** Highlights the matching text in the selected forum post or reply. **desktop/src/features/forum/ui/ForumView.tsx** Carries selected-result context from forum routing into the thread panel. **desktop/src/features/messages/ui/DiffMessage.tsx** Forwards the query into diff descriptions and rendered diff content. **desktop/src/features/messages/ui/DiffViewer.tsx** Provides a highlighted match excerpt for structured diffs and highlights fallback raw diff text. **desktop/src/features/messages/ui/MessageRow.tsx** Passes destination queries into normal Markdown, diff, and wave message renderers. **desktop/src/features/messages/ui/MessageThreadPanel.tsx** Applies highlighting to the selected thread head or reply without tinting unrelated messages. **desktop/src/features/messages/ui/WaveMessageAttachment.tsx** Highlights matches in wave-message fallback text. **desktop/src/features/search/lib/searchMatch.test.mjs** Covers case-insensitive, literal, multi-term, prefix, one-character, and late-preview matches. **desktop/src/features/search/lib/searchMatch.ts** Centralizes token extraction, case-insensitive match splitting, and match-focused preview generation. **desktop/src/features/search/ui/HighlightedSearchText.tsx** Adds the reusable accessible mark renderer used by result and specialized message surfaces. **desktop/src/features/search/ui/TopbarSearch.tsx** Highlights result previews, centers excerpts around matches, strips search operators, and hides stale results during debounce transitions. **desktop/src/features/sidebar/ui/AppSidebar.types.ts** Updates the sidebar search callback contract to include the result query. **desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx** Passes query-aware result selection through the pinned search entry point. **desktop/src/shared/lib/rehypeSearchHighlight.ts** Uses the shared matcher to mark every matching Markdown text segment while leaving code blocks untouched. **desktop/src/shared/lib/searchHighlightStyle.ts** Defines one yellow highlight treatment for light and dark themes. **desktop/src/shared/ui/markdown/nodeCache.test.mjs** Verifies all Markdown matches, one-character scoped searches, code exclusion, and transient cache behavior. **desktop/src/shared/ui/markdown/nodeCache.ts** Enables transient highlighting for one-character scoped searches without polluting the Markdown cache. **desktop/tests/e2e/smoke.spec.ts** Exercises result and destination highlighting, same-route forum activation, and stale-query suppression end to end.
## Reproduction steps 1. Open a channel containing a longer message and press Cmd+F. 2. Search for a word that appears later in the message, such as `mentions`. 3. Confirm each matching result shows the word in yellow and keeps the match visible in its preview. 4. Open a result and confirm the same term is highlighted in yellow in the destination message. 5. Repeat with a thread reply or forum post, and quickly change the query to confirm stale results are not selectable. ## Demo - **Before:** Search results returned the correct message but offered no visual indication of where the query matched; opening the message also showed no match treatment. - **After:** Every matching term is marked in yellow in the result preview and in the exact channel, thread, or forum message opened from that result. --------- Signed-off-by: tulsi --- desktop/src/app/AppShell.tsx | 4 +- .../searchHighlightNavigation.test.mjs | 39 ++ .../navigation/searchHighlightNavigation.ts | 47 +++ .../navigation/searchHitNavigation.test.mjs | 40 ++ .../src/app/navigation/searchHitNavigation.ts | 19 +- .../src/app/navigation/useAppNavigation.ts | 39 +- desktop/src/app/routes/ChannelRouteScreen.tsx | 77 +++- .../channels.$channelId.posts.$postId.tsx | 7 +- .../src/app/routes/channels.$channelId.tsx | 8 +- .../routes/searchHighlightRouteState.test.mjs | 39 ++ .../app/routes/searchHighlightRouteState.ts | 17 + .../src/features/channels/ui/ChannelPane.tsx | 22 +- .../features/channels/ui/ChannelPane.types.ts | 4 + .../features/channels/ui/ChannelScreen.tsx | 375 +++++++++--------- .../channels/ui/ChannelScreen.types.ts | 4 + .../channels/ui/ForumChannelContent.tsx | 6 + .../channels/ui/searchTargetForwarding.tsx | 20 + .../channels/ui/useSearchHighlightProps.ts | 18 + .../features/forum/ui/ForumThreadPanel.tsx | 17 + desktop/src/features/forum/ui/ForumView.tsx | 6 + .../src/features/messages/ui/DiffMessage.tsx | 6 +- .../src/features/messages/ui/DiffViewer.tsx | 20 +- .../src/features/messages/ui/MessageRow.tsx | 2 + .../messages/ui/MessageThreadPanel.tsx | 12 + .../messages/ui/WaveMessageAttachment.tsx | 10 +- .../features/search/lib/searchMatch.test.mjs | 113 ++++++ .../src/features/search/lib/searchMatch.ts | 201 ++++++++++ .../search/ui/HighlightedSearchText.tsx | 26 ++ .../src/features/search/ui/TopbarSearch.tsx | 48 +-- .../features/sidebar/ui/AppSidebar.types.ts | 2 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 2 +- .../src/shared/lib/rehypeSearchHighlight.ts | 32 +- .../src/shared/lib/searchHighlightStyle.ts | 3 + .../src/shared/ui/markdown/nodeCache.test.mjs | 28 +- desktop/src/shared/ui/markdown/nodeCache.ts | 4 +- desktop/src/testing/e2eBridge.ts | 9 + desktop/tests/e2e/smoke.spec.ts | 165 ++++++++ 37 files changed, 1207 insertions(+), 284 deletions(-) create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.test.mjs create mode 100644 desktop/src/app/navigation/searchHighlightNavigation.ts create mode 100644 desktop/src/app/routes/searchHighlightRouteState.test.mjs create mode 100644 desktop/src/app/routes/searchHighlightRouteState.ts create mode 100644 desktop/src/features/channels/ui/searchTargetForwarding.tsx create mode 100644 desktop/src/features/channels/ui/useSearchHighlightProps.ts create mode 100644 desktop/src/features/search/lib/searchMatch.test.mjs create mode 100644 desktop/src/features/search/lib/searchMatch.ts create mode 100644 desktop/src/features/search/ui/HighlightedSearchText.tsx create mode 100644 desktop/src/shared/lib/searchHighlightStyle.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0..468435e15e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -648,8 +648,8 @@ export function AppShell() { ); const handleOpenSearchResult = React.useCallback( - (hit: SearchHit) => { - void openSearchHit(hit); + (hit: SearchHit, query: string) => { + void openSearchHit(hit, { query }); }, [openSearchHit], ); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.test.mjs b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs new file mode 100644 index 0000000000..1e05a62669 --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { createSearchHighlightNavigation, parseSearchHighlightNavigation } = + await import("./searchHighlightNavigation.ts"); + +test("creates trimmed transient state with a unique activation id", () => { + const first = createSearchHighlightNavigation("message", " Mentions "); + const second = createSearchHighlightNavigation("message", "Mentions"); + + assert.deepEqual( + { messageId: first.messageId, query: first.query }, + { messageId: "message", query: "Mentions" }, + ); + assert.notEqual(first.activationId, second.activationId); +}); + +test("does not create highlight state for an empty query", () => { + assert.equal(createSearchHighlightNavigation("message", " "), undefined); + assert.equal( + createSearchHighlightNavigation("message", undefined), + undefined, + ); +}); + +test("parses only complete highlight navigation state", () => { + const state = { + activationId: "activation", + messageId: "message", + query: "mentions", + }; + + assert.deepEqual(parseSearchHighlightNavigation(state), state); + assert.equal( + parseSearchHighlightNavigation({ messageId: "message", query: "mentions" }), + null, + ); + assert.equal(parseSearchHighlightNavigation(null), null); +}); diff --git a/desktop/src/app/navigation/searchHighlightNavigation.ts b/desktop/src/app/navigation/searchHighlightNavigation.ts new file mode 100644 index 0000000000..43d3506ec8 --- /dev/null +++ b/desktop/src/app/navigation/searchHighlightNavigation.ts @@ -0,0 +1,47 @@ +export type SearchHighlightNavigation = { + activationId: string; + messageId: string; + query: string; +}; + +export function createSearchHighlightNavigation( + messageId: string, + query: string | undefined, +): SearchHighlightNavigation | undefined { + const trimmedQuery = query?.trim(); + if (!trimmedQuery) { + return undefined; + } + + return { + activationId: crypto.randomUUID(), + messageId, + query: trimmedQuery, + }; +} + +export function parseSearchHighlightNavigation( + value: unknown, +): SearchHighlightNavigation | null { + if (!value || typeof value !== "object") { + return null; + } + + const candidate = value as Partial; + if ( + typeof candidate.activationId !== "string" || + candidate.activationId.length === 0 || + typeof candidate.messageId !== "string" || + candidate.messageId.length === 0 || + typeof candidate.query !== "string" || + candidate.query.length === 0 + ) { + return null; + } + + return { + activationId: candidate.activationId, + messageId: candidate.messageId, + query: candidate.query, + }; +} diff --git a/desktop/src/app/navigation/searchHitNavigation.test.mjs b/desktop/src/app/navigation/searchHitNavigation.test.mjs index 74e5f108af..02276d9eb3 100644 --- a/desktop/src/app/navigation/searchHitNavigation.test.mjs +++ b/desktop/src/app/navigation/searchHitNavigation.test.mjs @@ -51,6 +51,7 @@ test("search-hit navigation preserves forced message routing while active", asyn options: { force: true, messageId: "message", + searchHighlight: undefined, threadRootId: "thread-root", }, }, @@ -58,6 +59,45 @@ test("search-hit navigation preserves forced message routing while active", asyn assert.equal(getCachedSearchHitEvent("message")?.id, "message"); }); +test("search-hit navigation carries trimmed highlight state and forces repeated activations", async () => { + clearSearchHitEventCache(); + const calls = []; + + await openSearchHitWithNavigation(plainMessage, { + goChannel: async (channelId, options) => { + calls.push({ channelId, options }); + return true; + }, + goForumPost: async () => false, + query: " Mentions ", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "message"); + assert.equal(calls[0].options.searchHighlight.query, "Mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + +test("forum-post search navigation carries transient same-route activation state", async () => { + clearSearchHitEventCache(); + const forumPost = { ...forumComment, eventId: "post", kind: 45001 }; + const calls = []; + + await openSearchHitWithNavigation(forumPost, { + goChannel: async () => false, + goForumPost: async (channelId, postId, options) => { + calls.push({ channelId, postId, options }); + return true; + }, + query: "mentions", + }); + + assert.equal(calls[0].options.force, true); + assert.equal(calls[0].options.searchHighlight.messageId, "post"); + assert.equal(calls[0].options.searchHighlight.query, "mentions"); + assert.match(calls[0].options.searchHighlight.activationId, /.+/); +}); + test("cancelled search-hit navigation cannot repopulate cache or route", async () => { clearSearchHitEventCache(); let resolveLookup; diff --git a/desktop/src/app/navigation/searchHitNavigation.ts b/desktop/src/app/navigation/searchHitNavigation.ts index 8523340d48..6b180c33f0 100644 --- a/desktop/src/app/navigation/searchHitNavigation.ts +++ b/desktop/src/app/navigation/searchHitNavigation.ts @@ -1,21 +1,28 @@ import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination"; +import { createSearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import type { SearchHit } from "@/shared/api/types"; type SearchHitNavigationActions = { force?: boolean; + query?: string; goChannel: ( channelId: string, options?: { force?: boolean; messageId?: string; + searchHighlight?: ReturnType; threadRootId?: string | null; }, ) => Promise; goForumPost: ( channelId: string, postId: string, - options?: { force?: boolean; replyId?: string }, + options?: { + force?: boolean; + replyId?: string; + searchHighlight?: ReturnType; + }, ) => Promise; signal?: AbortSignal; }; @@ -30,6 +37,10 @@ export async function openSearchHitWithNavigation( } const isLifecycleBound = Boolean(actions.signal); + const searchHighlight = createSearchHighlightNavigation( + hit.eventId, + actions.query, + ); if (!isLifecycleBound) { cacheSearchHitEvent(hit); } @@ -47,14 +58,16 @@ export async function openSearchHitWithNavigation( if (destination.kind === "forum-post") { return actions.goForumPost(destination.channelId, destination.postId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), replyId: destination.replyId, + searchHighlight, }); } return actions.goChannel(destination.channelId, { - force: actions.force, + force: actions.force || Boolean(searchHighlight), messageId: destination.messageId, + searchHighlight, threadRootId: destination.threadRootId, }); } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 7a21f0dfbe..c82c4d96ee 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -6,6 +6,7 @@ import { useRouter, } from "@tanstack/react-router"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; import { allowNavigation, @@ -32,14 +33,23 @@ export function useAppNavigation() { to: string; params?: Record; search?: Record; - state?: Record; + state?: + | Record + | (( + previousState: Record, + ) => Record); }, behavior: NavigationBehavior = {}, guardedTarget?: GuardedNavigation, ) => { const nextLocation = router.buildLocation(next as never); + const hasStateUpdate = next.state !== undefined; - if (location.href === nextLocation.href && !behavior.force) { + if ( + location.href === nextLocation.href && + !behavior.force && + !hasStateUpdate + ) { return false; } @@ -265,6 +275,9 @@ export function useAppNavigation() { * silently swallowed (block/buzz#3509). */ force?: boolean; messageId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; replace?: boolean; /** Open this thread panel directly without waiting for a timeline row. */ thread?: string; @@ -290,6 +303,12 @@ export function useAppNavigation() { ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -329,6 +348,9 @@ export function useAppNavigation() { force?: boolean; replace?: boolean; replyId?: string; + /** Preserve an active search highlight; ordinary navigation clears it. */ + preserveSearchHighlight?: boolean; + searchHighlight?: SearchHighlightNavigation; }, ) => { return commitNavigation( @@ -338,7 +360,15 @@ export function useAppNavigation() { channelId, postId, }, - search: options?.replyId ? { replyId: options.replyId } : {}, + search: { + ...(options?.replyId ? { replyId: options.replyId } : {}), + }, + state: options?.preserveSearchHighlight + ? undefined + : (previousState: Record) => ({ + ...previousState, + searchHighlight: options?.searchHighlight ?? null, + }), }, { force: options?.force, @@ -406,6 +436,8 @@ export function useAppNavigation() { * Used by desktop-notification activation so a click is never * silently swallowed (block/buzz#3509). */ force?: boolean; + /** Search text to highlight after opening this result. */ + query?: string; /** Stop notification-driven routing when its owning lifecycle ends. */ signal?: AbortSignal; }, @@ -414,6 +446,7 @@ export function useAppNavigation() { force: behavior?.force, goChannel, goForumPost, + query: behavior?.query, signal: behavior?.signal, }), [goChannel, goForumPost], diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index d4626d2c6f..03b08196af 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -1,5 +1,6 @@ import * as React from "react"; +import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation"; import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; @@ -20,6 +21,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteScreenProps = { autoSendDraftKey: string | null; channelId: string; + searchHighlight: SearchHighlightNavigation | null | undefined; selectedPostId: string | null; targetMessageId: string | null; targetReplyId: string | null; @@ -100,6 +102,7 @@ async function fetchRouteTargetEvents( export function ChannelRouteScreen({ autoSendDraftKey, channelId, + searchHighlight, selectedPostId, targetMessageId, targetReplyId, @@ -132,23 +135,67 @@ export function ChannelRouteScreen({ const cachedTarget = getCachedSearchHitEvent(targetMessageId); return cachedTarget ? [cachedTarget] : []; }); - - // Reset spliced target events when the channel context changes (channel - // switch or entering/leaving a forum post). Tied to channel identity rather - // than the route target so clearing the `messageId` param mid-channel keeps - // the deep-linked row in view. Seeded with the mount key so the initial - // cache-seeded events survive first commit; only a genuine channel change - // clears them. Declared before the fetch effect so a channel switch clears - // stale events before the new target is fetched. - const previousResetKeyRef = React.useRef( - `${channelId}::${selectedPostId ?? ""}`, + const [activeSearchHighlight, setActiveSearchHighlight] = + React.useState(searchHighlight ?? null); + const appliedSearchActivationIdRef = React.useRef( + searchHighlight?.activationId ?? null, ); + + // Router state is transient and can be cleared by the target URL cleanup. + // Retain the applied activation locally until an ordinary route transition + // explicitly arrives without search state. React.useEffect(() => { - const resetKey = `${channelId}::${selectedPostId ?? ""}`; - if (previousResetKeyRef.current === resetKey) return; - previousResetKeyRef.current = resetKey; + if (searchHighlight === null) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + return; + } + if (!searchHighlight) { + const ordinaryTargetIds = [ + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ].filter((targetId): targetId is string => targetId !== null); + if ( + ordinaryTargetIds.length > 0 && + activeSearchHighlight && + !ordinaryTargetIds.includes(activeSearchHighlight.messageId) + ) { + appliedSearchActivationIdRef.current = null; + setActiveSearchHighlight(null); + } + return; + } + if (appliedSearchActivationIdRef.current === searchHighlight.activationId) { + return; + } + + appliedSearchActivationIdRef.current = searchHighlight.activationId; + setActiveSearchHighlight(searchHighlight); + }, [ + activeSearchHighlight, + searchHighlight, + selectedPostId, + targetMessageId, + targetReplyId, + targetThreadRootId, + ]); + + // Reset spliced target events when the channel changes. Tied to channel + // identity rather than the route target so clearing the `messageId` param + // mid-channel keeps the deep-linked row in view. Seeded with the mount key so + // the initial cache-seeded events survive first commit; only a genuine + // channel change clears them. Declared before the fetch effect so a channel + // switch clears stale events before the new target is fetched. + const previousResetKeyRef = React.useRef(channelId); + React.useEffect(() => { + if (previousResetKeyRef.current === channelId) return; + previousResetKeyRef.current = channelId; + appliedSearchActivationIdRef.current = null; setTargetMessageEvents([]); - }, [channelId, selectedPostId]); + setActiveSearchHighlight(null); + }, [channelId]); React.useEffect(() => { let isCancelled = false; @@ -234,6 +281,8 @@ export function ChannelRouteScreen({ targetForumReplyId={targetReplyId} targetMessageEvents={targetMessageEvents} targetMessageId={targetMessageId} + targetSearchMessageId={activeSearchHighlight?.messageId} + targetSearchQuery={activeSearchHighlight?.query} /> ); } diff --git a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx index 1025cc1e89..8fcab41817 100644 --- a/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx +++ b/desktop/src/app/routes/channels.$channelId.posts.$postId.tsx @@ -1,6 +1,7 @@ import * as React from "react"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, useLocation } from "@tanstack/react-router"; +import { selectSearchHighlightRouteState } from "@/app/routes/searchHighlightRouteState"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -33,6 +34,9 @@ function ForumPostRouteComponent() { usePreviewFeatureWarning("forum"); const { channelId, postId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const searchHighlight = useLocation({ + select: selectSearchHighlightRouteState, + }); const isHuddleTranscript = huddleWindowChannelId() !== null; return ( @@ -74,6 +79,7 @@ function ChannelRouteComponent() { { + assert.deepEqual( + selectSearchHighlightRouteState({ state: { searchHighlight } }), + searchHighlight, + ); +}); + +test("target cleanup without highlight state preserves the selection", () => { + assert.equal(selectSearchHighlightRouteState({ state: {} }), undefined); +}); + +test("ordinary navigation explicitly clears the selection", () => { + assert.equal( + selectSearchHighlightRouteState({ state: { searchHighlight: null } }), + null, + ); +}); + +test("ignores malformed router state", () => { + assert.equal( + selectSearchHighlightRouteState({ + state: { searchHighlight: { messageId: "message", query: "mentions" } }, + }), + undefined, + ); +}); diff --git a/desktop/src/app/routes/searchHighlightRouteState.ts b/desktop/src/app/routes/searchHighlightRouteState.ts new file mode 100644 index 0000000000..4fcc01c8d3 --- /dev/null +++ b/desktop/src/app/routes/searchHighlightRouteState.ts @@ -0,0 +1,17 @@ +import { + parseSearchHighlightNavigation, + type SearchHighlightNavigation, +} from "@/app/navigation/searchHighlightNavigation"; + +export function selectSearchHighlightRouteState(location: { + state: unknown; +}): SearchHighlightNavigation | null | undefined { + const state = location.state as { searchHighlight?: unknown } | undefined; + if (!(state && "searchHighlight" in state)) { + return undefined; + } + if (state.searchHighlight === null) { + return null; + } + return parseSearchHighlightNavigation(state.searchHighlight) ?? undefined; +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 4086a4e1c1..19bb21b62f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -49,6 +49,7 @@ import { import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; +import { useSearchHighlightProps } from "@/features/channels/ui/useSearchHighlightProps"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; @@ -152,6 +153,8 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + targetSearchMessageId, + targetSearchQuery, threadAllMessages, threadHeadMessage, threadMessages, @@ -176,6 +179,10 @@ export const ChannelPane = React.memo(function ChannelPane({ currentPubkey, ); const mainComposerMedia = useMediaUpload({ deferUploadsUntilSend: true }); + const searchHighlightProps = useSearchHighlightProps( + targetSearchMessageId, + targetSearchQuery, + ); const [isMainDeferredEditPending, setMainDeferredEditPending] = React.useState(false); const isNonMemberView = @@ -194,8 +201,6 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear only the auto-send key so thread state survives deferred submission; - // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -252,9 +257,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ); const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; - // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → - // ordinary DM, composer enabled. const relaySelfQuery = useRelaySelfQuery(activeChannel?.channelType === "dm"); const isModerationDmChannel = isModerationDm( activeChannel ?? null, @@ -339,10 +341,6 @@ export const ChannelPane = React.memo(function ChannelPane({ !isMainDeferredEditPending && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; - // Unified working set for the composer bar: observer-derived turns primary, - // bot typing fallback (both folded together by agentWorkingSignal). This is - // what makes the bar show for an agent whose observer stream is live but - // whose typing signal never arrives — and vice versa. const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( activeChannel?.id ?? null, ); @@ -696,6 +694,7 @@ export const ChannelPane = React.memo(function ChannelPane({ } onTargetReached={onTargetReached} onToggleReaction={onToggleReaction} + {...searchHighlightProps.timeline} targetMessageId={targetMessageId} splitThreadPanelOpen={ useSplitAuxiliaryPane && @@ -894,6 +893,7 @@ export const ChannelPane = React.memo(function ChannelPane({ replyTargetMessage={threadReplyTargetMessage} scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} + {...searchHighlightProps.thread} threadHead={threadHeadMessage} videoReviewPresentation={threadVideoReviewPresentation} widthPx={threadPanelWidthPx} @@ -940,10 +940,6 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : activeChannel && selectedAgent ? ( (() => { - // When the panel was opened from a different channel than the - // currently active one, re-scope it to the active channel so - // that both the content/header AND channel-backed actions (e.g. - // Stop current turn) operate on the same channel object. const effectiveAgentSessionChannelId = openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 0296822168..d1b88ae3a6 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -181,6 +181,10 @@ export type ChannelPaneProps = { threadReplyUnreadCounts?: ReadonlyMap; threadFirstUnreadReplyId?: string | null; targetMessageId: string | null; + /** Exact clicked result id, including a reply routed into the thread panel. */ + targetSearchMessageId?: string | null; + /** Search text to highlight within the clicked result. */ + targetSearchQuery?: string; typingPubkeys: string[]; isFollowingThread?: boolean; onFollowThread?: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 240a9ad70c..fe0c4d23b7 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -7,24 +7,14 @@ import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandle import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys"; import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles"; import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync"; -import { - useChannelMembersQuery, - useJoinChannelMutation, -} from "@/features/channels/hooks"; -import { - MSG_PREFIX, - THREAD_PREFIX, -} from "@/features/channels/readState/readStateFormat"; +import * as channelHooks from "@/features/channels/hooks"; +import * as readStateFormat from "@/features/channels/readState/readStateFormat"; import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState"; import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader"; import { WelcomeAgentCreateDialog } from "@/features/channels/ui/WelcomeAgentCreateDialog"; import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent"; import { MembersSidebar } from "@/features/channels/ui/MembersSidebar"; -import { - useManagedAgentsQuery, - usePersonasQuery, - useRelayAgentsQuery, -} from "@/features/agents/hooks"; +import * as agentHooks from "@/features/agents/hooks"; import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide"; @@ -44,10 +34,7 @@ import { import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { - getThreadReference, - isThreadReply, -} from "@/features/messages/lib/threading"; +import * as threading from "@/features/messages/lib/threading"; import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache"; import { resolveTimelineLoadingLatch, @@ -62,10 +49,7 @@ import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; -import { - useHuddleChannelMessages, - useIsHuddleTranscript, -} from "@/features/channels/ui/useHuddleChannelMessages"; +import * as huddleMessages from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; @@ -89,6 +73,7 @@ import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; +import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, @@ -101,6 +86,7 @@ export function ChannelScreen({ targetForumReplyId, targetMessageEvents, targetMessageId, + ...searchTarget }: ChannelScreenProps) { const queryClient = useQueryClient(); const { goHome } = useAppNavigation(); @@ -171,7 +157,8 @@ export function ChannelScreen({ const mainInsetRef = useMainInsetRef(); const currentPubkey = currentIdentity?.pubkey; const activeChannelId = activeChannel?.id ?? null; - const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); + const isHuddleTranscript = + huddleMessages.useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; const requireThreadEditResolutionRef = React.useRef<() => boolean>( () => true, @@ -214,7 +201,7 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) + if (threading.getThreadReference(messages[index].tags).parentId === null) return messages[index]; } return null; @@ -233,7 +220,8 @@ export function ChannelScreen({ return; } setContextParentResolver((contextId) => - contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX) + contextId.startsWith(readStateFormat.THREAD_PREFIX) || + contextId.startsWith(readStateFormat.MSG_PREFIX) ? activeChannelId : null, ); @@ -253,13 +241,14 @@ export function ChannelScreen({ const toggleReactionMutation = useToggleReactionMutation(); const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); - const joinChannelMutation = useJoinChannelMutation(activeChannelId); + const joinChannelMutation = + channelHooks.useJoinChannelMutation(activeChannelId); const { resolvedMessages, threadSummaries, threadRepliesError: huddleThreadRepliesError, onRetryThreadReplies: onRetryHuddleThreadReplies, - } = useHuddleChannelMessages({ + } = huddleMessages.useHuddleChannelMessages({ activeChannel, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, @@ -305,9 +294,11 @@ export function ChannelScreen({ : [], [activeChannel], ); - const channelMembersQuery = useChannelMembersQuery(activeChannel?.id ?? null); + const channelMembersQuery = channelHooks.useChannelMembersQuery( + activeChannel?.id ?? null, + ); const channelMembers = channelMembersQuery.data; - const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgentsQuery = agentHooks.useManagedAgentsQuery(); const managedAgents = managedAgentsQuery.data ?? []; const welcomeGuideAgent = React.useMemo( () => pickWelcomeGuideAgent(managedAgents), @@ -318,7 +309,7 @@ export function ChannelScreen({ currentIdentity, welcomeGuideAgent, }); - const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentsQuery = agentHooks.useRelayAgentsQuery(); const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -385,7 +376,7 @@ export function ChannelScreen({ } return pubkeys; }, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]); - const personasQuery = usePersonasQuery(); + const personasQuery = agentHooks.usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; const personaById = new Map( @@ -502,7 +493,8 @@ export function ChannelScreen({ editMessageMutation, editTargetId, editTargetIsThreadReply: - editTargetMessage !== null && isThreadReply(editTargetMessage.tags ?? []), + editTargetMessage !== null && + threading.isThreadReply(editTargetMessage.tags ?? []), expandedThreadReplyIds, getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, @@ -632,9 +624,6 @@ export function ChannelScreen({ isPlaceholderData: messagesQuery.isPlaceholderData, dataLength: messagesQuery.data?.length ?? null, }, - // A persisted head only counts as hydrated when it has rows to paint - // (channelHeadCache.ts), so this bypass never settles onto an empty - // placeholder while the authoritative refresh is still in flight. hasSettledThisChannel || (activeChannelId !== null && hasPersistedHydratedChannel(queryClient, activeChannelId)), @@ -825,27 +814,30 @@ export function ChannelScreen({ > {activeChannel ? ( activeChannel.channelType === "forum" ? ( - + searchForwarding.renderSearchAwareForum( + , + searchTarget, + ) ) : ( } > - - knownAgentPubkeys.has(pubkey) || - !!messageProfiles?.[pubkey]?.isAgent, - ) - : null - } - followThreadById={followThread} - unfollowThreadById={unfollowThread} - isFollowingThreadById={isFollowingThread} - isMessageUnreadById={isMessageUnread} - isFollowingThread={isNotifiedForEffectiveThread} - isSending={sendMessageMutation.isPending} - isSinglePanelView={isSinglePanelView} - isTimelineLoading={isTimelineLoading} - messages={timelineMessages} - threadSummaries={threadSummaries} - huddleThreadRepliesError={huddleThreadRepliesError} - onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} - onCancelEdit={handleCancelEdit} - onCancelThreadReply={handleCancelThreadReply} - onChannelManagementDeleted={handleChannelManagementDeleted} - onFollowThread={ - effectiveOpenThreadHeadId != null && - !isNotifiedForEffectiveThread - ? () => followThread(effectiveOpenThreadHeadId) - : undefined - } - onUnfollowThread={ - effectiveOpenThreadHeadId != null && - isNotifiedForEffectiveThread - ? () => unfollowThread(effectiveOpenThreadHeadId) - : undefined - } - onCloseAgentSession={handleCloseAgentSession} - onBackFromAgentSession={ - hasAgentSessionReturnTarget - ? handleBackFromAgentSession - : undefined - } - onCloseChannelManagement={handleCloseChannelManagement} - onCloseThread={handleCloseThread} - onDelete={ - activeChannel?.archivedAt ? undefined : handleDelete - } - onEdit={activeChannel?.archivedAt ? undefined : handleEdit} - onEditSave={ - activeChannel?.archivedAt ? undefined : handleEditSave - } - onMarkUnread={handleMessageMarkUnread} - onMarkRead={handleMessageMarkRead} - onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} - onOpenDm={handleOpenDm} - onOpenProfilePanel={handleOpenProfilePanel} - onResetThreadPanelWidth={handleThreadPanelWidthReset} - onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} - onSelectThreadReplyTarget={handleSelectThreadReplyTarget} - onSendMessage={handleSendMessage} - onSendToChannel={handleSendToChannel} - onSendVideoReviewComment={effectiveSendVideoReviewComment} - onSendThreadReply={handleSendThreadReply} - onThreadScrollTargetResolved={() => - setThreadScrollTargetId(null) - } - onThreadPanelResizeStart={handleThreadPanelResizeStart} - onTargetReached={() => - clearMessageRouteTarget({ replace: true }) - } - onToggleReaction={effectiveToggleReaction} - openAgentSessionChannelId={openAgentSessionChannelId} - openAgentSessionPubkey={openAgentSessionPubkey} - openThreadHeadId={effectiveOpenThreadHeadId} - shouldShowThreadSkeleton={shouldShowThreadSkeleton} - onProfilePanelViewChange={setProfilePanelView} - onProfilePanelTabChange={setProfilePanelTab} - profilePanelPubkey={profilePanelPubkey} - profilePanelTab={profilePanelTab} - profilePanelView={profilePanelView} - personaLookup={personaLookup} - profiles={messageProfiles} - ownerProfiles={messageOwnerProfiles} - firstUnreadMessageId={firstUnreadMessageId} - unreadCount={unreadCount} - targetMessageId={mainTimelineTargetMessageId} - threadAllMessages={displayedThreadAllMessages} - threadHeadMessage={displayedThreadHeadMessage} - threadMessages={displayedThreadMessages} - threadMessagesPending={threadRepliesQuery.isPending} - threadMessagesError={threadRepliesQuery.isError} - onRetryThreadReplies={() => { - void threadRepliesQuery.refetch(); - }} - threadPanelWidthPx={threadPanelWidthPx} - threadTypingPubkeys={threadTypingPubkeys} - threadReplyTargetMessage={displayedThreadReplyTargetMessage} - threadScrollTargetId={threadScrollTargetId} - threadUnreadCounts={threadUnreadCounts} - threadReplyUnreadCounts={threadReplyUnreadCounts} - threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} - isJoining={joinChannelMutation.isPending} - onJoinChannel={joinChannelMutation.mutateAsync} - typingPubkeys={humanTypingPubkeys} - /> + {searchForwarding.renderSearchAwareChannel( + + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) + : null + } + followThreadById={followThread} + unfollowThreadById={unfollowThread} + isFollowingThreadById={isFollowingThread} + isMessageUnreadById={isMessageUnread} + isFollowingThread={isNotifiedForEffectiveThread} + isSending={sendMessageMutation.isPending} + isSinglePanelView={isSinglePanelView} + isTimelineLoading={isTimelineLoading} + messages={timelineMessages} + threadSummaries={threadSummaries} + huddleThreadRepliesError={huddleThreadRepliesError} + onRetryHuddleThreadReplies={onRetryHuddleThreadReplies} + onCancelEdit={handleCancelEdit} + onCancelThreadReply={handleCancelThreadReply} + onChannelManagementDeleted={handleChannelManagementDeleted} + onFollowThread={ + effectiveOpenThreadHeadId != null && + !isNotifiedForEffectiveThread + ? () => followThread(effectiveOpenThreadHeadId) + : undefined + } + onUnfollowThread={ + effectiveOpenThreadHeadId != null && + isNotifiedForEffectiveThread + ? () => unfollowThread(effectiveOpenThreadHeadId) + : undefined + } + onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } + onCloseChannelManagement={handleCloseChannelManagement} + onCloseThread={handleCloseThread} + onDelete={ + activeChannel?.archivedAt ? undefined : handleDelete + } + onEdit={activeChannel?.archivedAt ? undefined : handleEdit} + onEditSave={ + activeChannel?.archivedAt ? undefined : handleEditSave + } + onMarkUnread={handleMessageMarkUnread} + onMarkRead={handleMessageMarkRead} + onExpandThreadReplies={handleExpandThreadReplies} + onOpenAgentSession={handleOpenAgentSession} + onOpenDm={handleOpenDm} + onOpenProfilePanel={handleOpenProfilePanel} + onResetThreadPanelWidth={handleThreadPanelWidthReset} + onCloseProfilePanel={handleCloseProfilePanel} + onOpenThread={handleOpenThreadAndCloseAgentSession} + onSelectThreadReplyTarget={handleSelectThreadReplyTarget} + onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} + onSendVideoReviewComment={effectiveSendVideoReviewComment} + onSendThreadReply={handleSendThreadReply} + onThreadScrollTargetResolved={() => + setThreadScrollTargetId(null) + } + onThreadPanelResizeStart={handleThreadPanelResizeStart} + onTargetReached={() => + clearMessageRouteTarget({ replace: true }) + } + onToggleReaction={effectiveToggleReaction} + openAgentSessionChannelId={openAgentSessionChannelId} + openAgentSessionPubkey={openAgentSessionPubkey} + openThreadHeadId={effectiveOpenThreadHeadId} + shouldShowThreadSkeleton={shouldShowThreadSkeleton} + onProfilePanelViewChange={setProfilePanelView} + onProfilePanelTabChange={setProfilePanelTab} + profilePanelPubkey={profilePanelPubkey} + profilePanelTab={profilePanelTab} + profilePanelView={profilePanelView} + personaLookup={personaLookup} + profiles={messageProfiles} + ownerProfiles={messageOwnerProfiles} + firstUnreadMessageId={firstUnreadMessageId} + unreadCount={unreadCount} + targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} + threadHeadMessage={displayedThreadHeadMessage} + threadMessages={displayedThreadMessages} + threadMessagesPending={threadRepliesQuery.isPending} + threadMessagesError={threadRepliesQuery.isError} + onRetryThreadReplies={() => { + void threadRepliesQuery.refetch(); + }} + threadPanelWidthPx={threadPanelWidthPx} + threadTypingPubkeys={threadTypingPubkeys} + threadReplyTargetMessage={displayedThreadReplyTargetMessage} + threadScrollTargetId={threadScrollTargetId} + threadUnreadCounts={threadUnreadCounts} + threadReplyUnreadCounts={threadReplyUnreadCounts} + threadFirstUnreadReplyId={displayedThreadFirstUnreadReplyId} + isJoining={joinChannelMutation.isPending} + onJoinChannel={joinChannelMutation.mutateAsync} + typingPubkeys={humanTypingPubkeys} + />, + searchTarget, + )} ) ) : ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.types.ts b/desktop/src/features/channels/ui/ChannelScreen.types.ts index 371af6faf5..a64937a74b 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.types.ts +++ b/desktop/src/features/channels/ui/ChannelScreen.types.ts @@ -22,4 +22,8 @@ export type ChannelScreenProps = { targetForumReplyId: string | null; targetMessageEvents: RelayEvent[]; targetMessageId: string | null; + /** Exact clicked result id, retained after route target cleanup. */ + targetSearchMessageId?: string; + /** Search text to highlight within the opened result message. */ + targetSearchQuery?: string; }; diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx index 3565502616..7826938665 100644 --- a/desktop/src/features/channels/ui/ForumChannelContent.tsx +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -42,6 +42,8 @@ type ForumChannelContentProps = { profilePanelView: ProfilePanelView; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; /** @@ -71,6 +73,8 @@ export function ForumChannelContent({ profilePanelView, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumChannelContentProps) { return ( <> @@ -88,6 +92,8 @@ export function ForumChannelContent({ onSelectPost={onSelectPost} selectedPostId={selectedPostId} targetReplyId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} /> diff --git a/desktop/src/features/channels/ui/searchTargetForwarding.tsx b/desktop/src/features/channels/ui/searchTargetForwarding.tsx new file mode 100644 index 0000000000..925156258c --- /dev/null +++ b/desktop/src/features/channels/ui/searchTargetForwarding.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; + +import type { ForumChannelContent } from "./ForumChannelContent"; +import type { GuardedChannelPane } from "./GuardedChannelPane"; +import type { ChannelScreenProps } from "./ChannelScreen.types"; + +type SearchTarget = Pick< + ChannelScreenProps, + "targetSearchMessageId" | "targetSearchQuery" +>; + +export const renderSearchAwareForum = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); + +export const renderSearchAwareChannel = ( + node: React.ReactElement>, + target: SearchTarget, +) => React.cloneElement(node, target); diff --git a/desktop/src/features/channels/ui/useSearchHighlightProps.ts b/desktop/src/features/channels/ui/useSearchHighlightProps.ts new file mode 100644 index 0000000000..b506fee182 --- /dev/null +++ b/desktop/src/features/channels/ui/useSearchHighlightProps.ts @@ -0,0 +1,18 @@ +import * as React from "react"; + +export function useSearchHighlightProps( + messageId: string | null | undefined, + query: string | undefined, +) { + const searchMatchingMessageIds = React.useMemo( + () => (messageId ? new Set([messageId]) : undefined), + [messageId], + ); + return React.useMemo( + () => ({ + thread: { searchMessageId: messageId, searchQuery: query }, + timeline: { searchMatchingMessageIds, searchQuery: query }, + }), + [messageId, query, searchMatchingMessageIds], + ); +} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 7bf40fbffe..348fa15700 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -41,6 +41,8 @@ type ForumThreadPanelProps = { canDeletePost?: boolean; isDeletingPost?: boolean; targetEventId?: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDeleteReply( @@ -57,12 +59,14 @@ function ReplyRow({ profiles, channelNames, onDelete, + searchQuery, }: { reply: ThreadReply; currentPubkey?: string; profiles?: UserProfileLookup; channelNames?: string[]; onDelete?: (eventId: string) => void; + searchQuery?: string; }) { const replyAuthorLabel = resolveUserLabel({ pubkey: reply.pubkey, @@ -122,6 +126,7 @@ function ReplyRow({ imetaByUrl={parseImetaTags(reply.tags)} mentionNames={replyMentionNames} mentionPubkeysByName={replyMentionPubkeysByName} + searchQuery={searchQuery} /> @@ -143,6 +148,8 @@ export function ForumThreadPanel({ canDeletePost, isDeletingPost, targetEventId, + targetSearchMessageId, + targetSearchQuery, }: ForumThreadPanelProps) { const scrollRef = React.useRef(null); const { channels } = useChannelNavigation(); @@ -268,6 +275,11 @@ export function ForumThreadPanel({ imetaByUrl={parseImetaTags(post.tags)} mentionNames={postMentionNames} mentionPubkeysByName={postMentionPubkeysByName} + searchQuery={ + targetSearchMessageId === post.eventId + ? targetSearchQuery + : undefined + } /> @@ -286,6 +298,11 @@ export function ForumThreadPanel({ onDelete={onDeleteReply} profiles={profiles} reply={reply} + searchQuery={ + targetSearchMessageId === reply.eventId + ? targetSearchQuery + : undefined + } /> ))} diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 9efada5549..670ee4e6df 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -30,6 +30,8 @@ type ForumViewProps = { onTargetReached?: (messageId: string) => void; selectedPostId: string | null; targetReplyId: string | null; + targetSearchMessageId?: string; + targetSearchQuery?: string; }; function canDelete(postPubkey: string, currentPubkey?: string): boolean { @@ -47,6 +49,8 @@ export function ForumView({ onTargetReached, selectedPostId, targetReplyId, + targetSearchMessageId, + targetSearchQuery, }: ForumViewProps) { const [isComposerOpen, setIsComposerOpen] = React.useState(false); const postsScrollRef = React.useRef(null); @@ -156,6 +160,8 @@ export function ForumView({ onTargetReached={onTargetReached} profiles={profiles} targetEventId={targetReplyId} + targetSearchMessageId={targetSearchMessageId} + targetSearchQuery={targetSearchQuery} thread={threadQuery.data} /> ); diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx index 71a3460ce8..f1ff265339 100644 --- a/desktop/src/features/messages/ui/DiffMessage.tsx +++ b/desktop/src/features/messages/ui/DiffMessage.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { FileDiff, Maximize2 } from "lucide-react"; import { getDiffTitleBadge } from "@/features/messages/lib/parseDiff"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { isSafeUrl } from "@/shared/lib/url"; import { Button } from "@/shared/ui/button"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; @@ -14,6 +15,7 @@ type DiffMessageProps = { filePath?: string; commitSha?: string; description?: string; + searchQuery?: string; truncated?: boolean; onExpand?: () => void; }; @@ -32,6 +34,7 @@ export default function DiffMessage({ filePath, commitSha, description, + searchQuery, truncated, onExpand, }: DiffMessageProps) { @@ -116,7 +119,7 @@ export default function DiffMessage({ {description && (
- {description} +
)} @@ -126,6 +129,7 @@ export default function DiffMessage({ className="p-3" content={content} fallbackFilePath={filePath} + searchQuery={searchQuery} viewType="unified" /> diff --git a/desktop/src/features/messages/ui/DiffViewer.tsx b/desktop/src/features/messages/ui/DiffViewer.tsx index 1444cf9977..ed6703f66c 100644 --- a/desktop/src/features/messages/ui/DiffViewer.tsx +++ b/desktop/src/features/messages/ui/DiffViewer.tsx @@ -2,6 +2,8 @@ import { Diff, Hunk, type ViewType } from "react-diff-view"; import "react-diff-view/style/index.css"; import { useMemo } from "react"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { countDiffFileChanges, DIFF_TYPE_LABELS, @@ -18,6 +20,7 @@ type DiffViewerProps = { fallbackFilePath?: string; viewType?: ViewType; className?: string; + searchQuery?: string; }; function FileChangeBadge({ @@ -46,16 +49,20 @@ export function DiffViewer({ fallbackFilePath, viewType = "unified", className, + searchQuery, }: DiffViewerProps) { const { files, parseError } = useMemo( () => parseUnifiedDiff(content), [content], ); + const searchPreview = searchQuery + ? buildSearchResultPreview(content, searchQuery, 160) + : null; if (parseError) { return (
-        {content}
+        
       
); } @@ -70,6 +77,17 @@ export function DiffViewer({ return (
+ {searchPreview ? ( +
+          
+        
+ ) : null}
{files.map((file) => { const label = getDiffFileLabel(file, fallbackFilePath); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 536631b02d..0573a3186b 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -396,6 +396,7 @@ export const MessageRow = React.memo( setExpandedDiffId(message.id); }} repoUrl={getTag("repo")} + searchQuery={searchQuery} truncated={getTag("truncated") === "true"} /> @@ -417,6 +418,7 @@ export const MessageRow = React.memo( fallbackText={waveMessage.fallbackText} huddleMemberPubkeys={huddleMemberPubkeys} huddleMemberPubkeysPending={huddleMemberPubkeysPending} + searchQuery={searchQuery} /> ); } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 01fbcda7ff..d3b1394829 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -81,6 +81,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { onScrollTargetResolved: () => void; onScrollTargetSettled?: (messageId: string) => void; scrollTargetHighlights?: boolean; + searchMessageId?: string | null; + searchQuery?: string; onSelectReplyTarget: (message: TimelineMessage) => void; onSend: ( content: string, @@ -185,6 +187,8 @@ export function MessageThreadPanel({ replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, + searchMessageId, + searchQuery, threadHead, videoReviewPresentation, threadReplies, @@ -562,6 +566,9 @@ export function MessageThreadPanel({ onUnfollowThread ? (_msg) => onUnfollowThread() : undefined } profiles={profiles} + searchQuery={ + searchMessageId === threadHead.id ? searchQuery : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( threadHead.id, @@ -725,6 +732,11 @@ export function MessageThreadPanel({ onSendToChannel={stableSendToChannel} onToggleReaction={onToggleReaction} profiles={profiles} + searchQuery={ + searchMessageId === entry.message.id + ? searchQuery + : undefined + } showDepthGuides={shouldShowThreadBranchGuides} videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get( entry.message.id, diff --git a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx index f782326295..9c1a6feeee 100644 --- a/desktop/src/features/messages/ui/WaveMessageAttachment.tsx +++ b/desktop/src/features/messages/ui/WaveMessageAttachment.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { channelsQueryKey } from "@/features/channels/hooks"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useHuddle } from "@/features/huddle"; import { formatHuddleActionError } from "@/features/huddle/lib/huddleError"; import { @@ -20,6 +21,7 @@ type WaveMessageAttachmentProps = { fallbackText: string; huddleMemberPubkeys?: readonly string[]; huddleMemberPubkeysPending?: boolean; + searchQuery?: string; }; export function WaveMessageAttachment({ @@ -27,6 +29,7 @@ export function WaveMessageAttachment({ fallbackText, huddleMemberPubkeys = [], huddleMemberPubkeysPending = false, + searchQuery, }: WaveMessageAttachmentProps) { const queryClient = useQueryClient(); const { isStarting, startHuddle } = useHuddle(); @@ -68,7 +71,12 @@ export function WaveMessageAttachment({ 👋 - {fallbackText} + + + Start a huddle to talk to them. diff --git a/desktop/src/features/search/lib/searchMatch.test.mjs b/desktop/src/features/search/lib/searchMatch.test.mjs new file mode 100644 index 0000000000..4f9651a75d --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSearchResultPreview, splitSearchMatches } from "./searchMatch.ts"; + +test("splitSearchMatches highlights every case-insensitive lexeme match", () => { + assert.deepEqual(splitSearchMatches("Mentions and mentions", "mentions"), [ + { isMatch: true, key: "0-8", text: "Mentions" }, + { isMatch: false, key: "8-5", text: " and " }, + { isMatch: true, key: "13-8", text: "mentions" }, + ]); +}); + +test("splitSearchMatches normalizes punctuation into search lexemes", () => { + assert.deepEqual(splitSearchMatches("foo bar release", "foo-bar"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "bar" }, + { isMatch: false, key: "7-8", text: " release" }, + ]); +}); + +test("splitSearchMatches keeps completed tokens on lexeme boundaries", () => { + assert.deepEqual( + splitSearchMatches("projectile notes about project planning", "project pl"), + [ + { + isMatch: false, + key: "0-23", + text: "projectile notes about ", + }, + { isMatch: true, key: "23-7", text: "project" }, + { isMatch: false, key: "30-1", text: " " }, + { isMatch: true, key: "31-2", text: "pl" }, + { isMatch: false, key: "33-6", text: "anning" }, + ], + ); +}); + +test("splitSearchMatches preserves exact and prefix modes for a repeated term", () => { + assert.deepEqual(splitSearchMatches("foo foobar", "foo foo"), [ + { isMatch: true, key: "0-3", text: "foo" }, + { isMatch: false, key: "3-1", text: " " }, + { isMatch: true, key: "4-3", text: "foo" }, + { isMatch: false, key: "7-3", text: "bar" }, + ]); +}); + +test("splitSearchMatches highlights non-adjacent prefix-search terms", () => { + assert.deepEqual(splitSearchMatches("agent status mentions", "agent ment"), [ + { isMatch: true, key: "0-5", text: "agent" }, + { isMatch: false, key: "5-8", text: " status " }, + { isMatch: true, key: "13-4", text: "ment" }, + { isMatch: false, key: "17-4", text: "ions" }, + ]); +}); + +test("splitSearchMatches keeps one-character prefixes on lexeme boundaries", () => { + assert.deepEqual(splitSearchMatches("A plan", "a"), [ + { isMatch: true, key: "0-1", text: "A" }, + { isMatch: false, key: "1-5", text: " plan" }, + ]); +}); + +test("splitSearchMatches maps expanding lowercase prefixes to original spans", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "İs"), [ + { isMatch: true, key: "0-2", text: "İs" }, + { isMatch: false, key: "2-14", text: "tanbul release" }, + ]); + assert.deepEqual(splitSearchMatches("İstanbul release", "İst"), [ + { isMatch: true, key: "0-3", text: "İst" }, + { isMatch: false, key: "3-13", text: "anbul release" }, + ]); +}); + +test("splitSearchMatches does not split a character whose lowercase form expands", () => { + assert.deepEqual(splitSearchMatches("İstanbul release", "i"), [ + { isMatch: true, key: "0-1", text: "İ" }, + { isMatch: false, key: "1-15", text: "stanbul release" }, + ]); +}); + +test("splitSearchMatches preserves UTF-16 boundaries for supplementary letters", () => { + assert.deepEqual(splitSearchMatches("𐐀İstanbul release", "𐐨İs"), [ + { isMatch: true, key: "0-4", text: "𐐀İs" }, + { isMatch: false, key: "4-14", text: "tanbul release" }, + ]); +}); + +test("buildSearchResultPreview keeps a late match visible", () => { + const content = `${"prefix ".repeat(30)}mentions appear here ${"suffix ".repeat(20)}`; + const preview = buildSearchResultPreview(content, "mentions", 96); + + assert.equal(preview.length <= 96, true); + assert.match(preview, /mentions/i); + assert.match(preview, /^\.\.\./); + assert.match(preview, /\.\.\.$/); +}); + +test("buildSearchResultPreview ignores an invalid completed-token substring", () => { + const content = `${"projectile filler ".repeat(20)}project planning release notes`; + const preview = buildSearchResultPreview(content, "project pl", 80); + + assert.match(preview, /project planning/); + assert.match(preview, /^\.\.\./); +}); + +test("buildSearchResultPreview keeps the existing leading excerpt without a match", () => { + assert.equal( + buildSearchResultPreview("abcdefghijklmnopqrstuvwxyz", "missing", 10), + "abcdefg...", + ); +}); diff --git a/desktop/src/features/search/lib/searchMatch.ts b/desktop/src/features/search/lib/searchMatch.ts new file mode 100644 index 0000000000..f74d1a2678 --- /dev/null +++ b/desktop/src/features/search/lib/searchMatch.ts @@ -0,0 +1,201 @@ +export type SearchMatchPart = { + isMatch: boolean; + key: string; + text: string; +}; + +type SearchHighlightTerm = { + isPrefix: boolean; + value: string; +}; + +type TextLexeme = { + end: number; + normalized: string; + start: number; +}; + +// PostgreSQL's `simple` text-search configuration breaks ordinary punctuation +// into lexemes (for example, `foo-bar` contributes `foo` and `bar`). Keep the +// desktop highlighter on those lexical boundaries rather than treating raw +// whitespace tokens as unrestricted substrings. +const LEXEME_PATTERN = /[\p{L}\p{N}]+/gu; + +function extractLexemes(value: string): string[] { + return Array.from(value.matchAll(LEXEME_PATTERN), (match) => + match[0].toLowerCase(), + ); +} + +function getSearchHighlightMatchers(query: string): SearchHighlightTerm[] { + const rawTokens = query.trim().split(/\s+/).filter(Boolean); + const matchers: SearchHighlightTerm[] = []; + + rawTokens.forEach((rawToken, tokenIndex) => { + const isPrefix = tokenIndex === rawTokens.length - 1; + for (const value of extractLexemes(rawToken)) { + matchers.push({ isPrefix, value }); + } + }); + + // Deduplicate repeated constraints without collapsing exact and prefix modes: + // `foo foo` asks Postgres for both an exact `foo` and a `foo:*` lexeme. + const deduped = new Map(); + for (const matcher of matchers) { + deduped.set( + `${matcher.isPrefix ? "prefix" : "exact"}:${matcher.value}`, + matcher, + ); + } + return [...deduped.values()].sort( + (left, right) => right.value.length - left.value.length, + ); +} + +/** + * Lexemes used by desktop prefix search after punctuation normalization. + * Completed whitespace-delimited tokens match exactly; only lexemes from the + * trailing token match prefixes. + */ +export function getSearchHighlightTerms(query: string): string[] { + return getSearchHighlightMatchers(query).map((matcher) => matcher.value); +} + +function getTextLexemes(text: string): TextLexeme[] { + return Array.from(text.matchAll(LEXEME_PATTERN), (match) => ({ + end: (match.index ?? 0) + match[0].length, + normalized: match[0].toLowerCase(), + start: match.index ?? 0, + })); +} + +function getOriginalPrefixLength( + original: string, + normalizedLength: number, +): number { + let normalizedOffset = 0; + let originalOffset = 0; + + for (const character of original) { + normalizedOffset += character.toLowerCase().length; + originalOffset += character.length; + if (normalizedOffset >= normalizedLength) { + return originalOffset; + } + } + + return original.length; +} + +function getMatchSpans( + text: string, + query: string, +): Array<{ end: number; start: number }> { + const matchers = getSearchHighlightMatchers(query); + if (matchers.length === 0) { + return []; + } + + const spans: Array<{ end: number; start: number }> = []; + for (const lexeme of getTextLexemes(text)) { + const exactMatch = matchers.find( + (matcher) => !matcher.isPrefix && matcher.value === lexeme.normalized, + ); + if (exactMatch) { + spans.push({ start: lexeme.start, end: lexeme.end }); + continue; + } + + const prefixMatch = matchers.find( + (matcher) => + matcher.isPrefix && lexeme.normalized.startsWith(matcher.value), + ); + if (prefixMatch) { + const originalLexeme = text.slice(lexeme.start, lexeme.end); + spans.push({ + start: lexeme.start, + end: + lexeme.start + + getOriginalPrefixLength(originalLexeme, prefixMatch.value.length), + }); + } + } + + return spans; +} + +/** Split text around case-insensitive lexeme/prefix matches of the query. */ +export function splitSearchMatches( + text: string, + query: string, +): SearchMatchPart[] { + const spans = getMatchSpans(text, query); + if (spans.length === 0) { + return [{ isMatch: false, key: "0", text }]; + } + + const parts: SearchMatchPart[] = []; + let offset = 0; + for (const span of spans) { + if (span.start > offset) { + parts.push({ + isMatch: false, + key: `${offset}-${span.start - offset}`, + text: text.slice(offset, span.start), + }); + } + parts.push({ + isMatch: true, + key: `${span.start}-${span.end - span.start}`, + text: text.slice(span.start, span.end), + }); + offset = span.end; + } + if (offset < text.length) { + parts.push({ + isMatch: false, + key: `${offset}-${text.length - offset}`, + text: text.slice(offset), + }); + } + return parts; +} + +/** + * Build a compact result excerpt that keeps the first matching search term + * visible. Context is biased before the match so the excerpt still reads like + * a sentence while avoiding a match that is clipped offscreen. + */ +export function buildSearchResultPreview( + content: string, + query: string, + maxLength = 96, +): string { + const text = content.trim(); + if (!text) { + return "No message body."; + } + if (text.length <= maxLength) { + return text; + } + + const matchIndex = getMatchSpans(text, query)[0]?.start ?? -1; + if (matchIndex < 0) { + return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; + } + + const contextBefore = Math.min(32, Math.floor(maxLength / 3)); + let start = Math.max(0, matchIndex - contextBefore); + const end = Math.min(text.length, start + maxLength); + + if (end === text.length) { + start = Math.max(0, end - maxLength); + } + + const prefix = start > 0 ? "..." : ""; + const suffix = end < text.length ? "..." : ""; + const available = Math.max(0, maxLength - prefix.length - suffix.length); + const excerpt = text.slice(start, start + available).trim(); + + return `${prefix}${excerpt}${suffix}`; +} diff --git a/desktop/src/features/search/ui/HighlightedSearchText.tsx b/desktop/src/features/search/ui/HighlightedSearchText.tsx new file mode 100644 index 0000000000..51745f8873 --- /dev/null +++ b/desktop/src/features/search/ui/HighlightedSearchText.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + +export function HighlightedSearchText({ + query, + text, +}: { + query: string; + text: string; +}) { + return splitSearchMatches(text, query).map((part) => + part.isMatch ? ( + + {part.text} + + ) : ( + {part.text} + ), + ); +} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 963ae6cf42..70365497bc 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; +import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; +import { buildSearchResultPreview } from "@/features/search/lib/searchMatch"; import { useSearchResults } from "@/features/search/useSearchResults"; import { resultIcon, @@ -15,6 +17,7 @@ import { getChannelScopeLabel, SearchDialogInputRow, } from "@/features/search/ui/SearchScopeControls"; +import { HighlightedSearchText } from "@/features/search/ui/HighlightedSearchText"; import { useSearchMenuKeyboardNavigation } from "@/features/search/ui/useSearchMenuKeyboardNavigation"; import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -36,7 +39,7 @@ type TopbarSearchProps = { currentChannelId?: string | null; focusRequest?: number; onOpenChannel: (channelId: string) => void; - onOpenResult: (hit: SearchHit) => void; + onOpenResult: (hit: SearchHit, query: string) => void; onOpenUser?: (user: UserSearchResult) => void | Promise; onBrowseChannels?: () => void | Promise; onCreateAgent?: () => void | Promise; @@ -72,19 +75,6 @@ type SearchHitContextLabel = { text: string; }; -function truncateResultText(content: string, maxLength = 96) { - const trimmed = content.trim(); - if (trimmed.length === 0) { - return "No message body."; - } - - if (trimmed.length <= maxLength) { - return trimmed; - } - - return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`; -} - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; @@ -436,6 +426,10 @@ export function TopbarSearch({ scopeChannelId, }); const trimmedQuery = query.trim(); + // Bind highlights to the debounced result source so stale results can never + // pair with newly typed text during the debounce window. + const resultQuery = parseSearchOperators(debouncedQuery).text; + const resultsAreCurrent = debouncedQuery === trimmedQuery; const isIconVariant = variant === "icon"; const currentChannel = currentChannelId ? (channelLookup.get(currentChannelId) ?? null) @@ -504,9 +498,10 @@ export function TopbarSearch({ ), [currentPubkeyNormalized, results], ); + const visibleSearchableResults = resultsAreCurrent ? searchableResults : []; const searchResultSections = React.useMemo( - () => groupSearchResults(searchableResults), - [searchableResults], + () => groupSearchResults(visibleSearchableResults), + [visibleSearchableResults], ); const groupedSearchResults = React.useMemo( () => searchResultSections.flatMap((section) => section.results), @@ -516,8 +511,11 @@ export function TopbarSearch({ ? scopeChannel ? [] : suggestionResults - : groupedSearchResults; + : resultsAreCurrent + ? groupedSearchResults + : []; const isSearchLoading = + (!isShowingSuggestions && !resultsAreCurrent) || isWaitingOnFromResolution || searchQuery.isLoading || fuzzyUserCandidatesQuery.isLoading || @@ -581,7 +579,7 @@ export function TopbarSearch({ return; } - onOpenResult(result.hit); + onOpenResult(result.hit, resultQuery); }, [ onBrowseChannels, @@ -592,6 +590,7 @@ export function TopbarSearch({ onOpenUser, openAfterExit, setQuery, + resultQuery, ], ); @@ -697,7 +696,7 @@ export function TopbarSearch({ ? result.action.description : result.kind === "user" ? getUserSecondaryLabel(result.user) - : truncateResultText(result.hit.content); + : buildSearchResultPreview(result.hit.content, resultQuery); const trailingLabel = result.kind === "channel" ? getChannelSuggestionMeta(result.channel) @@ -771,7 +770,7 @@ export function TopbarSearch({ ) : null} {preview ? ( - {preview} + ) : null} @@ -873,12 +872,13 @@ export function TopbarSearch({
) - ) : isSearchLoading && searchableResults.length === 0 ? ( + ) : isSearchLoading && visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}
- ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( + ) : searchQuery.error instanceof Error && + visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

- ) : searchableResults.length === 0 ? ( + ) : visibleSearchableResults.length === 0 ? (
{currentChannelSearchAction}

{ diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 8d626c4593..ab35b8b598 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -88,7 +88,7 @@ export type AppSidebarProps = { onSelectWorkflows: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; /** Full channel set for global search, including channels outside the joined sidebar list. */ searchChannels: Channel[]; searchFocusRequests: readonly [global: number, channel: number]; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 1e0db29cac..a9bf7058eb 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -30,7 +30,7 @@ type AppSidebarPinnedHeaderProps = { onCreateAgent: () => void; onCreateChannel: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; - onOpenSearchResult: (hit: SearchHit) => void; + onOpenSearchResult: (hit: SearchHit, query: string) => void; onSelectChannel: (channelId: string) => void; searchChannels: Channel[]; searchFocusRequest: number; diff --git a/desktop/src/shared/lib/rehypeSearchHighlight.ts b/desktop/src/shared/lib/rehypeSearchHighlight.ts index df7f1de292..e3de49a664 100644 --- a/desktop/src/shared/lib/rehypeSearchHighlight.ts +++ b/desktop/src/shared/lib/rehypeSearchHighlight.ts @@ -6,6 +6,9 @@ * ReactMarkdown's architecture — no post-render tree walking needed. */ +import { splitSearchMatches } from "@/features/search/lib/searchMatch"; +import { SEARCH_MATCH_HIGHLIGHT_CLASS } from "@/shared/lib/searchHighlightStyle"; + // Minimal HAST types — matches the pattern in rehypeImageGallery.ts. interface HastText { type: "text"; @@ -34,45 +37,32 @@ function isText(node: HastNode): node is HastText { return node.type === "text"; } -function escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - export default function rehypeSearchHighlight({ query }: { query: string }) { return (tree: HastRoot) => { - const trimmed = query.trim(); - if (trimmed.length < 2) return; - - const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "i"); - function walk(nodes: HastNode[]): HastNode[] { const result: HastNode[] = []; for (const node of nodes) { if (isText(node)) { - const parts = node.value.split(pattern); - if (parts.length === 1) { + const parts = splitSearchMatches(node.value, query); + if (!parts.some((part) => part.isMatch)) { result.push(node); continue; } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - if (!part) continue; - - if (i % 2 === 1) { - // Odd indices from split-with-capture are always the match. + for (const part of parts) { + if (part.isMatch) { result.push({ type: "element", tagName: "mark", properties: { - className: - "rounded-xs bg-primary/20 text-foreground dark:bg-primary/30", + className: SEARCH_MATCH_HIGHLIGHT_CLASS, + "data-search-match": "true", }, - children: [{ type: "text", value: part }], + children: [{ type: "text", value: part.text }], }); } else { - result.push({ type: "text", value: part }); + result.push({ type: "text", value: part.text }); } } } else if (isElement(node)) { diff --git a/desktop/src/shared/lib/searchHighlightStyle.ts b/desktop/src/shared/lib/searchHighlightStyle.ts new file mode 100644 index 0000000000..0ede60986c --- /dev/null +++ b/desktop/src/shared/lib/searchHighlightStyle.ts @@ -0,0 +1,3 @@ +/** Shared visual treatment for literal search matches. */ +export const SEARCH_MATCH_HIGHLIGHT_CLASS = + "rounded-xs bg-yellow-300/80 text-yellow-950 dark:bg-yellow-300/70 dark:text-yellow-950"; diff --git a/desktop/src/shared/ui/markdown/nodeCache.test.mjs b/desktop/src/shared/ui/markdown/nodeCache.test.mjs index e29abe1ee1..becca46499 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.test.mjs +++ b/desktop/src/shared/ui/markdown/nodeCache.test.mjs @@ -227,9 +227,27 @@ test("hardLineBreaks changes the parse and the cache key", () => { assert.equal(withoutBreaks, withoutBreaksAgain); }); -test("active search queries bypass the cache", () => { - clearMarkdownNodeCache(); - const first = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - const second = renderCachedMarkdown({ ...BASE, searchQuery: "bold" }); - assert.notEqual(first, second); +test("single-character scoped search stays on lexeme boundaries", () => { + const html = renderToStaticMarkup( + renderCachedMarkdown({ ...BASE, content: "A plan", searchQuery: "a" }), + ); + + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 1); +}); + +test("active search queries bypass the cache and highlight every match", () => { + clearMarkdownNodeCache(); + const input = { + ...BASE, + content: "Bold and bold, but not code `bold`.", + searchQuery: "bold", + }; + const first = renderCachedMarkdown(input); + const second = renderCachedMarkdown(input); + const html = renderToStaticMarkup(first); + + assert.notEqual(first, second); + assert.equal((html.match(/data-search-match="true"/g) ?? []).length, 2); + assert.match(html, /bg-yellow-300/); + assert.match(html, /bold<\/code>/); }); diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 549ca892ea..9e012f0549 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -94,7 +94,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { if (input.leadingInlineContent) { rehypePlugins.push(rehypeLeadingInlineContent); } - if (input.searchQuery && input.searchQuery.trim().length >= 2) { + if (input.searchQuery && input.searchQuery.trim().length >= 1) { rehypePlugins.push([rehypeSearchHighlight, { query: input.searchQuery }]); } // Called as a plain function rather than rendered as : @@ -131,7 +131,7 @@ export function renderCachedMarkdown( // than churn the cache with per-query variants. Oversized content parses // fresh too — see MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH. if ( - (input.searchQuery && input.searchQuery.trim().length >= 2) || + (input.searchQuery && input.searchQuery.trim().length >= 1) || input.content.length > MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH ) { return buildMarkdownElement(input); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5bbc160752..0bd7bc6eec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4418,6 +4418,15 @@ function getMockMessageStore(channelId: string): RelayEvent[] { content: "Release checklist: async feedback thread.", sig: "mocksig".repeat(20).slice(0, 128), }, + { + id: "mock-forum-offsite-thread", + pubkey: ALICE_PUBKEY, + created_at: Math.floor(Date.now() / 1000) - 85 * 60, + kind: 45001, + tags: [["h", channelId]], + content: "Team offsite planning and travel notes.", + sig: "mocksig".repeat(20).slice(0, 128), + }, { id: "mock-forum-release-reply", pubkey: ALICE_PUBKEY, diff --git a/desktop/tests/e2e/smoke.spec.ts b/desktop/tests/e2e/smoke.spec.ts index 14fc299e9b..7902b3eace 100644 --- a/desktop/tests/e2e/smoke.spec.ts +++ b/desktop/tests/e2e/smoke.spec.ts @@ -346,6 +346,171 @@ test("opens sidebar search with the shortcut and loads the exact result", async ); }); +test("highlights the query in search results and the opened message", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("SHIPPED"); + + const result = page.getByTestId("search-result-mock-engineering-shipped"); + await expect(result).toBeVisible(); + await expect(result.locator("mark")).toHaveText("shipped"); + await expect(result.locator("mark")).toHaveClass(/bg-yellow-300/); + + await result.click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message).toBeVisible(); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); +}); + +test("highlights the clicked forum post when its route is already open", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await expect( + page.locator('[data-forum-event-id="mock-forum-release-thread"]'), + ).toBeVisible(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("checklist"); + + const result = page.getByTestId("search-result-mock-forum-release-thread"); + await expect(result).toBeVisible(); + await result.click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); +}); + +test("ordinary same-channel activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("shipped"); + await page.getByTestId("search-result-mock-engineering-shipped").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-engineering-shipped"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "shipped", + ); + await expect(page).toHaveURL( + /#\/channels\/1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9(?:\?thread=mock-engineering-shipped)?$/, + ); + + await page.getByTestId("channel-engineering").click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary rendered channel link clears a prior search highlight", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.keyboard.press("ControlOrMeta+f"); + await page.getByTestId("search-dialog-input").fill("welcome"); + await page.getByTestId("search-result-mock-general-welcome").click(); + + const message = page + .getByTestId("message-timeline") + .locator('[data-message-id="mock-general-welcome"]'); + await expect(message.locator('[data-search-match="true"]')).toHaveText( + "Welcome", + ); + + await message.locator('[data-channel-link=""]').click(); + + await expect(message.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary same-forum activation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const post = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(post.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(post.locator('[data-search-match="true"]')).toHaveCount(0); +}); + +test("ordinary forum navigation clears a prior search highlight", async ({ + page, +}) => { + await page.goto( + "/#/channels/a27e1ee9-76a6-5bdf-a5d5-1d85610dad11/posts/mock-forum-release-thread", + ); + await page.getByTestId("open-search").click(); + await page.getByTestId("search-dialog-input").fill("checklist"); + await page.getByTestId("search-result-mock-forum-release-thread").click(); + + const releasePost = page.locator( + '[data-forum-event-id="mock-forum-release-thread"]', + ); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveText( + "checklist", + ); + + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Team offsite planning and travel notes.").click(); + await expect( + page.locator('[data-forum-event-id="mock-forum-offsite-thread"]'), + ).toBeVisible(); + await page.getByTestId("channel-watercooler").click(); + await page.getByText("Release checklist: async feedback thread.").click(); + + await expect(releasePost).toBeVisible(); + await expect(releasePost.locator('[data-search-match="true"]')).toHaveCount( + 0, + ); +}); + +test("does not expose stale search results with a newly typed query", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-engineering").click(); + await page.keyboard.press("ControlOrMeta+f"); + const input = page.getByTestId("search-dialog-input"); + await input.fill("shipped"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toBeVisible(); + + await input.fill("mentions"); + await expect( + page.getByTestId("search-result-mock-engineering-shipped"), + ).toHaveCount(0); +}); + test("opens channel matches from search", async ({ page }) => { await page.goto("/"); From 12f3fea26e4c638a5fae20dce1ec0876e3bbca41 Mon Sep 17 00:00:00 2001 From: tulsi Date: Tue, 25 Aug 2026 12:49:43 -0400 Subject: [PATCH 11/24] revert fixed mention highlight (#6716) **Category:** fix **User Impact:** Mention chips now follow the active theme without a fixed yellow fill and remain readable across every supported theme. **Problem:** PR #6696 added an opaque yellow treatment to every human and agent mention. Removing that fixed color exposed another issue: some theme accent colors do not provide WCAG AA contrast when also used as normal-size mention text. **Solution:** Remove the fixed yellow treatment and keep the active theme accent as a subtle translucent chip background. Mention labels use a light/dark semantic foreground selected for readable contrast, including hover states.

File changes **desktop/src/shared/styles/globals/markdown.css** Removes the fixed-yellow treatment, retains theme-derived accent backgrounds, and applies an AA-safe semantic foreground to human and agent mention labels in resting and hover states. **desktop/src/shared/styles/globals/theme.css** Removes the obsolete fixed-yellow tokens and defines light/dark mention foreground values. **desktop/tests/e2e/mentions.spec.ts** Adds rendered contrast coverage for resting and hover states across every supported syntax theme, compositing translucent chip and ancestor surfaces before checking the 4.5:1 threshold.
## Reproduction steps 1. Open a message containing a human or agent mention. 2. Confirm its subtle background follows the active theme accent rather than using yellow. 3. Hover the mention and confirm the accent background strengthens while the label remains readable. 4. Switch between default, light, dark, and custom syntax themes and confirm mention labels remain legible. This is a focused visual rollback of the mention treatment introduced by #6696. The channel notification badge changes from that PR remain intact. --------- Signed-off-by: tulsi --- .../src/shared/styles/globals/markdown.css | 15 ------ desktop/src/shared/styles/globals/theme.css | 4 -- desktop/tests/e2e/mentions.spec.ts | 48 ------------------- 3 files changed, 67 deletions(-) diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 135c036e74..251fdb525c 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -303,21 +303,6 @@ color: hsl(var(--primary) / 0.9); } -/* @mentions use an opaque highlighter-yellow treatment so they scan - differently from channel links while retaining AA contrast on every theme. - Shared by human and agent chips in both timeline and composer. */ -.message-markdown .mention-chip.inline-chip-icon-human, -.message-markdown .mention-chip.inline-chip-icon-agent { - background: hsl(var(--mention-highlight)); - color: hsl(var(--buzz-content-dark)); -} - -.message-markdown .mention-chip-hover.inline-chip-icon-human:hover, -.message-markdown .mention-chip-hover.inline-chip-icon-agent:hover { - background: hsl(var(--mention-highlight-hover)); - color: hsl(var(--buzz-content-dark)); -} - .message-markdown .mention-prefix-hidden { display: inline-block; width: 0; diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 875a17aedf..9b55da2703 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -32,10 +32,6 @@ destructive, these must stay recognizably red with AA label contrast. */ --notification: 348 65% 48%; --notification-foreground: 350 100% 98%; - /* Opaque highlighter colors keep near-black mention text readable on every - message surface instead of depending on the active theme underneath. */ - --mention-highlight: 48 96% 70%; - --mention-highlight-hover: 48 96% 62%; --border: 225 13.56% 76.86%; --input: 225 13.56% 76.86%; --ring: 234 16.02% 35.49%; diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index e90281a52f..6726a22402 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -39,37 +39,6 @@ const DM_THREAD_AGENT_MENTION_ERROR_TEXT = const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT = "Checking conversation members. Try again in a moment."; -async function expectTextContrast( - locator: import("@playwright/test").Locator, - minimum = 4.5, -) { - const contrastRatio = await locator.evaluate((element) => { - const parseRgb = (value: string) => - (value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number); - const luminance = (color: number[]) => - color - .map((channel) => { - const value = channel / 255; - return value <= 0.04045 - ? value / 12.92 - : ((value + 0.055) / 1.055) ** 2.4; - }) - .reduce( - (sum, channel, index) => - sum + channel * [0.2126, 0.7152, 0.0722][index], - 0, - ); - const style = getComputedStyle(element); - const foreground = luminance(parseRgb(style.color)); - const background = luminance(parseRgb(style.backgroundColor)); - return ( - (Math.max(foreground, background) + 0.05) / - (Math.min(foreground, background) + 0.05) - ); - }); - expect(contrastRatio).toBeGreaterThanOrEqual(minimum); -} - /** Locator scoped to the mention autocomplete dropdown inside the composer. */ function autocomplete(page: import("@playwright/test").Page) { return page @@ -2749,9 +2718,6 @@ test("sent non-member person mention uses the normal mention style", async ({ test("sent managed non-member agent mention uses the agent mention style", async ({ page, }) => { - await page.addInitScript(() => { - window.localStorage.setItem("buzz-theme", "buzz-dark"); - }); await installMockBridge(page, { managedAgents: [ { @@ -2781,13 +2747,6 @@ test("sent managed non-member agent mention uses the agent mention style", async await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("charlie"); await expect(mentionChip).toHaveClass(/agent-mention-highlight/); - await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); - await mentionChip.hover(); - await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); }); test("mention button opens autocomplete and inserts a selected member", async ({ @@ -2893,13 +2852,6 @@ test("mention text is highlighted in sent messages", async ({ page }) => { await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); - await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); - await mentionChip.hover(); - await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); - await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); - await expectTextContrast(mentionChip); }); test("clicking author name opens user profile panel", async ({ page }) => { From 113a33b7e49b7173ee1767c49ef2f49c63803034 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 25 Aug 2026 13:00:22 -0400 Subject: [PATCH 12/24] Add database pressure observability (#6700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Database pressure currently collapses several distinct delays into one symptom. This adds the evidence layer needed to distinguish pool acquisition wait, logical database operation time, advisory-lock wait, and selected transaction duration before changing timeout or retry policy. This is the phase 2 Lane A observability bundle for [#26](https://github.com/TheSentinel454/buzz/issues/26), [#28](https://github.com/TheSentinel454/buzz/issues/28), and [#33](https://github.com/TheSentinel454/buzz/issues/33). It is stacked on #6668. ## What - Record explicit reader/writer checkout wait and acquisition outcomes with `buzz_db_pool_acquire_wait_seconds` and `buzz_db_pool_acquisitions_total`. - Extend the compile-time `#[datastore_span(name = "...")]` seam with `buzz_db_operation_duration_seconds`, so operation labels remain static source literals instead of request data. - Route correctness-critical replacement, membership, push-gate, deletion, and migration/schema-safety advisory locks through one observer without changing their SQL, order, scope, or blocking behavior. - Measure six internally owned transaction lifetimes with `buzz_db_transaction_duration_seconds`, starting after `BEGIN` succeeds and ending after explicit commit/rollback or scope exit. - Emit root slow-operation warnings at 500 ms, logging the first slow completion and then 1/100 per call site with only `operation`, `outcome`, and `elapsed_ms`. - Document names, units, fixed label vocabularies, measurement boundaries, and blind spots in this PR description. Fixed labels are deliberately small: - `pool_role`: `writer`, `reader` - `lock_type`: `replacement`, `membership`, `push_gate`, `deletion`, `migration_schema_safety` - `outcome`: `success`, `error`, `timeout` where SQLx/PostgreSQL can distinguish it accurately - `operation`: compile-time datastore names plus the six closed transaction operation names documented in the runbook No metric or slow warning contains community IDs, event IDs, event kinds, coordinates, d-tags, SQL/query text, query IDs, returned errors, or event content. ## Coverage boundaries - Operation duration is the complete annotated logical function body, not pure SQL execution; it may include implicit checkout, lock wait, nested operations, and application work. Cancelled futures do not reach its completion hook. - Pool timing covers explicit helper checkouts, including proved-reader routing and selected writer-owned transactions. Implicit SQLx checkout through `&PgPool` remains folded into operation duration. - Lock timing covers application-side blocking locks in the five named families. Trigger/stored-procedure locks, channel-TTL locking, the usage try-lock, and the audit service session lock remain outside this slice. - Transaction timing covers only the six wholly owned boundaries documented in the runbook. It excludes pool wait, `BEGIN`, asynchronous rollback cleanup after an early return, and caller-owned `Db::begin_transaction` lifetime. ## Relationship to #6229 #6229 is the incident-driven timeout precursor. This PR does not add or change `statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout`, retries, audit durability, or client-visible conflicts. It provides the missing distributions needed to evaluate those policies later and intentionally leaves #6229's open audit retry/durability finding untouched. The branches overlap in `crates/buzz-db/src/lib.rs` and `crates/buzz-db/src/migration.rs`, so a later rebase may need textual conflict resolution, but the behavior is complementary rather than duplicated. ## Risk assessment Moderate-low. The primary risk is instrumentation overhead and added static series. Cardinality is source-bounded, slow logs are sampled/redacted root events, and the lock/transaction changes wrap existing awaits without changing policy or ordering. ## Verification Author workstation: `buzz-tornquist-db-pressure-observability` (`2010927`), exact head `d7cf833e26c528adfcde3917ded80daf6f4ddac9`, parent `6f50e6b2b2a996349149af61d35bdd6a355f77fd`. - `cargo fmt --all --check` — passed - `cargo clippy -p buzz-datastore-tracing -p buzz-db -p buzz-audit -p buzz-search -p buzz-relay --all-targets -- -D warnings` — passed - `cargo test -p buzz-datastore-tracing --quiet` — 4 passed - `cargo test -p buzz-db --quiet` — 109 passed, 200 ignored - `cargo test -p buzz-audit -p buzz-search --quiet` — 16 passed, 25 ignored - `cargo test -p buzz-relay --lib --quiet -- --test-threads=1` — 906 passed, 48 ignored - Native PostgreSQL focused tests for pool success/timeout/error, lock success/contention/timeout/error, replacement, membership serialization, push ordering, deletion fencing, migration/schema exclusion, and reader fallback — 8 passed The default-parallel relay run passed once; subsequent runs exposed the existing load-sensitive `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` 504 at the end of the suite. That test passes in isolation and the full relay suite passes serially. Independent exact-head review workstation: `buzz-tornquist-db-pressure-observability-review` (`2013067`). Formatting, the same all-target clippy command, datastore instrumentation tests, DB unit tests, source privacy guards, and diff/non-goal audits passed; no review findings. Generated with Codex --------- Signed-off-by: tornquist --- .github/workflows/ci.yml | 12 + Cargo.lock | 4 + crates/buzz-audit/Cargo.toml | 1 + crates/buzz-datastore-tracing/Cargo.toml | 2 + crates/buzz-datastore-tracing/src/lib.rs | 40 ++ .../buzz-datastore-tracing/tests/runtime.rs | 129 ++++ crates/buzz-db/src/channel.rs | 30 +- crates/buzz-db/src/community.rs | 11 +- crates/buzz-db/src/deletion.rs | 96 ++- crates/buzz-db/src/lib.rs | 78 ++- crates/buzz-db/src/migration.rs | 15 +- crates/buzz-db/src/observability.rs | 636 ++++++++++++++++++ crates/buzz-db/src/push.rs | 45 +- crates/buzz-db/src/relay_members.rs | 13 +- crates/buzz-db/src/replaceable.rs | 56 +- crates/buzz-db/tests/observability_source.rs | 40 ++ crates/buzz-search/Cargo.toml | 1 + 17 files changed, 1086 insertions(+), 123 deletions(-) create mode 100644 crates/buzz-db/src/observability.rs create mode 100644 crates/buzz-db/tests/observability_source.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8016766812..1fa6fb94ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,6 +692,18 @@ jobs: env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + - name: Database pressure observability PostgreSQL tests + # Explicit pool acquisition and advisory-lock metrics require real + # Postgres and are ignored by the infrastructure-free unit-test job. + run: | + filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E "${filter}" \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay diff --git a/Cargo.lock b/Cargo.lock index 9ff8ccf618..5e9c5aade3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -920,6 +920,7 @@ dependencies = [ "chrono", "futures-util", "hex", + "metrics", "serde", "serde_json", "sha2 0.11.0", @@ -1035,6 +1036,8 @@ dependencies = [ name = "buzz-datastore-tracing" version = "0.1.0" dependencies = [ + "metrics", + "metrics-util", "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", "proc-macro2", @@ -1361,6 +1364,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-datastore-tracing", + "metrics", "sqlx", "thiserror 2.0.18", "tokio", diff --git a/crates/buzz-audit/Cargo.toml b/crates/buzz-audit/Cargo.toml index dfa73353de..766ade6505 100644 --- a/crates/buzz-audit/Cargo.toml +++ b/crates/buzz-audit/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +metrics = { workspace = true } thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } diff --git a/crates/buzz-datastore-tracing/Cargo.toml b/crates/buzz-datastore-tracing/Cargo.toml index e93900c54c..fb7ba6f37d 100644 --- a/crates/buzz-datastore-tracing/Cargo.toml +++ b/crates/buzz-datastore-tracing/Cargo.toml @@ -16,6 +16,8 @@ quote = "1" syn = { version = "2", features = ["full"] } [dev-dependencies] +metrics = { workspace = true } +metrics-util = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["testing"] } tokio = { workspace = true } diff --git a/crates/buzz-datastore-tracing/src/lib.rs b/crates/buzz-datastore-tracing/src/lib.rs index f2645cb8f3..217f2335de 100644 --- a/crates/buzz-datastore-tracing/src/lib.rs +++ b/crates/buzz-datastore-tracing/src/lib.rs @@ -70,6 +70,9 @@ impl Parse for DatastoreArgs { /// PostgreSQL spans always omit function arguments, use the `buzz_datastore` /// target, and expose only canonical semantic fields plus explicitly supplied /// safe fields. An `Err` sets `otel.status_code` without inspecting the error. +/// The literal `name` also labels a logical-operation duration histogram. Slow +/// completions are sampled and logged with only that name, outcome, and elapsed +/// time; arguments, error values, and return values are never formatted. #[proc_macro_attribute] pub fn datastore_span(args: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(args as DatastoreArgs); @@ -129,9 +132,46 @@ pub fn datastore_span(args: TokenStream, item: TokenStream) -> TokenStream { } } }); + let outcome = if returns_result { + quote! { + if #result.is_err() { "error" } else { "success" } + } + } else { + quote!("success") + }; function.block = Box::new(syn::parse_quote!({ + let __buzz_datastore_started_7f3a9c = ::std::time::Instant::now(); let #result: #return_type = (async #original_body).await; #record_error + let __buzz_datastore_outcome_7f3a9c = #outcome; + let __buzz_datastore_elapsed_7f3a9c = __buzz_datastore_started_7f3a9c.elapsed(); + ::metrics::histogram!( + "buzz_db_operation_duration_seconds", + "operation" => #name, + "outcome" => __buzz_datastore_outcome_7f3a9c, + ) + .record(__buzz_datastore_elapsed_7f3a9c.as_secs_f64()); + if __buzz_datastore_elapsed_7f3a9c >= ::std::time::Duration::from_millis(500) { + static __BUZZ_DATASTORE_SLOW_SAMPLE_7F3A9C: + ::std::sync::atomic::AtomicU64 = ::std::sync::atomic::AtomicU64::new(0); + if __BUZZ_DATASTORE_SLOW_SAMPLE_7F3A9C.fetch_add( + 1, + ::std::sync::atomic::Ordering::Relaxed, + ) % 100 == 0 { + let __buzz_datastore_elapsed_ms_7f3a9c = + __buzz_datastore_elapsed_7f3a9c + .as_millis() + .min(::std::primitive::u64::MAX as u128) as u64; + ::tracing::warn!( + target: "buzz_datastore", + parent: None, + operation = #name, + outcome = __buzz_datastore_outcome_7f3a9c, + elapsed_ms = __buzz_datastore_elapsed_ms_7f3a9c, + "slow datastore operation" + ); + } + } #result })); diff --git a/crates/buzz-datastore-tracing/tests/runtime.rs b/crates/buzz-datastore-tracing/tests/runtime.rs index b58dca8715..3355190956 100644 --- a/crates/buzz-datastore-tracing/tests/runtime.rs +++ b/crates/buzz-datastore-tracing/tests/runtime.rs @@ -1,6 +1,12 @@ use buzz_datastore_tracing::datastore_span; +use metrics_util::debugging::{DebugValue, DebuggingRecorder}; use opentelemetry::trace::{SpanKind, Status, TracerProvider as _}; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::{Context, Layer}; use tracing_subscriber::prelude::*; const DIRECT_ERROR: &str = "raw-secret-direct-error"; @@ -27,8 +33,48 @@ async fn operation( Ok(limit) } +#[datastore_span(name = "slow_test_operation", system = "postgresql")] +async fn slow_operation(delay: std::time::Duration) -> Result<(), &'static str> { + tokio::time::sleep(delay).await; + Err(DIRECT_ERROR) +} + +#[derive(Default)] +struct EventFields(BTreeMap); + +impl Visit for EventFields { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.0.insert(field.name().to_owned(), format!("{value:?}")); + } + + fn record_str(&mut self, field: &Field, value: &str) { + self.0.insert(field.name().to_owned(), value.to_owned()); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.0.insert(field.name().to_owned(), value.to_string()); + } +} + +#[derive(Clone, Default)] +struct EventCapture(Arc>>); + +impl Layer for EventCapture +where + S: Subscriber, +{ + fn on_event(&self, event: &Event<'_>, _context: Context<'_, S>) { + let mut fields = EventFields::default(); + event.record(&mut fields); + self.0.lock().expect("capture lock").push(fields); + } +} + #[tokio::test(flavor = "current_thread")] async fn exports_policy_fields_without_error_or_argument_data() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _metrics_guard = metrics::set_default_local_recorder(&recorder); let exporter = InMemorySpanExporter::default(); let provider = SdkTracerProvider::builder() .with_simple_exporter(exporter.clone()) @@ -41,6 +87,37 @@ async fn exports_policy_fields_without_error_or_argument_data() { assert_eq!(operation(8, true, false).await, Err(DIRECT_ERROR)); assert_eq!(operation(9, false, true).await, Err(QUESTION_ERROR)); + let operation_samples = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_operation_duration_seconds") + .map(|(key, _, _, value)| { + let DebugValue::Histogram(samples) = value else { + panic!("operation duration must be a histogram"); + }; + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (labels, samples) + }) + .collect::>(); + assert_eq!(operation_samples.len(), 2); + for (labels, samples) in operation_samples { + assert_eq!( + labels.get("operation").map(String::as_str), + Some("test_operation") + ); + assert!(matches!( + labels.get("outcome").map(String::as_str), + Some("success" | "error") + )); + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0)); + } + provider.force_flush().expect("spans flush"); let spans = exporter.get_finished_spans().expect("exported spans"); assert_eq!(spans.len(), 3); @@ -78,3 +155,55 @@ async fn exports_policy_fields_without_error_or_argument_data() { } } } + +#[tokio::test(flavor = "current_thread")] +async fn slow_operation_logging_is_guarded_sampled_and_redacted() { + let capture = EventCapture::default(); + let subscriber = tracing_subscriber::registry().with(capture.clone()); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + + assert_eq!( + slow_operation(std::time::Duration::from_millis(1)).await, + Err(DIRECT_ERROR) + ); + assert_eq!( + slow_operation(std::time::Duration::from_millis(510)).await, + Err(DIRECT_ERROR) + ); + assert_eq!( + slow_operation(std::time::Duration::from_millis(510)).await, + Err(DIRECT_ERROR) + ); + + let events = capture.0.lock().expect("capture lock"); + let slow = events + .iter() + .filter(|event| { + event + .0 + .get("message") + .is_some_and(|message| message.contains("slow datastore operation")) + }) + .collect::>(); + assert_eq!( + slow.len(), + 1, + "first slow call is logged, next 99 are sampled out" + ); + let fields = &slow[0].0; + assert_eq!( + fields.get("operation").map(String::as_str), + Some("slow_test_operation") + ); + assert_eq!(fields.get("outcome").map(String::as_str), Some("error")); + assert!(fields + .get("elapsed_ms") + .and_then(|value| value.parse::().ok()) + .is_some_and(|elapsed| elapsed >= 500)); + assert_eq!( + fields.len(), + 4, + "only message and fixed safe fields are logged" + ); + assert!(!format!("{fields:?}").contains(DIRECT_ERROR)); +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index a1890adb56..98790e3d62 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -478,14 +478,17 @@ async fn acquire_channel_membership_lock( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") - .bind(format!( - "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", - community_id.as_uuid(), - channel_id - )) - .execute(&mut **tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!( + "{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}", + community_id.as_uuid(), + channel_id + )) + .execute(&mut **tx), + ) + .await?; Ok(()) } @@ -631,10 +634,13 @@ pub async fn lock_member_snapshot( relay_pubkey, Some(channel_id.as_bytes()), ); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(replacement_lock) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(replacement_lock) + .execute(&mut *tx), + ) + .await?; acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; let rows = sqlx::query( r#" diff --git a/crates/buzz-db/src/community.rs b/crates/buzz-db/src/community.rs index 64c116a520..5df896a350 100644 --- a/crates/buzz-db/src/community.rs +++ b/crates/buzz-db/src/community.rs @@ -325,10 +325,13 @@ impl Db { // Serialize on the owner pubkey so concurrent creates to the same // owner cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(relay_members::owner_count_advisory_lock_key(&owner_pubkey)) + .execute(&mut *tx), + ) + .await?; let row = sqlx::query( r#" diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index 7db0df8ee3..98a039d62f 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -1210,12 +1210,15 @@ impl DeletionStore { /// Already-acquired leases remain renewable, verifiable, and releasable so /// admitted remote effects retain their exclusion proof until completion. pub async fn begin_quiescing(&self, token: &LeaseToken) -> Result<()> { - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + &self.pool, + crate::observability::TransactionOperation::BeginCommunityDeletionQuiescing, + ) + .await?; + transaction_timer + .observe(async { verify_lease(&mut tx, token, DeletionStage::Approved).await?; - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(token.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, token.community_id).await?; verify_lease(&mut tx, token, DeletionStage::Approved).await?; let (generation, archived_at): (i64, Option>) = sqlx::query_as( "SELECT deletion_fence_generation, archived_at FROM communities WHERE id = $1 FOR UPDATE", @@ -1259,16 +1262,21 @@ impl DeletionStore { .await?; tx.commit().await?; Ok(()) + }) + .await } /// Acquire the universal durable fence after all pre-quiesce serving leases drain. pub async fn fence(&self, token: &LeaseToken) -> Result { - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + &self.pool, + crate::observability::TransactionOperation::FenceCommunityDeletion, + ) + .await?; + transaction_timer + .observe(async { verify_lease(&mut tx, token, DeletionStage::Approved).await?; - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(token.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, token.community_id).await?; verify_lease(&mut tx, token, DeletionStage::Approved).await?; let active_serving_writes = sqlx::query( "SELECT count(*)::BIGINT AS active_count, \ @@ -1331,6 +1339,8 @@ impl DeletionStore { .await?; tx.commit().await?; Ok(generation) + }) + .await } /// Freeze the exact post-fence storage binding manifest. @@ -1967,10 +1977,7 @@ impl DeletionStore { .ok_or_else(|| DbError::NotFound(format!("community deletion {request_id}")))?; // Every lifecycle transition takes the community lock before any row lock. // Inverting this order lets abort and the executor deadlock each other. - sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") - .bind(community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion(&mut tx, community_id).await?; let row = sqlx::query("SELECT * FROM community_deletion_requests WHERE id = $1 FOR UPDATE") .bind(request_id) .fetch_optional(&mut *tx) @@ -2254,10 +2261,7 @@ impl DeletionStore { tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(community.as_uuid()) - .execute(&mut **tx) - .await?; + lock_community_deletion_shared(tx, community).await?; let state: Option = sqlx::query_scalar( "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", ) @@ -2286,10 +2290,7 @@ impl DeletionStore { tx: &mut Transaction<'_, Postgres>, lease: &ServingWriteLease, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut **tx) - .await?; + lock_community_deletion_shared(tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -2407,10 +2408,7 @@ impl DeletionStore { ) -> Result<()> { let lease_seconds = i64::try_from(lease_duration.as_secs()).unwrap_or(i64::MAX); let mut tx = self.pool.begin().await?; - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion_shared(&mut tx, lease.community_id).await?; let lease_until: Option> = sqlx::query_scalar( "UPDATE community_serving_write_leases lease \ SET lease_until = now() + make_interval(secs => $6), heartbeat_at = now() \ @@ -2465,10 +2463,7 @@ impl DeletionStore { /// admitted remote effect. pub async fn verify_serving_write_lease(&self, lease: &ServingWriteLease) -> Result<()> { let mut tx = self.pool.begin().await?; - sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") - .bind(lease.community_id.as_uuid()) - .execute(&mut *tx) - .await?; + lock_community_deletion_shared(&mut tx, lease.community_id).await?; let valid: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM community_serving_write_leases lease \ JOIN communities community ON community.id = lease.community_id \ @@ -2572,6 +2567,34 @@ impl DeletionStore { } } +async fn lock_community_deletion( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + crate::observability::observe_advisory_lock( + crate::observability::LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut **tx), + ) + .await?; + Ok(()) +} + +async fn lock_community_deletion_shared( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + crate::observability::observe_advisory_lock( + crate::observability::LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock_shared(community_deletion_lock_key($1))") + .bind(community.as_uuid()) + .execute(&mut **tx), + ) + .await?; + Ok(()) +} + /// Take the shared schema/destruction advisory lock for the current /// transaction. /// @@ -2580,10 +2603,13 @@ impl DeletionStore { /// whole run (see [`crate::migration::run_migrations`]); shared holders do /// not block each other, so concurrent deletion executors are unaffected. async fn lock_schema_destruction_shared(conn: &mut PgConnection) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock_shared($1)") - .bind(SCHEMA_DESTRUCTION_LOCK_KEY) - .execute(conn) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_xact_lock_shared($1)") + .bind(SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(conn), + ) + .await?; Ok(()) } @@ -3302,7 +3328,7 @@ mod postgres_tests { async fn store() -> (Db, DeletionStore) { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials let db = Db::new(&DbConfig { database_url, max_connections: 5, diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index a5c03a256e..a6cd77c979 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -50,6 +50,7 @@ pub mod git_repo; pub mod migration; /// Community moderation: reports, bans/timeouts, audit actions. pub mod moderation; +mod observability; /// Monthly table partition management. pub mod partition; /// Buzz product-feedback sidecar persistence. @@ -712,7 +713,7 @@ impl Db { }; let aurora_identity = self.reader_aurora_identity.clone(); tokio::spawn(async move { - match read_pool.acquire().await { + match observability::acquire(&read_pool, observability::PoolRole::Reader).await { Ok(mut conn) => { tracing::info!("read replica reachable at boot"); match replica_fence::reader_supports_aurora_identity(&mut conn).await { @@ -854,7 +855,7 @@ impl Db { // `read_pool` separately would spend a second budget whenever the // capability is uncached — i.e. after a failed boot ping, which is // precisely the reader-unavailable case the bound must hold for. - let conn = match read_pool.acquire().await { + let conn = match observability::acquire(read_pool, observability::PoolRole::Reader).await { Ok(conn) => conn, Err(sqlx::Error::PoolTimedOut) => { tracing::warn!("reader pool acquire timed out; routing to writer"); @@ -1030,7 +1031,8 @@ impl Db { &self, lock_key: i64, ) -> Result> { - let mut connection = self.pool.acquire().await?; + let mut connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") .bind(lock_key) .fetch_one(&mut *connection) @@ -1177,7 +1179,11 @@ impl Db { /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. /// The transaction holds an owned pool handle, not a borrow. pub async fn begin_transaction(&self) -> Result> { - self.pool.begin().await.map_err(Into::into) + let connection = + observability::acquire(&self.pool, observability::PoolRole::Writer).await?; + sqlx::Transaction::begin(connection, None) + .await + .map_err(Into::into) } /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. @@ -4349,14 +4355,23 @@ impl Db { channel_id.as_ref().map(|id| id.as_bytes().as_slice()), ); - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::ReplaceAddressableEvent, + ) + .await?; + transaction_timer + .observe(async { // Serialize all writers for the same (kind, pubkey, channel_id) tuple. // Advisory lock is transaction-scoped — released on commit/rollback. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; + observability::observe_advisory_lock( + observability::LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; // Check for the newest existing event. ORDER BY + LIMIT 1 is defensive against // historical data where prior bugs may have left multiple live rows. @@ -4452,6 +4467,8 @@ impl Db { StoredEvent::with_received_at(event.clone(), received_at, channel_id, true), true, )) + }) + .await } /// Returns whether the relay-authored NIP-43 snapshot is absent or differs @@ -4532,16 +4549,25 @@ impl Db { None, ); - let mut tx = self.pool.begin().await?; + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + observability::TransactionOperation::PublishNip43MembershipLocked, + ) + .await?; + let (event, received_at, was_inserted, member_count) = transaction_timer + .observe(async { // Acquire the per-community snapshot lock BEFORE reading members. // This serializes the entire read-build-write cycle: a concurrent // publication will block here until our transaction commits, then // read the updated membership state. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut *tx) - .await?; + observability::observe_advisory_lock( + observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx), + ) + .await?; // Read current members inside the locked transaction. let rows = sqlx::query( @@ -4616,24 +4642,24 @@ impl Db { .await?; let was_inserted = insert_result.rows_affected() > 0; - if !was_inserted { + if was_inserted { + tx.commit().await?; + } else { tx.rollback().await?; - return Ok(( - StoredEvent::with_received_at(event, received_at, None, false), - false, - member_count, - )); } + Ok::<_, DbError>((event, received_at, was_inserted, member_count)) + }) + .await?; - tx.commit().await?; - - if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { - tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + if was_inserted { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, &event, None).await { + tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); + } } Ok(( - StoredEvent::with_received_at(event, received_at, None, true), - true, + StoredEvent::with_received_at(event, received_at, None, was_inserted), + was_inserted, member_count, )) } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 94c7aea2fa..9df02c8abf 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -81,11 +81,16 @@ where F: FnOnce(PgConnection) -> Fut, Fut: Future)>, { - let mut lock_conn = pool.acquire().await?.detach(); - sqlx::query("SELECT pg_advisory_lock($1)") - .bind(SCHEMA_DESTRUCTION_LOCK_KEY) - .execute(&mut lock_conn) - .await?; + let mut lock_conn = crate::observability::acquire(pool, crate::observability::PoolRole::Writer) + .await? + .detach(); + crate::observability::observe_advisory_lock( + crate::observability::LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_lock($1)") + .bind(SCHEMA_DESTRUCTION_LOCK_KEY) + .execute(&mut lock_conn), + ) + .await?; let (mut lock_conn, outcome) = op(lock_conn).await; let unlock = sqlx::query("SELECT pg_advisory_unlock($1)") .bind(SCHEMA_DESTRUCTION_LOCK_KEY) diff --git a/crates/buzz-db/src/observability.rs b/crates/buzz-db/src/observability.rs new file mode 100644 index 0000000000..afe1d20b30 --- /dev/null +++ b/crates/buzz-db/src/observability.rs @@ -0,0 +1,636 @@ +//! Bounded-cardinality database pressure instrumentation primitives. +//! +//! Label values come only from the closed enums in this module. Callers must +//! never derive labels from tenant data, events, SQL text, or query identifiers. + +use std::future::Future; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PoolRole { + Writer, + Reader, +} + +impl PoolRole { + #[cfg(test)] + pub(crate) const ALL: [Self; 2] = [Self::Writer, Self::Reader]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Writer => "writer", + Self::Reader => "reader", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LockType { + Replacement, + Membership, + PushGate, + Deletion, + MigrationSchemaSafety, +} + +impl LockType { + #[cfg(test)] + pub(crate) const ALL: [Self; 5] = [ + Self::Replacement, + Self::Membership, + Self::PushGate, + Self::Deletion, + Self::MigrationSchemaSafety, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Replacement => "replacement", + Self::Membership => "membership", + Self::PushGate => "push_gate", + Self::Deletion => "deletion", + Self::MigrationSchemaSafety => "migration_schema_safety", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Outcome { + Success, + Error, + Timeout, +} + +impl Outcome { + #[cfg(test)] + pub(crate) const ALL: [Self; 3] = [Self::Success, Self::Error, Self::Timeout]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Success => "success", + Self::Error => "error", + Self::Timeout => "timeout", + } + } + + fn from_sqlx_error(error: &sqlx::Error) -> Self { + match error { + sqlx::Error::PoolTimedOut => Self::Timeout, + sqlx::Error::Database(database) if database.code().as_deref() == Some("55P03") => { + Self::Timeout + } + _ => Self::Error, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TransactionOperation { + ReplaceParameterizedEvent, + ReplaceAddressableEvent, + PublishNip43MembershipLocked, + AcceptPushLeaseEvent, + BeginCommunityDeletionQuiescing, + FenceCommunityDeletion, +} + +impl TransactionOperation { + #[cfg(test)] + pub(crate) const ALL: [Self; 6] = [ + Self::ReplaceParameterizedEvent, + Self::ReplaceAddressableEvent, + Self::PublishNip43MembershipLocked, + Self::AcceptPushLeaseEvent, + Self::BeginCommunityDeletionQuiescing, + Self::FenceCommunityDeletion, + ]; + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::ReplaceParameterizedEvent => "replace_parameterized_event", + Self::ReplaceAddressableEvent => "replace_addressable_event", + Self::PublishNip43MembershipLocked => "publish_nip43_membership_locked", + Self::AcceptPushLeaseEvent => "accept_push_lease_event", + Self::BeginCommunityDeletionQuiescing => "begin_community_deletion_quiescing", + Self::FenceCommunityDeletion => "fence_community_deletion", + } + } +} + +pub(crate) fn record_pool_acquire(role: PoolRole, outcome: Outcome, elapsed: Duration) { + metrics::histogram!( + "buzz_db_pool_acquire_wait_seconds", + "pool_role" => role.as_str(), + "outcome" => outcome.as_str(), + ) + .record(elapsed.as_secs_f64()); + metrics::counter!( + "buzz_db_pool_acquisitions_total", + "pool_role" => role.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); +} + +pub(crate) async fn acquire( + pool: &sqlx::PgPool, + role: PoolRole, +) -> sqlx::Result> { + let started = Instant::now(); + let result = pool.acquire().await; + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + record_pool_acquire(role, outcome, started.elapsed()); + result +} + +pub(crate) async fn begin_transaction( + pool: &sqlx::PgPool, + operation: TransactionOperation, +) -> sqlx::Result<(sqlx::Transaction<'static, sqlx::Postgres>, TransactionTimer)> { + let connection = acquire(pool, PoolRole::Writer).await?; + let transaction = sqlx::Transaction::begin(connection, None).await?; + Ok((transaction, TransactionTimer::start(operation))) +} + +pub(crate) async fn observe_advisory_lock(lock_type: LockType, future: F) -> sqlx::Result +where + F: Future>, +{ + let started = Instant::now(); + let result = future.await; + let outcome = result + .as_ref() + .map(|_| Outcome::Success) + .unwrap_or_else(Outcome::from_sqlx_error); + metrics::histogram!( + "buzz_db_advisory_lock_wait_seconds", + "lock_type" => lock_type.as_str(), + "outcome" => outcome.as_str(), + ) + .record(started.elapsed().as_secs_f64()); + metrics::counter!( + "buzz_db_advisory_lock_acquisitions_total", + "lock_type" => lock_type.as_str(), + "outcome" => outcome.as_str(), + ) + .increment(1); + result +} + +pub(crate) struct TransactionTimer { + operation: TransactionOperation, + started: Instant, + outcome: Outcome, +} + +impl TransactionTimer { + pub(crate) fn start(operation: TransactionOperation) -> Self { + Self { + operation, + started: Instant::now(), + outcome: Outcome::Error, + } + } + + pub(crate) async fn observe(mut self, future: F) -> Result + where + F: Future>, + { + let result = future.await; + if result.is_ok() { + self.outcome = Outcome::Success; + } + result + } +} + +impl Drop for TransactionTimer { + fn drop(&mut self) { + metrics::histogram!( + "buzz_db_transaction_duration_seconds", + "operation" => self.operation.as_str(), + "outcome" => self.outcome.as_str(), + ) + .record(self.started.elapsed().as_secs_f64()); + } +} + +#[cfg(test)] +mod tests { + use super::{ + acquire, observe_advisory_lock, record_pool_acquire, LockType, Outcome, PoolRole, + TransactionOperation, TransactionTimer, + }; + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use std::collections::{BTreeMap, BTreeSet}; + use std::time::Duration; + + #[test] + fn label_vocabularies_are_closed_and_documented() { + assert_eq!(PoolRole::ALL.map(PoolRole::as_str), ["writer", "reader"]); + assert_eq!( + LockType::ALL.map(LockType::as_str), + [ + "replacement", + "membership", + "push_gate", + "deletion", + "migration_schema_safety", + ] + ); + assert_eq!( + Outcome::ALL.map(Outcome::as_str), + ["success", "error", "timeout"] + ); + assert_eq!( + TransactionOperation::ALL.map(TransactionOperation::as_str), + [ + "replace_parameterized_event", + "replace_addressable_event", + "publish_nip43_membership_locked", + "accept_push_lease_event", + "begin_community_deletion_quiescing", + "fence_community_deletion", + ] + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn transaction_timer_observe_classifies_result_outcomes() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let success = TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent) + .observe(async { Ok::<_, &str>("committed") }) + .await; + assert_eq!(success, Ok("committed")); + + let error = TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent) + .observe(async { Err::<(), _>("rollback") }) + .await; + assert_eq!(error, Err("rollback")); + + let keys = snapshotter + .snapshot() + .into_vec() + .into_iter() + .map(|(key, ..)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (key.key().name().to_owned(), labels) + }) + .collect::>(); + + for (operation, outcome) in [ + ("replace_parameterized_event", "success"), + ("accept_push_lease_event", "error"), + ] { + assert!(keys.contains(&( + "buzz_db_transaction_duration_seconds".to_owned(), + [ + ("operation".to_owned(), operation.to_owned()), + ("outcome".to_owned(), outcome.to_owned()), + ] + .into_iter() + .collect(), + ))); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn primitives_record_fixed_success_error_and_timeout_labels() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + record_pool_acquire( + PoolRole::Writer, + Outcome::Success, + Duration::from_millis(12), + ); + record_pool_acquire( + PoolRole::Reader, + Outcome::Timeout, + Duration::from_millis(34), + ); + let lock_ok: sqlx::Result<()> = + observe_advisory_lock(LockType::Replacement, async { Ok(()) }).await; + assert!(lock_ok.is_ok()); + let lock_error: sqlx::Result<()> = + observe_advisory_lock(LockType::Membership, async { Err(sqlx::Error::PoolClosed) }) + .await; + assert!(lock_error.is_err()); + + let committed: Result<(), ()> = + TransactionTimer::start(TransactionOperation::ReplaceParameterizedEvent) + .observe(async { Ok(()) }) + .await; + assert!(committed.is_ok()); + let rolled_back: Result<(), ()> = + TransactionTimer::start(TransactionOperation::AcceptPushLeaseEvent) + .observe(async { Err(()) }) + .await; + assert!(rolled_back.is_err()); + + let snapshot = snapshotter.snapshot().into_vec(); + let keys = snapshot + .iter() + .map(|(key, ..)| { + let labels = key + .key() + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + (key.key().name().to_owned(), labels) + }) + .collect::>(); + + for expected in [ + ( + "buzz_db_pool_acquire_wait_seconds", + [("outcome", "success"), ("pool_role", "writer")], + ), + ( + "buzz_db_pool_acquire_wait_seconds", + [("outcome", "timeout"), ("pool_role", "reader")], + ), + ( + "buzz_db_pool_acquisitions_total", + [("outcome", "success"), ("pool_role", "writer")], + ), + ( + "buzz_db_pool_acquisitions_total", + [("outcome", "timeout"), ("pool_role", "reader")], + ), + ( + "buzz_db_advisory_lock_wait_seconds", + [("lock_type", "replacement"), ("outcome", "success")], + ), + ( + "buzz_db_advisory_lock_wait_seconds", + [("lock_type", "membership"), ("outcome", "error")], + ), + ( + "buzz_db_advisory_lock_acquisitions_total", + [("lock_type", "replacement"), ("outcome", "success")], + ), + ( + "buzz_db_advisory_lock_acquisitions_total", + [("lock_type", "membership"), ("outcome", "error")], + ), + ( + "buzz_db_transaction_duration_seconds", + [ + ("operation", "replace_parameterized_event"), + ("outcome", "success"), + ], + ), + ( + "buzz_db_transaction_duration_seconds", + [ + ("operation", "accept_push_lease_event"), + ("outcome", "error"), + ], + ), + ] { + let expected_labels = expected + .1 + .into_iter() + .map(|(key, value)| (key.to_owned(), value.to_owned())) + .collect::>(); + assert!( + keys.contains(&(expected.0.to_owned(), expected_labels)), + "missing metric series {expected:?}; got {keys:?}" + ); + } + + for (key, _, _, value) in snapshot { + if key.key().name().ends_with("_seconds") { + let DebugValue::Histogram(samples) = value else { + panic!("seconds metrics must be histograms"); + }; + assert!(samples.iter().all(|sample| sample.into_inner() >= 0.0)); + } else if key.key().name().ends_with("_total") { + let DebugValue::Counter(value) = value else { + panic!("total metrics must be counters"); + }; + assert_eq!(value, 1); + } + } + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(Duration::from_millis(75)) + .connect(&database_url) + .await + .expect("connect size-one test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let held = acquire(&pool, PoolRole::Writer) + .await + .expect("writer acquire succeeds"); + let timeout = acquire(&pool, PoolRole::Reader) + .await + .expect_err("reader-labeled checkout times out while pool is saturated"); + assert!(matches!(timeout, sqlx::Error::PoolTimedOut)); + drop(held); + pool.close().await; + let closed = acquire(&pool, PoolRole::Writer) + .await + .expect_err("closed pool acquire errors"); + assert!(matches!(closed, sqlx::Error::PoolClosed)); + + let mut outcomes = BTreeMap::<(String, String), Vec>::new(); + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + if key.key().name() != "buzz_db_pool_acquire_wait_seconds" { + continue; + } + let DebugValue::Histogram(samples) = value else { + panic!("pool wait must be a histogram"); + }; + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value().to_owned()) + .unwrap_or_default() + }; + outcomes.insert( + (label("pool_role"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } + assert!(outcomes.contains_key(&("writer".to_owned(), "success".to_owned()))); + assert!(outcomes.contains_key(&("writer".to_owned(), "error".to_owned()))); + let timeout_samples = outcomes + .get(&("reader".to_owned(), "timeout".to_owned())) + .expect("reader timeout series"); + assert!( + timeout_samples.iter().any(|sample| *sample >= 0.05), + "timeout wait must include the saturated checkout delay: {timeout_samples:?}" + ); + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn advisory_lock_records_success_contention_timeout_and_error() { + let database_url = std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(4) + .connect(&database_url) + .await + .expect("connect advisory-lock test pool"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let _guard = metrics::set_default_local_recorder(&recorder); + + let mut success_tx = pool.begin().await.expect("begin success transaction"); + observe_advisory_lock( + LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(0x62757a7a6f627331_i64) + .execute(&mut *success_tx), + ) + .await + .expect("uncontended lock succeeds"); + success_tx + .rollback() + .await + .expect("rollback success transaction"); + + let contention_key = 0x62757a7a6f627332_i64; + let mut holder = pool.begin().await.expect("begin lock holder"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(contention_key) + .execute(&mut *holder) + .await + .expect("holder acquires contention key"); + let mut waiter = pool.begin().await.expect("begin lock waiter"); + let waiter_task = tokio::spawn(async move { + let result = observe_advisory_lock( + LockType::Deletion, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(contention_key) + .execute(&mut *waiter), + ) + .await; + (waiter, result) + }); + tokio::time::sleep(Duration::from_millis(60)).await; + assert!( + !waiter_task.is_finished(), + "waiter must be blocked by holder" + ); + holder.commit().await.expect("release contention key"); + let (waiter, waited) = waiter_task.await.expect("join lock waiter"); + waited.expect("contended lock succeeds after release"); + waiter.rollback().await.expect("rollback waiter"); + + let timeout_key = 0x62757a7a6f627333_i64; + let mut timeout_holder = pool.begin().await.expect("begin timeout holder"); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(timeout_key) + .execute(&mut *timeout_holder) + .await + .expect("holder acquires timeout key"); + let mut timeout_waiter = pool.begin().await.expect("begin timeout waiter"); + sqlx::query("SET LOCAL lock_timeout = '30ms'") + .execute(&mut *timeout_waiter) + .await + .expect("set test-only lock timeout"); + let timed_out = observe_advisory_lock( + LockType::MigrationSchemaSafety, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(timeout_key) + .execute(&mut *timeout_waiter), + ) + .await + .expect_err("lock wait times out"); + assert_eq!( + timed_out + .as_database_error() + .and_then(|error| error.code()) + .as_deref(), + Some("55P03") + ); + timeout_holder + .rollback() + .await + .expect("release timeout key"); + + let mut aborted = pool.begin().await.expect("begin error transaction"); + sqlx::query("SELECT 1 / 0") + .execute(&mut *aborted) + .await + .expect_err("abort transaction before lock"); + observe_advisory_lock( + LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(0x62757a7a6f627334_i64) + .execute(&mut *aborted), + ) + .await + .expect_err("lock statement fails in aborted transaction"); + aborted + .rollback() + .await + .expect("rollback aborted transaction"); + + let mut outcomes = BTreeMap::<(String, String), Vec>::new(); + for (key, _, _, value) in snapshotter.snapshot().into_vec() { + if key.key().name() != "buzz_db_advisory_lock_wait_seconds" { + continue; + } + let DebugValue::Histogram(samples) = value else { + panic!("lock wait must be a histogram"); + }; + let labels = key.key().labels().collect::>(); + let label = |name: &str| { + labels + .iter() + .find(|label| label.key() == name) + .map(|label| label.value().to_owned()) + .unwrap_or_default() + }; + outcomes.insert( + (label("lock_type"), label("outcome")), + samples + .into_iter() + .map(|sample| sample.into_inner()) + .collect(), + ); + } + assert!(outcomes.contains_key(&("replacement".to_owned(), "success".to_owned()))); + assert!(outcomes.contains_key(&("membership".to_owned(), "error".to_owned()))); + assert!( + outcomes.contains_key(&("migration_schema_safety".to_owned(), "timeout".to_owned())) + ); + let contention = outcomes + .get(&("deletion".to_owned(), "success".to_owned())) + .expect("deletion contention series"); + assert!( + contention.iter().any(|sample| *sample >= 0.04), + "lock timer must include the holder wait: {contention:?}" + ); + } +} diff --git a/crates/buzz-db/src/push.rs b/crates/buzz-db/src/push.rs index 0b3245ffcc..3aa6cd9b3f 100644 --- a/crates/buzz-db/src/push.rs +++ b/crates/buzz-db/src/push.rs @@ -25,10 +25,13 @@ async fn acquire_push_gate_lock( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, community: CommunityId, ) -> Result<()> { - sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") - .bind(format!("{PUSH_GATE_LOCK_NAMESPACE}{}", community.as_uuid())) - .execute(&mut **tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") + .bind(format!("{PUSH_GATE_LOCK_NAMESPACE}{}", community.as_uuid())) + .execute(&mut **tx), + ) + .await?; Ok(()) } @@ -220,7 +223,13 @@ pub async fn accept_lease_event( max_active_leases: i64, ) -> Result { let author = event.pubkey.as_bytes(); - let mut tx = pool.begin().await?; + let (mut tx, transaction_timer) = crate::observability::begin_transaction( + pool, + crate::observability::TransactionOperation::AcceptPushLeaseEvent, + ) + .await?; + transaction_timer + .observe(async { let mut address_lock = Vec::with_capacity(16 + author.len() + installation_id.len()); address_lock.extend_from_slice(community.as_uuid().as_bytes()); address_lock.extend_from_slice(author); @@ -230,14 +239,20 @@ pub async fn accept_lease_event( author_lock.extend_from_slice(community.as_uuid().as_bytes()); author_lock.extend_from_slice(author); let author_lock = i64::from_le_bytes(Sha256::digest(&author_lock)[..8].try_into().unwrap()); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(address_lock) - .execute(&mut *tx) - .await?; - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(author_lock) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(address_lock) + .execute(&mut *tx), + ) + .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::PushGate, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(author_lock) + .execute(&mut *tx), + ) + .await?; // T1b: an activation can flip the community from "no eligible lease" to // "eligible", so it must serialize against the trigger's shared gate lock. // Acquired after the address/author locks to keep one global lock order. @@ -392,6 +407,8 @@ pub async fn accept_lease_event( } tx.commit().await?; Ok(AcceptLeaseOutcome::Accepted) + }) + .await } fn constraint_acceptance_outcome(error: &sqlx::Error) -> Option { @@ -1271,7 +1288,7 @@ mod tests { async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); diff --git a/crates/buzz-db/src/relay_members.rs b/crates/buzz-db/src/relay_members.rs index 402229cdec..3cb86e8a43 100644 --- a/crates/buzz-db/src/relay_members.rs +++ b/crates/buzz-db/src/relay_members.rs @@ -473,10 +473,13 @@ pub async fn transfer_ownership( // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(owner_count_advisory_lock_key(&pubkey)) - .execute(&mut *tx) - .await?; + crate::observability::observe_advisory_lock( + crate::observability::LockType::Membership, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(owner_count_advisory_lock_key(&pubkey)) + .execute(&mut *tx), + ) + .await?; // 2. Lock the current owner row FOR UPDATE and verify the expected owner. // FOR UPDATE prevents the stale-owner race: a concurrent transfer that @@ -637,7 +640,7 @@ mod tests { use super::*; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") diff --git a/crates/buzz-db/src/replaceable.rs b/crates/buzz-db/src/replaceable.rs index aa57a51666..4904dfe209 100644 --- a/crates/buzz-db/src/replaceable.rs +++ b/crates/buzz-db/src/replaceable.rs @@ -6,6 +6,7 @@ use chrono::{DateTime, Utc}; use sqlx::{Acquire, Postgres, Transaction}; use uuid::Uuid; +use crate::observability::{self, LockType, TransactionOperation}; use crate::{Db, DbError, Result}; /// Result category for a parameterized-replaceable event write. @@ -122,10 +123,13 @@ async fn replace_parameterized_event_in_transaction_impl( pubkey_bytes.as_slice(), Some(d_tag.as_bytes()), ); - sqlx::query("SELECT pg_advisory_xact_lock($1)") - .bind(lock_key) - .execute(&mut **tx) - .await?; + observability::observe_advisory_lock( + LockType::Replacement, + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut **tx), + ) + .await?; let d_tag_count = event .tags @@ -395,23 +399,31 @@ impl Db { d_tag: &str, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let mut tx = self.pool.begin().await?; - let result = self - .replace_parameterized_event_in_transaction( - &mut tx, - community_id, - event, - d_tag, - channel_id, - ParameterizedReplacePrecondition::Unconditional, - ) - .await?; - let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; - if was_inserted { - tx.commit().await?; - } else { - tx.rollback().await?; - } - Ok((result.event, was_inserted)) + let (mut tx, transaction_timer) = observability::begin_transaction( + &self.pool, + TransactionOperation::ReplaceParameterizedEvent, + ) + .await?; + transaction_timer + .observe(async { + let result = self + .replace_parameterized_event_in_transaction( + &mut tx, + community_id, + event, + d_tag, + channel_id, + ParameterizedReplacePrecondition::Unconditional, + ) + .await?; + let was_inserted = result.status == ParameterizedReplaceStatus::Inserted; + if was_inserted { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok((result.event, was_inserted)) + }) + .await } } diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs new file mode 100644 index 0000000000..724a9b47ed --- /dev/null +++ b/crates/buzz-db/tests/observability_source.rs @@ -0,0 +1,40 @@ +#[test] +fn database_metrics_and_slow_logs_exclude_sensitive_or_unbounded_fields() { + let implementation = include_str!("../src/observability.rs"); + let datastore_macro = include_str!("../../buzz-datastore-tracing/src/lib.rs"); + let instrumentation = format!("{implementation}\n{datastore_macro}"); + + for forbidden in [ + "\"community\" =>", + "\"event_id\" =>", + "\"event_kind\" =>", + "\"kind\" =>", + "\"sql\" =>", + "\"query\" =>", + "\"query_id\" =>", + "\"d_tag\" =>", + "\"coordinate\" =>", + "community =", + "event_id =", + "event_kind =", + "sql =", + "query_id =", + "d_tag =", + "coordinate =", + ] { + assert!( + !instrumentation.contains(forbidden), + "database instrumentation must not expose {forbidden}" + ); + } + + assert!(datastore_macro.contains("name: LitStr")); + assert!(datastore_macro.contains("\"operation\" => #name")); + assert!(datastore_macro.contains("elapsed_ms =")); + assert!( + datastore_macro.contains("parent: None"), + "slow warnings must not inherit dynamic datastore span fields" + ); + // The runtime tracing-layer assertion covers field names because a source + // search would also match ordinary local variables such as `record_error`. +} diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e28c5b6840..6c6b9ada22 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -14,6 +14,7 @@ sqlx = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +metrics = { workspace = true } [dev-dependencies] tokio = { workspace = true } From a526dca9bcaa08dfb5db77999cc1f584a17a9d64 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Tue, 25 Aug 2026 11:34:16 -0700 Subject: [PATCH 13/24] feat: navigate images across message threads (#6705) ## Summary - navigate across image occurrences that are currently rendered inside the nearest explicit open-thread DOM scope - preserve rendered DOM order, duplicate occurrence identity, hidden-spoiler exclusion, preview-image entry parity, and the exact thumbnail return target - keep adjacent timeline messages and unrelated surfaces as separate galleries - make Copy and Download follow the current gallery item, including when a link-preview image opens the gallery first and navigation reaches Markdown media ## Scope contract This is intentionally a **rendered-media gallery only**. Gallery membership comes from mounted image triggers under the nearest explicit thread scope. Collapsed or unloaded descendants are excluded until the thread UI renders them. There is no canonical thread fetch, descendant projection, depth override, or synchronous message reparse in this change. `ChannelPane.tsx` remains unchanged from `main`; `useSearchHighlightProps` and both timeline/thread prop spreads are preserved. ## Verification - rebased onto `origin/main` at `113a33b7e49b7173ee1767c49ef2f49c63803034` - `git diff --check` - differential file-size check - full Desktop check (Biome plus text/pubkey policy checks; only pre-existing informational diagnostics) - Desktop TypeScript typecheck - Desktop production build and rebuilt E2E build - full Desktop JS units: `5,476/5,476` passed - full Desktop Tauri workspace tests passed (one documented native performance test ignored) - rebuilt image gallery Playwright spec: `14/14` passed - search-highlight smoke cases: `5/5` passed - reduced diff: 6 files, `+417/-41`; production `+78/-41`, E2E `+339/-0` The four added E2E cases cover cross-message thread navigation and return targeting, rendered-only exclusion of collapsed descendants, preview-first current-item actions, and adjacent-message isolation. --------- Signed-off-by: Kalvin Chau Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .../messages/ui/MessageThreadPanel.tsx | 4 + desktop/src/shared/ui/markdown.tsx | 62 ++-- .../shared/ui/markdown/ImageGalleryStatus.tsx | 31 ++ .../ui/markdown/LinkPreviewImageLightbox.tsx | 10 +- .../src/shared/ui/markdown/imageLightbox.ts | 12 +- .../e2e/image-attachment-gallery.spec.ts | 339 ++++++++++++++++++ 6 files changed, 417 insertions(+), 41 deletions(-) create mode 100644 desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index d3b1394829..8797bec7a0 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -510,8 +510,12 @@ export function MessageThreadPanel({ tabIndex={-1} ref={threadBodyRef} > + {/* The gallery is intentionally DOM-scoped: only media currently rendered + in this open thread participates. Collapsed or unloaded descendants + join only after the thread UI renders them. */}
{ + toast.success("Copied to clipboard"); + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Copy failed"; + toast.error(msg); + }); +} + +function downloadImage(src: string | undefined) { + if (!src) return; + invokeTauri("download_image", { url: src }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Download failed"; + toast.error(msg); + }); +} + function ImageZoomOverlay({ alt, galleryIndex = 0, galleryItems, - onCopy, - onDownload, onClose, resolvedSrc, sourceBox, @@ -161,8 +180,6 @@ function ImageZoomOverlay({ alt: string | undefined; galleryIndex?: number; galleryItems?: ImageGalleryItem[]; - onCopy: (src: string | undefined) => void; - onDownload: (src: string | undefined) => void; onClose: () => void; resolvedSrc: string; sourceBox: ImageLightboxBox; @@ -718,13 +735,13 @@ function ImageZoomOverlay({ const handleMenuCopy = React.useCallback(() => { setMenu(null); markControlGesture(); - onCopy(currentItem.src); - }, [currentItem.src, markControlGesture, onCopy]); + copyImageToClipboard(currentItem.src); + }, [currentItem.src, markControlGesture]); const handleMenuDownload = React.useCallback(() => { setMenu(null); markControlGesture(); - onDownload(currentItem.src); - }, [currentItem.src, markControlGesture, onDownload]); + downloadImage(currentItem.src); + }, [currentItem.src, markControlGesture]); return createPortal(
{ event.stopPropagation(); - onDownload(currentItem.src); + downloadImage(currentItem.src); }} > @@ -952,6 +969,7 @@ function ImageZoomOverlay({ updateZoom={updateZoom} zoom={zoom} /> +
{menu && canActOnCurrentImage ? ( @@ -1000,7 +1018,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const triggerRef = React.useRef(null); useSmoothCorners(inlineImageRef); useSmoothCorners(thumbnailImageRef); - const [spoilerMediaSize, setSpoilerMediaSize] = React.useState<{ height: number; src: string; @@ -1078,7 +1095,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { return () => observer.disconnect(); }, []); - const closeMenu = React.useCallback(() => setMenu(null), []); useDismissMediaContextMenu(Boolean(menu), closeMenu); @@ -1089,7 +1105,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { e.nativeEvent.stopImmediatePropagation(); setMenu({ x: e.clientX, y: e.clientY }); }; - const openLightbox = React.useCallback( (image: HTMLImageElement) => { if (!resolvedSrc || isInsideHiddenSpoiler(image)) { @@ -1113,6 +1128,7 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { { alt, dim, + trigger: triggerRef.current, resolvedSrc, src, thumbnailBox: sourceBox, @@ -1140,27 +1156,13 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { const handleCopyImage = React.useCallback((copySrc: string | undefined) => { setMenu(null); - if (!copySrc) return; - invokeTauri("copy_image_to_clipboard", { url: copySrc }) - .then(() => { - toast.success("Copied to clipboard"); - }) - .catch((err: unknown) => { - const msg = err instanceof Error ? err.message : "Copy failed"; - toast.error(msg); - }); + copyImageToClipboard(copySrc); }, []); const handleDownload = React.useCallback( (downloadSrc: string | undefined) => { setMenu(null); - if (!downloadSrc) return; - invokeTauri("download_image", { url: downloadSrc }).catch( - (err: unknown) => { - const msg = err instanceof Error ? err.message : "Download failed"; - toast.error(msg); - }, - ); + downloadImage(downloadSrc); }, [], ); @@ -1215,8 +1217,6 @@ function ImageBlock({ alt, dim, resolvedSrc, src, thumbSrc }: ImageBlockProps) { alt={alt} galleryIndex={lightboxState.galleryIndex} galleryItems={lightboxState.galleryItems} - onCopy={handleCopyImage} - onDownload={handleDownload} onClose={() => setLightboxState(null)} resolvedSrc={resolvedSrc} sourceBox={lightboxState.sourceBox} diff --git a/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx new file mode 100644 index 0000000000..66e7b68b8b --- /dev/null +++ b/desktop/src/shared/ui/markdown/ImageGalleryStatus.tsx @@ -0,0 +1,31 @@ +type ImageGalleryStatusProps = { + currentIndex: number; + itemCount: number; +}; + +export function ImageGalleryStatus({ + currentIndex, + itemCount, +}: ImageGalleryStatusProps) { + if (itemCount <= 1) { + return null; + } + + const position = currentIndex + 1; + return ( + <> + Promise; placeholder?: string; profiles?: UserProfileLookup; + /** Explicit mention pubkeys from the loaded channel window, newest first. */ + recentMentionPubkeys?: readonly string[]; replyTarget?: { author: string; body: string; diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index d4e2284b85..5d5a1f5876 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -26,6 +26,7 @@ const ignoreAddressRemoval = () => {}; export const MessageComposerToolbar = React.memo( function MessageComposerToolbar({ addressedAgents = NO_ADDRESSED_AGENTS, + autoPinConfirmationTitle, composerDisabled, editor, extraActions, @@ -35,6 +36,8 @@ export const MessageComposerToolbar = React.memo( isSending, isUploading, onCaptureSelection, + onAutoPinConfirmationDismiss, + onAutoPinConfirmationTurnOff, onEmojiPickerOpenChange, onEmojiSelect, onFormattingToggle, @@ -47,6 +50,7 @@ export const MessageComposerToolbar = React.memo( shakeVersionByPubkey, }: { addressedAgents?: readonly ComposerAddressAgent[]; + autoPinConfirmationTitle?: string | null; composerDisabled: boolean; editor: Editor | null; extraActions?: React.ReactNode; @@ -56,6 +60,8 @@ export const MessageComposerToolbar = React.memo( isSending: boolean; isUploading: boolean; onCaptureSelection: () => void; + onAutoPinConfirmationDismiss?: () => void; + onAutoPinConfirmationTurnOff?: () => void; onEmojiPickerOpenChange: (open: boolean) => void; onEmojiSelect: (emoji: string) => void; onFormattingToggle: (pressed: boolean) => void; @@ -175,7 +181,10 @@ export const MessageComposerToolbar = React.memo( > import("./DiffMessage")); @@ -280,10 +280,12 @@ export const MessageRow = React.memo( return Object.keys(values).length > 0 ? values : undefined; }, [isKnownAgentPubkey, mentionPubkeysByName]); const addressedAgentPubkeys = React.useMemo(() => { - return getAgentAddressMentionPubkeys(message.tags).filter( - isKnownAgentPubkey, + return getVisibleAgentAddressPubkeys( + message.body, + getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey), + mentionPubkeysByName, ); - }, [isKnownAgentPubkey, message.tags]); + }, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]); const agentAddressPrefix = addressedAgentPubkeys.length > 0 ? ( Promise; profiles?: UserProfileLookup; + recentMentionPubkeys?: readonly string[]; replyTargetMessage: TimelineMessage | null; scrollTargetId: string | null; threadHead: TimelineMessage | null; @@ -184,6 +185,7 @@ export function MessageThreadPanel({ onToggleReaction, onUnfollowThread, profiles, + recentMentionPubkeys, replyTargetMessage, scrollTargetId, scrollTargetHighlights = true, @@ -855,6 +857,7 @@ export function MessageThreadPanel({ : `Reply in thread to ${threadHead.author}` } profiles={profiles} + recentMentionPubkeys={recentMentionPubkeys} replyTarget={composerReplyTarget} typingParentEventId={threadHead.id} typingRootEventId={threadHead.rootId} diff --git a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs index e98763a4a5..b95528dd93 100644 --- a/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs +++ b/desktop/src/features/messages/ui/composerAgentKeyboard.test.mjs @@ -44,13 +44,13 @@ test("agent picker preference skips people", async () => { assert.equal(view.result.current.mentionSelectedIndex, 1); }); -test("primary+Shift+Enter opens the picker or toggles in place", async () => { +test("primary+Shift+M addresses the default agent or toggles the tray selection", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAlwaysAddressShortcut } = await import( "./useAlwaysAddressShortcut.ts" ); const { isMacPlatform } = await import("@/shared/lib/platform"); - const opened = []; + const selected = []; const toggled = []; const suggestion = { displayName: "Agent Ada", @@ -59,8 +59,9 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { }; const createEvent = () => ({ altKey: false, + code: "KeyM", ctrlKey: !isMacPlatform(), - key: "Enter", + key: "M", metaKey: isMacPlatform(), preventDefault() {}, repeat: false, @@ -71,24 +72,123 @@ test("primary+Shift+Enter opens the picker or toggles in place", async () => { useAlwaysAddressShortcut({ enabled: true, mentions: { + getDefaultAgentSuggestion: () => suggestion, isMentionOpen, mentionSelectedIndex: 0, suggestions: [suggestion], }, - onOpenPicker: (insertTrigger) => opened.push(insertTrigger), + onOpenPicker: () => {}, + onSelect: (value) => selected.push(value), onToggle: (value) => toggled.push(value), }), { initialProps: { isMentionOpen: false } }, ); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(opened, [false]); - assert.deepEqual(toggled, []); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, []); view.rerender({ isMentionOpen: true }); act(() => assert.equal(view.result.current(createEvent()), true)); assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion]); act(() => assert.equal(view.result.current(createEvent()), true)); - assert.deepEqual(toggled, [suggestion, suggestion]); + assert.deepEqual(toggled, [suggestion]); + assert.deepEqual(selected, [suggestion, suggestion]); +}); + +test("primary+Shift+M removes the current locked agent before choosing a new default", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + const lockedAgent = { + avatarUrl: null, + displayName: "Agent Ada", + pubkey: "agent-a", + }; + const defaultAgent = { + displayName: "Agent Bea", + isAgent: true, + pubkey: "agent-b", + }; + const toggled = []; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + lockedAgent, + mentions: { + getDefaultAgentSuggestion: () => defaultAgent, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => {}, + onSelect: () => {}, + onToggle: (value) => toggled.push(value), + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.deepEqual(toggled, [{ ...lockedAgent, isAgent: true }]); +}); + +test("primary+Shift+M opens the picker when no default agent is ready", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAlwaysAddressShortcut } = await import( + "./useAlwaysAddressShortcut.ts" + ); + const { isMacPlatform } = await import("@/shared/lib/platform"); + let opened = 0; + const { result } = renderHook(() => + useAlwaysAddressShortcut({ + enabled: true, + mentions: { + getDefaultAgentSuggestion: () => null, + isMentionOpen: false, + mentionSelectedIndex: 0, + suggestions: [], + }, + onOpenPicker: () => { + opened += 1; + }, + onSelect: () => {}, + onToggle: () => {}, + }), + ); + + act(() => + assert.equal( + result.current({ + altKey: false, + code: "KeyM", + ctrlKey: !isMacPlatform(), + key: "m", + metaKey: isMacPlatform(), + preventDefault() {}, + repeat: false, + shiftKey: true, + }), + true, + ), + ); + + assert.equal(opened, 1); }); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 4acd89cf0a..95e0af9e06 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -23,7 +23,7 @@ afterEach(async () => { after(() => dom.window.close()); -test("always addressing an agent keeps autocomplete open, adds the lock, and pulses", async () => { +test("always addressing an agent keeps autocomplete open, inserts the chip, adds the lock, and pulses", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -32,7 +32,7 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul const addedPubkeys = []; const pulsedPubkeys = []; let cancelCount = 0; - const text = "Ask @Agent Ada later @"; + const text = "@"; const mentions = { cancelMentionAutocomplete: () => { cancelCount += 1; @@ -45,6 +45,9 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }, ], getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: true, + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -73,7 +76,14 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: "@Agent Ada ", + preserveSelection: true, + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); @@ -83,6 +93,49 @@ test("always addressing an agent keeps autocomplete open, adds the lock, and pul ); }); +test("always addressing a new agent delegates the first add for immediate confirmation", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const addressedSuggestions = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + isInlineMentionSelection: () => false, + isMentionOpen: false, + registerMentionPubkey: () => {}, + }, + onAddressAgentMention: (value) => addressedSuggestions.push(value), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText: { + getPlainTextAndCursor: () => ({ text: "@Agent Ada ", cursor: 11 }), + }, + }), + ); + + act(() => result.current.toggleAlwaysAddressAgent(suggestion)); + + assert.deepEqual(addressedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); +}); + test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( @@ -105,6 +158,7 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }, ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, mentionStartIndex: text.lastIndexOf("@"), }; const audience = { @@ -136,7 +190,13 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 4, + replaceToOffset: 15, + insertText: "", + }, + ]); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); @@ -158,10 +218,13 @@ test("selecting an already addressed agent from the explicit picker pulses its b cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("an already addressed agent must not be inserted"); - }, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), mentionStartIndex: 5, }; const audience = { @@ -190,16 +253,23 @@ test("selecting an already addressed agent from the explicit picker pulses its b }); }); - assert.deepEqual(appliedEdits, []); + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }, + ]); assert.deepEqual(addedPubkeys, []); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); }); -test("selecting an agent from a typed query leaves the inline mention for send", async () => { +test("selecting an agent from a typed query immediately auto-addresses it", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" ); + const autoPinnedSuggestions = []; const appliedEdits = []; const addedPubkeys = []; const pulsedPubkeys = []; @@ -207,6 +277,7 @@ test("selecting an agent from a typed query leaves the inline mention for send", cancelMentionAutocomplete: () => {}, getDraftMentionRefs: () => [], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => true, insertMention: () => ({ replaceFromOffset: 5, @@ -224,6 +295,206 @@ test("selecting an agent from a typed query leaves the inline mention for send", // makes the editor text/cursor insufficient to re-detect the typed query. getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience, + audienceScope: "channel-scope", + mentions, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), + richText, + }), + ); + + const suggestion = { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }; + act(() => result.current.selectMentionSuggestion(suggestion)); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 5, + replaceToOffset: 6, + insertText: "@Agent Ada ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, [suggestion]); + assert.deepEqual(addedPubkeys, []); + assert.deepEqual(pulsedPubkeys, []); + assert.equal(result.current.announcement, ""); +}); + +test("selecting a human mention never changes automatic addressing", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const autoPinnedSuggestions = []; + const appliedEdits = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: (edit) => appliedEdits.push(edit), + audience: { pubkeys: [], addPubkey: () => {} }, + audienceScope: "channel-scope", + mentions: { + getMentionDisplayName: () => "Alice", + insertMention: () => ({ + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }), + }, + onAutoPinAgentMention: (suggestion) => + autoPinnedSuggestions.push(suggestion), + onPulseAddressLock: () => {}, + richText: { + getPlainTextAndCursor: () => ({ text: "@Al", cursor: 3 }), + }, + }), + ); + + act(() => + result.current.selectMentionSuggestion({ + pubkey: "human-pubkey", + displayName: "Alice", + isAgent: false, + }), + ); + + assert.deepEqual(appliedEdits, [ + { + replaceFromOffset: 0, + replaceToOffset: 3, + insertText: "@Alice ", + }, + ]); + assert.deepEqual(autoPinnedSuggestions, []); +}); + +test("removing the last agent chip clears its automatic address", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const mentionRefsByText = { + "@Agent Ada first @Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "@Agent Ada second": [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], + "": [], + }; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["agent-pubkey", "existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => mentionRefsByText[text] ?? [], + getMentionDisplayName: () => "Agent Ada", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => result.current.trackMentionAddressedAgent("agent-pubkey")); + act(() => + result.current.syncAddressedAgentsFromText( + "@Agent Ada first @Agent Ada second", + ), + ); + act(() => result.current.syncAddressedAgentsFromText("@Agent Ada second")); + assert.deepEqual(removedPubkeys, []); + + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["agent-pubkey"]); +}); + +test("removing human mentions is ignored while removing a restored agent chip clears its lock", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const removedPubkeys = []; + const { result } = renderHook(() => + useAgentAddressLockPicker({ + applyAutocompleteEdit: () => {}, + audience: { + pubkeys: ["existing-lock"], + removePubkey: (pubkey) => removedPubkeys.push(pubkey), + }, + audienceScope: "channel-scope", + mentions: { + getDraftMentionRefs: (text) => { + if (text === "@Alice @Existing Agent") { + return [ + { displayName: "Alice", pubkey: "human-pubkey", isAgent: false }, + { + displayName: "Existing Agent", + pubkey: "existing-lock", + isAgent: true, + }, + ]; + } + return text + ? [{ displayName: "Alice", pubkey: "human-pubkey", isAgent: false }] + : []; + }, + getMentionDisplayName: () => "Existing Agent", + }, + onPulseAddressLock: () => {}, + richText: { getPlainTextAndCursor: () => ({ text: "", cursor: 0 }) }, + }), + ); + + act(() => + result.current.syncAddressedAgentsFromText("@Alice @Existing Agent"), + ); + act(() => result.current.syncAddressedAgentsFromText("@Alice")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); + act(() => result.current.syncAddressedAgentsFromText("")); + assert.deepEqual(removedPubkeys, ["existing-lock"]); +}); + +test("selecting an agent from the explicit picker auto-addresses it", async () => { + const { act, renderHook } = await import("@testing-library/react"); + const { useAgentAddressLockPicker } = await import( + "./useAgentAddressLockPicker.ts" + ); + const appliedEdits = []; + const addedPubkeys = []; + const pulsedPubkeys = []; + const mentions = { + cancelMentionAutocomplete: () => {}, + getDraftMentionRefs: () => [], + getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, + isInlineMentionSelection: () => false, + insertMention: () => ({ + replaceFromOffset: 5, + replaceToOffset: 5, + insertText: "@Agent Ada ", + }), + mentionStartIndex: 5, + }; + const audience = { + pubkeys: [], + addPubkey: (pubkey) => addedPubkeys.push(pubkey), + }; + const richText = { + getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), + }; const { result } = renderHook(() => useAgentAddressLockPicker({ applyAutocompleteEdit: (edit) => appliedEdits.push(edit), @@ -246,60 +517,10 @@ test("selecting an agent from a typed query leaves the inline mention for send", assert.deepEqual(appliedEdits, [ { replaceFromOffset: 5, - replaceToOffset: 6, + replaceToOffset: 5, insertText: "@Agent Ada ", }, ]); - assert.deepEqual(addedPubkeys, []); - assert.deepEqual(pulsedPubkeys, []); - assert.equal(result.current.announcement, ""); -}); - -test("selecting an agent from the explicit picker auto-addresses it", async () => { - const { act, renderHook } = await import("@testing-library/react"); - const { useAgentAddressLockPicker } = await import( - "./useAgentAddressLockPicker.ts" - ); - const appliedEdits = []; - const addedPubkeys = []; - const pulsedPubkeys = []; - const mentions = { - cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], - getMentionDisplayName: () => "Agent Ada", - isInlineMentionSelection: () => false, - insertMention: () => { - throw new Error("explicit picker selections must become addressing"); - }, - mentionStartIndex: 5, - }; - const audience = { - pubkeys: [], - addPubkey: (pubkey) => addedPubkeys.push(pubkey), - }; - const richText = { - getPlainTextAndCursor: () => ({ text: "ping ", cursor: 5 }), - }; - const { result } = renderHook(() => - useAgentAddressLockPicker({ - applyAutocompleteEdit: (edit) => appliedEdits.push(edit), - audience, - audienceScope: "channel-scope", - mentions, - onPulseAddressLock: (pubkey) => pulsedPubkeys.push(pubkey), - richText, - }), - ); - - act(() => { - result.current.selectMentionSuggestion({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); - }); - - assert.deepEqual(appliedEdits, []); assert.deepEqual(addedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, ["agent-pubkey"]); assert.equal( @@ -319,8 +540,11 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn const pulsedPubkeys = []; const mentions = { cancelMentionAutocomplete: () => {}, - getDraftMentionRefs: () => [], + getDraftMentionRefs: () => [ + { displayName: "Agent Ada", pubkey: "agent-pubkey", isAgent: true }, + ], getMentionDisplayName: () => "Agent Ada", + registerMentionPubkey: () => {}, isInlineMentionSelection: () => false, insertMention: () => ({ replaceFromOffset: 0, @@ -330,7 +554,10 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn mentionStartIndex: 0, }; const richText = { - getPlainTextAndCursor: () => ({ text: "", cursor: 0 }), + getPlainTextAndCursor: () => ({ + text: "@Agent Ada keep this authored text", + cursor: 35, + }), }; const { result, rerender } = renderHook( ({ pubkeys }) => @@ -350,6 +577,7 @@ test("selecting an explicitly unpinned agent inserts a mention until send", asyn ); act(() => result.current.removeAddressedAgent("AGENT-PUBKEY")); + assert.deepEqual(appliedEdits, []); rerender({ pubkeys: [] }); act(() => { result.current.selectMentionSuggestion({ diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 8d5a87b9b8..17883900ec 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -16,8 +16,7 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; function buildMentionRemovalEdits( text: string, displayNames: readonly string[], - queryStart: number, - cursor: number, + queryRange?: { start: number; end: number }, ): AutocompleteEdit[] { const ranges = displayNames.flatMap((displayName) => getMentionOffsets(text, displayName).map((start) => { @@ -26,10 +25,12 @@ function buildMentionRemovalEdits( return { start, end }; }), ); - ranges.push({ - start: Math.max(0, Math.min(queryStart, text.length)), - end: Math.max(0, Math.min(cursor, text.length)), - }); + if (queryRange) { + ranges.push({ + start: Math.max(0, Math.min(queryRange.start, text.length)), + end: Math.max(0, Math.min(queryRange.end, text.length)), + }); + } const merged = ranges .filter(({ start, end }) => start < end) @@ -56,6 +57,8 @@ export function useAgentAddressLockPicker({ audience, audienceScope, mentions, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, profiles, richText, @@ -64,6 +67,8 @@ export function useAgentAddressLockPicker({ audience: ReturnType; audienceScope: string | null; mentions: UseMentionsResult; + onAddressAgentMention?: (suggestion: MentionSuggestion) => void; + onAutoPinAgentMention?: (suggestion: MentionSuggestion) => void; onPulseAddressLock: (pubkey: string) => void; profiles?: UserProfileLookup; richText: UseRichTextEditorResult; @@ -79,6 +84,12 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.clear(); } const lockedAgentNamesRef = React.useRef(new Map()); + const visibleAgentMentionPubkeysRef = React.useRef(new Set()); + const mentionSyncScopeRef = React.useRef(audienceScope); + if (mentionSyncScopeRef.current !== audienceScope) { + mentionSyncScopeRef.current = audienceScope; + visibleAgentMentionPubkeysRef.current.clear(); + } const [announcement, setAnnouncement] = React.useState(""); const lockedAgents = React.useMemo( () => @@ -104,41 +115,42 @@ export function useAgentAddressLockPicker({ }), [audience.pubkeys, mentions.getMentionDisplayName, profiles], ); - const consumeAddressSuggestion = React.useCallback( - ( - suggestion: MentionSuggestion, - { removeInlineMentions }: { removeInlineMentions: boolean }, - ): string | null => { - const pubkey = normalizePubkey(suggestion.pubkey ?? ""); - if (!audienceScope || !pubkey || !suggestion.isAgent) return null; - - const { text, cursor } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = removeInlineMentions - ? mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === pubkey) - .map((ref) => ref.displayName) - : []; - mentions.cancelMentionAutocomplete(); - for (const edit of buildMentionRemovalEdits( - text, - matchingDisplayNames, - mentions.mentionStartIndex, - cursor, - )) { - applyAutocompleteEdit(edit); + const trackMentionAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (audienceScope && normalized) { + visibleAgentMentionPubkeysRef.current.add(normalized); } - return pubkey; + }, + [audienceScope], + ); + const syncAddressedAgentsFromText = React.useCallback( + (text: string) => { + if (!audienceScope) return; + const presentAgentPubkeys = new Set( + mentions + .getDraftMentionRefs(text) + .filter((ref) => ref.isAgent) + .map((ref) => normalizePubkey(ref.pubkey)), + ); + for (const pubkey of visibleAgentMentionPubkeysRef.current) { + if ( + !presentAgentPubkeys.has(pubkey) && + lockedAgentPubkeys.has(pubkey) + ) { + audience.removePubkey(pubkey); + } + } + visibleAgentMentionPubkeysRef.current = presentAgentPubkeys; }, [ - applyAutocompleteEdit, + audience.removePubkey, audienceScope, - mentions.cancelMentionAutocomplete, + lockedAgentPubkeys, mentions.getDraftMentionRefs, - mentions.mentionStartIndex, - richText.getPlainTextAndCursor, ], ); + const removeAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); @@ -148,24 +160,63 @@ export function useAgentAddressLockPicker({ }, [audience.removePubkey, audienceScope], ); + const removeAddressedAgentMentions = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + const { text } = richText.getPlainTextAndCursor(); + const matchingDisplayNames = mentions + .getDraftMentionRefs(text) + .filter((ref) => normalizePubkey(ref.pubkey) === normalized) + .map((ref) => ref.displayName); + for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { + applyAutocompleteEdit(edit); + } + removeAddressedAgent(normalized); + }, + [ + applyAutocompleteEdit, + audienceScope, + mentions.getDraftMentionRefs, + removeAddressedAgent, + richText.getPlainTextAndCursor, + ], + ); const toggleAlwaysAddressAgent = React.useCallback( (suggestion: MentionSuggestion) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgent(pubkey); + removeAddressedAgentMentions(pubkey); setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); } else { unpinnedAgentPubkeysRef.current.delete(pubkey); - audience.addPubkey(pubkey); - onPulseAddressLock(pubkey); + mentions.registerMentionPubkey(suggestion.displayName, pubkey, { + isAgent: true, + }); + const { text } = richText.getPlainTextAndCursor(); + if (getMentionOffsets(text, suggestion.displayName).length === 0) { + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: `@${suggestion.displayName} `, + preserveSelection: true, + }); + } + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); } - if (mentions.isMentionOpen) { + if (mentions.isMentionOpen && mentions.isInlineMentionSelection()) { const { text, cursor } = richText.getPlainTextAndCursor(); const activeMention = detectPrefixQuery("@", text, cursor, [ suggestion.displayName.toLowerCase(), @@ -190,12 +241,16 @@ export function useAgentAddressLockPicker({ audience.addPubkey, audienceScope, lockedAgentPubkeys, + mentions.isInlineMentionSelection, mentions.isMentionOpen, mentions.mentionStartIndex, mentions.openMentionPicker, + mentions.registerMentionPubkey, + onAddressAgentMention, onPulseAddressLock, - removeAddressedAgent, + removeAddressedAgentMentions, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, ], ); @@ -209,15 +264,25 @@ export function useAgentAddressLockPicker({ unpinnedAgentPubkeysRef.current.has(pubkey); if (mentions.isInlineMentionSelection() || wasUnpinned) { applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); + if (wasUnpinned) unpinnedAgentPubkeysRef.current.delete(pubkey); + trackMentionAddressedAgent(pubkey); + onAutoPinAgentMention?.(suggestion); return; } - consumeAddressSuggestion(suggestion, { removeInlineMentions: false }); + applyAutocompleteEdit(mentions.insertMention(suggestion, cursor)); if (!lockedAgentPubkeys.has(pubkey)) { - audience.addPubkey(pubkey); + trackMentionAddressedAgent(pubkey); + if (onAddressAgentMention) { + onAddressAgentMention(suggestion); + } else { + audience.addPubkey(pubkey); + onPulseAddressLock(pubkey); + } setAnnouncement(`Automatically mentioning ${suggestion.displayName}`); + } else { + onPulseAddressLock(pubkey); } - onPulseAddressLock(pubkey); return; } @@ -228,12 +293,84 @@ export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience.addPubkey, audienceScope, - consumeAddressSuggestion, lockedAgentPubkeys, mentions.isInlineMentionSelection, mentions.insertMention, + onAddressAgentMention, + onAutoPinAgentMention, onPulseAddressLock, richText.getPlainTextAndCursor, + trackMentionAddressedAgent, + ], + ); + + const restoreAddressedAgentMentions = React.useCallback( + ( + pubkeys?: readonly string[], + allowedUnpinnedPubkeys: readonly string[] = [], + ) => { + const restorePubkeys = pubkeys + ? new Set(pubkeys.map(normalizePubkey)) + : null; + const allowedUnpinned = new Set( + allowedUnpinnedPubkeys.map(normalizePubkey), + ); + const currentAudiencePubkeys = new Set( + audience.pubkeys.map(normalizePubkey), + ); + const targetAgents = [...(restorePubkeys ?? currentAudiencePubkeys)] + .filter( + (pubkey) => + currentAudiencePubkeys.has(pubkey) || allowedUnpinned.has(pubkey), + ) + .map((pubkey) => { + const profile = profiles?.[pubkey]; + const displayName = + profile?.displayName?.trim() || + profile?.name?.trim() || + profile?.nip05Handle?.trim() || + mentions.getMentionDisplayName(pubkey)?.trim() || + lockedAgentNamesRef.current.get(pubkey) || + truncatePubkey(pubkey); + return { pubkey, displayName }; + }); + const { text } = richText.getPlainTextAndCursor(); + for (const agent of targetAgents) { + if (getMentionOffsets(text, agent.displayName).length > 0) { + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + } + const missingAgents = targetAgents.filter( + (agent) => + (!unpinnedAgentPubkeysRef.current.has(agent.pubkey) || + allowedUnpinned.has(agent.pubkey)) && + getMentionOffsets(text, agent.displayName).length === 0, + ); + if (missingAgents.length === 0) return text; + for (const agent of missingAgents) { + mentions.registerMentionPubkey(agent.displayName, agent.pubkey, { + isAgent: true, + }); + visibleAgentMentionPubkeysRef.current.add(agent.pubkey); + } + const insertedText = `${missingAgents + .map((agent) => `@${agent.displayName}`) + .join(" ")} `; + applyAutocompleteEdit({ + replaceFromOffset: 0, + replaceToOffset: 0, + insertText: insertedText, + preserveSelection: true, + }); + return `${insertedText}${text}`; + }, + [ + applyAutocompleteEdit, + audience.pubkeys, + mentions.getMentionDisplayName, + mentions.registerMentionPubkey, + profiles, + richText.getPlainTextAndCursor, ], ); @@ -242,7 +379,10 @@ export function useAgentAddressLockPicker({ lockedAgents, lockedAgentPubkeys, removeAddressedAgent, + restoreAddressedAgentMentions, selectMentionSuggestion, + syncAddressedAgentsFromText, toggleAlwaysAddressAgent, + trackMentionAddressedAgent, }; } diff --git a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts index 8fd253df22..ed92e7b3f0 100644 --- a/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts +++ b/desktop/src/features/messages/ui/useAlwaysAddressShortcut.ts @@ -6,21 +6,30 @@ import type { MentionSuggestion } from "./MentionAutocomplete"; export function useAlwaysAddressShortcut({ enabled, + lockedAgent, mentions, onOpenPicker, + onSelect, onToggle, }: { enabled: boolean; + lockedAgent?: Pick; mentions: UseMentionsResult; onOpenPicker: (insertTrigger?: boolean) => void; + onSelect: (suggestion: MentionSuggestion) => void; onToggle: (suggestion: MentionSuggestion) => void; }) { - const { isMentionOpen, mentionSelectedIndex, suggestions } = mentions; + const { + getDefaultAgentSuggestion, + isMentionOpen, + mentionSelectedIndex, + suggestions, + } = mentions; return React.useCallback( (event: React.KeyboardEvent): boolean => { if ( !enabled || - event.key !== "Enter" || + event.code !== "KeyM" || !hasPrimaryShortcutModifier(event) || event.altKey || !event.shiftKey @@ -30,21 +39,30 @@ export function useAlwaysAddressShortcut({ event.preventDefault(); if (event.repeat) return true; - if (!isMentionOpen) { - onOpenPicker(false); + const suggestion = isMentionOpen + ? suggestions[mentionSelectedIndex] + : lockedAgent + ? { ...lockedAgent, isAgent: true } + : getDefaultAgentSuggestion(); + if (!suggestion?.isAgent || !suggestion.pubkey) { + if (!isMentionOpen) onOpenPicker(false); return true; } - - const suggestion = suggestions[mentionSelectedIndex]; - if (!suggestion?.isAgent || !suggestion.pubkey) return true; - onToggle(suggestion); + if (isMentionOpen) { + onSelect(suggestion); + } else { + onToggle(suggestion); + } return true; }, [ enabled, + getDefaultAgentSuggestion, isMentionOpen, + lockedAgent, mentionSelectedIndex, onOpenPicker, + onSelect, onToggle, suggestions, ], diff --git a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts index 44b87c9f1d..d0557567fd 100644 --- a/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts +++ b/desktop/src/features/messages/ui/useAutoPinMentionedAgents.ts @@ -1,36 +1,62 @@ import * as React from "react"; -import { toast } from "sonner"; import { + getPersistentAgentAudienceRevision, promotePersistentAgentAudienceIfUnchanged, removePersistentAgentAudienceMembersIfUnchanged, } from "@/features/messages/lib/persistentAgentAudience"; import { normalizePubkey } from "@/shared/lib/pubkey"; +const CONFIRMATION_DURATION_MS = 4_000; + +type Confirmation = { + expectedRevision: number; + pubkeys: readonly string[]; + scope: string; + title: string; +}; + type Options = { audienceScope: string | null; enabled: boolean; getDisplayName: (pubkey: string) => string | null | undefined; - onOpenOptions: () => void; onPulse: (pubkey: string) => void; + onTurnOff: () => void; }; export function useAutoPinMentionedAgents({ audienceScope, enabled, getDisplayName, - onOpenOptions, onPulse, + onTurnOff, }: Options) { - return React.useCallback( + const [confirmation, setConfirmation] = React.useState( + null, + ); + + React.useEffect(() => { + if (!confirmation) return; + const timeout = window.setTimeout( + () => setConfirmation(null), + CONFIRMATION_DURATION_MS, + ); + return () => window.clearTimeout(timeout); + }, [confirmation]); + + const promoteAgents = React.useCallback( ({ - expectedRevision, + expectedRevision = audienceScope + ? getPersistentAgentAudienceRevision(audienceScope) + : 0, pubkeys, + requirePreference, }: { - expectedRevision: number; + expectedRevision?: number; pubkeys: readonly string[]; + requirePreference: boolean; }) => { - if (!audienceScope || !enabled) return; + if (!audienceScope || (requirePreference && !enabled)) return; const normalizedPubkeys = [ ...new Set(pubkeys.map(normalizePubkey)), ].filter(Boolean); @@ -52,23 +78,47 @@ export function useAutoPinMentionedAgents({ : promotedPubkeys.length === 1 ? "Agent will be mentioned automatically" : `${promotedPubkeys.length} agents will be mentioned automatically`; - toast.success(title, { - action: { - label: "Undo", - onClick: () => { - if ( - removePersistentAgentAudienceMembersIfUnchanged({ - expectedRevision: revision, - pubkeys: promotedPubkeys, - scope: audienceScope, - }) - ) { - onOpenOptions(); - } - }, - }, + setConfirmation({ + expectedRevision: revision, + pubkeys: promotedPubkeys, + scope: audienceScope, + title, }); }, - [audienceScope, enabled, getDisplayName, onOpenOptions, onPulse], + [audienceScope, enabled, getDisplayName, onPulse], ); + const promoteMentionedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: true }), + [promoteAgents], + ); + const promoteExplicitlyAddressedAgents = React.useCallback( + (promotion: { expectedRevision?: number; pubkeys: readonly string[] }) => + promoteAgents({ ...promotion, requirePreference: false }), + [promoteAgents], + ); + + const dismissConfirmation = React.useCallback( + () => setConfirmation(null), + [], + ); + const turnOffConfirmation = React.useCallback(() => { + if (!confirmation) return; + setConfirmation(null); + removePersistentAgentAudienceMembersIfUnchanged({ + expectedRevision: confirmation.expectedRevision, + pubkeys: confirmation.pubkeys, + scope: confirmation.scope, + }); + onTurnOff(); + }, [confirmation, onTurnOff]); + + return { + confirmationTitle: + confirmation?.scope === audienceScope ? confirmation.title : null, + dismissConfirmation, + promoteExplicitlyAddressedAgents, + promoteMentionedAgents, + turnOffConfirmation, + }; } diff --git a/desktop/src/features/messages/ui/useComposerPasteHandler.ts b/desktop/src/features/messages/ui/useComposerPasteHandler.ts new file mode 100644 index 0000000000..8e56da071a --- /dev/null +++ b/desktop/src/features/messages/ui/useComposerPasteHandler.ts @@ -0,0 +1,73 @@ +import * as React from "react"; +import type { Editor } from "@tiptap/react"; +import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { + hasMentionClipboardHtml, + normalizeMentionClipboardHtml, +} from "@/features/messages/lib/normalizeMentionClipboard"; +import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; + +export function useComposerPasteHandler(options: { + editor: Editor | null; + scrollToBottom: () => void; + setPendingImeta: ( + update: (current: BlobDescriptor[]) => BlobDescriptor[], + ) => void; + uploadFile: (file: File) => Promise; +}) { + const uploadFileRef = React.useRef(options.uploadFile); + uploadFileRef.current = options.uploadFile; + React.useEffect(() => { + const editor = options.editor; + if (!editor) return; + editor.setOptions({ + editorProps: { + ...editor.options.editorProps, + handlePaste: (view, event) => { + const mediaItem = Array.from(event.clipboardData?.items ?? []).find( + (item) => item.kind === "file", + ); + if (mediaItem) { + const file = mediaItem.getAsFile(); + if (file) void uploadFileRef.current(file); + return true; + } + const codeBlockText = getBuzzCodeBlockClipboardText( + event.clipboardData, + ); + if (codeBlockText !== null) { + event.preventDefault(); + editor + .chain() + .focus() + .insertContent([ + { + type: "codeBlock", + content: + codeBlockText.length > 0 + ? [{ type: "text", text: codeBlockText }] + : [], + }, + { type: "paragraph" }, + ]) + .run(); + options.scrollToBottom(); + return true; + } + if (handleAgentSnapshotPaste(event, options.setPendingImeta)) + return true; + const html = event.clipboardData?.getData("text/html"); + if (html && hasMentionClipboardHtml(html)) { + event.preventDefault(); + view.pasteHTML(normalizeMentionClipboardHtml(html)); + return true; + } + if ((event.clipboardData?.getData("text/plain") ?? "").includes("\n")) + options.scrollToBottom(); + return false; + }, + }, + }); + }, [options.editor, options.scrollToBottom, options.setPendingImeta]); +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts index 8176bb13bb..ab7d8c2f4d 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.helpers.ts @@ -13,7 +13,6 @@ export { MENTION_REFERENCE_TAG }; export type PendingNonMemberMentionSend = { addressedAgentPubkeys: string[]; - audienceRevision: number; inlineAgentMentionPubkeys: string[]; capturedChannelId: string | null; capturedThreadContext: { @@ -38,7 +37,6 @@ export type PendingNonMemberMentionSend = { export type SendMessageWithMentionFlowInput = { addressedAgentPubkeys?: readonly string[]; - audienceRevision?: number; capturedChannelId: string | null; capturedThreadContext?: PendingNonMemberMentionSend["capturedThreadContext"]; pendingImeta: ImetaMedia[]; diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 601c3b4113..10ca033272 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -17,21 +17,14 @@ import { dmThreadAgentMentionError } from "@/features/messages/lib/dmThreadAgent import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -47,46 +40,8 @@ import { uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; import { buildAgentAddressMentionTags } from "@/features/messages/lib/agentAddressMention.mjs"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; - onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; - onInlineAgentMentionsSent?: (promotion: { - expectedRevision: number; - pubkeys: readonly string[]; - }) => void; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; + export function useMentionSendFlow({ channelId, channelLinks, @@ -98,8 +53,9 @@ export function useMentionSendFlow({ mentions, onPrepareSendChannel, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onSendRef, richText, setContent, @@ -406,6 +362,7 @@ export function useMentionSendFlow({ ); }; let composerCleared = false; + let optimisticComposerContent = ""; const restoreComposerAfterFailure = () => { if (!composerCleared) return; composerCleared = false; @@ -422,7 +379,7 @@ export function useMentionSendFlow({ } const canRestoreCurrentComposer = canAnimateCurrentComposer && - contentRef.current.trim().length === 0 && + contentRef.current.trim() === optimisticComposerContent.trim() && !hasUnsavedMedia(); if (!canRestoreCurrentComposer && draft.recoveryDraftKey) { saveQueuedAttachmentsForDraft( @@ -451,6 +408,12 @@ export function useMentionSendFlow({ onAddressedAgentsSendStarted?.(draft.addressedAgentPubkeys); } clearComposer(); + if (draft.addressedAgentPubkeys.length > 0) { + optimisticComposerContent = + onAddressedAgentsComposerCleared?.(draft.addressedAgentPubkeys) ?? + ""; + contentRef.current = optimisticComposerContent; + } composerCleared = true; } let uploadStarted = false; @@ -587,12 +550,23 @@ export function useMentionSendFlow({ const sentMentionPubkeys = new Set( revalidatedMentionPubkeys.map(normalizePubkey), ); - onInlineAgentMentionsSent?.({ - expectedRevision: draft.audienceRevision, - pubkeys: draft.inlineAgentMentionPubkeys.filter((pubkey) => - sentMentionPubkeys.has(normalizePubkey(pubkey)), - ), - }); + const newlyPinnedPubkeys = draft.inlineAgentMentionPubkeys.filter( + (pubkey) => sentMentionPubkeys.has(normalizePubkey(pubkey)), + ); + if ( + draft.capturedChannelId === channelIdRef.current || + channelIdRef.current === null + ) { + onAddressedAgentsSendSucceeded?.( + [ + ...new Set([ + ...draft.addressedAgentPubkeys, + ...newlyPinnedPubkeys, + ]), + ], + newlyPinnedPubkeys, + ); + } if (draft.sentDraftKey) { drafts.markDraftSent( draft.sentDraftKey, @@ -604,12 +578,18 @@ export function useMentionSendFlow({ } }; if (preparedUpload) { + let settleUpload!: () => void; + const uploadSettled = new Promise((resolve) => { + settleUpload = resolve; + }); uploadStarted = preparedUpload.start({ onComplete: async (uploaded, signal) => { try { await finishSend(uploaded, signal); } catch { restoreComposerAfterFailure(); + } finally { + settleUpload(); } }, onError: (error) => { @@ -617,14 +597,18 @@ export function useMentionSendFlow({ toast.error( `Upload failed: ${getErrorMessage(error, "Unknown error")}`, ); + settleUpload(); }, onCancel: () => { restoreComposerAfterFailure(); + settleUpload(); }, }); if (!uploadStarted) { + settleUpload(); return restoreComposerAfterFailure(); } + await uploadSettled; } if (!preparedUpload) { try { @@ -657,8 +641,9 @@ export function useMentionSendFlow({ mentions.isAgentPubkey, mentions.revalidateMentionPubkeys, onAddressedAgentsSendStarted, + onAddressedAgentsComposerCleared, onAddressedAgentsSendFailed, - onInlineAgentMentionsSent, + onAddressedAgentsSendSucceeded, onPrepareSendChannel, onSendRef, richText.setContent, @@ -674,7 +659,6 @@ export function useMentionSendFlow({ const sendMessageWithMentionFlow = React.useCallback( async ({ addressedAgentPubkeys = [], - audienceRevision = 0, capturedChannelId, capturedThreadContext = null, pendingImeta, @@ -783,7 +767,6 @@ export function useMentionSendFlow({ const savedMentionRefs = mentions.getDraftMentionRefs(trimmed); const pendingDraft: PendingNonMemberMentionSend = { addressedAgentPubkeys: uniqueNormalizedPubkeys(addressedAgentPubkeys), - audienceRevision, inlineAgentMentionPubkeys: uniqueNormalizedPubkeys( savedMentionRefs .filter((ref) => ref.isAgent) diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 0000000000..496a73a94a --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,52 @@ +import type * as React from "react"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import type { ChannelType } from "@/shared/api/types"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onAddressedAgentsSendStarted?: (pubkeys: readonly string[]) => void; + onAddressedAgentsComposerCleared?: (pubkeys: readonly string[]) => string; + onAddressedAgentsSendFailed?: (pubkeys: readonly string[]) => void; + onAddressedAgentsSendSucceeded?: ( + pubkeys: readonly string[], + newlyPinnedPubkeys: readonly string[], + ) => void; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + ) => Promise + >; + richText: Pick; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; +}; diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts index e8b388d0dc..d8e1550549 100644 --- a/desktop/src/shared/lib/keyboard-shortcuts.ts +++ b/desktop/src/shared/lib/keyboard-shortcuts.ts @@ -166,9 +166,9 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ { id: "always-address-agent", label: "Always address agent", - description: "Open the agent picker, or toggle the highlighted agent", - keys: "⇧⌘↵", - keysWindows: "Ctrl+Shift+Enter", + description: "Address the default agent, or select the highlighted agent", + keys: "⇧⌘M", + keysWindows: "Ctrl+Shift+M", category: "Messages", }, { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6726a22402..2136e2e12f 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -430,7 +430,7 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect .poll(() => readOutgoingMentionPubkeys(page, "local")) .toEqual([managedPubkey]); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@carl "); await page.getByTestId(`composer-address-lock-${managedPubkey}`).click(); await input.fill("@carl"); @@ -720,7 +720,7 @@ test("defers agent mentions until DM members finish loading", async ({ expect(commandCount(await readCommandLog(page), "add_channel_members")).toBe( commandCount(baselineCommands, "add_channel_members"), ); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@alice "); await expect(threadPanel).toContainText("before members resolve"); }); @@ -1290,7 +1290,8 @@ test("managed relay-profile agents with member roles use the agent address tray" await expect(dropdown.getByText("agent")).toBeVisible(); await input.press("Enter"); - await expect(input).toBeEmpty(); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); @@ -2642,8 +2643,8 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await expect(input.locator(".mention-chip")).toHaveCount(0); await input.press("Enter"); - await expect(input).toBeEmpty(); - await expect(input.locator(".mention-chip")).toHaveCount(0); + await expect(input).toHaveText("@charlie "); + await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeVisible(); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index be1dbc86c3..f9481c91f0 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "@playwright/test"; +import { expect, test, type Locator, type Page } from "@playwright/test"; import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; @@ -28,6 +28,9 @@ async function automaticallyMention( .getByTestId("mention-autocomplete") .getByRole("button", { name: `Automatically mention ${displayName}` }) .click(); + await expect(composer.getByTestId("message-input")).toContainText( + `@${displayName}`, + ); await composer.locator("[data-mention-picker-trigger]").click(); } @@ -54,7 +57,20 @@ function threadComposer(page: Page) { return page.getByTestId("thread-composer-overlay"); } -async function pressPrimaryShift(page: Page, key: "Enter" | "M") { +async function readComposerCaret(input: Locator) { + return input.evaluate((element) => { + const selection = window.getSelection(); + if (!selection?.anchorNode || !element.contains(selection.anchorNode)) { + return null; + } + const range = document.createRange(); + range.selectNodeContents(element); + range.setEnd(selection.anchorNode, selection.anchorOffset); + return range.toString().length; + }); +} + +async function pressPrimaryShift(page: Page, key: "M") { const isMac = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform), ); @@ -106,11 +122,38 @@ async function readOutgoingMentionPubkeys(page: Page, content: string) { }, content); } +async function emitMockMessage( + page: Page, + content: string, + mentionPubkeys: string[], +) { + await page.evaluate( + ({ body, mentions }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + mentionPubkeys: mentions, + }); + }, + { body: content, mentions: mentionPubkeys }, + ); +} + async function installAudienceFixtures( page: Page, options: { + deferredComposerUploads?: boolean; sendMessageDelayMs?: number; sendMessageErrors?: string[]; + uploadDelayMs?: number; + uploadDescriptors?: Array<{ + filename: string; + sha256: string; + size: number; + type: string; + uploaded: number; + url: string; + }>; usersBatchDelayMs?: number; } = {}, ) { @@ -133,6 +176,80 @@ async function installAudienceFixtures( }); } +test("keeps a queued-attachment send locked through upload and send settlement", async ({ + page, +}) => { + await installAudienceFixtures(page, { + deferredComposerUploads: true, + uploadDelayMs: 2_000, + sendMessageDelayMs: 2_000, + uploadDescriptors: [ + { + filename: "delayed-video.mp4", + sha256: "d".repeat(64), + size: 16, + type: "video/mp4", + uploaded: 1, + url: `https://mock.relay/media/${"d".repeat(64)}.mp4`, + }, + ], + }); + await openGeneral(page); + + const composer = channelComposer(page); + const composerForm = composer.getByTestId("message-composer"); + const input = composer.getByTestId("message-input"); + await input.fill("delayed upload"); + + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + composer.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("delayed video"), + mimeType: "video/mp4", + name: "delayed-video.mp4", + }); + + const sendAttempts = () => + page.evaluate( + () => + window.__BUZZ_E2E_COMMAND_LOG__?.filter( + (entry) => entry.command === "send_channel_message", + ).length ?? 0, + ); + const sendAttemptsBefore = await sendAttempts(); + await input.press("Enter"); + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + + // Observe the synchronous lock itself after upload has started. The exact + // premature-release mutation (`void uploadSettled; await Promise.resolve()`) + // has already cleared this attribute by this boundary, before any retryable + // downstream command or restored-audience timing can obscure the defect. + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect.poll(sendAttempts).toBe(sendAttemptsBefore + 1); + + // Command entry marks the independently delayed finishSend() window. Keep + // the synchronous lock held and fence repeated submits until it settles. + await expect(composer.getByTestId("composer-upload-progress")).toBeVisible(); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + await input.press("Enter"); + await input.press("Enter"); + await expect(composerForm).toHaveAttribute("data-submit-locked", "true"); + + await expect( + page + .getByTestId("message-row") + .filter({ hasText: "delayed upload" }) + .last(), + ).toBeVisible({ timeout: 5_000 }); + await expect(composerForm).toHaveAttribute("data-submit-locked", "false"); +}); + test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { @@ -154,9 +271,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab keeps a manually selected agent as an inline mention", async ({ - page, -}) => { +test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -169,12 +284,21 @@ test("Tab keeps a manually selected agent as an inline mention", async ({ await expect(input).toHaveText("@Morgarita "); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + const selectAllShortcut = await page.evaluate(() => + /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", + ); + await input.press(selectAllShortcut); + await input.press("Backspace"); + await expect(input).toHaveText(""); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); }); -test("primary+Shift+Enter opens the picker, then pins the highlighted agent", async ({ +test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { await installAudienceFixtures(page); @@ -183,23 +307,65 @@ test("primary+Shift+Enter opens the picker, then pins the highlighted agent", as const composer = channelComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - const menu = composer.getByTestId("mention-autocomplete"); - await expect(menu).toBeVisible(); + await expect(input).toHaveText("@alice draft text"); + await expect(input.locator(".agent-mention-highlight")).toHaveText("alice"); + await expect( + page.getByTestId("composer-auto-pin-confirmation"), + ).toContainText("alice will be mentioned automatically"); + await pressPrimaryShift(page, "M"); await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); - await input.fill("@Mor"); - await expect(menu.getByTestId(`mention-suggestion-${AGENT_A}`)).toHaveClass( + await input.fill("@Vog"); + const menu = composer.getByTestId("mention-autocomplete"); + await expect(menu.getByTestId(`mention-suggestion-${AGENT_B}`)).toHaveClass( /(?:^|\s)bg-accent(?:\s|$)/, ); - await pressPrimaryShift(page, "Enter"); + await pressPrimaryShift(page, "M"); - await expect(menu).toBeVisible(); - await expect(input).toHaveText(""); + await expect(menu).toHaveCount(0); + await expect(input).toHaveText("@Vogue "); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_B}`), + ).toBeVisible(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); + await expect( + composer + .getByTestId("composer-address-locks") + .getByRole("button", { name: /^Stop automatically mentioning / }), + ).toHaveCount(1); +}); + +test("primary+Shift+M favors the most recently mentioned eligible agent", async ({ + page, +}) => { + await installAudienceFixtures(page); + await openGeneral(page); + await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); + + const input = channelComposer(page).getByTestId("message-input"); + await input.fill("draft text"); + await input.press("ArrowLeft"); + await input.press("ArrowLeft"); + await expect.poll(() => readComposerCaret(input)).toBe(8); + await pressPrimaryShift(page, "M"); + + await expect(input).toHaveText("@Vogue draft text"); + await expect.poll(() => readComposerCaret(input)).toBe(15); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@Vogue draft text"); }); test("the mention button opens settings and can undo an address", async ({ @@ -215,11 +381,11 @@ test("the mention button opens settings and can undo an address", async ({ name: "Manage automatic agent mentions", }); - await input.fill("draft text"); + await input.type("draft text"); await ingress.click(); const menu = composer.getByTestId("mention-autocomplete"); await expect(menu).toBeVisible(); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await page.getByTestId("mention-options-trigger").click(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), @@ -235,7 +401,7 @@ test("the mention button opens settings and can undo an address", async ({ if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out"); await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2); await expect(menu).toHaveCount(0); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute( @@ -247,7 +413,7 @@ test("the mention button opens settings and can undo an address", async ({ ).toHaveCount(0); await ingress.click(); await expect(menu).toHaveCount(0); - await input.fill(""); + await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); @@ -260,10 +426,11 @@ test("the mention button opens settings and can undo an address", async ({ await menu .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) .click(); - await expect(input).toHaveText(""); + await expect(input).toHaveText("draft text"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); + await input.fill(""); await menu .getByRole("button", { name: "Mention Morgarita", exact: true }) @@ -271,11 +438,11 @@ test("the mention button opens settings and can undo an address", async ({ await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + ).toBeVisible(); await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText(""); + await expect(input).toHaveText("@Morgarita "); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); @@ -296,15 +463,41 @@ test("always-mentioned agents remain in the mention button while Enter-send reso const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); - const send = composer.getByTestId("send-message"); + await input.evaluate((element) => { + const snapshots = [element.textContent ?? ""]; + new MutationObserver(() => + snapshots.push(element.textContent ?? ""), + ).observe(element, { childList: true, characterData: true, subtree: true }); + ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ = snapshots; + }); const avatar = composer.getByTestId(`composer-address-lock-${AGENT_A}`); const initialPulseVersion = Number( await avatar.getAttribute("data-pulse-version"), ); - await input.fill("hello"); + await input.type("hello"); + await input.evaluate((element) => { + const snapshots = ( + window as typeof window & { __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[] } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__; + snapshots?.splice(0, snapshots.length, element.textContent ?? ""); + }); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); + await expect(input).toHaveText("@Morgarita ", { timeout: 500 }); + await expect + .poll(() => + page.evaluate( + () => + ( + window as typeof window & { + __BUZZ_COMPOSER_TEXT_SNAPSHOTS__?: string[]; + } + ).__BUZZ_COMPOSER_TEXT_SNAPSHOTS__ ?? [], + ), + ) + .not.toContain(""); await expect(avatar).toHaveAttribute( "data-pulse-version", String(initialPulseVersion + 1), @@ -316,10 +509,10 @@ test("always-mentioned agents remain in the mention button while Enter-send reso await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await expect(send).toBeDisabled(); await expect - .poll(() => readOutgoingMentionPubkeys(page, "hello")) + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); + await expect(input).toHaveText("@Morgarita "); const sentRow = page .getByTestId("message-row") @@ -331,6 +524,18 @@ test("always-mentioned agents remain in the mention button while Enter-send reso ); await expect(addressPrefix).toBeVisible(); await expect(addressPrefix).toHaveText("Morgarita"); + + await input.type("follow up"); + await input.press("Enter"); + const inlineMentionRow = page + .getByTestId("message-row") + .filter({ hasText: "follow up" }) + .last(); + await expect( + inlineMentionRow.locator("[data-mention].agent-mention-highlight", { + hasText: "Morgarita", + }), + ).toHaveCount(1); }); test("a failed always-mentioned send shakes the composer avatar", async ({ @@ -351,7 +556,7 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ ); await expect(avatar).toHaveAttribute("data-shake-version", "0"); - await input.fill("please retry"); + await input.type("please retry"); await input.press("Enter"); await expect(avatar).toHaveAttribute( @@ -359,11 +564,11 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ String(initialPulseVersion + 1), { timeout: 500 }, ); - await expect(input).toHaveText("please retry"); + await expect(input).toHaveText("@Morgarita please retry"); await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected after the message sends", async ({ +test("a manually mentioned agent becomes selected immediately", async ({ page, }) => { await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); @@ -377,42 +582,74 @@ test("a manually mentioned agent becomes selected after the message sends", asyn await expect(input).toHaveText("@Morgarita "); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toBeVisible(); + + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await expect(autoPinConfirmation).not.toContainText( + "Future messages in this channel will include this agent.", + ); + await expect(autoPinConfirmation).toHaveAttribute("data-side", "right"); + await expect(autoPinConfirmation.locator("span")).toHaveCSS( + "white-space", + "nowrap", + ); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "Morgarita will be mentioned automatically" }), ).toHaveCount(0); + const addressControlBox = await composer + .getByTestId("composer-address-locks") + .locator("..") + .boundingBox(); + const confirmationBox = await autoPinConfirmation.boundingBox(); + expect(addressControlBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressControlBox || !confirmationBox) { + throw new Error("Automatic mention confirmation is not laid out"); + } + expect(confirmationBox.x).toBeGreaterThan( + addressControlBox.x + addressControlBox.width, + ); + const turnOffAction = autoPinConfirmation.getByRole("button", { + name: "Turn off", + }); + await expect(turnOffAction).toHaveRole("button"); + await expect(turnOffAction).toHaveText("Turn off"); + + await input.press("Escape"); + await expect(autoPinConfirmation).toHaveCount(0); await input.type("hello"); await input.press("Enter"); - await expect(input).toHaveText("", { timeout: 500 }); - await expect(input.locator("[data-placeholder]").first()).toHaveAttribute( - "data-placeholder", - "Message #general", - { timeout: 500 }, - ); + await expect(input).toHaveText("@Morgarita ", { timeout: 2_500 }); + await expect(input.locator("[data-placeholder]")).toHaveCount(0); await expect(input).toBeFocused(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible({ timeout: 2_500 }); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await expect(autoPinToast).not.toContainText( - "Future messages in this channel will include this agent.", - ); - const undoAction = autoPinToast.locator("[data-action]"); - await expect(undoAction).toHaveRole("button"); - await expect(undoAction).toHaveText("Undo"); + await expect(autoPinConfirmation).toHaveCount(0); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); await input.fill("follow up"); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); await input.press("Enter"); await expect .poll(() => readOutgoingMentionPubkeys(page, "follow up")) - .toContain(AGENT_A); + .not.toContain(AGENT_A); }); -test("the auto-pin toast can undo and the picker can restore the agent", async ({ +test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { await installAudienceFixtures(page); @@ -424,41 +661,32 @@ test("the auto-pin toast can undo and the picker can restore the agent", async ( await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); await input.press("Tab"); await expect(input).toHaveText("@Morgarita "); - await input.press("Space"); - await input.type("undo me"); - await input.press("Enter"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); - const autoPinToast = page - .locator("[data-sonner-toast][data-removed='false']") - .filter({ hasText: "Morgarita will be mentioned automatically" }); - await autoPinToast.locator("[data-action]").click(); + const autoPinConfirmation = page.getByTestId( + "composer-auto-pin-confirmation", + ); + await expect(autoPinConfirmation).toContainText( + "Morgarita will be mentioned automatically", + ); + await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await expect(autoPinConfirmation).toHaveCount(0); + + await composer.getByTestId("message-insert-mention").click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "true", - ); + await composer.getByTestId("mention-options-trigger").click(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), - ).toBeVisible(); + ).toHaveAttribute("data-state", "unchecked"); await input.press("Escape"); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); - await composer.locator("[data-mention-picker-trigger]").click(); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: "Mention Morgarita", exact: true }) - .click(); - await expect(input).toHaveText(""); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); }); test("channel automatic mentions carry into threads and stay synchronized", async ({ @@ -479,12 +707,36 @@ test("channel automatic mentions carry into threads and stay synchronized", asyn await expect(threadAutomaticMention).toBeVisible(); await threadComposer(page) - .getByRole("button", { name: "Stop automatically mentioning Morgarita" }) + .getByTestId(`composer-address-lock-remove-${AGENT_A}`) .click(); await expect(threadAutomaticMention).toHaveCount(0); await expect(channelAutomaticMention).toHaveCount(0); }); +test("reduced motion removes addressed agents without spatial animation", async ({ + page, +}) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + const removeButton = composer.getByTestId( + `composer-address-lock-remove-${AGENT_A}`, + ); + await expect(removeButton).toBeVisible(); + await expect(removeButton).toHaveAttribute("style", /opacity: 1/); + await expect(removeButton).toHaveCSS("transform", "none"); + + await removeButton.click(); + await expect(input).toHaveText("@Morgarita "); + await expect(removeButton).toHaveCount(0); +}); + for (const theme of ["buzz", "buzz-dark"]) { test(`captures the mention-button placement in ${theme}`, async ({ page, @@ -519,3 +771,45 @@ test("the mention-button placement fits the narrow composer", async ({ await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); }); + +test("captures the lightweight auto-pin popover", async ({ page }) => { + await seedTheme(page, "buzz-dark"); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await input.fill("draft text"); + await pressPrimaryShift(page, "M"); + await expect(input).toHaveText("@alice draft text"); + + const addressControl = composer + .getByTestId("composer-address-locks") + .locator(".."); + const confirmation = page.getByTestId("composer-auto-pin-confirmation"); + await expect(confirmation).toContainText( + "alice will be mentioned automatically", + ); + await waitForAnimations(page); + + const addressBox = await addressControl.boundingBox(); + const confirmationBox = await confirmation.boundingBox(); + expect(addressBox).not.toBeNull(); + expect(confirmationBox).not.toBeNull(); + if (!addressBox || !confirmationBox) { + throw new Error("Popover is not laid out"); + } + + const left = addressBox.x - 14; + const top = Math.min(addressBox.y, confirmationBox.y) - 14; + const right = confirmationBox.x + confirmationBox.width + 14; + const bottom = + Math.max( + addressBox.y + addressBox.height, + confirmationBox.y + confirmationBox.height, + ) + 14; + await page.screenshot({ + path: `${SHOTS}/auto-pin-popover-dark.png`, + clip: { x: left, y: top, width: right - left, height: bottom - top }, + }); +}); diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index b0edbf6630..3a7fdf7613 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -84,8 +84,9 @@ test("message with agent mention lands in compose-time channel despite mid-send await input.press("Enter"); await page.keyboard.type(` ${MESSAGE_TEXT}`); - // Verify the agent is addressed before submitting. - await expect(input).toHaveText(` ${MESSAGE_TEXT}`); + // Verify the inline mention and persistent address are present before submitting. + await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`); + await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA"); await expect( page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`), ).toBeVisible(); From f24971033178926153b49d320bd876d15d9cb2bf Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 25 Aug 2026 14:44:49 -0400 Subject: [PATCH 15/24] Qualify canonical relay images for staged delivery (#6781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Phase 1 of progressive delivery needs an explicit eligibility boundary before deployment automation can safely consume relay artifacts. Today the canonical Docker workflow can publish independently of the same-SHA CI result, the relay does not expose its source/build identity, and the Helm chart only renders mutable tags. The existing `ghcr.io/block/buzz-staging-dev` workflow in #6709 remains a manual pre-merge preview lane. It is deliberately not a canonical promotion input. ## What changed - Compile the full source SHA and GitHub Actions build identity into canonical relay binaries and expose it at `/_status`. - Gate creation of tagged multi-architecture manifests on the latest exact-SHA `CI` push run succeeding on `main` or `release`. - Preserve the signed SLSA provenance and add a signed, release-only deployment-eligibility predicate with source, build run, qualifying CI run, and exact compatible Buzz chart version. - Keep failed or pending builds untagged and without an eligibility predicate; preview-package artifacts are excluded by package and signer contract. - Add optional `image.digest` support to Buzz chart `0.1.8`, retaining the existing tag fallback. - Document verification and the intentional boundary between image/chart compatibility and backwards-compatible database migrations. ## Risk and rollout Canonical tag publication now waits for same-SHA CI and fails closed when the newest matching run fails. Per-architecture blobs may remain untagged after a failed qualification; they are not promotion inputs. Debug manifests are also CI-gated but do not receive the release eligibility predicate. There are no migration or schema-orchestration changes. The companion BuilderBot infrastructure PR will pin a separately verified canonical main digest and expose a read-only running-deployment inspection command. Signed eligibility is available only for artifacts published after this workflow lands. ## Verification - Blox: workflow contract fixtures, including stale-green rejection and predicate structure - Blox: `actionlint` for `ci.yml` and `docker.yml` - Blox: 46 Helm unit tests, chart lint, and production/quickstart render fixtures - Blox: relay status unit test, workspace Rust formatting, and workspace all-target clippy - Live evidence: exact-SHA CI selector chose successful run `32857345153` for source `9d1e4b257657f382d3111ce748f3da8d063b7671` - Existing canonical digest provenance verified with `gh attestation verify` ## References - #6709 — developer-preview precursor; intentionally separate from canonical eligibility - `docs/deployment-identity.md` *Generated with Codex* --------- Signed-off-by: Luke Tornquist --- .github/workflows/ci.yml | 2 + .github/workflows/docker.yml | 151 +++++++++++++++++- Dockerfile | 8 + crates/buzz-relay/src/build_info.rs | 16 ++ crates/buzz-relay/src/lib.rs | 1 + crates/buzz-relay/src/router.rs | 35 +++- deploy/charts/buzz/Chart.yaml | 4 +- deploy/charts/buzz/README.md | 11 +- deploy/charts/buzz/templates/_helpers.tpl | 4 + deploy/charts/buzz/tests/render_test.yaml | 18 +++ deploy/charts/buzz/tests/validation_test.yaml | 10 ++ deploy/charts/buzz/values.schema.json | 5 + deploy/charts/buzz/values.yaml | 1 + docs/deployment-identity.md | 68 ++++++++ ...create-deployment-eligibility-predicate.jq | 26 +++ scripts/select-qualified-ci-run.jq | 9 ++ .../test-relay-image-eligibility-workflow.sh | 108 +++++++++++++ 17 files changed, 467 insertions(+), 10 deletions(-) create mode 100644 crates/buzz-relay/src/build_info.rs create mode 100644 docs/deployment-identity.md create mode 100644 scripts/create-deployment-eligibility-predicate.jq create mode 100644 scripts/select-qualified-ci-run.jq create mode 100755 scripts/test-relay-image-eligibility-workflow.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fa6fb94ad..f2bc8db65e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Relay image eligibility contract + run: scripts/test-relay-image-eligibility-workflow.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: OSS desktop promotion contract diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 564cd74e9d..ddb55f8542 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -49,6 +49,10 @@ on: - "Dockerfile.push-gateway" - ".dockerignore" - ".github/workflows/docker.yml" + - "deploy/charts/buzz/Chart.yaml" + - "scripts/create-deployment-eligibility-predicate.jq" + - "scripts/select-qualified-ci-run.jq" + - "scripts/test-relay-image-eligibility-workflow.sh" - "Cargo.toml" - "Cargo.lock" - "rust-toolchain.toml" @@ -172,6 +176,10 @@ jobs: target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} # Push by digest, not by tag — the merge job assembles the tags # into one multi-arch manifest. This is what makes the native-arm # matrix possible. @@ -190,6 +198,10 @@ jobs: target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + BUZZ_SOURCE_SHA=${{ github.sha }} + BUZZ_BUILD_ID=github-actions:${{ github.run_id }}:${{ github.run_attempt }} + BUZZ_BUILD_URL=https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} @@ -222,11 +234,86 @@ jobs: if-no-files-found: error retention-days: 1 + qualify: + name: Qualify relay image source + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 70 + permissions: + actions: read + contents: read + outputs: + source_sha: ${{ steps.qualify.outputs.source_sha }} + ci_run_id: ${{ steps.qualify.outputs.ci_run_id }} + ci_run_attempt: ${{ steps.qualify.outputs.ci_run_attempt }} + ci_run_url: ${{ steps.qualify.outputs.ci_run_url }} + chart_version: ${{ steps.qualify.outputs.chart_version }} + steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Require successful same-SHA CI run + id: qualify + env: + GH_TOKEN: ${{ github.token }} + SOURCE_SHA: ${{ github.sha }} + run: | + set -euo pipefail + + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Invalid source SHA: $SOURCE_SHA" + exit 1 + fi + + deadline=$((SECONDS + 3900)) + while (( SECONDS < deadline )); do + payload=$(gh api \ + "/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${SOURCE_SHA}&event=push&per_page=100") + successful_run=$(jq -c --arg source_sha "$SOURCE_SHA" \ + -f "$GITHUB_WORKSPACE/scripts/select-qualified-ci-run.jq" <<<"$payload") + + if [[ -n "$successful_run" ]]; then + ci_run_id=$(jq -r '.id' <<<"$successful_run") + ci_run_attempt=$(jq -r '.run_attempt' <<<"$successful_run") + ci_run_url=$(jq -r '.html_url' <<<"$successful_run") + chart_version=$(awk '/^version:/ { print $2; exit }' deploy/charts/buzz/Chart.yaml) + if [[ ! "$chart_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Invalid Buzz chart version: $chart_version" + exit 1 + fi + { + echo "source_sha=$SOURCE_SHA" + echo "ci_run_id=$ci_run_id" + echo "ci_run_attempt=$ci_run_attempt" + echo "ci_run_url=$ci_run_url" + echo "chart_version=$chart_version" + } >>"$GITHUB_OUTPUT" + echo "Qualified source $SOURCE_SHA with CI run $ci_run_id (attempt $ci_run_attempt)." + exit 0 + fi + + matching=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release")] | length' <<<"$payload") + pending=$(jq --arg source_sha "$SOURCE_SHA" '[.workflow_runs[] | select(.head_sha == $source_sha) | select(.event == "push") | select(.head_branch == "main" or .head_branch == "release") | select(.status != "completed")] | length' <<<"$payload") + if (( matching > 0 && pending == 0 )); then + echo "::error::No successful CI run exists for source $SOURCE_SHA" + jq -r --arg source_sha "$SOURCE_SHA" '.workflow_runs[] | select(.head_sha == $source_sha) | "run=\(.id) attempt=\(.run_attempt) status=\(.status) conclusion=\(.conclusion)"' <<<"$payload" + exit 1 + fi + + echo "Waiting for same-SHA CI qualification ($matching matching, $pending pending)..." + sleep 30 + done + + echo "::error::Timed out waiting for successful CI qualification of $SOURCE_SHA" + exit 1 + merge: name: Merge ${{ matrix.variant }} multi-arch manifest if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 - needs: build + needs: [build, qualify] timeout-minutes: 15 permissions: contents: read @@ -286,6 +373,10 @@ jobs: env: IMAGE_NAME: ${{ env.IMAGE_NAME }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | set -euo pipefail # Build -t flags from the metadata-action output. @@ -300,7 +391,15 @@ jobs: digests+=("${IMAGE_NAME}@sha256:${digest}") done - docker buildx imagetools create "${tags[@]}" "${digests[@]}" + annotations=( + --annotation "index:org.opencontainers.image.revision=${SOURCE_SHA}" + --annotation "index:xyz.block.buzz.build.id=github-actions:${GITHUB_RUN_ID}:${GITHUB_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-run-id=${CI_RUN_ID}" + --annotation "index:xyz.block.buzz.qualification.ci-run-attempt=${CI_RUN_ATTEMPT}" + --annotation "index:xyz.block.buzz.qualification.ci-conclusion=success" + --annotation "index:xyz.block.buzz.helm-chart.version=${CHART_VERSION}" + ) + docker buildx imagetools create "${tags[@]}" "${annotations[@]}" "${digests[@]}" # Capture the merged manifest digest for the attestation step. first_tag=$(echo "$META_TAGS" | head -n1) @@ -317,17 +416,58 @@ jobs: subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true + - name: Create deployment eligibility predicate + if: matrix.variant == 'release' + env: + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_ID: ${{ needs.qualify.outputs.ci_run_id }} + CI_RUN_ATTEMPT: ${{ needs.qualify.outputs.ci_run_attempt }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} + run: | + jq -n \ + --arg source_repository "$GITHUB_REPOSITORY" \ + --arg source_ref "$GITHUB_REF" \ + --arg source_sha "$SOURCE_SHA" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id "$GITHUB_RUN_ID" \ + --argjson build_run_attempt "$GITHUB_RUN_ATTEMPT" \ + --arg build_run_url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/attempts/${GITHUB_RUN_ATTEMPT}" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id "$CI_RUN_ID" \ + --argjson qualification_run_attempt "$CI_RUN_ATTEMPT" \ + --arg qualification_run_url "$CI_RUN_URL" \ + --arg chart_version "$CHART_VERSION" \ + -f "$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq" \ + >/tmp/deployment-eligibility.json + + - name: Attest deployment eligibility + if: matrix.variant == 'release' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + predicate-type: https://buzz.block.xyz/attestations/deployment-eligibility/v1 + predicate-path: /tmp/deployment-eligibility.json + push-to-registry: true + - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} + SOURCE_SHA: ${{ needs.qualify.outputs.source_sha }} + CI_RUN_URL: ${{ needs.qualify.outputs.ci_run_url }} + CHART_VERSION: ${{ needs.qualify.outputs.chart_version }} run: | { echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" echo echo "**Digest:** \`${MERGED_DIGEST}\`" + echo "**Source:** \`${SOURCE_SHA}\`" + echo "**Compatible Buzz chart:** \`${CHART_VERSION}\`" + echo "**Qualifying CI:** ${CI_RUN_URL} (success)" echo echo "**Tags:**" echo '```' @@ -338,6 +478,13 @@ jobs: echo '```' echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block" echo '```' + if [[ "$VARIANT" == "release" ]]; then + echo + echo "Verify deployment eligibility:" + echo '```' + echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --repo block/buzz --signer-workflow block/buzz/.github/workflows/docker.yml --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 --source-digest ${SOURCE_SHA}" + echo '```' + fi } >> "$GITHUB_STEP_SUMMARY" push-gateway-build: diff --git a/Dockerfile b/Dockerfile index d883ac6b01..2c1e8adbb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -67,6 +67,14 @@ COPY --from=planner /build/recipe.json recipe.json # scoping to -p buzz-relay misses transitive deps and re-builds them later. RUN cargo chef cook --release --recipe-path recipe.json COPY . . +# Compile immutable artifact identity into the relay. Defaults preserve local +# and third-party builds that do not run in provenance-aware CI. +ARG BUZZ_SOURCE_SHA=unknown +ARG BUZZ_BUILD_ID=local +ARG BUZZ_BUILD_URL=unknown +ENV BUZZ_SOURCE_SHA=${BUZZ_SOURCE_SHA} \ + BUZZ_BUILD_ID=${BUZZ_BUILD_ID} \ + BUZZ_BUILD_URL=${BUZZ_BUILD_URL} RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \ -p buzz-admin --bin buzz-admin \ -p buzz-pair-relay --bin buzz-pair-relay diff --git a/crates/buzz-relay/src/build_info.rs b/crates/buzz-relay/src/build_info.rs new file mode 100644 index 0000000000..c7073505b3 --- /dev/null +++ b/crates/buzz-relay/src/build_info.rs @@ -0,0 +1,16 @@ +//! Build-time identity compiled into the relay binary. + +/// Full source commit SHA, or `unknown` outside a provenance-aware build. +pub(crate) fn source_sha() -> &'static str { + option_env!("BUZZ_SOURCE_SHA").unwrap_or("unknown") +} + +/// Stable build identifier, or `local` outside CI. +pub(crate) fn build_id() -> &'static str { + option_env!("BUZZ_BUILD_ID").unwrap_or("local") +} + +/// Build details URL, or `unknown` outside CI. +pub(crate) fn build_url() -> &'static str { + option_env!("BUZZ_BUILD_URL").unwrap_or("unknown") +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e..800433a849 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -3,6 +3,7 @@ //! NIP-01 WebSocket relay for Buzz private team communication. mod admission; +mod build_info; /// REST API route handlers. pub mod api; diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e..653e21f093 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -413,14 +413,22 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } } -/// Status endpoint — service name, version, uptime. -async fn status_handler(State(state): State>) -> impl IntoResponse { - let uptime_secs = state.started_at.elapsed().as_secs(); - Json(json!({ +fn status_payload(uptime_secs: u64) -> serde_json::Value { + json!({ "service": "buzz-relay", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": uptime_secs, - })) + "build": { + "source_sha": crate::build_info::source_sha(), + "id": crate::build_info::build_id(), + "url": crate::build_info::build_url(), + }, + }) +} + +/// Status endpoint — service name, version, uptime, and intrinsic build identity. +async fn status_handler(State(state): State>) -> impl IntoResponse { + Json(status_payload(state.started_at.elapsed().as_secs())) } /// `/_mesh` — live mesh status: peer table, connection/phi state, per-peer @@ -506,6 +514,23 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[test] + fn status_payload_exposes_source_and_build_identity() { + let payload = status_payload(42); + + assert_eq!(payload["service"], "buzz-relay"); + assert_eq!(payload["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(payload["uptime_seconds"], 42); + for field in ["source_sha", "id", "url"] { + assert!( + payload["build"][field] + .as_str() + .is_some_and(|value| !value.is_empty()), + "build.{field} must be a non-empty string" + ); + } + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default(); diff --git a/deploy/charts/buzz/Chart.yaml b/deploy/charts/buzz/Chart.yaml index 9309074895..49a6fafd19 100644 --- a/deploy/charts/buzz/Chart.yaml +++ b/deploy/charts/buzz/Chart.yaml @@ -7,7 +7,7 @@ description: | PostgreSQL and Redis. Configurable for single-node evaluation (subcharts on) and HA production (external services, existingSecret). type: application -version: 0.1.7 +version: 0.1.8 appVersion: "0.1.0" home: https://github.com/block/buzz sources: @@ -24,7 +24,7 @@ maintainers: annotations: artifacthub.io/changes: | - kind: added - description: Generic init-container, volume, volume-mount, command, and args extension points for the relay Pod. + description: Optional immutable relay image digest pinning with backwards-compatible tag fallback. artifacthub.io/license: Apache-2.0 # Optional eval-only subcharts. Production deploys disable both and point diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 30cee4f406..e0645ef6fb 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -12,7 +12,7 @@ This chart has two operating profiles selected by values: ## Quickstart (eval only) ```sh -helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.8 \ --create-namespace --namespace buzz \ --set quickstart=true \ --set postgresql.enabled=true \ @@ -29,6 +29,15 @@ intent marker surfaced in NOTES.txt; the bundled services are opted in via the four `*.enabled` flags above (see `ci/quickstart-values.yaml` for the exact set CI installs). Eval-only: every bundled service is a single replica with no HA. +For immutable delivery, pin the OCI digest instead of a tag. `image.digest` +overrides `image.tag` when both are present: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + ## Production (GitOps) The chart is designed for ArgoCD and Flux. Both render charts with `helm template`, in which mode Helm's `lookup` function returns empty — any chart-side `randAlphaNum` call would regenerate secrets on every sync. The chart-managed Secret path is **only** safe for `helm install` / `helm upgrade`. diff --git a/deploy/charts/buzz/templates/_helpers.tpl b/deploy/charts/buzz/templates/_helpers.tpl index ff070379eb..13efe0d1fd 100644 --- a/deploy/charts/buzz/templates/_helpers.tpl +++ b/deploy/charts/buzz/templates/_helpers.tpl @@ -53,9 +53,13 @@ app.kubernetes.io/component: relay {{- end -}} {{- define "buzz.image" -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}} +{{- else -}} {{- $tag := default .Chart.AppVersion .Values.image.tag -}} {{- printf "%s:%s" .Values.image.repository $tag -}} {{- end -}} +{{- end -}} {{/* Name of the chart-managed Secret holding relay-identity material and any diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 10a1a34d1f..f288f8df2d 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -195,6 +195,24 @@ tests: path: spec.template.spec.containers[0].args template: templates/deployment.yaml + - it: renders an immutable digest instead of a configured tag + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + image.repository: ghcr.io/block/buzz + image.tag: sha-deadbee + image.digest: sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: ghcr.io/block/buzz@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + template: templates/deployment.yaml + - it: appends generic Pod extensions and overrides the relay command set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index a5a0050a86..3498189c8a 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -2,6 +2,16 @@ suite: validation templates: - templates/deployment.yaml tests: + - it: rejects a malformed image digest + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + image.digest: sha256:not-a-digest + asserts: + - failedTemplate: + errorPattern: "image.digest: Does not match pattern" + - it: fails when relayUrl is missing set: relayUrl: "" diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 94d369c890..aaab848fd3 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -15,6 +15,11 @@ "properties": { "repository": { "type": "string", "minLength": 1 }, "tag": { "type": "string" }, + "digest": { + "type": "string", + "pattern": "^$|^sha256:[0-9a-f]{64}$", + "description": "Optional immutable OCI image digest. When set, the chart renders repository@digest and ignores tag." + }, "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }, "pullSecrets": { "type": "array", diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 448e15ca97..738c50eec3 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -25,6 +25,7 @@ quickstart: false image: repository: ghcr.io/block/buzz tag: "" # empty → .Chart.AppVersion + digest: "" # optional sha256:...; when set, overrides tag pullPolicy: IfNotPresent pullSecrets: [] diff --git a/docs/deployment-identity.md b/docs/deployment-identity.md new file mode 100644 index 0000000000..32d8c426e7 --- /dev/null +++ b/docs/deployment-identity.md @@ -0,0 +1,68 @@ +# Relay deployment identity + +Canonical relay images from `ghcr.io/block/buzz` carry two signed +attestations: + +- SLSA build provenance maps the immutable image digest to the source commit + and Docker workflow run. +- The Buzz deployment-eligibility predicate records the successful same-SHA + CI run and the exact Buzz Helm chart version from that source commit. + +The Docker workflow creates tagged multi-architecture manifests only after the +same full source SHA has a successful `CI` push run on `main` or `release`. +Architecture-specific build manifests may exist without tags while CI is +running or after it fails; they do not receive the deployment-eligibility +predicate and are not promotion inputs. + +Verify a canonical eligible digest with: + +```bash +gh attestation verify \ + oci://ghcr.io/block/buzz@sha256: \ + --repo block/buzz \ + --signer-workflow block/buzz/.github/workflows/docker.yml \ + --predicate-type https://buzz.block.xyz/attestations/deployment-eligibility/v1 \ + --source-ref refs/heads/main +``` + +The predicate's `helm_chart.compatible_version` is image-to-chart metadata. It +does not describe database schema compatibility and does not relax Buzz's rule +that migrations remain backwards compatible. + +The manual pre-merge workflow publishes only to +`ghcr.io/block/buzz-staging-dev`. Those preview images are intentionally +ineligible: they use a different package, may name non-main source, and do not +receive the canonical deployment-eligibility predicate. + +## Runtime inspection + +The relay health listener exposes intrinsic build identity at `/_status`: + +```json +{ + "service": "buzz-relay", + "version": "0.2.1", + "uptime_seconds": 123, + "build": { + "source_sha": "<40-character-source-sha>", + "id": "github-actions::", + "url": "https://github.com/block/buzz/actions/runs//attempts/" + } +} +``` + +Non-CI builds report stable `unknown` or `local` fallback values instead of +claiming provenance they do not have. + +## Helm digest pinning + +Buzz chart `0.1.8` and newer accept an immutable image digest: + +```yaml +image: + repository: ghcr.io/block/buzz + digest: sha256:<64-lowercase-hex-characters> +``` + +When `image.digest` is set, the chart renders `repository@digest` and ignores +`image.tag`. Existing tag-only values remain backwards compatible. diff --git a/scripts/create-deployment-eligibility-predicate.jq b/scripts/create-deployment-eligibility-predicate.jq new file mode 100644 index 0000000000..3d984b4e6e --- /dev/null +++ b/scripts/create-deployment-eligibility-predicate.jq @@ -0,0 +1,26 @@ +{ + predicate_version: 1, + eligible: true, + source: { + repository: $source_repository, + ref: $source_ref, + sha: $source_sha + }, + build: { + workflow: $build_workflow, + run_id: $build_run_id, + run_attempt: $build_run_attempt, + run_url: $build_run_url + }, + qualification: { + workflow: $qualification_workflow, + run_id: $qualification_run_id, + run_attempt: $qualification_run_attempt, + run_url: $qualification_run_url, + conclusion: "success" + }, + helm_chart: { + name: "buzz", + compatible_version: $chart_version + } +} diff --git a/scripts/select-qualified-ci-run.jq b/scripts/select-qualified-ci-run.jq new file mode 100644 index 0000000000..4d99a9e60f --- /dev/null +++ b/scripts/select-qualified-ci-run.jq @@ -0,0 +1,9 @@ +[ + .workflow_runs[] + | select(.head_sha == $source_sha) + | select(.event == "push") + | select(.head_branch == "main" or .head_branch == "release") +] +| sort_by([.id, .run_attempt]) +| last // empty +| select(.conclusion == "success") diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh new file mode 100755 index 0000000000..168f9d3689 --- /dev/null +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +workflow="$repo_root/.github/workflows/docker.yml" +selector="$repo_root/scripts/select-qualified-ci-run.jq" +predicate_builder="$repo_root/scripts/create-deployment-eligibility-predicate.jq" + +require_literal() { + local needle=$1 + grep -Fq -- "$needle" "$workflow" || { + echo "relay image workflow is missing required delivery contract: $needle" >&2 + exit 1 + } +} + +require_literal " qualify:" +require_literal "actions: read" +require_literal "actions/workflows/ci.yml/runs" +require_literal 'select-qualified-ci-run.jq' +require_literal "needs: [build, qualify]" +require_literal "https://buzz.block.xyz/attestations/deployment-eligibility/v1" +require_literal "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" +require_literal "if: matrix.variant == 'release'" +require_literal "BUZZ_SOURCE_SHA" +require_literal "BUZZ_BUILD_ID" +require_literal "BUZZ_BUILD_URL" +require_literal '- "deploy/charts/buzz/Chart.yaml"' +require_literal '- "scripts/create-deployment-eligibility-predicate.jq"' +require_literal '- "scripts/select-qualified-ci-run.jq"' +require_literal '- "scripts/test-relay-image-eligibility-workflow.sh"' + +if grep -Fq "buzz-staging-dev" "$workflow"; then + echo "canonical relay image workflow references the preview-only image package" >&2 + exit 1 +fi + +select_run() { + jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":100,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"failure"}, + {"id":101,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"success"} +]} +JSON +) +[[ "$selected" == "101" ]] || { + echo "latest successful same-SHA main run was not selected" >&2 + exit 1 +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":100,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"success"}, + {"id":101,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"main","event":"push","status":"completed","conclusion":"failure"} +]} +JSON +) +[[ -z "$selected" ]] || { + echo "stale successful run remained eligible after a newer failure" >&2 + exit 1 +} + +selected=$(select_run <<'JSON' +{"workflow_runs":[ + {"id":102,"run_attempt":1,"head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","head_branch":"main","event":"push","status":"completed","conclusion":"success"}, + {"id":103,"run_attempt":1,"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_branch":"feature","event":"pull_request","status":"completed","conclusion":"success"} +]} +JSON +) +[[ -z "$selected" ]] || { + echo "wrong-SHA or pull-request CI run was accepted" >&2 + exit 1 +} + +predicate=$(jq -n \ + --arg source_repository "block/buzz" \ + --arg source_ref "refs/heads/main" \ + --arg source_sha "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ + --arg build_workflow ".github/workflows/docker.yml" \ + --argjson build_run_id 200 \ + --argjson build_run_attempt 2 \ + --arg build_run_url "https://github.com/block/buzz/actions/runs/200/attempts/2" \ + --arg qualification_workflow ".github/workflows/ci.yml" \ + --argjson qualification_run_id 201 \ + --argjson qualification_run_attempt 1 \ + --arg qualification_run_url "https://github.com/block/buzz/actions/runs/201" \ + --arg chart_version "0.1.8" \ + -f "$predicate_builder") + +jq -e ' + .predicate_version == 1 and + .eligible == true and + .source.sha == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" and + .build.run_id == 200 and + .qualification.run_id == 201 and + .qualification.conclusion == "success" and + .helm_chart == {"name":"buzz","compatible_version":"0.1.8"} and + (has("schema") | not) and + (.helm_chart | has("schema_compatibility") | not) +' <<<"$predicate" >/dev/null || { + echo "deployment eligibility predicate has an invalid contract" >&2 + exit 1 +} + +echo "relay image eligibility workflow contract passed" From b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 13:32:09 -0700 Subject: [PATCH 16/24] fix(desktop-messages): preserve inline agent mentions with persistent addressing (#6793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Users can place `@Agent` mentions inline anywhere in a message—even when that agent is also persistently addressed—so separate agents can receive separate instructions in the same message. **Problem:** Selecting an agent from the composer mention picker could turn the selection into persistent addressing instead of leaving an inline `@Agent` at the cursor. That made persistent addressing and inline composition mutually exclusive: once the persistent behavior took over, users lost the clear `@Agent A do X, @Agent B do Y` message structure they previously had. **Solution:** Make inline mentioning and persistent addressing independent behaviors: - Ordinary mention selections always insert `@Agent` inline at the current cursor position. - This remains true when the agent is already persistently addressed; the persistent audience never blocks or replaces an inline mention. - If **Automatically mention agents** is enabled, a successfully sent inline mention may additionally make that agent persistent for later messages. Existing saved preferences remain respected. - The dedicated automatic-mention control and primary+Shift+Enter shortcut continue to add or remove persistent addressing directly. - For users without a saved preference, **Automatically mention agents** now defaults off.
File changes **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs** Updates preference coverage for the default-off behavior while preserving explicit saved choices. **desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts** Defaults automatic post-send persistence off when no valid preference exists. **desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs** Verifies ordinary picker selections insert inline mentions without changing or pulsing the persistent audience, including when the selected agent is already persistently addressed. **desktop/src/features/messages/ui/useAgentAddressLockPicker.ts** Keeps ordinary mention selection on the existing inline insertion path while preserving the separate persistent-address controls and shortcut. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Covers separately targeted inline instructions to two agents, signed outgoing recipients, default-off persistence, and explicit opt-in persistence scenarios.
## Reproduction steps ### Inline mentions without persistence 1. Open a channel with at least two available agents and leave **Automatically mention agents** disabled. 2. Use the composer mention picker to insert the first agent, type an instruction, then insert a second agent and type a different instruction. 3. Confirm the draft reads like `@Agent A review this, @Agent B test that` and neither agent appears in the persistent addressed-agent controls. 4. Send the message and confirm both agents are recipients while neither remains persistently addressed for the next draft. ### Inline mentions with persistence 1. Enable **Automatically mention agents**, then mention an agent inline and send successfully. 2. Confirm that agent becomes persistently addressed for later messages. 3. In a new draft, select the same agent from the mention picker again. 4. Confirm a new inline `@Agent` is inserted at the cursor while the agent remains persistently addressed. 5. Confirm the dedicated automatic-mention control and primary+Shift+Enter shortcut can still add or remove persistent addressing directly. ## Demo - **Before:** Persistent addressing could consume an ordinary picker selection, preventing users from placing that agent inline in the message. - **After:** Ordinary selection always produces an inline `@Agent`; persistence is a separate optional behavior that can coexist with inline mentions. ### Related issue N/A — reported through the Buzz feature room. ### Testing At `a4579c0a666f3644abe44271614186d7b9356bf5`: - Post-push hooks passed desktop check, desktop typecheck, and the full desktop unit suite. - The two persistence-focused Playwright smoke scenarios passed after explicitly opting into **Automatically mention agents**. - The one-time multi-agent targeting and persistent-address shortcut Playwright scenarios passed on the feature changes before the final rebase; CI is validating the rebased branch. --------- Signed-off-by: Taylor Ho Co-authored-by: Carl --- .../autoPinMentionedAgentsPreference.test.mjs | 20 +- .../lib/autoPinMentionedAgentsPreference.ts | 2 +- .../src/features/messages/lib/useDrafts.ts | 4 + .../features/messages/ui/MessageComposer.tsx | 55 ++--- .../MessageComposerDraftImagePersist.test.mjs | 219 ++++++++++++++++++ .../messages/ui/useDraftPersistSnapshot.ts | 73 +++++- desktop/tests/e2e/mentions.spec.ts | 30 ++- .../e2e/persistent-agent-audience.spec.ts | 26 ++- 8 files changed, 373 insertions(+), 56 deletions(-) diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs index d7516fe691..b30008109c 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs @@ -9,25 +9,25 @@ globalThis.localStorage = { const preference = await import("./autoPinMentionedAgentsPreference.ts"); -test("defaults missing and invalid values to keeping mentioned agents pinned", () => { - assert.equal(preference.parseKeepMentionedAgentsPinned(null), true); - assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), true); +test("defaults missing and invalid values to one-time agent mentions", () => { + assert.equal(preference.parseKeepMentionedAgentsPinned(null), false); + assert.equal(preference.parseKeepMentionedAgentsPinned("invalid"), false); assert.equal(preference.parseKeepMentionedAgentsPinned("true"), true); assert.equal(preference.parseKeepMentionedAgentsPinned("false"), false); }); test("persists changes to the post-mention pinning preference", () => { - preference.setKeepMentionedAgentsPinned(false); - assert.equal(preference.getKeepMentionedAgentsPinned(), false); - assert.equal( - values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), - "false", - ); - preference.setKeepMentionedAgentsPinned(true); assert.equal(preference.getKeepMentionedAgentsPinned(), true); assert.equal( values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), "true", ); + + preference.setKeepMentionedAgentsPinned(false); + assert.equal(preference.getKeepMentionedAgentsPinned(), false); + assert.equal( + values.get(preference.KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY), + "false", + ); }); diff --git a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts index 0a3821b4e3..8f8e0b12d6 100644 --- a/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts +++ b/desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts @@ -2,7 +2,7 @@ import * as React from "react"; export const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = "buzz.messages.keepMentionedAgentsPinned"; -export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = true; +export const DEFAULT_KEEP_MENTIONED_AGENTS_PINNED = false; const listeners = new Set<() => void>(); let keepMentionedAgentsPinned = readStoredPreference(); diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 2a78e88132..a3e0fcf919 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -114,6 +114,10 @@ function storageKey(): string { : legacyStorageKey(); } +export function getDraftStoreScope(): string { + return storageKey(); +} + function legacyStorageKey(): string { return `${LEGACY_DRAFT_STORE_KEY_PREFIX}:${currentPubkey}`; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 98c600e87f..d4db6f5d17 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -170,32 +170,33 @@ function MessageComposerImpl({ media.queuedAttachmentsRef.current.length === 0; const ownsDropZone = mediaController === undefined; const backgroundUpload = useBackgroundMediaUpload(); - useDraftPersistLifecycle({ - effectiveDraftKey, - channelId, - loadDraft: drafts.loadDraft, - persistDraft: drafts.persistDraft, - getMentionRefs: mentions.getDraftMentionRefs, - restoreMentionRefs: mentions.restoreDraftMentionRefs, - livePendingImeta: media.pendingImeta, - setPendingImeta: media.setPendingImeta, - getQueuedAttachments: () => media.queuedAttachmentsRef.current, - saveQueuedAttachmentsForDraft, - clearQueuedAttachments: media.clearQueuedAttachments, - restoreQueuedAttachments: media.restoreQueuedAttachments, - takeQueuedAttachmentsForDraft, - setContent: (content) => { - setComposerContent(content); - richText.setContent(content); - }, - clearContent: () => { - setComposerContent(""); - richText.clearContent(); - }, - setSpoileredAttachmentUrls, - spoileredAttachmentUrlsRef, - syncComposerContentFromEditor, - }); + const { trackAuthoredContent: trackDraftAuthoredContent } = + useDraftPersistLifecycle({ + effectiveDraftKey, + channelId, + loadDraft: drafts.loadDraft, + persistDraft: drafts.persistDraft, + getMentionRefs: mentions.getDraftMentionRefs, + restoreMentionRefs: mentions.restoreDraftMentionRefs, + livePendingImeta: media.pendingImeta, + setPendingImeta: media.setPendingImeta, + getQueuedAttachments: () => media.queuedAttachmentsRef.current, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: media.clearQueuedAttachments, + restoreQueuedAttachments: media.restoreQueuedAttachments, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + setComposerContent(content); + richText.setContent(content); + }, + clearContent: () => { + setComposerContent(""); + richText.clearContent(); + }, + setSpoileredAttachmentUrls, + spoileredAttachmentUrlsRef, + syncComposerContentFromEditor, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger React.useEffect(() => { media.setUploadState({ status: "idle" }); @@ -272,6 +273,8 @@ function MessageComposerImpl({ onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, onUpdate: ({ cursor, linkPreviewContent, text }) => { + trackDraftAuthoredContent(text); + contentRef.current = text; setComposerContentFromText(text); setPreviewContent(linkPreviewContent); if (!isSubmitLockedRef.current && !editTargetRef.current) { diff --git a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs index 46b5d91d5b..e5f08b6ebd 100644 --- a/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs +++ b/desktop/src/features/messages/ui/MessageComposerDraftImagePersist.test.mjs @@ -585,6 +585,225 @@ test("draft_lifecycle_empty_target_clears_stale_mention_refs", async () => { await handle.unmount(); }); +test("draft_lifecycle_persists_an_explicit_clear_before_async_rerender", async () => { + const DRAFT_KEY = "chan-clear-race"; + setupStore("pubkey-clear-race"); + persistDraftEntry(DRAFT_KEY, "draft text", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "draft text"); + + trackAuthoredContent(""); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "the authoritative update removes the stale body even while editor reads lag", + ); + + await handle.unmount(); + assert.equal( + loadDraftEntry(DRAFT_KEY), + undefined, + "async settlement must not repersist the deleted body", + ); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "the deleted body must not be restored"); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_image_and_spoiler_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-image"; + setupStore("pubkey-clear-caption-image"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [IMG_A], [IMG_A.url]); + + let editorContent = ""; + let pendingImeta = []; + let spoileredUrls = new Set(); + let trackAuthoredContent; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: pendingImeta, + setPendingImeta: (imeta) => { + pendingImeta = imeta; + }, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: (urls) => { + spoileredUrls = urls; + }, + spoileredAttachmentUrlsRef: { + get current() { + return spoileredUrls; + }, + }, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(pendingImeta[0]?.url, IMG_A.url); + assert.deepEqual([...spoileredUrls], [IMG_A.url]); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, ""); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_caption_preserves_queued_file_on_remount", async () => { + const DRAFT_KEY = "chan-clear-caption-file"; + setupStore("pubkey-clear-caption-file"); + persistDraftEntry(DRAFT_KEY, "caption", DRAFT_KEY, [], []); + const FILE_A = { + file: new File(["report"], "report.pdf", { type: "application/pdf" }), + id: 9, + spoilered: true, + }; + + let editorContent = ""; + let queuedAttachments = [FILE_A]; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + getQueuedAttachments: () => queuedAttachments, + saveQueuedAttachmentsForDraft, + clearQueuedAttachments: () => { + queuedAttachments = []; + }, + restoreQueuedAttachments: (attachments) => { + queuedAttachments = attachments; + }, + takeQueuedAttachmentsForDraft, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + saveQueuedAttachmentsForDraft(DRAFT_KEY, [FILE_A]); + const handle = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await handle.unmount(); + + const remounted = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, ""); + assert.equal(queuedAttachments[0]?.file.name, "report.pdf"); + assert.equal(queuedAttachments[0]?.spoilered, true); + await remounted.unmount(); +}); + +test("draft_lifecycle_clear_authority_is_scoped_to_relay_and_identity", async () => { + const DRAFT_KEY = "shared-key"; + installFreshLocalStorage(); + clearAllDrafts(); + initDraftStore("pubkey-a", "wss://relay-a.example"); + persistDraftEntry(DRAFT_KEY, "workspace A", DRAFT_KEY, [], []); + + let editorContent = ""; + let trackAuthoredContent; + const spoileredRef = { current: new Set() }; + function HarnessComposer() { + ({ trackAuthoredContent } = useDraftPersistLifecycle({ + effectiveDraftKey: DRAFT_KEY, + channelId: DRAFT_KEY, + loadDraft: loadDraftEntry, + persistDraft: persistDraftEntry, + getMentionRefs: () => [], + restoreMentionRefs: () => {}, + livePendingImeta: [], + setPendingImeta: () => {}, + setContent: (content) => { + editorContent = content; + }, + clearContent: () => { + editorContent = ""; + }, + setSpoileredAttachmentUrls: () => {}, + spoileredAttachmentUrlsRef: spoileredRef, + syncComposerContentFromEditor: () => editorContent, + })); + return null; + } + + const workspaceA = await mountStrictMode(HarnessComposer); + trackAuthoredContent(""); + editorContent = ""; + await workspaceA.unmount(); + + initDraftStore("pubkey-b", "wss://relay-b.example"); + persistDraftEntry(DRAFT_KEY, "workspace B", DRAFT_KEY, [], []); + const workspaceB = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "workspace B"); + await workspaceB.unmount(); + assert.equal(loadDraftEntry(DRAFT_KEY)?.content, "workspace B"); + + initDraftStore("pubkey-a", "wss://relay-a.example"); + const workspaceARemount = await mountStrictMode(HarnessComposer); + assert.equal(editorContent, "", "workspace A stale text must stay cleared"); + await workspaceARemount.unmount(); +}); + test("draft_lifecycle_preserves_local_files_across_a_b_a_switch", async () => { setupStore("pubkey-switch-files"); const FILE_A = { diff --git a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts index 14dae33adb..694bf6a2a5 100644 --- a/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts +++ b/desktop/src/features/messages/ui/useDraftPersistSnapshot.ts @@ -2,9 +2,10 @@ import * as React from "react"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { - DraftMentionRef, - DraftState, +import { + getDraftStoreScope, + type DraftMentionRef, + type DraftState, } from "@/features/messages/lib/useDrafts"; type UseDraftPersistLifecycleParams = { @@ -60,6 +61,21 @@ type UseDraftPersistLifecycleParams = { syncComposerContentFromEditor: () => string; }; +type UseDraftPersistLifecycleResult = { + /** + * Record the latest authored editor content. Empty content is persisted + * immediately and remains authoritative across composer remounts until a + * later non-empty editor update supersedes it. + */ + trackAuthoredContent: (content: string) => void; +}; + +const authoritativelyClearedDraftKeys = new Set(); + +function scopedDraftKey(draftKey: string): string { + return `${getDraftStoreScope()}:${draftKey}`; +} + /** * Owns the draft-persist lifecycle for `MessageComposer`. * @@ -104,8 +120,10 @@ export function useDraftPersistLifecycle({ setSpoileredAttachmentUrls, spoileredAttachmentUrlsRef, syncComposerContentFromEditor, -}: UseDraftPersistLifecycleParams): void { +}: UseDraftPersistLifecycleParams): UseDraftPersistLifecycleResult { const pendingImetaForPersistRef = React.useRef([]); + const emptyContentIsAuthoritativeRef = React.useRef(false); + const isRestoringContentRef = React.useRef(false); const restoredQueuedAttachmentsRef = React.useRef( [], ); @@ -117,7 +135,7 @@ export function useDraftPersistLifecycle({ pendingImetaForPersistRef.current = livePendingImeta; // biome-ignore lint/correctness/useExhaustiveDependencies: effectiveDraftKey is the sole trigger - React.useEffect(() => { + React.useLayoutEffect(() => { // The outgoing draft is persisted by the cleanup below, which runs before // this body on key changes and has the correct outgoing channelId in its // closure. Do NOT re-persist prevKey here: channelId in this render @@ -134,10 +152,21 @@ export function useDraftPersistLifecycle({ : []; } restoreQueuedAttachments?.(restoredQueuedAttachmentsRef.current); + const authoritativeDraftKey = effectiveDraftKey + ? scopedDraftKey(effectiveDraftKey) + : null; + const wasAuthoritativelyCleared = authoritativeDraftKey + ? authoritativelyClearedDraftKeys.has(authoritativeDraftKey) + : false; const saved = effectiveDraftKey ? loadDraft(effectiveDraftKey) : undefined; + emptyContentIsAuthoritativeRef.current = wasAuthoritativelyCleared; + isRestoringContentRef.current = true; if (saved) { - setContent(saved.content); - restoreMentionRefs(saved.mentionRefs ?? []); + const restoredContent = wasAuthoritativelyCleared ? "" : saved.content; + setContent(restoredContent); + restoreMentionRefs( + wasAuthoritativelyCleared ? [] : (saved.mentionRefs ?? []), + ); // Set the persist-snapshot ref SYNCHRONOUSLY before calling the async // state setter, so the cleanup closure (which may fire before the state // update commits in React StrictMode's simulate-unmount pass) reads the @@ -153,6 +182,7 @@ export function useDraftPersistLifecycle({ setPendingImeta([]); setSpoileredAttachmentUrls(new Set()); } + isRestoringContentRef.current = false; return () => { if (effectiveDraftKey) { @@ -160,7 +190,9 @@ export function useDraftPersistLifecycle({ if (queuedAttachments.length > 0) { saveQueuedAttachmentsForDraft?.(effectiveDraftKey, queuedAttachments); } - const content = syncComposerContentFromEditor(); + const content = emptyContentIsAuthoritativeRef.current + ? "" + : syncComposerContentFromEditor(); persistDraft( effectiveDraftKey, content, @@ -172,4 +204,29 @@ export function useDraftPersistLifecycle({ } }; }, [effectiveDraftKey]); + + const trackAuthoredContent = React.useCallback( + (content: string) => { + if (!effectiveDraftKey || isRestoringContentRef.current) return; + const authoritativeDraftKey = scopedDraftKey(effectiveDraftKey); + if (content.length > 0) { + authoritativelyClearedDraftKeys.delete(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = false; + return; + } + authoritativelyClearedDraftKeys.add(authoritativeDraftKey); + emptyContentIsAuthoritativeRef.current = true; + persistDraft( + effectiveDraftKey, + content, + channelId ?? effectiveDraftKey, + [...pendingImetaForPersistRef.current], + [...spoileredAttachmentUrlsRef.current], + [], + ); + }, + [channelId, effectiveDraftKey, persistDraft, spoileredAttachmentUrlsRef], + ); + + return { trackAuthoredContent }; } diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 2136e2e12f..edcf523e36 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1260,7 +1260,7 @@ test("selecting a persona mention reuses an existing persona agent", async ({ await expect(mentionChip).toHaveText("Fizz"); }); -test("managed relay-profile agents with member roles use the agent address tray", async ({ +test("managed relay-profile agents with member roles can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -1286,9 +1286,17 @@ test("managed relay-profile agents with member roles use the agent address tray" await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); - await expect(dropdown.getByText("agent")).toBeVisible(); - await input.press("Enter"); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); + await expect(charlieRow.getByText("agent")).toBeVisible(); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); @@ -2618,7 +2626,7 @@ test("system member-joined rows render the joined person as a plain profile name await expect(joinedPersonName).not.toHaveAttribute("data-mention"); }); -test("selecting a managed non-member agent from a DM addresses it", async ({ +test("a managed non-member agent from a DM can be addressed explicitly", async ({ page, }) => { await installMockBridge(page, { @@ -2638,10 +2646,18 @@ test("selecting a managed non-member agent from a DM addresses it", async ({ await input.fill("@char"); const dropdown = autocomplete(page); - await expect(dropdown.getByText("charlie")).toBeVisible(); + const charlieRow = dropdown.getByTestId( + `mention-suggestion-${TEST_IDENTITIES.charlie.pubkey}`, + ); + await expect(charlieRow.getByText("charlie")).toBeVisible(); await expect(autocomplete(page)).toHaveCount(1); await expect(input.locator(".mention-chip")).toHaveCount(0); - await input.press("Enter"); + await charlieRow + .getByRole("button", { + name: "Automatically mention charlie", + exact: true, + }) + .click(); await expect(input).toHaveText("@charlie "); await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index f9481c91f0..9a02e2e1a8 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -8,6 +8,20 @@ const CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENT_A = "a".repeat(64); const AGENT_B = "b".repeat(64); const THREAD_ROOT_ID = "mock-general-welcome"; +const KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY = + "buzz.messages.keepMentionedAgentsPinned"; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((storageKey) => { + window.localStorage.removeItem(storageKey); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +}); + +async function keepMentionedAgentsPinned(page: Page) { + await page.addInitScript((storageKey) => { + window.localStorage.setItem(storageKey, "true"); + }, KEEP_MENTIONED_AGENTS_PINNED_STORAGE_KEY); +} async function seedTheme(page: Page, theme: string, accent = "#c0a2f1") { await page.addInitScript( @@ -271,7 +285,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ ).toBeVisible(); }); -test("Tab immediately selects a manually mentioned agent", async ({ page }) => { +test("Tab inserts a one-time agent mention by default", async ({ page }) => { await installAudienceFixtures(page); await openGeneral(page); @@ -286,7 +300,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + ).toHaveCount(0); const selectAllShortcut = await page.evaluate(() => /mac|iphone|ipad|ipod/i.test(navigator.platform) ? "Meta+A" : "Control+A", ); @@ -301,6 +315,7 @@ test("Tab immediately selects a manually mentioned agent", async ({ page }) => { test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -448,7 +463,7 @@ test("the mention button opens settings and can undo an address", async ({ .toContain(AGENT_A); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(1); + ).toHaveCount(0); }); test("always-mentioned agents remain in the mention button while Enter-send resolves", async ({ @@ -568,9 +583,10 @@ test("a failed always-mentioned send shakes the composer avatar", async ({ await expect(avatar).toHaveAttribute("data-shake-version", "1"); }); -test("a manually mentioned agent becomes selected immediately", async ({ +test("a manual mention persists when automatic mentions are enabled", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); await openGeneral(page); @@ -652,6 +668,7 @@ test("a manually mentioned agent becomes selected immediately", async ({ test("the auto-pin popover can turn off automatic agent mentions", async ({ page, }) => { + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); @@ -717,6 +734,7 @@ test("reduced motion removes addressed agents without spatial animation", async page, }) => { await page.emulateMedia({ reducedMotion: "reduce" }); + await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openGeneral(page); From 22f32c99e2e983b7e015e8c058cac47438d05b2f Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 25 Aug 2026 14:35:53 -0600 Subject: [PATCH 17/24] docs(nest): make commit attribution policy-neutral (#6707) ## Summary - replace the generated Nest's unconditional human author/sign-off rules with portable guidance that separates authorship, material co-authorship, DCO certification, and cryptographic signing - defer attribution to repository-local policy, forbid inferred or guessed identities, and require inspection of every outgoing commit - bump the Nest template version so existing installations refresh, with regression coverage for fresh generation and upgrade preservation ### Related issue None found. Related runtime identity work exists in #6177, but this PR is intentionally limited to the generated Nest guidance and its refresh behavior. ### Testing - `bin/just desktop-tauri-clippy` - `bin/just desktop-tauri-test` - `bin/just file-size-check` - `git diff --check` - pre-push hooks: `push-head-scope`, `branch-skew`, `file-size-check`, and `desktop-tauri-checks` Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest/tests.rs | 43 +++++++++++++++++++ .../src/managed_agents/nest_agents.md | 17 +++++--- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 72cf466427..3d191926a3 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -48,7 +48,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); /// Template content version for AGENTS.md static content (above managed markers). /// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. /// Version 1 is implicitly "before this mechanism existed" (no version file). -const NEST_AGENTS_VERSION: u32 = 4; +const NEST_AGENTS_VERSION: u32 = 5; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index bc67a5b69e..9aa1eeb098 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -41,6 +41,21 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); } +#[test] +fn nest_agents_template_separates_commit_attribution_claims() { + assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1); + assert!(AGENTS_MD.contains( + "Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims" + )); + assert!(AGENTS_MD + .contains("Request, approval, review, or accountability alone is not co-authorship")); + assert!(AGENTS_MD.contains("A sign-off is not an approval marker")); + assert!(AGENTS_MD.contains("Never use another person's signing key")); + assert!(AGENTS_MD.contains("inspect every outgoing commit against the actual upstream or base")); + assert!(AGENTS_MD.contains("An agent-owned repository may use the agent as author")); + assert!(!AGENTS_MD.contains("every commit MUST include a `Signed-off-by`")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); @@ -431,6 +446,34 @@ fn refresh_agents_md_writes_version_file() { assert_eq!(version.trim(), NEST_AGENTS_VERSION.to_string()); } +#[test] +fn refresh_agents_md_upgrades_attribution_and_preserves_owned_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".buzz"); + ensure_nest_at(&root).unwrap(); + + let agents_md = root.join("AGENTS.md"); + fs::write( + &agents_md, + "# Buzz Nest\n\n## Git Commit Identity\n\n\ + - **Human sign-off (required):** every commit MUST include a `Signed-off-by`.\n\n\ + \n\ + ## Active Agents\n\n| Name | Persona | How to address |\n\ + |------|---------|----------------|\n| Kit | Builder | @Kit |\n\ + \n\n## Local Notes\n\nKeep me.\n", + ) + .unwrap(); + fs::write(root.join(".nest-agents-version"), "4\n").unwrap(); + + ensure_nest_at(&root).unwrap(); + + let content = fs::read_to_string(&agents_md).unwrap(); + assert_eq!(content.matches("## Git Commit Attribution").count(), 1); + assert!(!content.contains("**Human sign-off (required):**")); + assert!(content.contains("| Kit | Builder | @Kit |")); + assert!(content.contains("## Local Notes\n\nKeep me.")); +} + #[test] fn refresh_skill_md_writes_version_file() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 7cb7489b85..dbba2624db 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -44,15 +44,18 @@ created: 2026-01-15 - **`.scratch/` is disposable** — don't rely on it across sessions - **Stay on task** — only stage files relevant to your current work -## Git Commit Identity +## Git Commit Attribution -The human operator signs off for accountability. +Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims. Follow repository-local rules and the authorizing human's explicit directions; do not infer attribution from repository ownership or from who requested, approved, or reviewed the work. -- **Human sign-off (required):** every commit MUST include a `Signed-off-by` trailer for the human operator who is responsible for the agent's work. Add via `git commit --trailer "Signed-off-by: Human Name "`. One blank line must separate trailers from the commit body. -- **Human credit (`Co-authored-by`):** every commit MUST also include a `Co-authored-by` trailer for the same human operator, with identical name and email to the `Signed-off-by` line. GitHub parses `Co-authored-by` for contribution-graph credit; `Signed-off-by` alone does not grant it. Add via `git commit --trailer "Co-authored-by: Human Name "`. Place `Co-authored-by` before `Signed-off-by` in the trailer block. -- **Discovering the human's identity:** read `git config user.name` and `git config user.email` from the working repository. These reflect the human operator's configured identity for that repo (which may differ from their global config). Use these exact values for both trailers. Do NOT hardcode, guess, or prompt for the email — the repo config is the source of truth. If `git config user.email` returns empty, STOP and ask the human operator for their name and email before committing. -- **Signing:** if the agent has a registered signing key, sign commits. If not, commits will land unverified — this is acceptable until agent SSH keys are provisioned. Do NOT use the human's signing key. -- **Verify before pushing:** `git log -1` should show the human's `Signed-off-by` trailer. +- **Author:** use the person or agent required by the applicable policy. If no policy specifies an author, use the identity that actually authored the change. +- **Co-authors:** add `Co-authored-by` only for other people or agents who materially authored the change. Request, approval, review, or accountability alone is not co-authorship. +- **DCO:** add `Signed-off-by` only when repository policy requires that identity's DCO certification. A sign-off is not an approval marker. +- **Identity:** resolve required identities from trusted local configuration or explicit verified direction; never hard-code or guess them. A managed runtime may make effective `git config user.*` values identify the agent. Stop and ask if a required identity cannot be established. +- **Signing:** use only the signing key configured for the committing identity. Never use another person's signing key. +- **Verify before pushing:** inspect every outgoing commit against the actual upstream or base and confirm its attribution matches the applicable policy. + +A repository may require an accountable human as author and the implementing agent as co-author. An agent-owned repository may use the agent as author and require no human trailer. In both cases, repository-local policy controls. ## Active Agents From ee6ca5fa28bce04dfecb6717de65b08a57f2ac47 Mon Sep 17 00:00:00 2001 From: Jordan Mecom Date: Tue, 25 Aug 2026 13:57:23 -0700 Subject: [PATCH 18/24] Remove public relay signing key fallback (#6729) Require `BUZZ_RELAY_PRIVATE_KEY` on every relay startup and remove the shared public fallback key completely. For local development, `just bootstrap` now generates a random key once in the gitignored `.env` file. Re-running bootstrap preserves that key, so relay-authored events retain the same identity across restarts. The local relay recipes reload `.env`, while isolated CI and E2E launchers pass explicit per-run keys. Startup fails immediately when the key is missing or invalid, before connecting to Postgres or Redis. Deployed relays continue to receive their stable key from the existing chart-managed secret. Validated with: - `./scripts/test-ensure-local-relay-key.sh` - `cargo test -p buzz-relay --bin buzz-relay` - `cargo clippy -p buzz-relay --bin buzz-relay -- -D warnings` - `cargo fmt --all -- --check` - `shellcheck scripts/ensure-local-relay-key.sh scripts/test-ensure-local-relay-key.sh` Signed-off-by: Jordan Mecom --- .env.example | 4 +- .github/workflows/ci.yml | 2 + Justfile | 21 +++++++- TESTING.md | 11 +++-- crates/buzz-relay/src/main.rs | 55 +++++++++++---------- scripts/e2e-git-perms.sh | 1 + scripts/ensure-local-relay-key.sh | 66 ++++++++++++++++++++++++++ scripts/run-desktop-release-smoke.sh | 2 + scripts/start-isolated-test-relay.sh | 2 + scripts/start-relay-for-tests.sh | 5 +- scripts/test-ensure-local-relay-key.sh | 33 +++++++++++++ 11 files changed, 169 insertions(+), 33 deletions(-) create mode 100755 scripts/ensure-local-relay-key.sh create mode 100755 scripts/test-ensure-local-relay-key.sh diff --git a/.env.example b/.env.example index 0f7bbba6f1..cb503392b3 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,8 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 -# Stable relay signing key. Set this in dev if you want REST-created forum posts -# to keep resolving to the original author across relay restarts. +# Stable relay signing key (required). `just bootstrap` generates a random key in +# the gitignored .env file. Preserve that value across restarts and backups. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> # Optional: path to the web UI dist directory. When set, the relay serves # the web frontend at / for browser requests. Leave unset for local dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2bc8db65e..fd2949492f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -529,6 +529,7 @@ jobs: REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ @@ -714,6 +715,7 @@ jobs: REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ diff --git a/Justfile b/Justfile index fe5d7bf285..6c7740bc7a 100644 --- a/Justfile +++ b/Justfile @@ -43,6 +43,7 @@ bootstrap: cp .env.example .env echo "Created .env from .env.example — review it before running just dev." fi + ./scripts/ensure-local-relay-key.sh .env # Start Docker services, run migrations, install desktop deps setup: bootstrap @@ -307,6 +308,7 @@ test: test-unit: #!/usr/bin/env bash set -euo pipefail + ./scripts/test-ensure-local-relay-key.sh if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib @@ -423,6 +425,9 @@ relay: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay # Start the relay with the built web UI served from it @@ -430,6 +435,9 @@ relay-web: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C web build BUZZ_WEB_DIR=./web/dist cargo run -p buzz-relay @@ -439,6 +447,9 @@ admin: bootstrap _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport [[ -d node_modules ]] || pnpm install pnpm -C admin-web build export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" @@ -459,7 +470,12 @@ admin-check: fmt-check pnpm -C admin-web exec playwright test # Start the relay server in release mode -relay-release: _ensure-migrations +relay-release: bootstrap _ensure-migrations + #!/usr/bin/env bash + set -euo pipefail + set -o allexport + source .env + set +o allexport cargo run -p buzz-relay --release @@ -468,6 +484,9 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 health_port="${BUZZ_HEALTH_PORT:-8080}" diff --git a/TESTING.md b/TESTING.md index 29d07a80de..0e64b74066 100644 --- a/TESTING.md +++ b/TESTING.md @@ -29,7 +29,7 @@ CLI signs every request with NIP-98, so you don't need `nak` or hand-rolled ```bash . ./bin/activate-hermit # activate pinned toolchain -cp .env.example .env # one-time +just bootstrap # create .env and its stable relay key once just setup # start Docker services, run migrations ``` @@ -70,6 +70,9 @@ Rebuild after any code change — the steps below use the release binaries. In a separate terminal (it runs in the foreground): ```bash +set -o allexport +source .env # includes the key generated by just bootstrap +set +o allexport buzz-relay # release binary from step 2, serves ws://localhost:3000 # alternatives: # cargo run --release -p buzz-relay # rebuild + run in release @@ -89,9 +92,9 @@ curl -s http://localhost:8080/_readiness # → {"status":"ready"} > `BUZZ_HEALTH_PORT`) so K8s probes bypass auth middleware. The main app > port also exposes `/health` for convenience. -The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`). The startup -log emits a WARN about this — that's expected for local testing. See the env -vars table at the bottom if you need to lock it down. +The relay starts in dev mode (`BUZZ_REQUIRE_AUTH_TOKEN=false`) with the stable +relay identity generated in `.env`. See the env vars table at the bottom if +you need to lock it down. > **Already running Buzz Desktop (or another relay) on `:3000` / `:8080` / > `:9102`?** Buzz binds three ports — main, health, metrics — and any of diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 566b684f83..3e39b22f8b 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -35,6 +35,16 @@ fn buzz_auto_migrate_enabled(value: Option<&str>) -> bool { }) } +fn relay_keypair_from_config(relay_private_key: Option<&str>) -> anyhow::Result { + let hex = relay_private_key.ok_or_else(|| { + anyhow::anyhow!( + "BUZZ_RELAY_PRIVATE_KEY must be set. Run `just bootstrap` for local \ + development or configure a stable 32-byte hex private key." + ) + })?; + nostr::Keys::parse(hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}")) +} + /// Controls how many per-community gauge series the usage poller emits. /// /// Datadog cost is proportional to the number of unique time-series. With ~25 @@ -143,6 +153,7 @@ async fn main() -> anyhow::Result<()> { error!("Invalid configuration: {e}"); anyhow::anyhow!("Configuration error: {e}") })?; + let relay_keypair = relay_keypair_from_config(config.relay_private_key.as_deref())?; info!( bind_addr = %config.bind_addr, relay_url = %config.relay_url, @@ -422,29 +433,6 @@ async fn main() -> anyhow::Result<()> { let workflow_config = buzz_workflow::WorkflowConfig::default(); let workflow_engine = Arc::new(WorkflowEngine::new(db.clone(), workflow_config)); - let relay_keypair = if let Some(hex) = &config.relay_private_key { - nostr::Keys::parse(hex) - .map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))? - } else if !config.require_auth_token { - // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) - // replace correctly across restarts. Without this, each restart generates a new pubkey - // and replace_addressable_event inserts duplicates instead of replacing. - const DEV_RELAY_PRIVKEY: &str = - "0000000000000000000000000000000000000000000000000000000000000001"; - let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); - tracing::warn!( - pubkey = %keys.public_key().to_hex(), - "Using hardcoded dev relay keypair (BUZZ_REQUIRE_AUTH_TOKEN=false). \ - Set BUZZ_RELAY_PRIVATE_KEY for production." - ); - keys - } else { - panic!( - "BUZZ_RELAY_PRIVATE_KEY must be set when BUZZ_REQUIRE_AUTH_TOKEN=true. \ - A stable relay identity is required for production." - ); - }; - config .media .validate() @@ -2037,8 +2025,8 @@ mod tests { use super::{ buzz_auto_migrate_enabled, dropped_in_memory_keys, idle_timeout_secs, - refresh_legacy_active_gauge_recency, run_periodic_until_cancelled, EmissionScope, - InMemoryMetricKey, + refresh_legacy_active_gauge_recency, relay_keypair_from_config, + run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; use metrics::GaugeFn; use metrics_util::{ @@ -2086,6 +2074,23 @@ mod tests { assert!(buzz_auto_migrate_enabled(Some("on"))); } + #[test] + fn configured_relay_identity_is_preserved() { + let configured = nostr::Keys::generate(); + let secret = configured.secret_key().to_secret_hex(); + + let selected = relay_keypair_from_config(Some(&secret)).expect("configured key"); + + assert_eq!(selected.public_key(), configured.public_key()); + } + + #[test] + fn missing_relay_identity_is_rejected() { + let result = relay_keypair_from_config(None); + + assert!(result.is_err()); + } + #[test] fn test_emission_scope_off_disallows_every_community() { assert!(EmissionScope::All.allows(&Uuid::new_v4())); diff --git a/scripts/e2e-git-perms.sh b/scripts/e2e-git-perms.sh index f6a6a93063..bc507a7ff3 100755 --- a/scripts/e2e-git-perms.sh +++ b/scripts/e2e-git-perms.sh @@ -336,6 +336,7 @@ export BUZZ_GIT_HOOK_HMAC_SECRET="${HMAC_SECRET}" export BUZZ_BIND_ADDR="${RELAY_HOST}:${RELAY_PORT}" export RELAY_URL="${RELAY_WS}" export RUST_LOG="buzz_relay=warn" +export BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:-$(openssl rand -hex 32)}" export BUZZ_REQUIRE_AUTH_TOKEN=false # Clean repos dir (isolated test state) diff --git a/scripts/ensure-local-relay-key.sh b/scripts/ensure-local-relay-key.sh new file mode 100755 index 0000000000..8cf926a3d7 --- /dev/null +++ b/scripts/ensure-local-relay-key.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "error: ${ENV_FILE} does not exist" >&2 + exit 1 +fi + +existing_key="$({ + unset BUZZ_RELAY_PRIVATE_KEY + set +u + # shellcheck disable=SC1090 + source "${ENV_FILE}" || exit 1 + printf '%s' "${BUZZ_RELAY_PRIVATE_KEY:-}" +})" + +if [[ -n "${existing_key}" ]]; then + chmod 600 "${ENV_FILE}" + exit 0 +fi + +relay_key="$(node <<'NODE' +const { randomBytes } = require("node:crypto"); +const curveOrder = BigInt( + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", +); + +let bytes; +let scalar; +do { + bytes = randomBytes(32); + scalar = BigInt(`0x${bytes.toString("hex")}`); +} while (scalar === 0n || scalar >= curveOrder); + +process.stdout.write(bytes.toString("hex")); +NODE +)" + +temp_file="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" +trap 'rm -f "${temp_file}"' EXIT + +awk -v key="${relay_key}" ' + BEGIN { replaced = 0 } + /^[[:space:]]*(export[[:space:]]+)?BUZZ_RELAY_PRIVATE_KEY=/ { + if (!replaced) { + print "BUZZ_RELAY_PRIVATE_KEY=" key + replaced = 1 + } + next + } + { print } + END { + if (!replaced) { + if (NR > 0) print "" + print "BUZZ_RELAY_PRIVATE_KEY=" key + } + } +' "${ENV_FILE}" > "${temp_file}" + +chmod 600 "${temp_file}" +mv "${temp_file}" "${ENV_FILE}" +trap - EXIT + +echo "Generated BUZZ_RELAY_PRIVATE_KEY in ${ENV_FILE}." diff --git a/scripts/run-desktop-release-smoke.sh b/scripts/run-desktop-release-smoke.sh index 33f7013ab3..85aee2e05c 100755 --- a/scripts/run-desktop-release-smoke.sh +++ b/scripts/run-desktop-release-smoke.sh @@ -103,6 +103,7 @@ else RELAY_BIN="${ROOT}/target/ci/buzz-relay" fi log "starting relay at ${RELAY_HTTP_URL}" +RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" env \ DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/${DB_NAME}" \ REDIS_URL="redis://localhost:6379/${REDIS_DB}" \ @@ -110,6 +111,7 @@ env \ BUZZ_BIND_ADDR="127.0.0.1:${RELAY_PORT}" \ BUZZ_HEALTH_PORT="${HEALTH_PORT}" \ BUZZ_METRICS_PORT="${METRICS_PORT}" \ + BUZZ_RELAY_PRIVATE_KEY="${RELAY_PRIVATE_KEY}" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=1000000 \ diff --git a/scripts/start-isolated-test-relay.sh b/scripts/start-isolated-test-relay.sh index 1e2047e99c..1f6b6620c1 100755 --- a/scripts/start-isolated-test-relay.sh +++ b/scripts/start-isolated-test-relay.sh @@ -133,6 +133,7 @@ ok "Relay built" # survives (same pattern the perf stack uses). Logs to ${RELAY_LOG}. RELAY_LOG="${RELAY_LOG:-/tmp/dawn-relay-run.log}" TMUX_SESSION="${TMUX_SESSION:-dawn-relay}" +RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" tmux kill-session -t "${TMUX_SESSION}" 2>/dev/null || true if command -v lsof >/dev/null 2>&1 && lsof -nP -iTCP:"${RELAY_MAIN}" -sTCP:LISTEN >/dev/null 2>&1; then err "Port ${RELAY_MAIN} is already in use; refusing to report a stale relay as this harness." @@ -151,6 +152,7 @@ tmux new-session -d -s "${TMUX_SESSION}" "cd '${REPO_ROOT}' && env \ BUZZ_S3_ACCESS_KEY=buzz_dev \ BUZZ_S3_SECRET_KEY=buzz_dev_secret \ BUZZ_S3_BUCKET=buzz-media \ + BUZZ_RELAY_PRIVATE_KEY=${RELAY_PRIVATE_KEY} \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ './target/${CARGO_TARGET_PROFILE}/buzz-relay' > '${RELAY_LOG}' 2>&1" diff --git a/scripts/start-relay-for-tests.sh b/scripts/start-relay-for-tests.sh index b9d93935c0..4bd1a27408 100755 --- a/scripts/start-relay-for-tests.sh +++ b/scripts/start-relay-for-tests.sh @@ -152,6 +152,8 @@ fi log "Starting relay..." +TEST_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:-$(openssl rand -hex 32)}" + # Optional NIP-43 membership gating: exported by callers that need a # membership-gated relay (e.g. the mesh lifecycle smoke). All three must be # set together — the relay fails fast otherwise. @@ -160,8 +162,8 @@ if [[ "${BUZZ_REQUIRE_RELAY_MEMBERSHIP:-}" == "true" ]]; then MEMBERSHIP_ENV+=( BUZZ_REQUIRE_RELAY_MEMBERSHIP=true RELAY_OWNER_PUBKEY="${RELAY_OWNER_PUBKEY:?RELAY_OWNER_PUBKEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" - BUZZ_RELAY_PRIVATE_KEY="${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" ) + : "${BUZZ_RELAY_PRIVATE_KEY:?BUZZ_RELAY_PRIVATE_KEY required with BUZZ_REQUIRE_RELAY_MEMBERSHIP=true}" log "Membership gating enabled (NIP-43)" fi @@ -170,6 +172,7 @@ nohup env \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ + BUZZ_RELAY_PRIVATE_KEY="${TEST_RELAY_PRIVATE_KEY}" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ diff --git a/scripts/test-ensure-local-relay-key.sh b/scripts/test-ensure-local-relay-key.sh new file mode 100755 index 0000000000..be87100557 --- /dev/null +++ b/scripts/test-ensure-local-relay-key.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/buzz-relay-key-test.XXXXXX")" +trap 'rm -rf "${TEST_DIR}"' EXIT + +ENV_FILE="${TEST_DIR}/.env" +cp "${REPO_ROOT}/.env.example" "${ENV_FILE}" + +"${SCRIPT_DIR}/ensure-local-relay-key.sh" "${ENV_FILE}" >/dev/null +first_key="$(sed -n 's/^BUZZ_RELAY_PRIVATE_KEY=//p' "${ENV_FILE}")" + +if [[ ! "${first_key}" =~ ^[0-9a-f]{64}$ ]]; then + echo "FAIL: bootstrap did not generate a valid 32-byte hex relay key" >&2 + exit 1 +fi + +"${SCRIPT_DIR}/ensure-local-relay-key.sh" "${ENV_FILE}" >/dev/null +second_key="$(sed -n 's/^BUZZ_RELAY_PRIVATE_KEY=//p' "${ENV_FILE}")" + +if [[ "${first_key}" != "${second_key}" ]]; then + echo "FAIL: bootstrap replaced the existing relay key" >&2 + exit 1 +fi + +if [[ "$(grep -c '^BUZZ_RELAY_PRIVATE_KEY=' "${ENV_FILE}")" -ne 1 ]]; then + echo "FAIL: bootstrap wrote more than one relay key" >&2 + exit 1 +fi + +echo "PASS: bootstrap generates one relay key and reuses it" From 7a1b7d8e09f96c10b6617a66bb8589e9984ffb3c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 17:36:57 -0400 Subject: [PATCH 19/24] chore(release): release Buzz Desktop version 0.5.19 (#6828) ## Buzz Desktop release v0.5.19 - **Frozen main:** `ee6ca5fa28bce04dfecb6717de65b08a57f2ac47` - **Reviewed candidate:** `ab691bcdaeccaa6698a2199beaba3f6e93daae81` - **Previous desktop release:** `desktop-v0.5.18` - **Proposed immutable tag:** `desktop-v0.5.19` This PR may be **squash merged** after the Desktop Release Candidate check and all protected-branch checks pass. Merging authorizes publication of the exact reviewed candidate; later or unrelated changes on `main` cannot alter it. The checked-in changelog accounts for every non-merge commit in the release range. The Desktop tag points to the reviewed candidate commit, not the later squash commit. Publication remains bound to that immutable candidate tag. Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 14 +++--- CHANGELOG.md | 79 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 784725b2e6..e2711c63b3 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.18", - "base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", - "previous_tag": "desktop-v0.5.17", - "previous_base_sha": "3fdf289b78c40f80abce86575c25b5ed6361d82c", - "previous_merge_sha": "8232299cbe6d90692fac3de46cde0ec123edd6c1", - "tag": "desktop-v0.5.18", - "commit_count": 66 + "version": "0.5.19", + "base_sha": "ee6ca5fa28bce04dfecb6717de65b08a57f2ac47", + "previous_tag": "desktop-v0.5.18", + "previous_base_sha": "aea0ef8df9fc24d9aa8bf5c761ab2910026a601b", + "previous_merge_sha": "1e6f1a2584d1620fcc16baf8df962bda8d7c7ae9", + "tag": "desktop-v0.5.19", + "commit_count": 69 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 42fc482515..f721ba905b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # Changelog +## v0.5.19 + +### Desktop and shared changes + +- docs(nest): make commit attribution policy-neutral ([#6707](https://github.com/block/buzz/pull/6707)) ([`22f32c99e2e983b7e015e8c058cac47438d05b2f`](https://github.com/block/buzz/commit/22f32c99e2e983b7e015e8c058cac47438d05b2f)) +- fix(desktop-messages): preserve inline agent mentions with persistent addressing ([#6793](https://github.com/block/buzz/pull/6793)) ([`b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0`](https://github.com/block/buzz/commit/b58de7cfa7a13b1a6dbb2fc269186439a6fb79a0)) +- feat(desktop): persist agent addressing across composer messages ([#6714](https://github.com/block/buzz/pull/6714)) ([`7ba1197aa6b01616b5920487dd585a37d2e7f74d`](https://github.com/block/buzz/commit/7ba1197aa6b01616b5920487dd585a37d2e7f74d)) +- feat: navigate images across message threads ([#6705](https://github.com/block/buzz/pull/6705)) ([`a526dca9bcaa08dfb5db77999cc1f584a17a9d64`](https://github.com/block/buzz/commit/a526dca9bcaa08dfb5db77999cc1f584a17a9d64)) +- revert fixed mention highlight ([#6716](https://github.com/block/buzz/pull/6716)) ([`12f3fea26e4c638a5fae20dce1ec0876e3bbca41`](https://github.com/block/buzz/commit/12f3fea26e4c638a5fae20dce1ec0876e3bbca41)) +- highlight search terms in results and messages ([#6702](https://github.com/block/buzz/pull/6702)) ([`29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1`](https://github.com/block/buzz/commit/29f2054c69f2e0ea4ee90141ac6a80503e5f9bd1)) +- fix(desktop): make lightbox zoom controls interactive ([#6710](https://github.com/block/buzz/pull/6710)) ([`9b6a637d014607760d116b90c57062f82ab27cf3`](https://github.com/block/buzz/commit/9b6a637d014607760d116b90c57062f82ab27cf3)) +- Support community deletion in versioned media buckets ([#6738](https://github.com/block/buzz/pull/6738)) ([`d12dea4e67c5224a626d9c00f45e68d1def72d4c`](https://github.com/block/buzz/commit/d12dea4e67c5224a626d9c00f45e68d1def72d4c)) +- Fix TipTap editor mount race ([#6779](https://github.com/block/buzz/pull/6779)) ([`bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9`](https://github.com/block/buzz/commit/bb5b9357a7c8ddeaee73f6252c7d9f8a9014dbc9)) +- feat(buzz-agent): gate LLM tool calls on session/request_permission ([#5712](https://github.com/block/buzz/pull/5712)) ([`a1219070fa6c3263c8a29637c70b7a317d4ecd9d`](https://github.com/block/buzz/commit/a1219070fa6c3263c8a29637c70b7a317d4ecd9d)) +- Fix mobile Huddle agent voice turn states ([#6611](https://github.com/block/buzz/pull/6611)) ([`8b812017a79c9279594330fb2b04c1eaf0e6e6c1`](https://github.com/block/buzz/commit/8b812017a79c9279594330fb2b04c1eaf0e6e6c1)) +- fix(desktop): polish inline chip states ([#6718](https://github.com/block/buzz/pull/6718)) ([`a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21`](https://github.com/block/buzz/commit/a8e1c66c4a5017a32e41e04e2ba6059e2dfcae21)) +- feat(workflows): discover trigger filter values ([#6712](https://github.com/block/buzz/pull/6712)) ([`e760c51820b2103d965c22b44254678e10fb689a`](https://github.com/block/buzz/commit/e760c51820b2103d965c22b44254678e10fb689a)) +- feat(desktop): simplify the message action rail ([#6529](https://github.com/block/buzz/pull/6529)) ([`c5166f2164035ca96787daee6528d5dc04c4a02e`](https://github.com/block/buzz/commit/c5166f2164035ca96787daee6528d5dc04c4a02e)) +- fix(desktop): restore icon-only remote marker ([#6491](https://github.com/block/buzz/pull/6491)) ([`30d2fc52f96138311f2006627ffc1a6d5ff1865b`](https://github.com/block/buzz/commit/30d2fc52f96138311f2006627ffc1a6d5ff1865b)) +- fix(composer): wrap Buzz chip labels without orphaning icons ([#6581](https://github.com/block/buzz/pull/6581)) ([`f79d346a178408661fcad85122364ac2ad7e9cb2`](https://github.com/block/buzz/commit/f79d346a178408661fcad85122364ac2ad7e9cb2)) +- fix(desktop): bound thread /query and surface load errors, not false-empty ([#6447](https://github.com/block/buzz/pull/6447)) ([`f6e6617a9dcc2308d5039f8afaab974b49fb9577`](https://github.com/block/buzz/commit/f6e6617a9dcc2308d5039f8afaab974b49fb9577)) +- fix(messages): route edits to the owning composer ([#6575](https://github.com/block/buzz/pull/6575)) ([`4bf80978f52981f0035e6c0b86bdf1108bbf64c8`](https://github.com/block/buzz/commit/4bf80978f52981f0035e6c0b86bdf1108bbf64c8)) +- fix(desktop): align jump-to-latest pill with composer height ([#6606](https://github.com/block/buzz/pull/6606)) ([`9f55bf67456be10ff7c8238bf0d9e12e582848f6`](https://github.com/block/buzz/commit/9f55bf67456be10ff7c8238bf0d9e12e582848f6)) +- fix(desktop): emit singular `mention` feed category so alerts route correctly ([#6665](https://github.com/block/buzz/pull/6665)) ([`db5617dd1541aeab7bacaf039b6ca98f856776d0`](https://github.com/block/buzz/commit/db5617dd1541aeab7bacaf039b6ca98f856776d0)) +- show mention counts in channel notifications ([#6696](https://github.com/block/buzz/pull/6696)) ([`0e69b3fd7c44c09da62e2c4e89fdb4a26e666869`](https://github.com/block/buzz/commit/0e69b3fd7c44c09da62e2c4e89fdb4a26e666869)) +- fix(desktop): hide selection formatting tray on composer right-click ([#6683](https://github.com/block/buzz/pull/6683)) ([`2f13e30e88e84851e7ad336364dd3cfd547b8c16`](https://github.com/block/buzz/commit/2f13e30e88e84851e7ad336364dd3cfd547b8c16)) +- fix(desktop): stabilize members dialog scrolling ([#6670](https://github.com/block/buzz/pull/6670)) ([`72ba987c365abb98939153c4d43dde73257c1264`](https://github.com/block/buzz/commit/72ba987c365abb98939153c4d43dde73257c1264)) +- fix(desktop): keep member runtime status off the UI thread ([#6445](https://github.com/block/buzz/pull/6445)) ([`17af15effac63e6bc5338448326ce52ba4426e5f`](https://github.com/block/buzz/commit/17af15effac63e6bc5338448326ce52ba4426e5f)) +- perf(desktop): persist channel heads, collapse thread reads and reply sends ([#6572](https://github.com/block/buzz/pull/6572)) ([`2d280376ad36134cec1f23bead6d866d30bed147`](https://github.com/block/buzz/commit/2d280376ad36134cec1f23bead6d866d30bed147)) +- Downgrade desktop Huddles to audio protocol v2 ([#6610](https://github.com/block/buzz/pull/6610)) ([`0720f5380ce8a6c050afac159f8462c06cd51ab5`](https://github.com/block/buzz/commit/0720f5380ce8a6c050afac159f8462c06cd51ab5)) +- perf(desktop): make the Projects surface render-cheap ([#6460](https://github.com/block/buzz/pull/6460)) ([`040b203f73576e15ef749b0ff0ee6243f06a5c48`](https://github.com/block/buzz/commit/040b203f73576e15ef749b0ff0ee6243f06a5c48)) +- refactor(acp): clarify agent prompt sections ([#6501](https://github.com/block/buzz/pull/6501)) ([`f99532585a0715bac73b4a6361a9b4966bdb5095`](https://github.com/block/buzz/commit/f99532585a0715bac73b4a6361a9b4966bdb5095)) +- Add mobile Huddles voice MVP ([#6056](https://github.com/block/buzz/pull/6056)) ([`8c0f42e187ca82d701251fc849217530178ebace`](https://github.com/block/buzz/commit/8c0f42e187ca82d701251fc849217530178ebace)) +- feat(desktop-messages): keep agents addressed across messages ([#6315](https://github.com/block/buzz/pull/6315)) ([`a2d8be5efa126221c7676f7797555dfb2bf5b0e0`](https://github.com/block/buzz/commit/a2d8be5efa126221c7676f7797555dfb2bf5b0e0)) +- fix(desktop): remove Buzz entity link previews ([#6512](https://github.com/block/buzz/pull/6512)) ([`723affe5d1340896209bf3aca94c7b791bdcf38b`](https://github.com/block/buzz/commit/723affe5d1340896209bf3aca94c7b791bdcf38b)) +- fix(composer): preserve caret when inserting mentions mid-message ([#6531](https://github.com/block/buzz/pull/6531)) ([`074561233eef71df9690ec22c2a9c5e798c297a7`](https://github.com/block/buzz/commit/074561233eef71df9690ec22c2a9c5e798c297a7)) +- chore(deps): update rust crate futures-util to v0.3.33 ([#5448](https://github.com/block/buzz/pull/5448)) ([`d874d67c117e4582cc9549ebe85c942e5e49faf7`](https://github.com/block/buzz/commit/d874d67c117e4582cc9549ebe85c942e5e49faf7)) +- fix(desktop): restore true zoom by scaling the root rem ([#6514](https://github.com/block/buzz/pull/6514)) ([`97b1fee5c3d9ad574196e974b520061fccc47d07`](https://github.com/block/buzz/commit/97b1fee5c3d9ad574196e974b520061fccc47d07)) +- chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec ([#6517](https://github.com/block/buzz/pull/6517)) ([`eb484387d5816b6f9155ad3a0be79ede3c3d7ad5`](https://github.com/block/buzz/commit/eb484387d5816b6f9155ad3a0be79ede3c3d7ad5)) +- feat(workflows): clarify workflow setup and activation ([#6470](https://github.com/block/buzz/pull/6470)) ([`0e48ff26915aa32d5f05208847b9aba75f4f19cd`](https://github.com/block/buzz/commit/0e48ff26915aa32d5f05208847b9aba75f4f19cd)) +- perf(desktop): stop the Projects fan refetching on re-entry and running after leave ([#6458](https://github.com/block/buzz/pull/6458)) ([`b85d680fb1e3cc7eef6d31d47598741b35836a2e`](https://github.com/block/buzz/commit/b85d680fb1e3cc7eef6d31d47598741b35836a2e)) +- perf(desktop): keep the member roster off the channel-switch path ([#6456](https://github.com/block/buzz/pull/6456)) ([`b0466ac465336cb773fbf7355ec05f7d61f4a3aa`](https://github.com/block/buzz/commit/b0466ac465336cb773fbf7355ec05f7d61f4a3aa)) +- Clarify huddle message destination ([#6496](https://github.com/block/buzz/pull/6496)) ([`7da8f9abf3245d7ab31ba6e4ad72598b03471f2b`](https://github.com/block/buzz/commit/7da8f9abf3245d7ab31ba6e4ad72598b03471f2b)) +- feat(archive): add observer-frame retention schema and gated DB adapter ([#5719](https://github.com/block/buzz/pull/5719)) ([`fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce`](https://github.com/block/buzz/commit/fc2ce6728b3b4805040c0a2f2cc5c15f1c1806ce)) +- fix(desktop): restore human barge-in over agent TTS in huddles ([#6431](https://github.com/block/buzz/pull/6431)) ([`6039fed565fd73a07cdddc3143c86733cad91709`](https://github.com/block/buzz/commit/6039fed565fd73a07cdddc3143c86733cad91709)) + +### Other repository changes + +- Remove public relay signing key fallback ([#6729](https://github.com/block/buzz/pull/6729)) ([`ee6ca5fa28bce04dfecb6717de65b08a57f2ac47`](https://github.com/block/buzz/commit/ee6ca5fa28bce04dfecb6717de65b08a57f2ac47)) +- Qualify canonical relay images for staged delivery ([#6781](https://github.com/block/buzz/pull/6781)) ([`f24971033178926153b49d320bd876d15d9cb2bf`](https://github.com/block/buzz/commit/f24971033178926153b49d320bd876d15d9cb2bf)) +- Add database pressure observability ([#6700](https://github.com/block/buzz/pull/6700)) ([`113a33b7e49b7173ee1767c49ef2f49c63803034`](https://github.com/block/buzz/commit/113a33b7e49b7173ee1767c49ef2f49c63803034)) +- Add staging dev relay image workflow ([#6709](https://github.com/block/buzz/pull/6709)) ([`931747c9c42df14d5c23c87fe57e30b995321ae3`](https://github.com/block/buzz/commit/931747c9c42df14d5c23c87fe57e30b995321ae3)) +- Extract community persistence ([#6668](https://github.com/block/buzz/pull/6668)) ([`9d1e4b257657f382d3111ce748f3da8d063b7671`](https://github.com/block/buzz/commit/9d1e4b257657f382d3111ce748f3da8d063b7671)) +- Add inline profile camera capture ([#6680](https://github.com/block/buzz/pull/6680)) ([`9aa332af03b4fb416dd8648b320447617ffd6fa5`](https://github.com/block/buzz/commit/9aa332af03b4fb416dd8648b320447617ffd6fa5)) +- Hide Huddles in mobile agent DMs ([#6676](https://github.com/block/buzz/pull/6676)) ([`822c5ab231bc253d809d2d13da4b381f723dcd25`](https://github.com/block/buzz/commit/822c5ab231bc253d809d2d13da4b381f723dcd25)) +- Centralize replaceable event persistence ([#6660](https://github.com/block/buzz/pull/6660)) ([`8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8`](https://github.com/block/buzz/commit/8d2d0ff5ad42733e9949442c4b6358d0ba87f9a8)) +- fix(ci): prevent poisoned Rust caches ([#6618](https://github.com/block/buzz/pull/6618)) ([`69b1225923c9bd98784e86a6976bbc34a3dc8630`](https://github.com/block/buzz/commit/69b1225923c9bd98784e86a6976bbc34a3dc8630)) +- docs(security): route reports through private advisories ([#6728](https://github.com/block/buzz/pull/6728)) ([`02dc49f0e60f75027e926e33a2d0021f7b4e0cd3`](https://github.com/block/buzz/commit/02dc49f0e60f75027e926e33a2d0021f7b4e0cd3)) +- fix(mobile): join starter channels after accepting invite ([#5915](https://github.com/block/buzz/pull/5915)) ([`6eff84d1271eb1b90e07c5a0673343a76a0753fc`](https://github.com/block/buzz/commit/6eff84d1271eb1b90e07c5a0673343a76a0753fc)) +- Add mobile profile editing ([#6583](https://github.com/block/buzz/pull/6583)) ([`a0298539f7043cd0f2d961030e60cc0fd82970b1`](https://github.com/block/buzz/commit/a0298539f7043cd0f2d961030e60cc0fd82970b1)) +- fix(mobile): recover stale and shuffled messages ([#6691](https://github.com/block/buzz/pull/6691)) ([`01091c15a15d6057d80463dfd828e6e1e4b60743`](https://github.com/block/buzz/commit/01091c15a15d6057d80463dfd828e6e1e4b60743)) +- feat(mobile): browse and join open channels ([#6243](https://github.com/block/buzz/pull/6243)) ([`26f4c3ed304db2c273f0bd4d2746aa9598f38366`](https://github.com/block/buzz/commit/26f4c3ed304db2c273f0bd4d2746aa9598f38366)) +- Polish Huddle participant interactions ([#6312](https://github.com/block/buzz/pull/6312)) ([`e23632941331502c0330e51d407e667bea26ef57`](https://github.com/block/buzz/commit/e23632941331502c0330e51d407e667bea26ef57)) +- Downgrade mobile Huddles to audio protocol v2 ([#6558](https://github.com/block/buzz/pull/6558)) ([`4baccd5394d6166bb68ff03b24e376e322281a59`](https://github.com/block/buzz/commit/4baccd5394d6166bb68ff03b24e376e322281a59)) +- chore(deps): update rust crate async-trait to v0.1.92 ([#6094](https://github.com/block/buzz/pull/6094)) ([`f7942167372501576c9f0f589cf2c166882668bb`](https://github.com/block/buzz/commit/f7942167372501576c9f0f589cf2c166882668bb)) +- chore(deps): update dependency sonner to v2.0.8 ([#6093](https://github.com/block/buzz/pull/6093)) ([`2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67`](https://github.com/block/buzz/commit/2d93ea095535e42ee3a9933f00a4c0bd5e9e1c67)) +- chore(deps): update rust crate http-body-util to v0.1.4 ([#5452](https://github.com/block/buzz/pull/5452)) ([`9390e11c9babeef221aeb0a22cc61a52700d168f`](https://github.com/block/buzz/commit/9390e11c9babeef221aeb0a22cc61a52700d168f)) +- chore(deps): update rust crate http to v1.4.2 ([#5451](https://github.com/block/buzz/pull/5451)) ([`1a0a27d3586b14fb79ec8e162441e610f8548188`](https://github.com/block/buzz/commit/1a0a27d3586b14fb79ec8e162441e610f8548188)) +- chore(deps): update rust crate futures to v0.3.33 ([#5445](https://github.com/block/buzz/pull/5445)) ([`2cef92df676490654f13ebe1de56923b2636123f`](https://github.com/block/buzz/commit/2cef92df676490654f13ebe1de56923b2636123f)) +- chore(deps): update dependency @tauri-apps/api to v2.11.1 ([#5444](https://github.com/block/buzz/pull/5444)) ([`f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8`](https://github.com/block/buzz/commit/f84511c169cf9a98ac03e9c1acb6bf4d68de5cb8)) +- chore(deps): update ubuntu:24.04 docker digest to 561618e ([#5442](https://github.com/block/buzz/pull/5442)) ([`9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb`](https://github.com/block/buzz/commit/9008a4d1724afbd8dba0fd99e99cd8305b5d2ecb)) +- chore(deps): update swatinem/rust-cache digest to 6323deb ([#5441](https://github.com/block/buzz/pull/5441)) ([`694d804b24cd0b97403b3caf159a55ffd1fc3b08`](https://github.com/block/buzz/commit/694d804b24cd0b97403b3caf159a55ffd1fc3b08)) +- fix(deletion): allow IRSA S3 credentials ([#6495](https://github.com/block/buzz/pull/6495)) ([`47526784d5c1967de6b2c5d1ee642bccfefbfab9`](https://github.com/block/buzz/commit/47526784d5c1967de6b2c5d1ee642bccfefbfab9)) +- docs(nips): comprehensive NIP-FI — core + claimable profiles (EDGE/LIFECYCLE/DELEG/CONF) ([#5946](https://github.com/block/buzz/pull/5946)) ([`d97780b4777f2fe3430b4e30a7d47fc6837ee059`](https://github.com/block/buzz/commit/d97780b4777f2fe3430b4e30a7d47fc6837ee059)) +- fix(benchmarks): wait for scripted event delivery ([#6487](https://github.com/block/buzz/pull/6487)) ([`025425591ed67518a63870316f1473ffd02dd520`](https://github.com/block/buzz/commit/025425591ed67518a63870316f1473ffd02dd520)) +- Polish mobile channel navigation and message sends ([#6488](https://github.com/block/buzz/pull/6488)) ([`aeb741fd31044ec560d953b0986dec2e7e93e2c6`](https://github.com/block/buzz/commit/aeb741fd31044ec560d953b0986dec2e7e93e2c6)) + +[Compare desktop-v0.5.18...desktop-v0.5.19](https://github.com/block/buzz/compare/desktop-v0.5.18...desktop-v0.5.19) + ## v0.5.18 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index 4a6bdcd7f5..1064769148 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.18", + "version": "0.5.19", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 0933149f5b..82758c1d88 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.19" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 3f7189deea..97791da13f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.18" +version = "0.5.19" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 4a73c78064..c29a9b8dce 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.18", + "version": "0.5.19", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From cae7f826b39b38e7d49a90d342499234dec007bf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 18:50:47 -0400 Subject: [PATCH 20/24] fix(ci): check out source in docker.yml merge job (#6833) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Every `docker.yml` run on `main` has failed since [#6781](https://github.com/block/buzz/pull/6781) merged. That PR added a "Create deployment eligibility predicate" step to the `merge` job, which runs `jq` against `$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq`. But the `merge` job has no `actions/checkout` step — the workspace is empty, so `jq` can't open the file and exits `2` (`jq: Could not open ... No such file or directory`). The `build` and `qualify` jobs each check out the source; `merge` never needed one until this step was added. ## Fix - `.github/workflows/docker.yml`: add `actions/checkout` (same pinned SHA as the other jobs, `df4cb1c` / v6.0.3) as the first step of the `merge` job. - `scripts/test-relay-image-eligibility-workflow.sh`: guard the regression by asserting the `merge` job checks out the source before building the predicate. ## Verification CI cannot exercise the `merge` job on a pull request — the job is gated `if: github.event_name != 'pull_request'`, so it only runs on push to `main`. The proof is the root cause (missing checkout for a step that reads a repo file) plus the eligibility workflow test: `scripts/test-relay-image-eligibility-workflow.sh` passes with the fix and fails with the new guard's specific message (`merge job must check out the source before building the eligibility predicate`) when the checkout is removed. Signed-off-by: Will Pfleger --- .github/workflows/docker.yml | 5 +++++ scripts/test-relay-image-eligibility-workflow.sh | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ddb55f8542..f48f1bc92f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -330,6 +330,11 @@ jobs: tag_prefix: debug- steps: + - name: Checkout source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh index 168f9d3689..40745a8e88 100755 --- a/scripts/test-relay-image-eligibility-workflow.sh +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -35,6 +35,15 @@ if grep -Fq "buzz-staging-dev" "$workflow"; then exit 1 fi +# The merge job reads scripts/create-deployment-eligibility-predicate.jq from the +# workspace, so it must check out the source first. Guard against the checkout being +# dropped from that job (the workspace is otherwise empty and jq exits non-zero). +merge_job=$(awk '/^ merge:/{f=1} f&&/^ [a-z][a-z_-]*:$/&&!/^ merge:/{exit} f' "$workflow") +grep -Fq "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" <<<"$merge_job" || { + echo "merge job must check out the source before building the eligibility predicate" >&2 + exit 1 +} + select_run() { jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' } From 820a8589971df49bb9285a236ce1e2955a301abd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 25 Aug 2026 18:50:58 -0400 Subject: [PATCH 21/24] fix(release): attribute desktop candidates to the operator (#6831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop release tooling hardcoded one contributor's personal identity into every release candidate commit. `scripts/prepare-desktop-release.sh` committed the candidate with a `git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com'` override, and `scripts/desktop_release.py` `validate` required the candidate author to be exactly `Wes ` plus a matching `Signed-off-by` trailer. That leaked from Wes's working setup into the validation contract in #3568, so a release cut by any other operator was falsely attributed to and signed off by Wes (as happened on #6828). ## Change - `prepare-desktop-release.sh`: drop the `-c` identity overrides so `git commit -s` uses the operator's own configured identity to author and sign off the candidate. The automation `Co-authored-by` trailer is unchanged. - `desktop_release.py` `validate`: replace the exact-Wes checks with structural ones — the commit author must be non-empty, the body must contain a `Signed-off-by` trailer whose name and email match the commit author (honest DCO), and the existing automation `Co-authored-by` regex check stays. Failure messages remain specific. - `test-desktop-release-candidate.sh`: the fixture candidate now commits under the harness's own identity, and a new negative case rewrites the author to a mismatched identity and asserts the validator rejects it. Release authorization is bound to the merged PR via the GitHub API in `scripts/verify-desktop-release-merge.sh`, never the commit author field, so this does not weaken the trust model. `RELEASING.md` and `.github/workflows/desktop-release-candidate.yml` reference no author identity and need no change. Verified locally: `scripts/test-desktop-release-candidate.sh` passes, including the new sign-off/author-mismatch rejection. --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- scripts/desktop_release.py | 13 ++++---- scripts/prepare-desktop-release.sh | 3 +- scripts/test-desktop-release-candidate.sh | 36 ++++++++++++++++++++++- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py index ce9ceaab93..48e7ce86ce 100755 --- a/scripts/desktop_release.py +++ b/scripts/desktop_release.py @@ -257,12 +257,15 @@ def validate(args: argparse.Namespace) -> None: bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] if bad: raise SystemExit(f"version mismatch in: {', '.join(bad)}") - author = git("show", "-s", "--format=%an <%ae>", candidate) + name = git("show", "-s", "--format=%an", candidate) + email = git("show", "-s", "--format=%ae", candidate) + if not name or not email: + raise SystemExit("candidate has no author identity") + author = f"{name} <{email}>" body = git("show", "-s", "--format=%B", candidate) - if author != "Wes ": - raise SystemExit(f"unexpected candidate author: {author}") - if "Signed-off-by: Wes " not in body: - raise SystemExit("candidate is missing Wes Signed-off-by trailer") + signoffs = re.findall(rf"(?m)^Signed-off-by: {re.escape(author)}$", body) + if len(signoffs) != 1: + raise SystemExit(f"candidate must carry exactly one Signed-off-by trailer matching its author {author}") if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): raise SystemExit("candidate is missing automation Co-authored-by trailer") print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index 43785075a1..eec08ac8c5 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -42,8 +42,7 @@ chore(release): release Buzz Desktop version $version Co-authored-by: $agent_name <$agent_email> EOF -git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ - commit -s -F "$msg" +git commit -s -F "$msg" scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz candidate_sha="$(git rev-parse HEAD)" diff --git a/scripts/test-desktop-release-candidate.sh b/scripts/test-desktop-release-candidate.sh index c63a157c00..503c64eb37 100755 --- a/scripts/test-desktop-release-candidate.sh +++ b/scripts/test-desktop-release-candidate.sh @@ -64,8 +64,42 @@ for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): open('desktop/src-tauri/Cargo.toml','w').write('[package]\nversion = "1.0.1"\n') PY git add . - 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 ' + git 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 + good_candidate=$(git rev-parse HEAD) + + # A candidate whose Signed-off-by does not match its author is a dishonest + # DCO sign-off and must be rejected. Rewrite the author while keeping the + # original trailer body, then restore the honest candidate. + git -c user.name=Impostor -c user.email=impostor@example.com commit -q --amend --no-edit --reset-author + 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 candidate whose sign-off does not match its author" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + + # The trailer must be a complete anchored line, not substring-matched. A prose + # line that merely contains the sign-off text, or a real trailer with trailing + # garbage, must be rejected. Both were accepted before the anchored parse. + for bogus in \ + 'not-a-trailer Signed-off-by: test ' \ + 'Signed-off-by: test trailing-garbage'; do + git commit -q --amend -m 'chore(release): release Buzz Desktop version 1.0.1' \ + -m 'Co-authored-by: Test Automation ' -m "$bogus" + 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 malformed sign-off: $bogus" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + done + + # Two matching sign-offs are also invalid: the contract is exactly one. + git commit -q --amend -m 'chore(release): release Buzz Desktop version 1.0.1' \ + -m 'Co-authored-by: Test Automation ' \ + -m 'Signed-off-by: test ' -m 'Signed-off-by: test ' + 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 duplicate Signed-off-by trailers" >&2; exit 1 + fi + git reset -q --hard "$good_candidate" + grep -Fq "$unrelated_before" CHANGELOG.md grep -Fq "$unrelated_after" CHANGELOG.md ! grep -Fq "$prior_merge" CHANGELOG.md From e8cd7516e6df62c2a9025d7a821bf98e0e8f83b4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 25 Aug 2026 15:59:38 -0700 Subject: [PATCH 22/24] fix(desktop): respect automatic mention preference after send (#6837) **Category:** fix **User Impact:** Disabling automatic agent mentions now keeps one-time agent mentions out of the next message draft. **Problem:** A successfully sent inline agent mention was treated as eligible for post-send restoration even when **Automatically mention agents** was disabled, so the agent immediately reappeared in the composer. **Solution:** Gate the send-success restoration path on the live preference while leaving explicitly pinned agents and enabled automatic mentions unchanged.
File changes **desktop/src/features/messages/ui/MessageComposer.tsx** Skips the automatic post-send mention restoration path when the preference is disabled. **desktop/tests/e2e/persistent-agent-audience.spec.ts** Reproduces disabling the toggle before sending an inline agent mention and verifies the next composer is empty while the outgoing mention still reaches the agent.
## Reproduction steps 1. Enable **Automatically mention agents** in the composer mention options. 2. Disable it again. 3. Compose and send `@Agent test` using the inline mention picker. 4. Confirm the message still mentions the agent, but the cleared composer does not repopulate `@Agent`. 5. Re-enable the preference and repeat; confirm the mention is restored for the next message. ## Testing At `a179a2d00a6ac5cf0bc4a1c7c1ef3d5dfa9d0875`: - Full desktop unit suite: 5,501 passed. - Desktop check and typecheck passed in the pre-push hooks. - Desktop file-size ratchet passed in the pre-push hooks. - Full `persistent-agent-audience.spec.ts` Playwright file: 17 passed, including preference off (empty composer), preference on (restored mention), and the existing address-undo journey. ## Visual validation | Automatic mentions off | Automatic mentions on | | --- | --- | | After sending a one-time mention, the outgoing message is preserved and the next composer stays empty. | After sending, the agent remains addressed and is restored in the next composer. | | ![Automatic mentions disabled: empty composer after send](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/disabled-after-send.png) | ![Automatic mentions enabled: agent restored after send](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/enabled-after-send.png) |
Original regression With automatic mentions disabled, sending `@Alia test` still repopulated `@Alia` in the cleared composer. ![Original regression: the sent agent reappears in the composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6837/auto-mention-regression-before.png)
Signed-off-by: Taylor Ho Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageComposer.tsx | 2 +- .../e2e/persistent-agent-audience.spec.ts | 36 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index d4db6f5d17..c255f4a8ca 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -347,7 +347,7 @@ function MessageComposerImpl({ restoreAddressedAgentMentionsRef.current(pubkeys), onAddressedAgentsSendFailed: addressPulse.shakeMany, onAddressedAgentsSendSucceeded: (pubkeys, newlyPinnedPubkeys) => { - if (newlyPinnedPubkeys.length === 0) return; + if (!keepMentionedAgentsPinned || newlyPinnedPubkeys.length === 0) return; const sentChannelId = channelId; if (restoreAddressedAgentMentionsFrameRef.current !== null) { cancelAnimationFrame(restoreAddressedAgentMentionsFrameRef.current); diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 9a02e2e1a8..12a6f07819 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -312,6 +312,40 @@ test("Tab inserts a one-time agent mention by default", async ({ page }) => { ).toHaveCount(0); }); +test("disabling automatic mentions leaves the composer empty after send", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); + await openGeneral(page); + + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + await composer.getByTestId("message-insert-mention").click(); + await composer.getByTestId("mention-options-trigger").click(); + const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); + await expect(preference).toHaveAttribute("data-state", "checked"); + await preference.click(); + await expect(preference).toHaveAttribute("data-state", "unchecked"); + await input.press("Escape"); + + await input.fill("@Mor"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.type("test"); + await expect(input).toHaveText("@Morgarita test"); + await input.press("Enter"); + + await expect(input).toHaveText(""); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(0); + await expect( + composer.getByTestId(`composer-address-lock-${AGENT_A}`), + ).toHaveCount(0); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita test")) + .toContain(AGENT_A); +}); + test("primary+Shift+M addresses the default agent, then selects the highlighted agent", async ({ page, }) => { @@ -457,7 +491,7 @@ test("the mention button opens settings and can undo an address", async ({ await input.type("later"); await input.press("Enter"); - await expect(input).toHaveText("@Morgarita "); + await expect(input).toHaveText(""); await expect .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita later")) .toContain(AGENT_A); From 8471049c430073474939336dfc6aa98272bc8762 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 26 Aug 2026 00:09:55 +0100 Subject: [PATCH 23/24] feat(desktop): add KLIPY GIF search to composers (#5554) ## Summary - Supersedes #1913 with a KLIPY-hosted URL implementation. - Adds KLIPY GIF search and trending results to desktop message and forum composers. - Keeps selected GIFs hosted by KLIPY; Buzz stores only the external URL and media metadata (no imeta tag, since relays only accept hash-backed local `/media/` entries). - Aligns the Emoji/GIF picker with Buzz's standard segmented control, theme surfaces, and motion behavior. ## Relay-to-provider boundary - The relay proxies KLIPY search/share so the `BUZZ_KLIPY_API_KEY` never reaches the desktop; the key stays server-side behind a redacted `Debug` impl. - The dedicated GIF `reqwest` client sets `redirect::Policy::none()`. Because the API key rides in the request path, following a provider `3xx` could replay a key-bearing URL to an attacker-chosen host (an SSRF/key-disclosure primitive). With redirects disabled, a `3xx` returns as a non-success status that the handlers map to a generic `502`; the `Location` target is never read or forwarded. - Admission reuses the established NIP-98, membership, replay, and per-pubkey rate-limit gates, with an upstream response-size cap and allowlisting so KLIPY error bodies never cross the relay boundary. ## Accessibility - Under `prefers-reduced-motion: reduce`, the picker grid renders a static provider poster (a normalized `jpg` asset) instead of the animated preview, or a named static placeholder when no poster is available. It reacts to preference changes while mounted. `no-preference` keeps the animated preview. - Selected GIFs carry their title through `ImetaMedia`'s `displayLabel`, so the composer thumbnail, preview dialog, editor, lightbox, and remove control all derive one non-empty accessible name instead of an empty `Attachment ` label. Ordinary hashed uploads keep their existing hash-derived names. --------- Signed-off-by: kenny lopez Signed-off-by: Kenny Lopez Signed-off-by: Will Pfleger Signed-off-by: Duncan Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut Co-authored-by: Duncan Co-authored-by: Will Pfleger Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> --- .env.example | 6 + crates/buzz-auth/src/rate_limit.rs | 22 + crates/buzz-relay/src/api/gifs.rs | 613 ++++++++++++++++++ crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/config.rs | 53 ++ crates/buzz-relay/src/handlers/req.rs | 1 + crates/buzz-relay/src/nip11.rs | 86 ++- crates/buzz-relay/src/router.rs | 3 + crates/buzz-relay/src/state.rs | 5 + deploy/charts/buzz/README.md | 6 + .../charts/buzz/examples/secret-sample.yaml | 2 + deploy/charts/buzz/templates/deployment.yaml | 6 + deploy/charts/buzz/tests/secrets_test.yaml | 21 + deploy/charts/buzz/values.yaml | 1 + .../features/custom-emoji/ui/EmojiPicker.tsx | 5 +- .../src/features/forum/ui/ForumComposer.tsx | 1 + desktop/src/features/gifs/api.test.mjs | 179 +++++ desktop/src/features/gifs/api.ts | 185 ++++++ desktop/src/features/gifs/relay.ts | 142 ++++ .../src/features/gifs/ui/KlipyGifPicker.tsx | 161 +++++ .../messages/lib/imetaMediaMarkdown.test.mjs | 48 +- .../messages/lib/imetaMediaMarkdown.ts | 48 +- .../messages/ui/ComposerAttachments.tsx | 96 +-- .../messages/ui/ComposerEmojiPicker.tsx | 124 +++- .../features/messages/ui/MessageComposer.tsx | 56 +- .../messages/ui/MessageComposerToolbar.tsx | 4 + desktop/tests/e2e/channels.spec.ts | 9 + desktop/tests/e2e/composer-image-draw.spec.ts | 24 +- .../e2e/composer-tooltip-dismiss.spec.ts | 2 +- desktop/tests/e2e/messaging.spec.ts | 388 +++++++++++ desktop/tests/e2e/spoiler.spec.ts | 8 +- desktop/tests/e2e/video-attachment.spec.ts | 12 +- docs/gif-search.md | 41 ++ docs/multi-tenant-conformance.md | 2 +- 34 files changed, 2215 insertions(+), 146 deletions(-) create mode 100644 crates/buzz-relay/src/api/gifs.rs create mode 100644 desktop/src/features/gifs/api.test.mjs create mode 100644 desktop/src/features/gifs/api.ts create mode 100644 desktop/src/features/gifs/relay.ts create mode 100644 desktop/src/features/gifs/ui/KlipyGifPicker.tsx create mode 100644 docs/gif-search.md diff --git a/.env.example b/.env.example index cb503392b3..a6740f7a7d 100644 --- a/.env.example +++ b/.env.example @@ -59,9 +59,15 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# Optional relay-owned KLIPY key. When set, NIP-11 advertises GIF search and +# authenticated desktop clients use this relay as the metadata/search proxy. +# Keep the real value in your deployment's secret manager; never commit it. +# BUZZ_KLIPY_API_KEY= + # Shared Redis-backed admission limits. Defaults shown below; each value must # be a positive integer. # BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=60 +# BUZZ_RATE_LIMIT_GIF_SEARCHES_PER_MIN=30 # BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=300 # BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10 # BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN=120 diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index 8fd42c50fb..9e64627404 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -60,6 +60,8 @@ pub enum LimitType { Messages, /// HTTP REST API calls. ApiCalls, + /// Relay-proxied GIF metadata searches. + GifSearches, /// All WebSocket events (broader than `Messages`). WsEvents, /// Concurrent WebSocket connections from a single IP address. @@ -72,6 +74,7 @@ impl LimitType { match self { Self::Messages => "msg", Self::ApiCalls => "api", + Self::GifSearches => "gif", Self::WsEvents => "ws", Self::IpConnections => "conn", } @@ -87,6 +90,10 @@ pub struct RateLimitConfig { /// Maximum messages per minute for human users. Default: 60. #[serde(default = "default_human_msg")] pub human_messages_per_min: u64, + /// Maximum relay-proxied GIF searches per minute for each pubkey. + /// Default: 30. + #[serde(default = "default_gif_searches")] + pub gif_searches_per_min: u64, /// Maximum HTTP API calls per minute for human users. Default: 300. #[serde(default = "default_human_api")] pub human_api_calls_per_min: u64, @@ -110,6 +117,9 @@ pub struct RateLimitConfig { fn default_human_msg() -> u64 { 60 } +fn default_gif_searches() -> u64 { + 30 +} fn default_human_api() -> u64 { 300 } @@ -133,6 +143,7 @@ impl Default for RateLimitConfig { fn default() -> Self { Self { human_messages_per_min: default_human_msg(), + gif_searches_per_min: default_gif_searches(), human_api_calls_per_min: default_human_api(), human_ws_events_per_sec: default_human_ws(), agent_standard_messages_per_min: default_agent_std_msg(), @@ -272,6 +283,17 @@ mod tests { assert!(key.ends_with(":msg")); } + #[test] + fn gif_searches_have_an_independent_quota_key() { + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let gif_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::GifSearches); + let api_key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::ApiCalls); + + assert!(gif_key.ends_with(":gif")); + assert_ne!(gif_key, api_key); + } + #[test] fn rate_limit_key_isolates_communities_for_same_pubkey() { // The S1 cross-community isolation fence at the rate-limit key layer: diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs new file mode 100644 index 0000000000..a8848af295 --- /dev/null +++ b/crates/buzz-relay/src/api/gifs.rs @@ -0,0 +1,613 @@ +//! Relay-owned KLIPY GIF metadata/search proxy. +//! +//! KLIPY requires a provider credential, but desktop applications cannot keep +//! build-time credentials secret. These narrow endpoints keep the key on the +//! operator's relay while returning only KLIPY-hosted media URLs and metadata; +//! GIF bytes are never downloaded, cached, or stored by Buzz. +//! +//! Search and share reporting are the only relay endpoints. Sending a selected +//! GIF is a normal message containing its CDN URL, and clients render that URL +//! through the existing image path. No GIF bytes transit the relay. + +use std::sync::Arc; +use std::time::Duration; + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::Json, +}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; + +use crate::state::AppState; + +use buzz_auth::LimitType; + +use super::{api_error, bridge, internal_error, relay_members}; + +const KLIPY_API_ROOT: &str = "https://api.klipy.com/api/v1/"; +pub(crate) const SEARCH_PATH: &str = "/gifs/search"; +pub(crate) const SHARE_PATH: &str = "/gifs/share"; +const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_UPSTREAM_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +/// Build the dedicated KLIPY client. Redirects are disabled: the API key rides +/// in the request path, so following a provider 3xx could replay a key-bearing +/// URL to an attacker-chosen host. With no redirect policy, a 3xx comes back as +/// a non-success status that the handlers map to a generic `502`, and the +/// `Location` target is never read or forwarded. +pub fn build_gif_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(UPSTREAM_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("static GIF HTTP client configuration") +} + +#[derive(Debug, Deserialize)] +/// Client-owned search context forwarded to KLIPY by the relay. +pub struct SearchRequest { + /// Empty means trending; otherwise this is the user's search text. + query: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, + /// Desktop locale used to localize provider results. + locale: String, +} + +#[derive(Debug, Deserialize)] +/// Client-owned share context forwarded to KLIPY by the relay. +pub struct ShareRequest { + /// Provider slug for the selected GIF. + slug: String, + /// Stable anonymous installation identifier required by KLIPY. + customer_id: String, +} + +fn validate_text( + name: &str, + value: &str, + max_chars: usize, + allow_empty: bool, +) -> Result<(), (StatusCode, Json)> { + let count = value.chars().count(); + if (!allow_empty && value.trim().is_empty()) || count > max_chars { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!( + "{name} must be {} through {max_chars} characters", + if allow_empty { 0 } else { 1 } + ), + )); + } + Ok(()) +} + +fn klipy_url( + api_key: &str, + path: &[&str], + query: &[(&str, &str)], +) -> Result)> { + let mut url = url::Url::parse(KLIPY_API_ROOT) + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + { + let mut segments = url + .path_segments_mut() + .map_err(|_| internal_error("invalid static KLIPY API root"))?; + segments.pop_if_empty().push(api_key); + for segment in path { + segments.push(segment); + } + } + if !query.is_empty() { + url.query_pairs_mut().extend_pairs(query.iter().copied()); + } + Ok(url) +} + +fn klipy_share_request( + client: &reqwest::Client, + api_key: &str, + request: &ShareRequest, +) -> Result)> { + let url = klipy_url(api_key, &["gifs", "share", request.slug.trim()], &[])?; + Ok(client + .post(url) + .json(&serde_json::json!({ "customer_id": request.customer_id }))) +} + +async fn authenticate( + state: &Arc, + headers: &HeaderMap, + path: &str, + body: &[u8], +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + "POST", + &expected_url, + Some(body), + true, + true, + )?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey.to_bytes(), + headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()), + ) + .await?; + + Ok((tenant, pubkey)) +} + +async fn send_upstream( + request: reqwest::RequestBuilder, +) -> Result)> { + request + .timeout(UPSTREAM_TIMEOUT) + .send() + .await + .map_err(|error| { + tracing::warn!( + timeout = error.is_timeout(), + "KLIPY upstream request failed" + ); + api_error(StatusCode::BAD_GATEWAY, "GIF provider is unavailable") + }) +} + +async fn enforce_search_admission( + state: &AppState, + tenant: &buzz_core::TenantContext, + pubkey: &nostr::PublicKey, +) -> Result<(), (StatusCode, Json)> { + let limit = state.auth.config().rate_limits.gif_searches_per_min; + match crate::admission::check_principal( + state.admission_rate_limiter.as_ref(), + tenant, + pubkey, + LimitType::GifSearches, + 60, + limit, + ) + .await + { + Ok(()) => Ok(()), + Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => { + metrics::counter!("buzz_gif_search_rejections_total", "reason" => "quota").increment(1); + Err(api_error( + StatusCode::TOO_MANY_REQUESTS, + &format!("rate-limited: GIF search quota exceeded; retry in {reset_in_secs}s"), + )) + } + Err(crate::admission::AdmissionError::Unavailable) => Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "rate-limited: GIF search admission unavailable", + )), + } +} + +async fn limited_json(response: reqwest::Response) -> Result)> { + if response + .content_length() + .is_some_and(|length| length > MAX_UPSTREAM_RESPONSE_BYTES as u64) + { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response could not be read", + ) + })?; + if body.len().saturating_add(chunk.len()) > MAX_UPSTREAM_RESPONSE_BYTES { + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider response was too large", + )); + } + body.extend_from_slice(&chunk); + } + + serde_json::from_slice(&body).map_err(|_| { + api_error( + StatusCode::BAD_GATEWAY, + "GIF provider returned an invalid response", + ) + }) +} + +fn successful_search_payload(upstream: &Value) -> Result)> { + if upstream.get("result").and_then(Value::as_bool) != Some(true) { + tracing::warn!("KLIPY search returned an unsuccessful result"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + let data = upstream.get("data").cloned().unwrap_or(Value::Null); + Ok(serde_json::json!({ "result": true, "data": data })) +} + +/// Search or browse trending KLIPY GIF metadata for an authenticated member. +pub async fn search( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let request: SearchRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; + validate_text("query", &request.query, 200, true)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + validate_text("locale", &request.locale, 32, false)?; + enforce_search_admission(&state, &tenant, &pubkey).await?; + + let endpoint = if request.query.trim().is_empty() { + "trending" + } else { + "search" + }; + let mut query = vec![ + ("page", "1"), + ("per_page", "24"), + ("customer_id", request.customer_id.as_str()), + ("locale", request.locale.as_str()), + ]; + if !request.query.trim().is_empty() { + query.push(("q", request.query.trim())); + } + let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; + let response = send_upstream(state.gif_http_client.get(url)).await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + )); + } + + // Never forward the provider response wholesale. KLIPY may report an + // application-level failure with HTTP 200 and include request details in + // its error fields. Allowlist only successful result data so credentials + // and provider diagnostics cannot cross the relay boundary. + let upstream = limited_json(response).await?; + Ok(Json(successful_search_payload(&upstream)?)) +} + +/// Report a selected GIF to KLIPY so the provider can update Recents. +pub async fn share( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result)> { + let Some(config) = state.config.klipy.as_ref() else { + return Err(api_error( + StatusCode::NOT_FOUND, + "GIF search is not configured", + )); + }; + authenticate(&state, &headers, SHARE_PATH, &body).await?; + let request: ShareRequest = serde_json::from_slice(&body) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; + validate_text("slug", &request.slug, 200, false)?; + validate_text("customer_id", &request.customer_id, 128, false)?; + + let response = send_upstream(klipy_share_request( + &state.gif_http_client, + config.api_key(), + &request, + )?) + .await?; + if !response.status().is_success() { + tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); + return Err(api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the share request", + )); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + async fn unconfigured_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("test config"); + config.klipy = None; + config.redis_url = "redis://127.0.0.1:1".to_string(); + + let pool = sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://buzz:buzz_dev@127.0.0.1:1/buzz") // sadscan:disable np.postgres.1 + .expect("lazy test database pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("lazy test Redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("test pubsub"), + ); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = + buzz_media::MediaStorage::new(&config.media).expect("test media storage config"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + None::, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + #[tokio::test] + async fn search_route_returns_not_found_before_auth_when_unconfigured() { + let state = unconfigured_test_state().await; + let response = Router::new() + .route(SEARCH_PATH, axum::routing::post(search)) + .with_state(state) + .oneshot( + Request::post(SEARCH_PATH) + .body(Body::from("{}")) + .expect("search request"), + ) + .await + .expect("search response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn limited_json_rejects_oversized_streamed_bodies() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let address = listener.local_addr().expect("test server address"); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().route( + "/oversized", + get(|| async { + ( + [(header::CONTENT_TYPE, "application/json")], + "x".repeat(MAX_UPSTREAM_RESPONSE_BYTES + 1), + ) + }), + ), + ) + .await + .expect("serve oversized response"); + }); + let response = reqwest::get(format!("http://{address}/oversized")) + .await + .expect("test upstream response"); + let (status, _) = limited_json(response) + .await + .expect_err("oversized body must be rejected"); + + server.abort(); + let _ = server.await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + } + + #[test] + fn klipy_url_encodes_credentials_as_a_path_segment() { + let url = klipy_url( + "key/with spaces", + &["gifs", "search"], + &[("customer_id", "customer")], + ) + .expect("static URL is valid"); + + assert_eq!( + url.as_str(), + "https://api.klipy.com/api/v1/key%2Fwith%20spaces/gifs/search?customer_id=customer" + ); + } + + #[test] + fn klipy_share_request_uses_slug_path_and_customer_body() { + let request = ShareRequest { + slug: " ship/it ".to_string(), + customer_id: "customer-123".to_string(), + }; + let built = klipy_share_request(&reqwest::Client::new(), "secret-key", &request) + .expect("share request builds") + .build() + .expect("share request is valid"); + + assert_eq!(built.method(), reqwest::Method::POST); + assert_eq!( + built.url().as_str(), + "https://api.klipy.com/api/v1/secret-key/gifs/share/ship%2Fit" + ); + assert_eq!( + built.body().and_then(reqwest::Body::as_bytes), + Some(br#"{"customer_id":"customer-123"}"#.as_slice()) + ); + } + + #[test] + fn validation_bounds_provider_control_fields() { + assert!(validate_text("query", "", 200, true).is_ok()); + assert!(validate_text("customer_id", "", 128, false).is_err()); + assert!(validate_text("query", &"x".repeat(201), 200, true).is_err()); + } + + #[test] + fn successful_payload_strips_provider_errors_and_unknown_fields() { + let payload = successful_search_payload(&serde_json::json!({ + "result": true, + "data": { "data": [] }, + "errors": { "message": ["request used secret-key"] }, + "debug": "secret-key" + })) + .expect("successful payload"); + + assert_eq!( + payload, + serde_json::json!({ "result": true, "data": { "data": [] } }) + ); + } + + #[test] + fn unsuccessful_payload_is_rejected_without_provider_details() { + let (status, body) = successful_search_payload(&serde_json::json!({ + "result": false, + "errors": { "message": ["request used secret-key"] } + })) + .expect_err("unsuccessful provider payload must be rejected"); + + assert_eq!(status, StatusCode::BAD_GATEWAY); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert!(!serialized.contains("secret-key")); + } + + /// A provider 3xx must never cause a second connection, and the error + /// surfaced past the shared send/reject path must leak neither the API key + /// (carried in the request path) nor the redirect target. + /// + /// Mutation check: swapping `build_gif_http_client`'s redirect policy back + /// to the default makes the client follow the 302, the redirect listener + /// records a request, and this test fails on the `redirect_hits` assertion. + #[tokio::test] + async fn gif_client_refuses_provider_redirects_without_leaking_secrets() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SECRET_KEY: &str = "super-secret-klipy-key"; + + // Second listener: the redirect target. It must never be reached. + let redirect_hits = Arc::new(AtomicUsize::new(0)); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind redirect target"); + let redirect_addr = redirect_listener.local_addr().expect("redirect address"); + let redirect_hits_server = redirect_hits.clone(); + let redirect_server = tokio::spawn(async move { + axum::serve( + redirect_listener, + Router::new().route( + "/leaked", + get(move || { + redirect_hits_server.fetch_add(1, Ordering::SeqCst); + async { "reached the redirect target" } + }), + ), + ) + .await + .expect("serve redirect target"); + }); + + // Fake upstream: answers the key-bearing path with a 302 whose Location + // points at the second listener, exactly the disclosure vector. + let redirect_location = format!("http://{redirect_addr}/leaked"); + let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fake upstream"); + let upstream_addr = upstream_listener.local_addr().expect("upstream address"); + let location_header = redirect_location.clone(); + let upstream_server = tokio::spawn(async move { + axum::serve( + upstream_listener, + Router::new().route( + &format!("/{SECRET_KEY}/gifs/search"), + get(move || { + let location = location_header.clone(); + async move { + ( + StatusCode::FOUND, + [(header::LOCATION, location)], + "provider body naming the secret-key", + ) + } + }), + ), + ) + .await + .expect("serve fake upstream"); + }); + + let client = build_gif_http_client(); + let response = + send_upstream(client.get(format!("http://{upstream_addr}/{SECRET_KEY}/gifs/search"))) + .await + .expect("request completes without following the redirect"); + + // The redirect was not followed: the client surfaces the 3xx itself. + assert!(response.status().is_redirection()); + assert!(!response.status().is_success()); + assert_eq!(redirect_hits.load(Ordering::SeqCst), 0); + + // The shared reject path (both handlers gate on `!is_success`) returns a + // static generic error carrying no key and no redirect target. + let (status, body) = api_error( + StatusCode::BAD_GATEWAY, + "GIF provider rejected the search request", + ); + let serialized = serde_json::to_string(&body.0).expect("serialize generic error"); + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert!(!serialized.contains(SECRET_KEY)); + assert!(!serialized.contains(&redirect_location)); + + upstream_server.abort(); + redirect_server.abort(); + let _ = upstream_server.await; + let _ = redirect_server.await; + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc803..204ec360c3 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod bridge; pub mod events; +pub mod gifs; pub mod git; pub mod invites; pub mod media; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 037c6b1dd3..9fb96b7a19 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -46,6 +46,30 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Optional KLIPY GIF-search integration owned by the relay operator. +/// +/// The API key deliberately stays private and its [`Debug`] implementation is +/// redacted so dumping [`Config`] cannot disclose it. +#[derive(Clone)] +pub struct KlipyConfig { + api_key: String, +} + +impl KlipyConfig { + /// Return the key only to the outbound KLIPY client. + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } +} + +impl std::fmt::Debug for KlipyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KlipyConfig") + .field("api_key", &"[REDACTED]") + .finish() + } +} + /// Maximum configured jitter, leaving ten seconds of the hard-drain budget for /// WebSocket close-frame delivery after the final delayed cancellation. pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; @@ -218,6 +242,10 @@ pub struct Config { /// Default: `false`. Set via `BUZZ_ALLOW_NIP_OA_AUTH=true`. pub allow_nip_oa_auth: bool, + /// Relay-owned KLIPY integration. Unset means GIF search is not advertised + /// and its proxy routes return 404. + pub klipy: Option, + /// Media storage configuration (S3/MinIO). pub media: buzz_media::MediaConfig, /// Maximum concurrent media uploads handled by one relay process. @@ -319,6 +347,10 @@ fn rate_limit_config_from_env() -> Result, + /// Relay-owned GIF search integration. The descriptor is public and + /// provider-agnostic; provider credentials remain server-side. + #[serde(skip_serializing_if = "Option::is_none")] + pub gif: Option, /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). #[serde(rename = "self", skip_serializing_if = "Option::is_none")] pub relay_self: Option, } +/// Public capability descriptor for relay-proxied GIF search. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GifDescriptor { + /// Provider identifier understood by Buzz clients. + pub provider: String, + /// Relay-relative authenticated metadata search endpoint. + pub search: String, + /// Relay-relative authenticated share-reporting endpoint. + pub share: String, +} + /// Protocol and resource limits advertised in the NIP-11 document. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayLimitation { @@ -138,12 +153,18 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. + /// + /// `gif_provider` is a config-derived provider identifier. When present, + /// `build` advertises the provider-agnostic `buzz-gif` extension and the + /// relay-relative metadata search endpoint. It must never contain a + /// provider credential. pub fn build( relay_self: Option<&str>, icon: Option<&str>, advertise_nip43: bool, max_message_length: usize, pairing_relay_url: Option<&str>, + gif_provider: Option<&str>, ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), @@ -155,6 +176,16 @@ impl RelayInfo { supported_nips.push(NIP_RELAY_MEMBERSHIP); } + let mut supported_extensions = vec!["nip-er".to_string()]; + let gif = gif_provider.map(|provider| { + supported_extensions.push("buzz-gif".to_string()); + GifDescriptor { + provider: provider.to_string(), + search: crate::api::gifs::SEARCH_PATH.to_string(), + share: crate::api::gifs::SHARE_PATH.to_string(), + } + }); + Self { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), @@ -162,12 +193,13 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(supported_extensions), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation(max_message_length)), pairing_relay_url: pairing_relay_url.map(str::to_string), + gif, relay_self: relay_self.map(|s| s.to_string()), } } @@ -236,7 +268,8 @@ fn push_descriptor( /// Centralised so the content-negotiated root handler and the dedicated /// `/info` endpoint can't drift apart. Every input to `RelayInfo::build` /// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the -/// host-scoped workspace icon. +/// host-scoped workspace icon. Optional provider capabilities are passed as +/// config-derived scalar identifiers; no provider credential enters NIP-11. pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo { let (relay_self, advertise_nip43) = nip11_facts(state); let icon = workspace_icon_for_host(state, raw_host).await; @@ -246,6 +279,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st advertise_nip43, state.config.max_frame_bytes, state.config.pairing_relay_url.as_deref(), + state.config.klipy.as_ref().map(|_| "klipy"), ); let tenant_host = if state.config.push_gateway_delivery_url.is_some() { crate::tenant::bind_community(&state.db, raw_host) @@ -337,6 +371,7 @@ const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn( bool, usize, Option<&str>, + Option<&str>, ) -> RelayInfo = RelayInfo::build; #[cfg(test)] @@ -391,7 +426,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -403,6 +438,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, Some("wss://pairing.buzz.xyz"), + None, ); let json = serde_json::to_value(&info).expect("serialize"); assert_eq!( @@ -411,11 +447,40 @@ mod tests { Some("wss://pairing.buzz.xyz") ); - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); let json = serde_json::to_value(&info).expect("serialize"); assert!(json.get("pairing_relay_url").is_none()); } + #[test] + fn gif_descriptor_and_extension_are_config_gated_and_credential_free() { + let info = RelayInfo::build( + None, + None, + false, + DEFAULT_MAX_FRAME_BYTES, + None, + Some("klipy"), + ); + + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!(json["gif"]["provider"], "klipy"); + assert_eq!(json["gif"]["search"], "/gifs/search"); + assert_eq!(json["gif"]["share"], "/gifs/share"); + assert!(json["supported_extensions"] + .as_array() + .expect("extensions") + .contains(&serde_json::json!("buzz-gif"))); + assert!(!json.to_string().contains("api_key")); + + let unconfigured = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); + assert!(unconfigured.gif.is_none()); + assert!(!unconfigured + .supported_extensions + .expect("extensions") + .contains(&"buzz-gif".to_string())); + } + /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard /// `icon` field; no icon (or a cleared, empty icon) omits the field /// entirely so the JSON matches pre-icon documents byte-for-byte. @@ -427,6 +492,7 @@ mod tests { false, DEFAULT_MAX_FRAME_BYTES, None, + None, ); assert_eq!( info.icon.as_deref(), @@ -439,7 +505,7 @@ mod tests { ); for icon in [None, Some("")] { - let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, icon, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.icon.is_none()); let json = serde_json::to_value(&info).expect("serialize"); assert!( @@ -459,7 +525,7 @@ mod tests { #[test] fn max_message_length_uses_configured_frame_limit() { - let info = RelayInfo::build(None, None, false, 262_144, None); + let info = RelayInfo::build(None, None, false, 262_144, None, None); let limitation = info.limitation.expect("limitation"); assert_eq!(limitation.max_message_length, Some(262_144)); } @@ -490,7 +556,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(None, None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -503,7 +569,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, false, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -512,7 +578,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None); + let info = RelayInfo::build(Some(pk), None, true, DEFAULT_MAX_FRAME_BYTES, None, None); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -523,6 +589,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None); + let _ = RelayInfo::build(None, None, true, DEFAULT_MAX_FRAME_BYTES, None, None); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 653e21f093..fc5396407c 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -72,6 +72,9 @@ pub fn build_router(state: Arc) -> Router { .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) .route("/count", post(api::bridge::count_events)) + // Relay-owned third-party GIF metadata proxy (NIP-98 auth). + .route(api::gifs::SEARCH_PATH, post(api::gifs::search)) + .route(api::gifs::SHARE_PATH, post(api::gifs::share)) .route( "/workflows/{workflow_id}/runs", get(api::workflows::workflow_runs), diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c..ab3f1d8c7e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -722,6 +722,9 @@ pub struct AppState { /// replace this with process-local caching; replay freshness must survive /// cross-pod routing. pub nip98_replay: Arc, + /// Shared HTTP client for relay-proxied GIF provider requests. Reusing the + /// connection pool avoids a fresh TLS handshake for every search/share. + pub gif_http_client: reqwest::Client, /// Shared Redis-backed admission limits for ordinary HTTP and WebSocket work. pub admission_rate_limiter: Arc, @@ -852,6 +855,7 @@ impl AppState { ); let nip98_replay: Arc = Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone())); + let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); let state = Self { @@ -912,6 +916,7 @@ impl AppState { shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), nip98_replay, + gif_http_client, admission_rate_limiter, observer_rate_limiter: Arc::new(DashMap::new()), media_upload_rate_limiter: Arc::new(DashMap::new()), diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index e0645ef6fb..8a4b0c6d66 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -44,6 +44,12 @@ The chart is designed for ArgoCD and Flux. Both render charts with `helm templat Production deploys MUST use `secrets.existingSecret:`. The Secret is consumed for any keys present and ignored for keys missing — extras are harmless. +To enable relay-proxied KLIPY search, add `BUZZ_KLIPY_API_KEY` to that Secret. +The key stays in the relay pod; clients discover the public `buzz-gif` +extension and `gif` descriptor in NIP-11, then receive KLIPY-hosted media URLs. +See [`docs/gif-search.md`](../../../docs/gif-search.md) for the protocol and +security boundaries. + See: - [`examples/argocd-app.yaml`](examples/argocd-app.yaml) — ArgoCD Application diff --git a/deploy/charts/buzz/examples/secret-sample.yaml b/deploy/charts/buzz/examples/secret-sample.yaml index 42d3254d48..c615a0de31 100644 --- a/deploy/charts/buzz/examples/secret-sample.yaml +++ b/deploy/charts/buzz/examples/secret-sample.yaml @@ -12,6 +12,7 @@ # REDIS_URL — redis://... (required when replicaCount > 1) # BUZZ_S3_ACCESS_KEY # BUZZ_S3_SECRET_KEY +# BUZZ_KLIPY_API_KEY — omit to disable relay-proxied GIF search apiVersion: v1 kind: Secret metadata: @@ -25,3 +26,4 @@ stringData: REDIS_URL: "redis://:REPLACE@redis.buzz.svc.cluster.local:6379" BUZZ_S3_ACCESS_KEY: "REPLACE" BUZZ_S3_SECRET_KEY: "REPLACE" + BUZZ_KLIPY_API_KEY: "REPLACE" diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 451ebb1cde..319ec7f159 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -215,6 +215,12 @@ spec: name: {{ include "buzz.envSecretName" . }} key: BUZZ_S3_SECRET_KEY optional: true + - name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "buzz.envSecretName" . }} + key: BUZZ_KLIPY_API_KEY + optional: true - name: BUZZ_HUDDLE_AUDIO_AVAILABLE value: {{ include "buzz.huddleAudioAvailable" . | quote }} diff --git a/deploy/charts/buzz/tests/secrets_test.yaml b/deploy/charts/buzz/tests/secrets_test.yaml index dca83ce27f..d313caf4d4 100644 --- a/deploy/charts/buzz/tests/secrets_test.yaml +++ b/deploy/charts/buzz/tests/secrets_test.yaml @@ -80,6 +80,27 @@ tests: optional: true template: templates/deployment.yaml + - it: Deployment env points BUZZ_KLIPY_API_KEY at existingSecret as optional + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.accessKey: a + s3.secretKey: s + secrets.existingSecret: "buzz-secrets" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_KLIPY_API_KEY + valueFrom: + secretKeyRef: + name: buzz-secrets + key: BUZZ_KLIPY_API_KEY + optional: true + template: templates/deployment.yaml + - it: READ_DATABASE_URL stays optional against the chart-managed Secret set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 738c50eec3..6c57a5c8ac 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -94,6 +94,7 @@ ownerPubkey: "" # REDIS_URL — full Redis URL with auth # BUZZ_S3_ACCESS_KEY — S3 access key # BUZZ_S3_SECRET_KEY — S3 secret key +# BUZZ_KLIPY_API_KEY — KLIPY GIF search key; omit to disable GIF search secrets: existingSecret: "" # Inline overrides (NOT recommended for production; they land in values). diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 92d640afa9..ea7136db42 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -82,11 +82,14 @@ type EmojiPickerProps = { autoFocus?: boolean; /** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */ onSelect: (emoji: string) => void; + /** Number of emoji columns. Defaults to the compact picker used elsewhere. */ + perLine?: number; }; export const EmojiPicker = React.memo(function EmojiPicker({ autoFocus = false, onSelect, + perLine = 8, }: EmojiPickerProps) { const customEmoji = useCustomEmoji(); const custom = React.useMemo( @@ -116,7 +119,7 @@ export const EmojiPicker = React.memo(function EmojiPicker({ onSelect(value); } }} - perLine={8} + perLine={perLine} previewPosition="bottom" set="native" skinTonePosition="search" diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d79cce9c35..961716aaf4 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -610,6 +610,7 @@ export function ForumComposer({ ) : undefined } formattingDisabled={Boolean(disabled || isSubmissionPending)} + gifMediaController={media} isEmojiPickerOpen={isEmojiPickerOpen} isFormattingOpen={isFormattingOpen} isSending={Boolean(isSending || isSubmissionPending)} diff --git a/desktop/src/features/gifs/api.test.mjs b/desktop/src/features/gifs/api.test.mjs new file mode 100644 index 0000000000..60d44b29de --- /dev/null +++ b/desktop/src/features/gifs/api.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + klipyGifAttachment, + klipyGifFilename, + normalizeKlipyGifs, + relayKlipyCapability, +} from "./api.ts"; + +const GIF_ASSET = { + url: "https://static.klipy.com/example.gif", + width: 640, + height: 360, + size: 42, +}; + +test("normalizeKlipyGifs selects a compact preview and medium GIF", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 7, + title: " Ship it ", + slug: "ship-it", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + webp: { + url: "https://static.klipy.com/preview.webp", + width: 220, + height: 124, + size: 12, + }, + }, + }, + }, + ]); + + assert.equal(gif.title, "Ship it"); + assert.equal(gif.original.url, GIF_ASSET.url); + assert.equal(gif.preview.url, "https://static.klipy.com/preview.webp"); + assert.equal(gif.poster, null); +}); + +test("normalizeKlipyGifs carries a static jpg poster when present", () => { + const [gif] = normalizeKlipyGifs([ + { + id: 8, + title: "Static poster", + slug: "static-poster", + type: "gif", + file: { + md: { gif: GIF_ASSET }, + sm: { + jpg: { + url: "https://static.klipy.com/poster.jpg", + width: 220, + height: 124, + size: 8, + }, + }, + }, + }, + ]); + + assert.equal(gif.poster?.url, "https://static.klipy.com/poster.jpg"); +}); + +test("normalizeKlipyGifs omits ads and malformed file records", () => { + const gifs = normalizeKlipyGifs([ + { id: 1, slug: "ad", type: "ad" }, + { id: 2, slug: "missing", type: "gif", file: {} }, + ]); + + assert.deepEqual(gifs, []); +}); + +test("relayKlipyCapability requires safe search and share endpoints", () => { + assert.deepEqual( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["nip-er", "buzz-gif"], + }), + { searchPath: "/gifs/search", sharePath: "/gifs/share" }, + ); + assert.equal(relayKlipyCapability({}), null); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "another", + search: "/gifs/search", + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { provider: "klipy", search: "/gifs/search" }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + for (const path of [ + "https://attacker.example/search", + "//attacker.example/search", + "/\\attacker.example/search", + "/%5c%5cattacker.example/search", + "/gifs/../admin", + "/gifs/%2e%2e/admin", + "/gifs/search?redirect=https://attacker.example", + "/gifs/search#fragment", + ]) { + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: path, + share: "/gifs/share", + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + assert.equal( + relayKlipyCapability({ + gif: { + provider: "klipy", + search: "/gifs/search", + share: path, + }, + supported_extensions: ["buzz-gif"], + }), + null, + ); + } +}); + +test("klipyGifFilename sanitizes provider slugs", () => { + const gif = { + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: " That's a wrap! ", + title: "That's a wrap", + }; + + const filename = klipyGifFilename(gif); + + assert.equal(filename, "that-s-a-wrap.gif"); +}); + +test("klipyGifAttachment references KLIPY media without an uploaded copy", () => { + const attachment = klipyGifAttachment({ + id: 1, + original: GIF_ASSET, + poster: null, + preview: GIF_ASSET, + slug: "ship-it", + title: "Ship it", + }); + + assert.deepEqual(attachment, { + dim: "640x360", + displayLabel: "Ship it", + filename: "ship-it.gif", + sha256: "", + size: 42, + type: "image/gif", + uploaded: 0, + url: GIF_ASSET.url, + }); +}); diff --git a/desktop/src/features/gifs/api.ts b/desktop/src/features/gifs/api.ts new file mode 100644 index 0000000000..1408eaeb3c --- /dev/null +++ b/desktop/src/features/gifs/api.ts @@ -0,0 +1,185 @@ +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; + +type KlipyAsset = { + height?: number; + size?: number; + url?: string; + width?: number; +}; + +type KlipyFileSet = { + gif?: KlipyAsset; + jpg?: KlipyAsset; + webp?: KlipyAsset; +}; + +type KlipyRawGif = { + file?: { + hd?: KlipyFileSet; + md?: KlipyFileSet; + sm?: KlipyFileSet; + xs?: KlipyFileSet; + }; + id?: number; + slug?: string; + title?: string; + type?: string; +}; + +export type KlipyResponse = { + data?: { + data?: KlipyRawGif[]; + }; + result?: boolean; +}; + +export type KlipyGif = { + id: number | null; + original: Required; + /** + * Static (non-animated) poster for the GIF, when KLIPY exposes a `jpg` + * asset. Rendered in place of the animated preview under + * `prefers-reduced-motion: reduce`. + */ + poster: Required | null; + preview: Required; + slug: string; + title: string; +}; + +export type RelayGifSearchInfo = { + gif?: { + provider?: string; + search?: string; + share?: string; + }; + supported_extensions?: string[]; +}; + +export type RelayKlipyCapability = { + searchPath: string; + sharePath: string; +}; + +function safeRelayPath(path: unknown): path is string { + return ( + typeof path === "string" && + path.startsWith("/") && + !path.startsWith("//") && + !path.includes("\\") && + !path.includes("%") && + !path.includes("?") && + !path.includes("#") && + !path.split("/").some((segment) => segment === "." || segment === "..") + ); +} + +/** The safe relay-relative KLIPY endpoints advertised by NIP-11, if any. */ +export function relayKlipyCapability( + info: RelayGifSearchInfo, +): RelayKlipyCapability | null { + const searchPath = info.gif?.search; + const sharePath = info.gif?.share; + if ( + info.supported_extensions?.includes("buzz-gif") === true && + info.gif?.provider === "klipy" && + safeRelayPath(searchPath) && + safeRelayPath(sharePath) + ) { + return { searchPath, sharePath }; + } + return null; +} + +function isCompleteAsset( + asset: KlipyAsset | undefined, +): asset is Required { + return ( + typeof asset?.url === "string" && + asset.url.length > 0 && + typeof asset.width === "number" && + typeof asset.height === "number" && + typeof asset.size === "number" + ); +} + +function firstCompleteAsset( + ...assets: Array +): Required | null { + return assets.find(isCompleteAsset) ?? null; +} + +/** + * Normalize KLIPY's mixed media response to GIF-only results. The API can + * interleave ad/content records without a file payload; those are intentionally + * omitted until Buzz has an explicit third-party ad surface. + */ +export function normalizeKlipyGifs(items: KlipyRawGif[]): KlipyGif[] { + const gifs: KlipyGif[] = []; + + for (const item of items) { + if (item.type !== "gif" || !item.file || !item.slug) continue; + + const original = firstCompleteAsset( + item.file.md?.gif, + item.file.hd?.gif, + item.file.sm?.gif, + item.file.xs?.gif, + ); + const preview = firstCompleteAsset( + item.file.sm?.webp, + item.file.sm?.gif, + item.file.xs?.webp, + item.file.xs?.gif, + item.file.md?.webp, + original ?? undefined, + ); + if (!original || !preview) continue; + + const poster = firstCompleteAsset( + item.file.sm?.jpg, + item.file.xs?.jpg, + item.file.md?.jpg, + item.file.hd?.jpg, + ); + + gifs.push({ + id: item.id ?? null, + original, + poster, + preview, + slug: item.slug, + title: item.title?.trim() || "GIF", + }); + } + + return gifs; +} + +export function klipyGifFilename(gif: KlipyGif): string { + const safeSlug = gif.slug + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${safeSlug || "klipy-gif"}.gif`; +} + +/** + * Represent a selected KLIPY GIF as externally hosted media. The empty hash + * marks it as content-only media: the outgoing builder appends the image URL + * to the message body but deliberately omits an imeta tag, since Buzz relays + * only accept verified local `/media/` entries in imeta. + */ +export function klipyGifAttachment(gif: KlipyGif): ImetaMedia { + return { + dim: `${gif.original.width}x${gif.original.height}`, + displayLabel: gif.title, + filename: klipyGifFilename(gif), + sha256: "", + size: gif.original.size, + type: "image/gif", + uploaded: 0, + url: gif.original.url, + }; +} diff --git a/desktop/src/features/gifs/relay.ts b/desktop/src/features/gifs/relay.ts new file mode 100644 index 0000000000..bdcffd4f9f --- /dev/null +++ b/desktop/src/features/gifs/relay.ts @@ -0,0 +1,142 @@ +import { + type KlipyGif, + type KlipyResponse, + normalizeKlipyGifs, + relayKlipyCapability, + type RelayKlipyCapability, + type RelayGifSearchInfo, +} from "@/features/gifs/api"; +import { relayHttpFromWs } from "@/shared/api/inviteHelpers"; +import { signRelayEvent } from "@/shared/api/tauri"; + +const KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX = "buzz:klipy-customer-id:v1:"; +const NIP98_KIND = 27235; + +function customerId(relayUrl: string): string { + if (typeof window === "undefined") return globalThis.crypto.randomUUID(); + + try { + const storageKey = `${KLIPY_CUSTOMER_ID_STORAGE_KEY_PREFIX}${relayUrl}`; + const existing = window.localStorage.getItem(storageKey); + if (existing) return existing; + + const created = globalThis.crypto.randomUUID(); + window.localStorage.setItem(storageKey, created); + return created; + } catch { + // Storage can be unavailable in hardened webviews. Prefer an ephemeral ID + // over a process-wide fallback that would correlate unrelated relays. + return globalThis.crypto.randomUUID(); + } +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(text), + ); + return Array.from(new Uint8Array(digest)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function nip98PostHeader(url: string, body: string): Promise { + const authEvent = await signRelayEvent({ + kind: NIP98_KIND, + content: "", + tags: [ + ["u", url], + ["method", "POST"], + ["payload", await sha256Hex(body)], + ["nonce", crypto.randomUUID()], + ], + }); + return `Nostr ${btoa(JSON.stringify(authEvent))}`; +} + +const FRIENDLY_GIF_ERRORS: Record = { + relay_membership_required: "Join this community to search GIFs.", +}; + +function gifErrorMessage(error: string | undefined, status: number): string { + if (error && FRIENDLY_GIF_ERRORS[error]) return FRIENDLY_GIF_ERRORS[error]; + return error || `GIF request failed (${status})`; +} + +async function relayPost( + relayUrl: string, + path: string, + payload: Record, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}${path}`; + const body = JSON.stringify(payload); + const response = await fetch(url, { + body, + headers: { + Authorization: await nip98PostHeader(url, body), + "Content-Type": "application/json", + }, + method: "POST", + signal, + }); + if (!response.ok) { + const json = (await response.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(gifErrorMessage(json.error, response.status)); + } + if (response.status === 204) return undefined as T; + return (await response.json()) as T; +} + +/** The selected relay's advertised KLIPY endpoints, when supported. */ +export async function relayKlipyEndpoints( + relayUrl: string, + signal?: AbortSignal, +): Promise { + const url = `${relayHttpFromWs(relayUrl).replace(/\/+$/, "")}/info`; + const response = await fetch(url, { + headers: { Accept: "application/nostr+json" }, + signal, + }); + if (!response.ok) + throw new Error(`Could not read relay capabilities (${response.status})`); + const info = (await response.json()) as RelayGifSearchInfo; + return relayKlipyCapability(info); +} + +/** Search KLIPY through the selected relay without exposing its provider key. */ +export async function fetchKlipyGifs( + relayUrl: string, + searchPath: string, + query: string, + signal?: AbortSignal, +): Promise { + const response = await relayPost( + relayUrl, + searchPath, + { + customer_id: customerId(relayUrl), + locale: navigator.language || "en-US", + query: query.trim(), + }, + signal, + ); + if (response.result === false) { + throw new Error("GIF search failed"); + } + return normalizeKlipyGifs(response.data?.data ?? []); +} + +/** Report a selected GIF so KLIPY can update the anonymous user's Recents. */ +export async function reportKlipyShare( + relayUrl: string, + sharePath: string, + slug: string, +): Promise { + await relayPost(relayUrl, sharePath, { + customer_id: customerId(relayUrl), + slug, + }); +} diff --git a/desktop/src/features/gifs/ui/KlipyGifPicker.tsx b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx new file mode 100644 index 0000000000..6558cb24b8 --- /dev/null +++ b/desktop/src/features/gifs/ui/KlipyGifPicker.tsx @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query"; +import { LoaderCircle, Search } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import * as React from "react"; + +import type { KlipyGif } from "@/features/gifs/api"; +import { fetchKlipyGifs } from "@/features/gifs/relay"; +import { Input } from "@/shared/ui/input"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type KlipyGifPickerProps = { + onSelect: (gif: KlipyGif) => void; + relayUrl: string; + searchPath: string; +}; + +const LOADING_SKELETONS = [ + "tall-a", + "short-a", + "short-b", + "tall-b", + "short-c", + "short-d", + "tall-c", + "short-e", + "short-f", + "tall-d", +] as const; + +export const KlipyGifPicker = React.memo(function KlipyGifPicker({ + onSelect, + relayUrl, + searchPath, +}: KlipyGifPickerProps) { + const [search, setSearch] = React.useState(""); + const [debouncedSearch, setDebouncedSearch] = React.useState(""); + const prefersReducedMotion = useReducedMotion() ?? false; + + React.useEffect(() => { + const timeout = window.setTimeout( + () => setDebouncedSearch(search.trim()), + 500, + ); + return () => window.clearTimeout(timeout); + }, [search]); + + const gifsQuery = useQuery({ + queryFn: ({ signal }) => + fetchKlipyGifs(relayUrl, searchPath, debouncedSearch, signal), + queryKey: ["klipy-gifs", relayUrl, searchPath, debouncedSearch], + retry: false, + staleTime: 5 * 60 * 1_000, + }); + + return ( +
+
+
+ + setSearch(event.target.value)} + placeholder="Search KLIPY" + type="search" + value={search} + /> + {gifsQuery.isFetching ? ( + + ) : null} +
+
+ +
+ {gifsQuery.isPending ? ( +
+ Loading GIFs + {LOADING_SKELETONS.map((id) => ( + + ))} +
+ ) : gifsQuery.isError ? ( +
+

+ {gifsQuery.error.message} +

+ +
+ ) : gifsQuery.data.length === 0 ? ( +
+ No GIFs found. +
+ ) : ( +
+ {gifsQuery.data.map((gif) => { + const staticPoster = prefersReducedMotion ? gif.poster : null; + const showAnimated = !prefersReducedMotion; + return ( + + ); + })} +
+ )} +
+ +
+ Powered by KLIPY +
+
+ ); +}); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 79c193fb19..e1af5be6bc 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -597,22 +597,31 @@ test("imetaMediaFromTags: entry without size leaves size 0", () => { assert.equal(out[0].size, 0); }); -test("buildImetaTags: omits x line when sha256 is empty", () => { +test("buildImetaTags: omits hashless external media entirely", () => { const tags = buildImetaTags([ { - url: "https://b/a.png", - type: "image/png", + url: "https://static.klipy.com/a.gif", + type: "image/gif", sha256: "", size: 1, uploaded: 0, }, ]); - assert.equal(tags.length, 1); - // No element starts with "x " or "x\t" — no empty x line emitted. - assert.ok( - !tags[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(tags[0])}`, - ); + assert.deepEqual(tags, []); +}); + +test("buildOutgoingMessage: hashless external media is content-only", () => { + const out = buildOutgoingMessage("", [ + { + url: "https://static.klipy.com/a.gif", + type: "image/gif", + sha256: "", + size: 1, + uploaded: 0, + }, + ]); + assert.equal(out.content, "\n![image](https://static.klipy.com/a.gif)"); + assert.equal(out.mediaTags, undefined); }); test("buildImetaTags: omits size line when size is 0", () => { @@ -632,31 +641,14 @@ test("buildImetaTags: omits size line when size is 0", () => { ); }); -test("round-trip: sparse imeta from legacy tags rebuilds without empty x/size", () => { - // Legacy / cross-client entry: only url + m. No x, no size. +test("round-trip: sparse legacy imeta is not re-emitted without a hash", () => { const legacyTags = [["imeta", "url https://b/legacy.png", "m image/png"]]; const projected = imetaMediaFromTags(legacyTags); assert.equal(projected.length, 1); assert.equal(projected[0].sha256, ""); assert.equal(projected[0].size, 0); - const rebuilt = buildImetaTags(projected); - assert.equal(rebuilt.length, 1); - // Neither "x " nor "size 0" leaked into the rebuilt tag. - assert.ok( - !rebuilt[0].some((part) => /^x[\s\t]/.test(part)), - `expected no x line, got ${JSON.stringify(rebuilt[0])}`, - ); - assert.ok( - !rebuilt[0].some((part) => /^size[\s\t]/.test(part)), - `expected no size line, got ${JSON.stringify(rebuilt[0])}`, - ); - // url and m survived. - assert.deepEqual(rebuilt[0], [ - "imeta", - "url https://b/legacy.png", - "m image/png", - ]); + assert.deepEqual(buildImetaTags(projected), []); }); const IMETA = ["imeta", "url https://blossom/abc.png", "m image/png"]; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index fde8492289..f02b9906ca 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -79,29 +79,31 @@ export function imetaMediaFromTags( * Shared by the send path (initial post) and the edit path (full new tag set * on the edit event), so the two stay perfectly symmetric. * - * `url` and `m` are always emitted (NIP-92's only de-facto required fields; - * `m` carries a fallback in `imetaMediaFromTags`). All other fields are - * conditional — including `x` and `size` — because legacy and cross-client - * imeta entries can land without a sha256 or size, and our relay validator - * rejects literal `"x "` / `"size 0"` empties. NIP-92 itself treats every - * field except `url` as optional, so dropping them is spec-clean. + * `url`, `m`, and `x` are emitted for verified relay-hosted media. Entries + * without a hash represent external content (for example KLIPY GIFs); their + * markdown URL remains in the message body, but they are omitted from imeta + * because Buzz's relay validator requires a hash-backed local `/media/` path. + * Other fields remain conditional so legacy entries do not emit invalid + * literal `"size 0"` values. */ export function buildImetaTags( imetaMedia: ReadonlyArray, ): string[][] { - return imetaMedia.map((d) => [ - "imeta", - `url ${d.url}`, - `m ${d.type}`, - ...(d.sha256 ? [`x ${d.sha256}`] : []), - ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), - ...(d.dim ? [`dim ${d.dim}`] : []), - ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), - ...(d.thumb ? [`thumb ${d.thumb}`] : []), - ...(d.duration != null ? [`duration ${d.duration}`] : []), - ...(d.image ? [`image ${d.image}`] : []), - ...(d.filename ? [`filename ${d.filename}`] : []), - ]); + return imetaMedia + .filter((d) => d.sha256.length > 0) + .map((d) => [ + "imeta", + `url ${d.url}`, + `m ${d.type}`, + `x ${d.sha256}`, + ...(typeof d.size === "number" && d.size > 0 ? [`size ${d.size}`] : []), + ...(d.dim ? [`dim ${d.dim}`] : []), + ...(d.blurhash ? [`blurhash ${d.blurhash}`] : []), + ...(d.thumb ? [`thumb ${d.thumb}`] : []), + ...(d.duration != null ? [`duration ${d.duration}`] : []), + ...(d.image ? [`image ${d.image}`] : []), + ...(d.filename ? [`filename ${d.filename}`] : []), + ]); } const MEDIA_LINE_RE = @@ -329,9 +331,11 @@ export function buildOutgoingMessage( spoiler: spoileredMediaUrls.has(d.url), }); } - const mediaTags = - pendingImeta.length > 0 ? buildImetaTags(pendingImeta) : undefined; - return { content, mediaTags }; + const mediaTags = buildImetaTags(pendingImeta); + return { + content, + mediaTags: mediaTags.length > 0 ? mediaTags : undefined, + }; } /** diff --git a/desktop/src/features/messages/ui/ComposerAttachments.tsx b/desktop/src/features/messages/ui/ComposerAttachments.tsx index f46336b3e9..be0db49649 100644 --- a/desktop/src/features/messages/ui/ComposerAttachments.tsx +++ b/desktop/src/features/messages/ui/ComposerAttachments.tsx @@ -13,7 +13,6 @@ import { X, } from "lucide-react"; -import type { BlobDescriptor } from "@/shared/api/tauri"; import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { @@ -226,7 +225,7 @@ function composerMediaStyle(): React.CSSProperties { } type MediaAttachmentItemProps = { - attachment: BlobDescriptor; + attachment: ImetaMedia; isSpoilered: boolean; onEditSave?: (url: string, bytes: Uint8Array) => Promise; onRemove: (url: string) => void; @@ -266,6 +265,18 @@ const MediaAttachmentItem = React.forwardRef< const hash = shortHash(attachment.sha256); const isVideo = attachment.type.startsWith("video/"); + // One accessible name for every control/label in this item. Provider media + // (e.g. KLIPY GIFs) carries a `displayLabel` but no content hash; ordinary + // uploads keep their historical type-aware `Attachment ` / + // `Video attachment ` name; only genuinely hashless non-provider media + // falls back to a filename. + const mediaLabel = + attachment.displayLabel?.trim() || + (attachment.sha256 + ? isVideo + ? `Video attachment ${hash}` + : `Attachment ${hash}` + : attachment.filename?.trim() || `Attachment ${hash}`); const thumbUrl = attachment.thumb ? rewriteRelayUrl(attachment.thumb) : rewriteRelayUrl(attachment.url); @@ -275,7 +286,11 @@ const MediaAttachmentItem = React.forwardRef< ? rewriteRelayUrl(attachment.thumb) : undefined; - const canEdit = !isVideo && onEditSave !== undefined; + // Only Buzz-hosted uploads have a content hash. URL-only provider media + // must remain externally hosted instead of being copied into storage by the + // image editor's save path. + const canEdit = + !isVideo && onEditSave !== undefined && attachment.sha256.length === 64; const canRevert = !isVideo && onRevert !== undefined && originalUrl !== undefined; @@ -338,40 +353,41 @@ const MediaAttachmentItem = React.forwardRef< style={composerMediaStyle()} > - -
- {isVideo ? ( -
- {videoPosterUrl ? ( - {`Video - ) : ( -
- )} -
-
- -
+ + {isVideo ? ( +
+ {videoPosterUrl ? ( + + ) : ( +
+ )} +
+
+
- ) : ( - {`Attachment - )} - {isSpoilered ? ( -
- -
- ) : null} -
+
+ ) : ( + + )} + {isSpoilered ? ( +
+ +
+ ) : null} - Attachment {hash} preview + {mediaLabel} preview Full-size attachment preview. Press Escape or click outside to @@ -401,7 +417,7 @@ const MediaAttachmentItem = React.forwardRef< ) : null} {mode === "edit" && !isVideo ? ( ) : ( {`Attachment