From c09730e87466de572fb6218375aef2c3d915dd7d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 12 Oct 2026 09:39:53 -0700 Subject: [PATCH 01/13] fix(mobile): keep notification toggle usable and match row height (#8223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turning Notifications off invalidated the relay capability provider because it watched the entire saved community. If the redundant NIP-11 request stalled or failed, the switch became disabled and reopening Settings did not recover it. Discovery now follows community identity and connection status, so preference and lease bookkeeping retain the validated capability while a real reconnect still refreshes it. Each opt-in also starts a fresh active lease immediately instead of waiting for the old renewal timer. Failed attempts retain their gate until the retry delay expires, preventing rebuilds from bypassing backoff. Notifications now has the same single-line height as Set status, with the full switch hit area and one combined accessibility label. This follows [#8201](https://github.com/block/buzz/pull/8201); no duplicate open PR was found. ### Validation Added iOS widget regressions mount the production settings page and push bootstrap with production storage logic, signing, and lease publication. They verify a higher-generation active replacement after off/on with accepted, failed, and pending cleanup, plus closing/reopening Settings, switch and row taps, 2× text, accessibility, and rediscovery on reconnect. A gate test verifies that rebuilds cannot bypass failure backoff. Both UI failures and the missing active replacement were reproduced before their fixes. Mobile dependency installation, formatting/static checks, 3,234 configured tests, and three unconfigured-build tests passed. Captures use the real Flutter settings widget at 375pt width, with synthetic data and mocked native/network boundaries. Native navigation is outside the capture. No device installation or deployment was performed. After merge, validate the candidate on iOS: compare Notifications with Set status, toggle off then immediately on, close/reopen Settings while off and turn on, and repeat after reconnect. Confirm delivery resumes after opt-in. ### Layout Flutter widget captures with synthetic data; native navigation is excluded. | Before | After | | --- | --- | | ![Notifications row before](https://raw.githubusercontent.com/block/buzz/b2cf4b8b1f0abaa95893af0e5a4e194c6af41333/pr-8223--01-before.png) | ![Notifications row after](https://raw.githubusercontent.com/block/buzz/b2cf4b8b1f0abaa95893af0e5a4e194c6af41333/pr-8223--02-after.png) | --------- Signed-off-by: Tom Brow --- .../settings_page/notifications_section.dart | 54 +-- mobile/lib/shared/push/push_bootstrap.dart | 22 +- .../push/push_relay_capability_provider.dart | 15 +- .../settings/notification_toggle_test.dart | 343 ++++++++++++++++++ .../test/shared/push/push_bootstrap_test.dart | 17 + 5 files changed, 422 insertions(+), 29 deletions(-) create mode 100644 mobile/test/features/settings/notification_toggle_test.dart diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index 34b46a4cd09..23ba05a22f6 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -24,32 +24,44 @@ class _NotificationsSection extends ConsumerWidget { } final canToggle = hasCapability || community.pushNotificationsEnabled; + void setEnabled(bool enabled) { + unawaited( + ref + .read(communityListProvider.notifier) + .setPushNotificationsEnabled(community.id, enabled), + ); + } + return AppListCard( verticalPadding: Grid.twelve, children: [ - AppListRow( - key: const ValueKey('push-notifications-enabled'), - title: 'Notifications', - trailing: Switch.adaptive( - value: community.pushNotificationsEnabled, - onChanged: !canToggle - ? null - : (enabled) => unawaited( - ref - .read(communityListProvider.notifier) - .setPushNotificationsEnabled(community.id, enabled), + MergeSemantics( + key: const ValueKey('push-notifications-setting'), + child: Stack( + alignment: Alignment.centerRight, + children: [ + AppListRow( + key: const ValueKey('push-notifications-enabled'), + title: 'Notifications', + // Reserve horizontal room without letting the switch's 48dp + // layout height expand this single-line row. + trailing: const SizedBox(width: 60), + onTap: canToggle + ? () => setEnabled(!community.pushNotificationsEnabled) + : null, + ), + Positioned.fill( + right: Grid.xs, + child: Align( + alignment: Alignment.centerRight, + child: Switch.adaptive( + value: community.pushNotificationsEnabled, + onChanged: canToggle ? setEnabled : null, ), - ), - onTap: !canToggle - ? null - : () => unawaited( - ref - .read(communityListProvider.notifier) - .setPushNotificationsEnabled( - community.id, - !community.pushNotificationsEnabled, - ), ), + ), + ], + ), ), ], ); diff --git a/mobile/lib/shared/push/push_bootstrap.dart b/mobile/lib/shared/push/push_bootstrap.dart index 847668fefed..49b05859ec6 100644 --- a/mobile/lib/shared/push/push_bootstrap.dart +++ b/mobile/lib/shared/push/push_bootstrap.dart @@ -35,11 +35,12 @@ class BuzzPushAttemptGate { void failed(String attempt, {required VoidCallback retry}) { if (_attempt != attempt) return; - _attempt = null; _retryTimer?.cancel(); _retryTimer = Timer(retryDelay, () { _retryTimer = null; - if (_attempt == null) retry(); + if (_attempt != attempt) return; + _attempt = null; + retry(); }); } @@ -171,6 +172,16 @@ class BuzzPushBootstrap extends HookConsumerWidget { final community = ref.watch(activeCommunityProvider).value; final memberPubkey = ref.watch(myPubkeyProvider); final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; + final enablementEpoch = useRef(0); + useEffect(() { + // Each opt-in needs a fresh active lease even when endpoint, executor, + // and subscriptions match the lease replaced by the opt-out tombstone. + // A distinct key also fences renewal callbacks from the prior opt-in. + if (community?.pushNotificationsEnabled == true) { + enablementEpoch.value += 1; + } + return null; + }, [community?.id, community?.pushNotificationsEnabled]); useEffect(() { final listener = AppLifecycleListener( @@ -269,13 +280,14 @@ class BuzzPushBootstrap extends HookConsumerWidget { final activeDescriptor = descriptor!; final state = activeCommunity.pushSubscriptionState; if (state.desired.isEmpty) return null; - final attempt = buzzPushPublicationAttemptKey( + final publicationKey = buzzPushPublicationAttemptKey( communityId: activeCommunity.id, relayBaseUrl: config.baseUrl, token: token, descriptor: activeDescriptor, subscriptions: state.desired, ); + final attempt = '${enablementEpoch.value}|$publicationKey'; if (!publicationAttempt.tryBegin(attempt)) return null; final relay = SignedEventRelay( session: ref.read(relaySessionProvider.notifier), @@ -365,7 +377,9 @@ class BuzzPushBootstrap extends HookConsumerWidget { ) async { final state = community.pushSubscriptionState; final desired = state.desired; - final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final descriptor = await ref.read(buzzPushDescriptorFetcherProvider)( + config.baseUrl, + ); final grant = await enrollBuzzPush( config.wsUrl, Env.pushGatewayUrl, diff --git a/mobile/lib/shared/push/push_relay_capability_provider.dart b/mobile/lib/shared/push/push_relay_capability_provider.dart index bcfaf06fa47..6688797993e 100644 --- a/mobile/lib/shared/push/push_relay_capability_provider.dart +++ b/mobile/lib/shared/push/push_relay_capability_provider.dart @@ -22,12 +22,19 @@ final buzzPushDescriptorFetcherProvider = Provider( final currentRelayPushDescriptorProvider = FutureProvider.autoDispose((ref) async { if (!Env.pushGatewayConfigured) return null; - final session = ref.watch(relaySessionProvider); + final sessionStatus = ref.watch( + relaySessionProvider.select((session) => session.status), + ); final config = ref.watch(relayConfigProvider); - final community = ref.watch(activeCommunityProvider).value; + // Preference and lease bookkeeping do not change relay capability. + // Watching the whole community rediscovered NIP-11 after every toggle, + // disabling the off switch when that redundant request could not finish. + final communityId = ref.watch( + activeCommunityProvider.select((value) => value.value?.id), + ); final memberPubkey = ref.watch(myPubkeyProvider); - if (session.status != SessionStatus.connected || - community == null || + if (sessionStatus != SessionStatus.connected || + communityId == null || config.nsec == null || config.nsec!.isEmpty || memberPubkey == null || diff --git a/mobile/test/features/settings/notification_toggle_test.dart b/mobile/test/features/settings/notification_toggle_test.dart new file mode 100644 index 00000000000..39e46ad9e80 --- /dev/null +++ b/mobile/test/features/settings/notification_toggle_test.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bootstrap.dart'; +import 'package:buzz/shared/push/push_lease_revocation_outbox.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/push/push_relay_capability_provider.dart'; +import 'package:buzz/shared/push/push_subscription.dart'; +import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../shared/community/community_storage_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + final fonts = FontLoader('Inter') + ..addFont(rootBundle.load('assets/fonts/InterVariable.ttf')); + await fonts.load(); + }); + for (final outcome in ['accepted', 'failed', 'pending', 'scaled']) { + testWidgets('off then on remains usable with $outcome cleanup', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + // iPhone 12 mini width; use a wider screen for the 2x single-line case. + tester.view.physicalSize = Size(outcome == 'scaled' ? 430 : 375, 1800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + if (outcome == 'scaled') { + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'buzz', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = CommunityStorage(secure: FakeSecureStorage()); + final subscriptions = [ + BuzzPushSubscription( + filter: BuzzPushFilter(kinds: const [9], pTags: ['a' * 64]), + notificationClass: 'default', + ), + ]; + var discoveries = 0; + final session = _Connected(); + apnsDeviceToken.value = 'test-apns-token'; + addTearDown(() => apnsDeviceToken.value = null); + final grant = { + 'relayOrigin': 'wss://relay.example', + 'relayPubkey': _descriptor.executorPubkey, + 'installationId': 'b' * 32, + 'endpointGrant': 'test-endpoint-grant', + 'endpointHash': 'c' * 64, + 'appProfile': buzzDevPushAppProfile, + 'endpointEpoch': 1, + 'generation': 1, + 'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, + }; + const nativePush = MethodChannel('buzz/push'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler( + nativePush, + (call) async => switch (call.method) { + 'enrollPush' => grant, + 'endpointGrants' => [grant], + 'startRegistration' || 'syncPushSnapshot' => null, + _ => throw StateError('Unexpected native call ${call.method}'), + }, + ); + addTearDown(() => messenger.setMockMethodCallHandler(nativePush, null)); + final community = + Community.create( + name: 'Team', + relayUrl: 'https://relay.example', + ).copyWith( + nsec: nostr.Nip19.encode( + prefix: nostr.Nip19Prefix.nsec, + data: '1' * 64, + ), + pushNotificationsEnabled: true, + pushLeaseInstallationId: 'd' * 32, + pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired( + desired: subscriptions, + ).withAccepted(subscriptions: subscriptions, generation: 7), + ); + await storage.save(community); + await storage.saveActiveId(community.id); + + final cleanup = Completer(); + final showingSettings = ValueNotifier(true); + addTearDown(showingSettings.dispose); + final snapshots = >[]; + final tombstones = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + authProvider.overrideWith(_Auth.new), + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + communityPushLeaseDeactivatorProvider.overrideWithValue(( + community, { + generation, + }) async { + tombstones.add(generation); + if (outcome == 'failed') throw StateError('relay unavailable'); + if (outcome == 'pending') await cleanup.future; + await publishBuzzPushLeaseTombstone( + descriptor: _descriptor, + installationId: community.pushLeaseInstallationId!, + generation: generation!, + nsec: community.nsec!, + memberPubkey: pubkeyFromNsec(community.nsec)!, + submit: SignedEventRelay( + session: session, + nsec: community.nsec, + ).submit, + ); + }), + buzzPushLeaseRevocationStorageProvider.overrideWithValue( + BuzzPushLeaseRevocationStorage(secure: FakeSecureStorage()), + ), + relaySessionProvider.overrideWith(() => session), + myPubkeyProvider.overrideWithValue(nostr.Keys('1' * 64).public), + buzzPushDescriptorFetcherProvider.overrideWithValue((_) async { + discoveries++; + return _descriptor; + }), + appLifecycleProvider.overrideWith(_Lifecycle.new), + buzzPushAuthorizationStatusReaderProvider.overrideWithValue( + () async => BuzzPushAuthorizationStatus.denied, + ), + ], + child: BuzzPushBootstrap( + child: MaterialApp( + theme: AppTheme.light(), + home: ValueListenableBuilder( + valueListenable: showingSettings, + builder: (_, showing, _) => showing + ? SettingsPage( + profileHeader: const SizedBox.shrink(), + identityRecoveryPageBuilder: (_) => + const SizedBox.shrink(), + ) + : const SizedBox.shrink(), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = ProviderScope.containerOf( + tester.element(find.byType(SettingsPage)), + ); + expect( + container.read(activeCommunityProvider).value?.pushNotificationsEnabled, + isTrue, + reason: + '${container.read(activeCommunityProvider)} / ${container.read(communityListProvider)}', + ); + expect(session.leases.single['active'], isTrue); + // Capability discovery plus the active publication. + expect(discoveries, 2); + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect( + tester.getSemantics( + find.byKey(const ValueKey('push-notifications-setting')), + ), + isSemantics( + label: 'Notifications', + hasToggledState: true, + isToggled: true, + ), + ); + expect( + tester.getSize(find.byType(Switch)).height, + greaterThanOrEqualTo(48), + ); + // Compare rendered row bounds, not just their padding parameters. + final notificationHeight = tester + .getSize(find.byKey(const ValueKey('push-notifications-enabled'))) + .height; + final statusHeight = tester + .getSize(find.byKey(const ValueKey('settings-set-status'))) + .height; + expect( + notificationHeight, + statusHeight, + reason: 'single-line settings rows share a height', + ); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + final stored = (await storage.loadAll()).single; + expect(stored.pushNotificationsEnabled, isFalse); + expect( + stored.pushSubscriptionState.pendingTombstoneGeneration, + outcome == 'failed' || outcome == 'pending' + ? greaterThanOrEqualTo(9) + : isNull, + ); + expect(tombstones.first, 9); + expect( + tombstones.length, + lessThanOrEqualTo(2), + reason: 'failed cleanup must not defeat the retry backoff', + ); + expect(discoveries, 2); + expect(snapshots.last.single.pushNotificationsEnabled, isFalse); + final offSwitch = tester.widget(find.byType(Switch)); + expect(offSwitch.value, isFalse); + expect(offSwitch.onChanged, isNotNull); + // Closing and reopening Settings must not strand the off control either. + showingSettings.value = false; + await tester.pumpAndSettle(); + showingSettings.value = true; + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Switch)).onChanged, isNotNull); + await tester.tap(find.text('Notifications')); + await tester.pumpAndSettle(); + expect(tester.widget(find.byType(Switch)).value, isTrue); + expect((await storage.loadAll()).single.pushNotificationsEnabled, isTrue); + expect( + session.leases.where((lease) => lease['active'] == true).length, + 2, + reason: + 're-enabling must publish an active lease before the renewal timer', + ); + expect(session.leases.last['generation'], greaterThan(tombstones.last!)); + if (outcome == 'pending') { + cleanup.complete(); + await tester.pumpAndSettle(); + expect( + (await storage.loadAll()).single.pushNotificationsEnabled, + isTrue, + ); + } + final latestLease = session.leases.reduce( + (a, b) => (a['generation'] as int) > (b['generation'] as int) ? a : b, + ); + expect( + latestLease['active'], + isTrue, + reason: 'a delayed old tombstone cannot supersede the new active lease', + ); + expect(discoveries, 3); + // A real reconnect must still discover capability again. + (container.read(relaySessionProvider.notifier) as _Connected).setStatus( + SessionStatus.disconnected, + ); + await tester.pumpAndSettle(); + expect(container.read(currentRelayPushDescriptorProvider).value, isNull); + (container.read(relaySessionProvider.notifier) as _Connected).setStatus( + SessionStatus.connected, + ); + await tester.pumpAndSettle(); + expect(discoveries, 4); + expect(tester.widget(find.byType(Switch)).onChanged, isNotNull); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + semantics.dispose(); + debugDefaultTargetPlatformOverride = null; + }); + } +} + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} + +class _Auth extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _Connected extends RelaySessionNotifier { + final leases = >[]; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(event.kind, buzzPushLeaseKind); + final plaintext = nip44Decrypt( + getConversationKey('2' * 64, event.pubkey), + event.content, + ); + leases.add(jsonDecode(plaintext) as Map); + return event; + } + + void setStatus(SessionStatus status) => state = SessionState(status: status); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); +} + +final _descriptor = BuzzPushLeaseDescriptor( + origin: 'wss://relay.example', + executorKeyId: 'relay-v1', + executorPubkey: nostr.Keys('2' * 64).public, + transport: 'apns', + maxLeaseTtlSeconds: 3600, + maxContentLength: 4096, + maxPlaintextLength: 4096, + maxEndpointLength: 2048, + maxStringLength: 512, +); diff --git a/mobile/test/shared/push/push_bootstrap_test.dart b/mobile/test/shared/push/push_bootstrap_test.dart index 2a3752e9f86..6770ad546e2 100644 --- a/mobile/test/shared/push/push_bootstrap_test.dart +++ b/mobile/test/shared/push/push_bootstrap_test.dart @@ -53,6 +53,23 @@ void main() { expect(gate.tryBegin('attempt'), isTrue); }); + testWidgets('a failed attempt stays blocked until the retry delay', ( + tester, + ) async { + final gate = BuzzPushAttemptGate(); + addTearDown(gate.dispose); + var retries = 0; + expect(gate.tryBegin('attempt'), isTrue); + gate.failed('attempt', retry: () => retries++); + expect(gate.tryBegin('attempt'), isFalse); + await tester.pump(const Duration(seconds: 4)); + expect(gate.tryBegin('attempt'), isFalse); + expect(retries, 0); + await tester.pump(const Duration(seconds: 1)); + expect(retries, 1); + expect(gate.tryBegin('attempt'), isTrue); + }); + test('a new attempt cancels an obsolete scheduled retry', () async { final gate = BuzzPushAttemptGate(retryDelay: Duration.zero); addTearDown(gate.dispose); From 799cfd70504c78ebb1958279c08110ce925acd27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Quenaudon?= Date: Mon, 12 Oct 2026 19:10:30 +0100 Subject: [PATCH 02/13] Start the ACP idle interval when channel turns complete (#8149) A channel turn can outlast the idle interval, leaving its pool eligible for teardown immediately after completion. Reset the idle clock when a channel result settles, while letting heartbeat-only pools expire normally. Add regression coverage for both cases. This addresses premature idle teardown. Broader callback continuity and human workflow testing remain pending, so this PR stays in draft. Signed-off-by: Benoit Quenaudon --- crates/buzz-acp/src/config.rs | 8 ++- crates/buzz-acp/src/lib.rs | 131 ++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 24d59c2c933..da930dda454 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -506,7 +506,8 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, - /// Exit after this many seconds with no dispatched events and no turn in flight. + /// Exit after this many seconds since the last channel dispatch or completion, + /// with no turn in flight. Heartbeat completion does not renew this interval. /// 0 disables inactivity self-termination. #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)] pub exit_after_inactivity: u64, @@ -516,8 +517,9 @@ pub struct CliArgs { pub lazy_pool: bool, /// Tear the woken pool back down to the lazy empty-slot state after this - /// many seconds with no dispatched turn in flight and an empty queue, - /// releasing worker subprocesses until the next accepted event re-wakes. + /// many seconds since the last channel dispatch or completion, with no turn + /// in flight and an empty queue. Heartbeat completion does not renew the interval. + /// Worker subprocesses are released until the next accepted event re-wakes. /// Requires `--lazy-pool`; ignored otherwise. 0 disables idle re-sleep. #[arg(long, env = "BUZZ_ACP_IDLE_POOL_SLEEP", default_value_t = 0)] pub idle_pool_sleep: u64, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 07dc4cce559..0249f5be4db 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3998,6 +3998,7 @@ async fn run_harness( &mut respawn_tasks, observer.clone(), Some(&ctx.rest_client), + &mut last_activity, ) == LoopAction::Exit { break; @@ -5239,7 +5240,14 @@ fn handle_prompt_result( respawn_tasks: &mut tokio::task::JoinSet<()>, observer: Option, rest_client: Option<&relay::RestClient>, + last_activity: &mut tokio::time::Instant, ) -> LoopAction { + // Start a full idle interval when channel work settles, before releasing + // its in-flight guard. Heartbeats must not keep an otherwise idle pool alive. + match &result.source { + PromptSource::Channel(_) => *last_activity = tokio::time::Instant::now(), + PromptSource::Heartbeat => {} + } let before = pool.task_map().len(); let agent_index = result.agent.index; let successful_steer_deliveries = pool @@ -11286,6 +11294,114 @@ mod error_outcome_emission_tests { agent.state.set_scope_owner_generation(scope, generation); } + // Exercise result handling and both production idle decisions with a fake clock. + // No relay or provider is used; cat supplies only an owned process handle. + async fn assert_completion_idle_window(channel_turn: bool) { + let channel_id = Uuid::new_v4(); + let scope = scope::SessionScope::Conversation { channel_id }; + let mut agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + if channel_turn { + bind_agent_scope_owner(&mut pool, &mut agent, scope.clone()); + } + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: channel_turn.then_some(channel_id), + scope: channel_turn.then_some(scope.clone()), + turn_id: "idle-window".into(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + successful_steer_deliveries: HashSet::new(), + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = !channel_turn; + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let bound = Duration::from_secs(30); + let mut last_activity = tokio::time::Instant::now(); + tokio::time::advance(bound * 2).await; + let completed_at = tokio::time::Instant::now(); + let result = PromptResult { + agent, + source: if channel_turn { + PromptSource::Channel(scope) + } else { + PromptSource::Heartbeat + }, + turn_id: "idle-window".into(), + outcome: PromptOutcome::Ok(crate::acp::StopReason::EndTurn), + batch: None, + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &HashSet::new(), + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + &mut last_activity, + ); + // Match the main loop: consume finished prompt tasks before the idle check. + while pool.join_set.join_next().await.is_some() {} + assert!(pool.task_map().is_empty()); + assert!(!heartbeat_in_flight); + assert!(!queue.has_undispatched_work()); + let sleep_due = |now| { + idle_pool_sleep_due( + true, + last_activity, + now, + bound, + heartbeat_in_flight, + !pool.join_set.is_empty(), + queue.has_undispatched_work(), + any_respawn_in_flight(&crash_history), + ) + }; + assert_eq!(sleep_due(completed_at), !channel_turn); + assert_eq!( + inactivity_expired(last_activity, completed_at, bound, false), + !channel_turn + ); + if channel_turn { + assert_eq!(last_activity, completed_at); + assert!(!sleep_due(completed_at + bound - Duration::from_millis(1))); + } + assert!(sleep_due(completed_at + bound)); + assert!(inactivity_expired( + last_activity, + completed_at + bound, + bound, + false + )); + } + + #[tokio::test(start_paused = true)] + async fn completed_channel_turn_gets_a_full_bounded_idle_window() { + assert_completion_idle_window(true).await; + } + + #[tokio::test(start_paused = true)] + async fn completed_heartbeat_does_not_extend_the_idle_window() { + assert_completion_idle_window(false).await; + } + #[tokio::test] async fn successful_native_steer_is_transferred_to_live_session_delivery_state() { let channel_id = Uuid::new_v4(); @@ -11357,6 +11473,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); @@ -11437,6 +11554,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); @@ -11558,6 +11676,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); let returned = pool.agents_mut()[0].as_ref().expect("returned agent"); @@ -11627,6 +11746,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let turn_errors: Vec<_> = observer @@ -11896,6 +12016,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let events = observer.snapshot(); let turn_error = events.iter().find(|e| e.kind == "turn_error").unwrap(); @@ -11991,6 +12112,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); ( queue.pending_channels(), @@ -12100,6 +12222,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); ( queue.pending_channels(), @@ -12195,6 +12318,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let events = observer.snapshot(); @@ -12292,6 +12416,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); let events = observer.snapshot(); @@ -12413,6 +12538,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); // Batch preserved as a cancelled merge, not dead-lettered — same @@ -12549,6 +12675,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), None, + &mut tokio::time::Instant::now(), ); // No batch to merge — the queue has nothing pending for any channel. @@ -12680,6 +12807,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ), LoopAction::Continue )); @@ -12832,6 +12960,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); // The batch must not be requeued: pending_channels returns 0. @@ -12940,6 +13069,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, Some(observer.clone()), Some(&rest), + &mut tokio::time::Instant::now(), ); // The batch must not be requeued: pending_channels returns 0. @@ -13211,6 +13341,7 @@ mod error_outcome_emission_tests { &mut respawn_tasks, None, None, + &mut tokio::time::Instant::now(), ); // Non-auth application error: batch IS requeued (first attempt, retry budget > 0). From 1d9f2c81a5583f12be1239a088e2f0b379292dc3 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 12 Oct 2026 18:43:24 -0700 Subject: [PATCH 03/13] feat(relay): accept an owner's presence for its agents (kind 20003) (#8261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary App-hosted agents (buzz-app agents2) run inside the desktop app without a socket per agent, so they can't send their own kind 20001 presence and always read as offline — including to their owner on another device. This adds **kind 20003, an owner's presence for its agents**: - Owner-signed; `content` is the status (`online` / `away` / `offline`); each `p` tag names an agent (1–256 distinct). - WebSocket only (rejected on HTTP ingest, like 20001). - The relay keeps only the agents the signer owns (`users.agent_owner_pubkey`) — cached `true` from `observer_owner_cache`, the rest resolved with one `list_agents_for_owner` query. Only positive links are cached, so a just-linked agent isn't hidden for the cache TTL and the observer path isn't poisoned. - Sets (180s TTL) or clears each agent's presence in one Redis round trip, the same keys kind 20001 writes, so the presence snapshot reports them with no client change. - Not fanned out: live presence is matched by author, and the author here is the owner. A new kind rather than `p` tags on 20001, so a relay without this ignores the batch instead of storing it as the owner's own presence. ## Testing - `agent_presence_request_names_distinct_agents_with_a_known_status` (unit). - `presence_storage_postgres_tests::agent_presence_stores_only_owned_agents` (Postgres + Redis): only the owner's agent goes online, another owner's agent doesn't, a pre-cached `false` doesn't hide a linked agent, and `offline` clears it. All 5 presence Postgres tests pass via `scripts/postgres-test-run.sh -p buzz-relay --lib -E 'test(/presence/)'`. - `buzz-pubsub` ignored Redis test `test_presence_many_sets_with_ttl_and_clears` passes. - clippy clean. Plain `cargo test` failures locally were environmental only (buzz-media "role buzz does not exist", mesh_demo 504). - Not yet exercised end to end against a local relay + app. App side: block/buzz-app#862. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Bradley Axen Co-authored-by: Claude Opus 5.5 --- ARCHITECTURE.md | 3 + NOSTR.md | 1 + crates/buzz-core/src/kind.rs | 4 + crates/buzz-db/src/store/user.rs | 29 +++ crates/buzz-pubsub/src/lib.rs | 19 ++ crates/buzz-pubsub/src/presence.rs | 91 ++++++++ crates/buzz-relay/src/handlers/event.rs | 254 ++++++++++++++++++++++- crates/buzz-relay/src/handlers/ingest.rs | 6 +- 8 files changed, 405 insertions(+), 2 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 531b1ceee7a..bab06526ed7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -189,6 +189,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa | 45003 | KIND_FORUM_COMMENT | Forum thread reply | | 46001–46012 | KIND_WORKFLOW_* | Workflow execution events | | 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat | +| 20003 | KIND_AGENT_PRESENCE | Ephemeral presence heartbeat an owner sends for its agents | `buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+). @@ -305,6 +306,8 @@ Ephemeral events bypass DB storage, audit, and search. Two sub-paths: ``` Presence events skip membership checks and use local-only fan-out. Multi-node presence fan-out would require Redis pub/sub (documented as future work). +**Agent presence (kind 20003):** an owner's one heartbeat for all its agents, so an app that runs agents without a socket per agent can still show them online. Content is the status (`online`, `away` or `offline`) and each `p` tag names an agent, at most 256. WebSocket only. The relay keeps the agents the signer owns (`users.agent_owner_pubkey`, through the owner cache), skips the rest, and sets or clears their presence exactly as their own kind 20001 would, so the presence snapshot reports them unchanged. It is not fanned out, since live presence is matched by author; viewers see it on their next snapshot read. + **Other ephemeral events (e.g., typing indicators):** ``` 1. VERIFY — spawn_blocking(verify_event) diff --git a/NOSTR.md b/NOSTR.md index cce70f2f77f..cb1f20e84fb 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -62,6 +62,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members | | **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) | | **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. | +| **Agent presence (kind:20003)** | ✅ | Ephemeral; an owner's presence for the agents it owns, named by `p` tags (at most 256); `online`/`away` set and `offline` clears each agent's presence as its own kind:20001 would. Not fanned out. | | **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) | | **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist | | **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` | diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 32d8959fb69..332e97d9589 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -467,6 +467,9 @@ pub const KIND_PRESENCE_UPDATE: u32 = 20001; pub const KIND_PAIRING: u32 = 24134; /// Ephemeral: typing indicator for a channel. pub const KIND_TYPING_INDICATOR: u32 = 20002; +/// Ephemeral: an owner's presence for its agents. Content is the status, each +/// `p` tag names one agent the signer owns. Never the signer's own presence. +pub const KIND_AGENT_PRESENCE: u32 = 20003; /// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame. pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; /// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral @@ -703,6 +706,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_THREAD_WINDOW_BOUNDS, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, + KIND_AGENT_PRESENCE, KIND_HUDDLE_REACTION, KIND_BLOSSOM_AUTH, KIND_PAIRING, diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index e6f9ad4cd51..ec4b897a617 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -404,6 +404,24 @@ pub async fn list_agents_for_owner( .await?) } +/// Of `candidates`, the agents whose `agent_owner_pubkey` is `owner_pubkey` in +/// one community. +pub async fn owned_agents_among( + pool: &PgPool, + community_id: CommunityId, + owner_pubkey: &[u8], + candidates: &[Vec], +) -> Result>> { + Ok(sqlx::query_scalar::<_, Vec>( + "SELECT pubkey FROM users WHERE community_id = $1 AND agent_owner_pubkey = $2 AND pubkey = ANY($3)", + ) + .bind(community_id.as_uuid()) + .bind(owner_pubkey) + .bind(candidates) + .fetch_all(pool) + .await?) +} + /// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`. /// Queries `agent_owner_pubkey` directly rather than going through /// `get_agent_channel_policy`, which would fetch unrelated fields. @@ -593,6 +611,17 @@ impl Db { crate::user::list_agents_for_owner(&self.pool, community_id, owner_pubkey).await } + /// Of `candidates`, the agents owned by `owner_pubkey` in one community. + #[datastore_span(name = "owned_agents_among", system = "postgresql")] + pub async fn owned_agents_among( + &self, + community_id: CommunityId, + owner_pubkey: &[u8], + candidates: &[Vec], + ) -> Result>> { + crate::user::owned_agents_among(&self.pool, community_id, owner_pubkey, candidates).await + } + /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. #[datastore_span(name = "is_agent_owner", system = "postgresql")] pub async fn is_agent_owner( diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 93417e11c38..487ea9b6302 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -398,6 +398,25 @@ impl PubSubManager { presence::clear_presence(&self.pool, ctx, pubkey).await } + /// Set one status with the 180s TTL for each of `pubkeys`, in one round trip. + pub async fn set_presence_many( + &self, + ctx: &TenantContext, + pubkeys: &[PublicKey], + status: &str, + ) -> Result<(), PubSubError> { + presence::set_presence_many(&self.pool, ctx, pubkeys, status).await + } + + /// Remove presence for each of `pubkeys`. + pub async fn clear_presence_many( + &self, + ctx: &TenantContext, + pubkeys: &[PublicKey], + ) -> Result<(), PubSubError> { + presence::clear_presence_many(&self.pool, ctx, pubkeys).await + } + /// Returns the current presence status for `pubkey`, or `None` if not set. pub async fn get_presence( &self, diff --git a/crates/buzz-pubsub/src/presence.rs b/crates/buzz-pubsub/src/presence.rs index a3490222715..4de5fae8a3d 100644 --- a/crates/buzz-pubsub/src/presence.rs +++ b/crates/buzz-pubsub/src/presence.rs @@ -58,6 +58,53 @@ pub async fn clear_presence( Ok(()) } +/// Sets one status for every pubkey in `pubkeys`, each with the +/// [`PRESENCE_TTL_SECS`] TTL, in one atomic round trip. +pub async fn set_presence_many( + pool: &Pool, + ctx: &TenantContext, + pubkeys: &[PublicKey], + status: &str, +) -> Result<(), PubSubError> { + if pubkeys.is_empty() { + return Ok(()); + } + let mut conn = pool.get().await?; + let mut pipe = redis::pipe(); + pipe.atomic(); + for pubkey in pubkeys { + pipe.cmd("SET") + .arg(presence_key(ctx, pubkey)) + .arg(status) + .arg("EX") + .arg(PRESENCE_TTL_SECS) + .ignore(); + } + pipe.query_async::<()>(&mut conn).await?; + Ok(()) +} + +/// Removes the presence entries for every pubkey in `pubkeys`. +pub async fn clear_presence_many( + pool: &Pool, + ctx: &TenantContext, + pubkeys: &[PublicKey], +) -> Result<(), PubSubError> { + if pubkeys.is_empty() { + return Ok(()); + } + let mut conn = pool.get().await?; + let keys: Vec = pubkeys + .iter() + .map(|pubkey| presence_key(ctx, pubkey)) + .collect(); + redis::cmd("DEL") + .arg(&keys) + .query_async::<()>(&mut conn) + .await?; + Ok(()) +} + /// Returns the current presence status for `pubkey`, or `None` if not set or expired. pub async fn get_presence( pool: &Pool, @@ -209,6 +256,50 @@ mod tests { clear_presence(&pool, &ctx, &pk2).await.unwrap(); } + #[tokio::test] + #[ignore = "requires Redis"] + async fn test_presence_many_sets_with_ttl_and_clears() { + let pool = make_test_pool(); + let pk1 = make_pubkey(); + let pk2 = make_pubkey(); + let untouched = make_pubkey(); + let ctx = ctx(0xaaaa, "a.example"); + set_presence(&pool, &ctx, &untouched, "away").await.unwrap(); + + set_presence_many(&pool, &ctx, &[pk1, pk2], "online") + .await + .unwrap(); + let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, untouched]) + .await + .unwrap(); + assert_eq!( + result.get(&pk1.to_hex()).map(String::as_str), + Some("online") + ); + assert_eq!( + result.get(&pk2.to_hex()).map(String::as_str), + Some("online") + ); + assert_eq!( + result.get(&untouched.to_hex()).map(String::as_str), + Some("away") + ); + let mut conn = pool.get().await.unwrap(); + let ttl: i64 = redis::cmd("TTL") + .arg(presence_key(&ctx, &pk2)) + .query_async(&mut conn) + .await + .unwrap(); + assert!(ttl > 0 && ttl <= PRESENCE_TTL_SECS as i64, "got TTL {ttl}"); + + clear_presence_many(&pool, &ctx, &[pk1, pk2]).await.unwrap(); + let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, untouched]) + .await + .unwrap(); + assert_eq!(result.len(), 1, "only the untouched key remains"); + clear_presence(&pool, &ctx, &untouched).await.unwrap(); + } + #[tokio::test] #[ignore = "requires Redis"] async fn test_presence_ttl() { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index b4df5bec17f..a937ff0e4f2 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -8,7 +8,8 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, - KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, + KIND_AGENT_OBSERVER_FRAME, KIND_AGENT_PRESENCE, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, + KIND_TYPING_INDICATOR, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -926,6 +927,10 @@ async fn handle_ephemeral_event( Err(_) => return Err(IngestError::Internal("error: internal error".to_string())), } + if event_kind_u32(&event) == KIND_AGENT_PRESENCE { + return set_agent_presence(&state, &conn.tenant, &event, &auth_pubkey).await; + } + // Special handling for presence events (kind:20001). if event_kind_u32(&event) == KIND_PRESENCE_UPDATE { // Accept both bare strings ("online") and legacy JSON ({"status":"online"}). @@ -1026,6 +1031,89 @@ async fn handle_ephemeral_event( Ok(()) } +/// Most agents one agent presence event (kind:20003) may name. +const MAX_AGENT_PRESENCE: usize = 256; + +/// The status and distinct agents an agent presence event names. +fn agent_presence_request(event: &Event) -> Result<(&str, Vec), IngestError> { + let status = event.content.as_str(); + if !matches!(status, "online" | "away" | "offline") { + return Err(IngestError::Rejected( + "invalid: agent presence must be online, away or offline".into(), + )); + } + let mut agents = Vec::new(); + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(String::as_str) != Some("p") { + continue; + } + let agent = values + .get(1) + .and_then(|hex| PublicKey::from_hex(hex).ok()) + .ok_or_else(|| IngestError::Rejected("invalid: agent presence p tag".into()))?; + if !agents.contains(&agent) { + agents.push(agent); + } + } + if agents.is_empty() || agents.len() > MAX_AGENT_PRESENCE { + return Err(IngestError::Rejected(format!( + "invalid: agent presence must name 1 to {MAX_AGENT_PRESENCE} agents" + ))); + } + Ok((status, agents)) +} + +/// Stores an owner's presence for its agents (kind:20003), the way each +/// agent's own kind:20001 would be stored, so the presence snapshot reads it +/// back unchanged. Agents the signer does not own are skipped rather than +/// failing the rest, as an owner's list can be briefly stale. Not fanned out: +/// live presence is matched by author, and the author here is the owner. +async fn set_agent_presence( + state: &AppState, + tenant: &TenantContext, + event: &Event, + owner: &PublicKey, +) -> Result<(), IngestError> { + let (status, agents) = agent_presence_request(event)?; + let community = tenant.community(); + let owner_bytes = owner.to_bytes().to_vec(); + let key = |agent: &PublicKey| (community, agent.to_bytes().to_vec(), owner_bytes.clone()); + // An owner link never changes once made, so a cached `true` is final. A + // missing one may just not be made yet (an agent that has not signed in), + // so only `true` is cached here and the rest are asked again, in one query + // bounded by the event's own agents. + let (mut owned, unknown): (Vec<_>, Vec<_>) = agents + .into_iter() + .partition(|agent| state.observer_owner_cache.get(&key(agent)) == Some(true)); + if !unknown.is_empty() { + let candidates: Vec> = unknown.iter().map(|a| a.to_bytes().to_vec()).collect(); + let linked = state + .db + .owned_agents_among(community, &owner_bytes, &candidates) + .await + .map_err(|e| { + warn!(event_id = %event.id.to_hex(), "agent presence owner lookup failed: {e}"); + IngestError::Internal("error: agent ownership unavailable".into()) + })?; + for agent in unknown { + if linked.iter().any(|pubkey| pubkey[..] == agent.to_bytes()) { + state.observer_owner_cache.insert(key(&agent), true); + owned.push(agent); + } + } + } + let stored = if status == "offline" { + state.pubsub.clear_presence_many(tenant, &owned).await + } else { + state.pubsub.set_presence_many(tenant, &owned, status).await + }; + stored.map_err(|e| { + warn!(event_id = %event.id.to_hex(), "agent presence storage failed: {e}"); + IngestError::Internal("error: presence storage unavailable".into()) + }) +} + /// Publishes a verified channel-scoped ephemeral event to the channel's live /// subscribers, on this node and through Redis to the others. The sender must /// be a channel member. @@ -1460,6 +1548,48 @@ mod tests { ); } + #[test] + fn agent_presence_request_names_distinct_agents_with_a_known_status() { + let owner = Keys::generate(); + let agent = Keys::generate().public_key().to_hex(); + let event = |status: &str, tags: Vec>| { + EventBuilder::new(Kind::Custom(super::KIND_AGENT_PRESENCE as u16), status) + .tags(tags.into_iter().map(|tag| Tag::parse(tag).unwrap())) + .sign_with_keys(&owner) + .unwrap() + }; + let p = |hex: &str| vec!["p".to_string(), hex.to_string()]; + + let ok = event( + "away", + vec![p(&agent), p(&agent), vec!["h".into(), "x".into()]], + ); + let (status, agents) = super::agent_presence_request(&ok).unwrap(); + assert_eq!(status, "away"); + assert_eq!(agents.len(), 1, "duplicates collapse, other tags ignored"); + + for bad in [ + event("busy", vec![p(&agent)]), + event("online", vec![]), + event("online", vec![p("not-a-pubkey")]), + event( + "online", + (0..=super::MAX_AGENT_PRESENCE) + .map(|_| p(&Keys::generate().public_key().to_hex())) + .collect(), + ), + ] { + assert!( + matches!( + super::agent_presence_request(&bad), + Err(super::IngestError::Rejected(_)) + ), + "{:?} must be rejected", + bad.content + ); + } + } + #[test] fn agent_observer_route_accepts_agent_to_owner_telemetry() { let agent = Keys::generate(); @@ -1814,6 +1944,128 @@ mod tests { exercise(true, &["online", "offline"]).await; } + /// An owner's batch (kind:20003) stores presence for the agents it + /// owns, skips any it does not, and never touches its own presence. + #[tokio::test] + #[ignore = "requires PostgreSQL and Redis"] + async fn agent_presence_stores_only_owned_agents() { + let redis_url = std::env::var("REDIS_URL").expect("test Redis URL required"); + let state = fanout_access::test_state_with_redis_url(&redis_url).await; + let pool = sqlx::PgPool::connect(&state.config.database_url) + .await + .unwrap(); + let community_uuid = Uuid::new_v4(); + let host = format!("agent-presence-{}.example", community_uuid.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_uuid) + .bind(&host) + .execute(&pool) + .await + .unwrap(); + let tenant = TenantContext::resolved(CommunityId::from_uuid(community_uuid), host); + let owner = Keys::generate(); + let other = Keys::generate(); + let (mine, theirs) = (Keys::generate(), Keys::generate()); + for (user, user_owner) in [ + (&owner, None), + (&other, None), + (&mine, Some(&owner)), + (&theirs, Some(&other)), + ] { + sqlx::query( + "INSERT INTO users (community_id, pubkey, agent_owner_pubkey) VALUES ($1, $2, $3)", + ) + .bind(community_uuid) + .bind(user.public_key().to_bytes().to_vec()) + .bind(user_owner.map(|key| key.public_key().to_bytes().to_vec())) + .execute(&pool) + .await + .unwrap(); + } + let (send_tx, _send_rx) = mpsc::channel(10); + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: tenant.clone(), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: owner.public_key(), + scopes: vec![], + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: None, + }, + )), + subscriptions: Arc::new(Mutex::new(HashMap::new())), + send_tx, + ctrl_tx: mpsc::channel(1).0, + terminal_ctrl_tx: mpsc::channel(1).0, + cancel: CancellationToken::new(), + backpressure_count: Arc::new(AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode( + CancellationToken::new(), + ), + community_control: crate::state::CommunityConnectionControl::new( + CancellationToken::new(), + ), + }); + let send = |status: &str| { + let event = EventBuilder::new( + Kind::Custom(super::super::KIND_AGENT_PRESENCE as u16), + status, + ) + .tags([ + Tag::parse(["p", &mine.public_key().to_hex()]).unwrap(), + Tag::parse(["p", &theirs.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + super::super::handle_ephemeral_event( + event, + conn.conn_id, + owner.public_key().to_bytes().to_vec(), + owner.public_key(), + Arc::clone(&conn), + Arc::clone(&state), + ) + }; + let everyone = [mine.public_key(), theirs.public_key(), owner.public_key()]; + // A `false` cached before the link existed must not hide it now. + for agent in [&mine, &theirs] { + state.observer_owner_cache.insert( + ( + tenant.community(), + agent.public_key().to_bytes().to_vec(), + owner.public_key().to_bytes().to_vec(), + ), + false, + ); + } + + send("online").await.expect("owner batch accepted"); + let stored = state + .pubsub + .get_presence_bulk(&tenant, &everyone) + .await + .unwrap(); + assert_eq!( + stored.get(&mine.public_key().to_hex()).map(String::as_str), + Some("online") + ); + assert_eq!(stored.len(), 1, "only the owned agent: {stored:?}"); + + send("offline").await.expect("owner clear accepted"); + let stored = state + .pubsub + .get_presence_bulk(&tenant, &everyone) + .await + .unwrap(); + assert!(stored.is_empty(), "offline clears it: {stored:?}"); + } + /// Production-seam regression for the rejection classification: a /// Redis presence-storage outage must count as a server `error`, /// never as client `invalid`, while a genuinely invalid event diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 49b906ee3d8..564ad3d00dd 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2413,7 +2413,11 @@ async fn ingest_event_inner( // Typing indicators (kind:20002) never reach this gate: the HTTP bridge // routes them to `publish_http_typing` before ingest. - if auth.is_http() && (kind_u32 == KIND_GIFT_WRAP || kind_u32 == KIND_PRESENCE_UPDATE) { + if auth.is_http() + && (kind_u32 == KIND_GIFT_WRAP + || kind_u32 == KIND_PRESENCE_UPDATE + || kind_u32 == buzz_core::kind::KIND_AGENT_PRESENCE) + { return Err(IngestError::Rejected(format!( "invalid: kind {kind_u32} is only accepted via WebSocket" ))); From 03ce924e32388b350b5235ed307629e41b518d88 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 13 Oct 2026 10:46:31 -0600 Subject: [PATCH 04/13] fix(ci): restore green main after merge skew and new advisory (#8277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main CI has been red since c09730e87 (#8223). The Mobile and Security checks fail on main, and Docker image fails because it requires a green CI run for the same commit. Locally, the pre-push Rust lane also failed on load-sensitive `buzz-acp` timing tests. This PR fixes all three so main passes locally and on GitHub. - **Mobile:** #8223 was tested against a base from before #8206, which removed `buzzDevPushAppProfile` and app-profile selection. Its new `notification_toggle_test.dart` still put `appProfile` in the native grant map, so the merged tree doesn't compile. This removes the stale field. - **Security:** cargo-deny now reports [RUSTSEC-2024-0449](https://rustsec.org/advisories/RUSTSEC-2024-0449) (`unsafe-libyaml` is unmaintained), which comes in through `serde_yaml`. It's an unmaintained notice, not a vulnerability. The `serde_yaml_ng` fork depends on the same crate, so removing it means migrating YAML parsing in five crates. Like the existing unmaintained entries, it goes in `deny.toml` with a reason. - **buzz-acp test flakes:** the `spawn_script` test helper returned before bash was running, and the timing tests started their idle clock right away. Under full-suite load, process startup took longer than the idle windows. `keepalive_resets_idle_past_deadline`, `idle_resets_on_stdout_activity` and `acp_steer_request_omits_expected_run_id_and_carries_session_and_prompt` failed this way across three pre-push runs. The helper now has the script print a ready line and consumes it before returning. The keepalive loops also use brace expansion instead of `$(seq)`, which avoids an extra process launch before the first line. The keepalive test now uses a 250ms idle window with a 750ms floor. Both tests still fail if keepalives don't reset the idle clock. ### Validation - `cargo-deny check advisories` passes with this `deny.toml` and fails on main's. - The `notification_toggle_test.dart` tests and `flutter analyze` pass. - `cargo test -p buzz-acp --lib` passes all 1055 tests. - The `acp::tests` suite passed 20 of 20 runs with 4 concurrent copies over 5 rounds. Under the same load, the version without the brace-expansion change failed every run. - All pre-push hooks pass on 5dbaa4d32: rust-tests, mobile-checks, desktop-tauri-checks, file-size-check, branch-skew. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 --- crates/buzz-acp/src/acp.rs | 40 ++++++++++++++----- deny.toml | 5 +++ .../settings/notification_toggle_test.dart | 1 - 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 211c201d60a..985b29bee70 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -3198,10 +3198,30 @@ mod tests { ); } + /// Spawn `script` under bash and return once it is running. + /// + /// Process startup can take hundreds of milliseconds on a loaded host, + /// which would otherwise eat into the idle and timing windows the tests + /// measure. The script prints a ready line first, consumed here, so each + /// test's clock starts only after the child is actually executing. async fn spawn_script(script: &str) -> AcpClient { - AcpClient::spawn("bash", &["-c".into(), script.into()], &[], false) + const READY: &str = "buzz-acp-test-script-ready"; + let mut client = AcpClient::spawn( + "bash", + &["-c".into(), format!("echo {READY}; {script}")], + &[], + false, + ) + .await + .expect("failed to spawn test script"); + let ready = tokio::time::timeout(std::time::Duration::from_secs(10), client.reader.next()) .await - .expect("failed to spawn test script") + .expect("test script should start within 10s"); + assert!( + matches!(&ready, Some(Ok(line)) if line == READY), + "expected ready line, got {ready:?}" + ); + client } #[cfg(unix)] @@ -3367,7 +3387,7 @@ mod tests { // Send valid JSON (session/update notifications) to reset the idle timer. // Non-JSON lines no longer reset idle — only valid JSON notifications do. let mut client = spawn_script( - r#"for i in $(seq 1 10); do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"thinking"}}}}'; sleep 0.05; done; sleep 10"#, + r#"for i in {1..10}; do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"thinking"}}}}'; sleep 0.05; done; sleep 10"#, ) .await; let max_dur = std::time::Duration::from_secs(10); @@ -3561,10 +3581,10 @@ mod tests { #[tokio::test] async fn keepalive_resets_idle_past_deadline() { - // Keepalive session/update lines every 50ms against a 100ms idle deadline. - // The turn should survive well past the 100ms deadline (proves the fix). + // Keepalive session/update lines every 50ms against a 250ms idle deadline. + // The turn should survive well past the idle deadline (proves the fix). let mut client = spawn_script( - r#"for i in $(seq 1 20); do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"keepalive"}}}'; sleep 0.05; done; sleep 10"#, + r#"for i in {1..20}; do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"keepalive"}}}'; sleep 0.05; done; sleep 10"#, ) .await; let max_dur = std::time::Duration::from_secs(10); @@ -3574,16 +3594,16 @@ mod tests { .read_until_response_with_idle_timeout( "test", 999, - std::time::Duration::from_millis(100), + std::time::Duration::from_millis(250), hard_deadline, max_dur, ) .await; let elapsed = start.elapsed(); - // 20 keepalives × 50ms = ~1000ms of activity, then idle fires after 100ms more. - // Must survive well past the 100ms deadline. + // 20 keepalives × 50ms = ~1000ms of activity, then idle fires after 250ms more. + // Must survive well past the 250ms deadline. assert!( - elapsed >= std::time::Duration::from_millis(500), + elapsed >= std::time::Duration::from_millis(750), "keepalive should reset idle past the deadline; elapsed only {elapsed:?}" ); assert!(elapsed < std::time::Duration::from_secs(5)); diff --git a/deny.toml b/deny.toml index d3c5fcd4bc7..74e83fb3518 100644 --- a/deny.toml +++ b/deny.toml @@ -15,6 +15,11 @@ ignore = [ # remove these when upstream catches up. { id = "RUSTSEC-2026-0194", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, { id = "RUSTSEC-2026-0195", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, + # unsafe-libyaml 0.2.11 — unmaintained (repository archived 2024-03-25), not a + # vulnerability report. Transitive dep: serde_yaml → unsafe-libyaml. The serde_yaml_ng + # fork uses the same crate, so removing it means migrating YAML parsing in + # buzz-agent, buzz-persona, buzz-relay, buzz-workflow and desktop. + { id = "RUSTSEC-2024-0449", reason = "transitive via serde_yaml; unmaintained, not vulnerable; removal needs a YAML parser migration" }, ] [licenses] diff --git a/mobile/test/features/settings/notification_toggle_test.dart b/mobile/test/features/settings/notification_toggle_test.dart index 39e46ad9e80..19c80e2522c 100644 --- a/mobile/test/features/settings/notification_toggle_test.dart +++ b/mobile/test/features/settings/notification_toggle_test.dart @@ -78,7 +78,6 @@ void main() { 'installationId': 'b' * 32, 'endpointGrant': 'test-endpoint-grant', 'endpointHash': 'c' * 64, - 'appProfile': buzzDevPushAppProfile, 'endpointEpoch': 1, 'generation': 1, 'expiresAt': DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600, From b5223d2525defa2a53dcd29ddcc69f609b8711ab Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 13 Oct 2026 12:55:07 -0400 Subject: [PATCH 05/13] Use grouped message action sheets and fix date layering (#8280) ## Summary Replace message long-press popovers with the shared grouped bottom sheet: an emoji row, Reply / Copy link / Remind me tiles, and remaining actions in regular sections. Match emoji backgrounds to the sections and use the theme primary color for the selected reaction. Keep the scrolling date above the navigation blur in channels and threads. Restore composer focus when the sheet is canceled, keep section gaps consistent, and soften shared dividers to 7% opacity. The reaction details sheet shows all reactors by default. Emoji count chips toggle your own reaction, show your selected state, and update live without dismissing the sheet. Unselected reactions have visible outlines; adding a supported emoji plays the chat-style burst and respects reduced motion. ### Related issue No matching issue found. Related sheet styling: #4911. ### Testing - Full mobile checks and tests passed, including 23 reaction tests and composer-focus regression coverage. - Analysis and file-size checks passed; signed iOS build installed and launched on a physical iPhone. - Full `just ci` stopped during Rust compilation due to low disk space. - On-device visual capture unavailable. --------- Signed-off-by: kenny lopez --- ...NativeMessagePresentationCoordinator.swift | 14 + mobile/ios/Runner/NativeReactionDetails.swift | 117 +- .../channels/channel_detail_page.dart | 30 + .../channel_detail_page/message_list.dart | 27 +- .../features/channels/message_actions.dart | 304 ++-- .../message_action_popover.dart | 777 ---------- .../message_action_popover_widgets.dart | 223 --- .../message_reaction_tray.dart | 36 - .../message_actions/native_actions.dart | 242 --- .../message_actions/quick_reaction_row.dart | 56 +- .../message_actions/reaction_popover.dart | 345 ----- .../lib/features/channels/reaction_row.dart | 341 ++-- .../features/channels/thread_detail_page.dart | 25 +- mobile/lib/shared/widgets/app_list_card.dart | 2 +- .../lib/shared/widgets/frosted_scaffold.dart | 13 +- .../widgets/native_message_presentation.dart | 19 +- .../channels/channel_detail_page_test.dart | 443 +----- .../action_row_tests.dart | 4 +- .../channels/message_actions_test.dart | 1376 ++++------------- .../features/channels/reaction_row_test.dart | 242 ++- mobile/test/shared/widgets/app_list_test.dart | 2 +- .../shared/widgets/frosted_scaffold_test.dart | 37 + 22 files changed, 997 insertions(+), 3678 deletions(-) delete mode 100644 mobile/lib/features/channels/message_actions/message_action_popover.dart delete mode 100644 mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart delete mode 100644 mobile/lib/features/channels/message_actions/message_reaction_tray.dart delete mode 100644 mobile/lib/features/channels/message_actions/native_actions.dart delete mode 100644 mobile/lib/features/channels/message_actions/reaction_popover.dart diff --git a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift index 4a6d846c1f7..4b7821dcd3d 100644 --- a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift +++ b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift @@ -25,6 +25,15 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati result(["supported": true]) return } + if call.method == "updateReactions" { + if data["requestId"] as? String == requestID, + let sheet = presented as? NativeReactionDetailsViewController, + let reactions = data["reactions"] as? [[String: Any]] { + sheet.updateReactions(reactions) + } + result([:]) + return + } if call.method == "updateProfiles" { if data["requestId"] as? String == requestID, let sheet = presented as? NativeReactionDetailsViewController, @@ -65,6 +74,11 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } else { let sheet = NativeReactionDetailsViewController(data: data) sheet.onClose = { [weak self] in self?.dismiss([:]) } + sheet.onToggle = { [weak self] emoji in + self?.channel.invokeMethod("toggleReaction", arguments: [ + "requestId": data["requestId"] as? String ?? "", "emoji": emoji, + ]) + } sheet.modalPresentationStyle = .pageSheet if let presentation = sheet.sheetPresentationController { presentation.overrideTraitCollection = UITraitCollection(userInterfaceStyle: sheet.overrideUserInterfaceStyle) diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 00657f133db..8d6361e92c6 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -1,22 +1,23 @@ import UIKit import ImageIO -/// Native resizable sheet: selected emoji first, with All and per-emoji filters. +/// Native reaction toggles above the complete list of reactors. final class NativeReactionDetailsViewController: UIViewController, UITableViewDataSource { var onClose: (() -> Void)? + var onToggle: ((String) -> Void)? private let sheetColor: UIColor private let foregroundColor: UIColor - private let reactions: [[String: Any]] + private let outlineColor: UIColor + private var reactions: [[String: Any]] private var profiles: [String: [String: Any]] - private var selectedEmoji: String? private let table = UITableView(frame: .zero, style: .plain) private let filters = UIStackView() private var filterHeight: NSLayoutConstraint? private var rows: [(String, [String: Any])] = [] + private var burstGlyphs: [UILabel] = [] private func rebuildRows() { - rows = reactions.filter { selectedEmoji == nil || $0["emoji"] as? String == selectedEmoji } - .flatMap { reaction in + rows = reactions.flatMap { reaction in (reaction["users"] as? [String] ?? []).map { ($0, reaction) } } } @@ -31,10 +32,9 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa } sheetColor = color("sheetColor", fallback: .systemGroupedBackground) foregroundColor = color("foregroundColor", fallback: .label) + outlineColor = color("outlineColor", fallback: .separator) reactions = data["reactions"] as? [[String: Any]] ?? [] profiles = data["profiles"] as? [String: [String: Any]] ?? [:] - let initial = data["initialEmoji"] as? String - selectedEmoji = reactions.contains { $0["emoji"] as? String == initial } ? initial : nil super.init(nibName: nil, bundle: nil) rebuildRows() overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light @@ -132,8 +132,6 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa private func rebuildFilters() { for child in filters.arrangedSubviews { child.removeFromSuperview() } - let count = reactions.reduce(0) { $0 + ($1["count"] as? Int ?? 0) } - addFilter(title: "All \(count)", emoji: nil, reaction: nil) for reaction in reactions { guard let emoji = reaction["emoji"] as? String else { continue } addFilter(title: "\(reaction["count"] as? Int ?? 0)", emoji: emoji, reaction: reaction) @@ -144,20 +142,25 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa var config = UIButton.Configuration.tinted() config.cornerStyle = .capsule config.title = reaction == nil ? title : " \(title)" - config.baseBackgroundColor = selectedEmoji == emoji ? .systemBlue : .tertiarySystemFill - config.baseForegroundColor = selectedEmoji == emoji ? .systemBlue : .label - let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] _ in + let selected = reaction?["selected"] as? Bool == true + config.baseBackgroundColor = selected ? foregroundColor : .tertiarySystemFill + config.baseForegroundColor = selected ? foregroundColor : .label + config.background.strokeWidth = 1 + let stroke = selected ? foregroundColor.withAlphaComponent(0.40) : outlineColor + config.background.strokeColor = stroke + config.background.strokeColorTransformer = UIConfigurationColorTransformer { _ in stroke } + let button = UIButton(configuration: config, primaryAction: UIAction { [weak self] action in guard let self else { return } - guard self.selectedEmoji != emoji else { return } - self.selectedEmoji = emoji - self.rebuildRows() - self.rebuildFilters() - self.table.reloadData() - self.table.setContentOffset(.zero, animated: false) + guard let emoji else { return } + if !selected, reaction?["burst"] as? Bool == true, + let button = action.sender as? UIButton { + self.burst(emoji, from: button) + } + self.onToggle?(emoji) UISelectionFeedbackGenerator().selectionChanged() }) button.accessibilityLabel = reaction.map { "\($0["label"] as? String ?? emoji ?? "") \(title)" } ?? title - if selectedEmoji == emoji { button.accessibilityTraits.insert(.selected) } + if selected { button.accessibilityTraits.insert(.selected) } if let reaction { let glyph = NativeMessageGlyph(data: reaction, size: 22) glyph.isUserInteractionEnabled = false @@ -173,6 +176,82 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa filters.addArrangedSubview(button) } + /// Mirrors Flutter EmojiBurstController's five-particle, fixed-60Hz physics. + /// UIKit owns this sheet, so Flutter's underlying overlay cannot paint above it. + private func burst(_ emoji: String, from button: UIView) { + guard !UIAccessibility.isReduceMotionEnabled, + burstGlyphs.count + 5 <= 160, let window = view.window else { return } + let origin = button.convert(CGPoint(x: button.bounds.midX, y: button.bounds.midY), to: window) + for _ in 0..<5 { + let glyph = UILabel() + glyph.text = emoji + glyph.font = .systemFont(ofSize: 18 + ceil(CGFloat.random(in: 0..<24))) + glyph.sizeToFit() + glyph.center = origin + glyph.isUserInteractionEnabled = false + glyph.isAccessibilityElement = false + window.addSubview(glyph) + burstGlyphs.append(glyph) + var x = origin.x, y = origin.y + var xv = CGFloat.random(in: -2.2..<2.2) + var yv = -CGFloat.random(in: 2.1..<4.45) + var rotation = CGFloat.random(in: -11..<11) + let spin = CGFloat.random(in: -2.6..<2.6) + let gravity = -CGFloat.random(in: 0.018..<0.036) + var scale: CGFloat = 0.25 + var positions = [NSValue(cgPoint: origin)] + var rotations = [rotation * .pi / 180] + var scales = [scale] + var opacities: [CGFloat] = [1] + for frame in 1...108 { + rotation += spin + yv = (yv + gravity) * 0.998 + xv *= 0.965 + x += xv + y += yv + scale += (1 - scale) * 0.28 + positions.append(NSValue(cgPoint: CGPoint(x: x, y: y))) + rotations.append(rotation * .pi / 180) + scales.append(scale) + opacities.append(min(1, CGFloat(108 - frame) / (108 * 0.24))) + } + let values: [(String, [Any])] = [ + ("position", positions), ("transform.rotation.z", rotations), + ("transform.scale", scales), ("opacity", opacities), + ] + let group = CAAnimationGroup() + group.duration = 108.0 / 60.0 + group.animations = values.map { key, frames in + let animation = CAKeyframeAnimation(keyPath: key) + animation.values = frames + animation.duration = group.duration + animation.calculationMode = .linear + return animation + } + glyph.layer.opacity = 0 + glyph.layer.add(group, forKey: "reaction-burst") + DispatchQueue.main.asyncAfter(deadline: .now() + group.duration) { [weak self, weak glyph] in + glyph?.removeFromSuperview() + self?.burstGlyphs.removeAll { $0 === glyph } + } + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + burstGlyphs.forEach { $0.removeFromSuperview() } + burstGlyphs.removeAll() + } + + func updateReactions(_ reactions: [[String: Any]]) { + guard !reactions.isEmpty else { onClose?(); return } + self.reactions = reactions + rebuildRows() + guard isViewLoaded else { return } + rebuildFilters() + table.reloadData() + } + func updateProfiles(_ profiles: [String: [String: Any]]) { self.profiles.merge(profiles) { _, new in new } guard isViewLoaded else { return } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 5549d6af635..b25edaa883d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -268,6 +268,10 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final stickyDateHeaderState = useValueNotifier( + StickyDateHeaderState.hidden, + [channel.id], + ); final composerDockHeight = useState(0.0); final composerFocusNode = useFocusNode(); final restoreComposerFocus = useRef(null); @@ -610,6 +614,31 @@ class ChannelDetailPage extends HookConsumerWidget { ); return FrostedScaffold( + foreground: + !resolvedChannel.isForum && + timelineEntries.isNotEmpty && + !showInitialConnectionSkeleton && + !showConnectionSkeleton.value && + !messagesState.isLoading + ? Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: + frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ) + + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('channel-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), + ], + ) + : null, resizeToAvoidBottomInset: !usesFixedAndroidImeViewport || resolvedChannel.isForum, appBar: FrostedAppBar( @@ -746,6 +775,7 @@ class ChannelDetailPage extends HookConsumerWidget { ), data: (events) { return _MessageList( + stickyDateHeaderState: stickyDateHeaderState, entries: timelineEntries, allMessages: timelineMessages, initialMessageId: initialMessageId, diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 1a2c3c82115..2c18dcf7862 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -1,6 +1,7 @@ part of '../channel_detail_page.dart'; class _MessageList extends HookConsumerWidget { + final ValueNotifier stickyDateHeaderState; final List entries; final List allMessages; final String? initialMessageId; @@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget { const _MessageList({ required this.entries, + required this.stickyDateHeaderState, required this.allMessages, required this.initialMessageId, required this.initialThreadRootId, @@ -48,9 +50,6 @@ class _MessageList extends HookConsumerWidget { final displayEntries = groupMembershipTimelineEntries(entries); final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); - final stickyDateHeaderState = useValueNotifier( - StickyDateHeaderState.hidden, - ); final stickyDayTimestamp = useValueNotifier(null); final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height); final isLoadingOlder = useState(false); @@ -234,7 +233,9 @@ class _MessageList extends HookConsumerWidget { StickyDateHeaderState state, { int? activeDayTimestamp, }) { - stickyDateHeaderState.value = state; + stickyDateHeaderState.value = showUnreadNavigation + ? StickyDateHeaderState.hidden + : state; stickyDayTimestamp.value = activeDayTimestamp; } @@ -536,11 +537,12 @@ class _MessageList extends HookConsumerWidget { itemPositionsListener, appBarTitleContentHeight, composerBottomInset, + showUnreadNavigation, + stickyDateHeaderState, ], ); useEffect(() { - stickyDateHeaderState.value = StickyDateHeaderState.hidden; stickyDayTimestamp.value = null; return null; }, [channelId]); @@ -880,21 +882,6 @@ class _MessageList extends HookConsumerWidget { ), ), ), - if (!showUnreadNavigation) - Positioned( - left: 0, - right: 0, - top: - frostedAppBarHeight( - context, - titleContentHeight: appBarTitleContentHeight, - ) + - Grid.twelve, - child: StickyDateHeader( - key: const ValueKey('channel-sticky-date-header'), - state: stickyDateHeaderState, - ), - ), if (showUnreadNavigation) Positioned( left: 0, diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 883f456078f..6aac44bfa6a 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -1,13 +1,9 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math' as math; -import 'dart:ui'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/physics.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; @@ -23,17 +19,14 @@ import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/native_emoji_glyph.dart'; +import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/widgets/modal_presentation.dart'; -import '../../shared/widgets/native_message_presentation.dart'; -import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; import 'emoji_picker.dart'; -import 'message_action_backdrop_state.dart'; -import 'message_actions/native_message_action_selection.dart'; import 'reaction_row.dart'; import 'recent_emoji_provider.dart'; import '../../shared/read_state/message_read_state.dart'; @@ -44,38 +37,30 @@ import 'thread_follows/thread_follows_provider.dart'; import 'timeline_message.dart'; import 'unread_badge/is_high_priority_event.dart'; -part 'message_actions/reaction_popover.dart'; part 'message_actions/quick_reaction_row.dart'; -part 'message_actions/message_action_popover.dart'; -part 'message_actions/message_action_popover_widgets.dart'; -part 'message_actions/message_reaction_tray.dart'; -part 'message_actions/native_actions.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. const _reminderPreviewLength = 100; -const _messageActionBackdropBlurSigma = 20.0; -const _messageActionBackdropTintOpacity = 0.10; -final _messageActionBackdropFilter = ImageFilter.blur( - sigmaX: _messageActionBackdropBlurSigma, - sigmaY: _messageActionBackdropBlurSigma, -); - -/// Presents the actions for [message] as an anchored popover when both -/// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet. -/// -/// Popover capture is asynchronous: [captureAnchorSnapshot] must remain valid -/// until its future completes, and the returned image becomes this function's -/// responsibility to dispose. [onPopoverPreviewVisibilityChanged] reports -/// whether the constrained layout actually renders the lifted preview; -/// [onPopoverDismissed] runs after the route completes while [context] is still -/// mounted. Neither callback runs for sheet fallback or a failed capture. -/// -/// [composerFocusNode] remains caller-owned and must outlive the popover. If it -/// has focus when this function is called, the popover unfocuses it and invokes -/// [restoreComposerFocus] only after a dismissal with no selected action. The -/// restorer must remain callable for the same lifetime and no-op if its composer -/// is later disposed or replaced. + +// A weak, route-owned latch shared by every action, including pointers that +// were already down when another selection started dismissing the sheet. +final _selectedMessageActionRoutes = Expando(); + +bool _selectMessageAction(BuildContext context) { + final route = ModalRoute.of(context); + if (route == null || + !route.isCurrent || + _selectedMessageActionRoutes[route] == true) { + return false; + } + _selectedMessageActionRoutes[route] = true; + Navigator.of(context).pop(true); + return true; +} + +/// Presents message reactions and grouped actions in the shared bottom sheet. +/// Snapshot and preview callbacks are not used by the sheet presentation. Future showMessageActions({ required BuildContext context, required WidgetRef ref, @@ -94,66 +79,22 @@ Future showMessageActions({ bool isArchived = false, EdgeInsets popoverSpotlightPadding = const EdgeInsets.all(Grid.xxs), }) async { - if (NativeMessagePresentation.isSupportedPlatform && - anchorRect != null && - await _showNativeMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - composerFocusNode: composerFocusNode, - restoreComposerFocus: restoreComposerFocus, - onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - onPopoverDismissed: onPopoverDismissed, - )) { - return; - } if (!context.mounted) return; - final hasReactionOnlyActions = message.isSystem && !canManageMessage; - if (anchorRect != null && hasReactionOnlyActions) { - _showMessageReactionPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - spotlightPadding: popoverSpotlightPadding, - ); - return; - } - - if (_tryShowMessageActionsPopover( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - onPopoverDismissed: onPopoverDismissed, - composerFocusNode: composerFocusNode, - restoreComposerFocus: restoreComposerFocus, - )) { - return; - } + unawaited(HapticFeedback.mediumImpact()); + final restoreFocusOnCancel = composerFocusNode?.hasFocus ?? false; + composerFocusNode?.unfocus(); - showBuzzModalBottomSheet( + final selectedAction = await showBuzzModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, showCloseButton: false, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), builder: (sheetContext) => SafeArea( + top: false, child: IconTheme.merge( data: const IconThemeData(size: 22), child: ConstrainedBox( @@ -177,10 +118,10 @@ Future showMessageActions({ pageContext: context, pageRef: ref, ), - const SizedBox(height: Grid.xs), + SizedBox(height: message.isSystem ? Grid.xxs : Grid.xs), if (!message.isSystem) ...[ // Fast actions: respond now, hand off context, defer. - _FastActionsRow( + _MessageQuickActionsRow( message: message, channelId: channelId, allMessages: allMessages, @@ -189,7 +130,8 @@ Future showMessageActions({ isArchived: isArchived, pageContext: context, ), - const SizedBox(height: Grid.xs), + // Section cards supply the other half of the 16pt gap. + const SizedBox(height: Grid.xxs), // Triage: come back to this message later. SheetActionSection( children: [ @@ -208,7 +150,7 @@ Future showMessageActions({ leading: const Icon(BuzzIcons.copy), title: const Text('Copy text'), onTap: () { - Navigator.of(sheetContext).pop(); + if (!_selectMessageAction(sheetContext)) return; // Copy to clipboard final data = ClipboardData(text: message.content); Clipboard.setData(data); @@ -224,7 +166,7 @@ Future showMessageActions({ leading: const Icon(BuzzIcons.pencil), title: const Text('Edit message'), onTap: () { - Navigator.of(sheetContext).pop(); + if (!_selectMessageAction(sheetContext)) return; _showEditSheet( context: context, ref: ref, @@ -243,7 +185,7 @@ Future showMessageActions({ style: TextStyle(color: sheetContext.colors.error), ), onTap: () { - Navigator.of(sheetContext).pop(); + if (!_selectMessageAction(sheetContext)) return; _confirmDelete( context: context, ref: ref, @@ -262,6 +204,9 @@ Future showMessageActions({ ), ), ); + if (selectedAction != true && restoreFocusOnCancel && context.mounted) { + restoreComposerFocus?.call(); + } } /// Image-focused actions shown from the full-screen viewer. @@ -567,7 +512,7 @@ class _MarkReadUnreadTile extends ConsumerWidget { leading: Icon(unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen), title: Text(unread ? 'Mark read' : 'Mark unread'), onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; final notifier = ref.read(readStateProvider.notifier); if (unread) { // Advances the `msg:` marker and clears this message's own forced @@ -608,7 +553,7 @@ class _FollowThreadTile extends ConsumerWidget { leading: Icon(following ? BuzzIcons.bellOff : BuzzIcons.bellRing), title: Text(following ? 'Unfollow thread' : 'Follow thread'), onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; final notifier = ref.read(threadFollowsProvider.notifier); if (following) { notifier.unfollowThread(rootId); @@ -620,7 +565,7 @@ class _FollowThreadTile extends ConsumerWidget { } } -class _FastActionsRow extends ConsumerWidget { +class _MessageQuickActionsRow extends ConsumerWidget { final TimelineMessage message; final String channelId; final List? allMessages; @@ -632,7 +577,7 @@ class _FastActionsRow extends ConsumerWidget { /// for the thread push and the copy-link snackbar. final BuildContext pageContext; - const _FastActionsRow({ + const _MessageQuickActionsRow({ required this.message, required this.channelId, required this.allMessages, @@ -649,11 +594,11 @@ class _FastActionsRow extends ConsumerWidget { final tiles = [ if (messages != null) - _FastActionTile( + _MessageQuickActionTile( icon: BuzzIcons.messageSquareReply, label: 'Reply', onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; Navigator.of(pageContext).push( MaterialPageRoute( builder: (_) => ThreadDetailPage( @@ -668,11 +613,11 @@ class _FastActionsRow extends ConsumerWidget { ); }, ), - _FastActionTile( + _MessageQuickActionTile( icon: BuzzIcons.link2, label: 'Copy link', onTap: () { - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; copyToClipboard( pageContext, messageLinkFor(message: message, channelId: channelId), @@ -681,7 +626,7 @@ class _FastActionsRow extends ConsumerWidget { }, ), if (canRemind) - _FastActionTile( + _MessageQuickActionTile( icon: BuzzIcons.clock, label: 'Remind me', onTap: () { @@ -689,7 +634,7 @@ class _FastActionsRow extends ConsumerWidget { context, rootNavigator: true, ).context; - Navigator.of(context).pop(); + if (!_selectMessageAction(context)) return; showRemindMeLaterSheet( context: rootContext, ref: ref, @@ -706,57 +651,56 @@ class _FastActionsRow extends ConsumerWidget { ), ]; - return Row( - children: [ - for (var index = 0; index < tiles.length; index++) ...[ - Expanded(child: tiles[index]), - if (index < tiles.length - 1) const SizedBox(width: Grid.twelve), + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < tiles.length; index++) ...[ + Expanded(child: tiles[index]), + if (index < tiles.length - 1) const SizedBox(width: Grid.twelve), + ], ], - ], + ), ); } } -class _FastActionTile extends StatelessWidget { +class _MessageQuickActionTile extends StatelessWidget { final IconData icon; final String label; final VoidCallback onTap; - const _FastActionTile({ + const _MessageQuickActionTile({ required this.icon, required this.label, required this.onTap, }); @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onTap(); - }, - behavior: HitTestBehavior.opaque, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // The full-width tiles deliberately contrast with the compact emoji - // reactions immediately above them. - Container( - height: 68 + (Grid.xxs * 2), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - ), + Widget build(BuildContext context) => Semantics( + button: true, + child: Material( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 68 + (Grid.xxs * 2)), + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), child: Column( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 22, color: context.colors.onSurface), const SizedBox(height: Grid.xxs), Text( label, - maxLines: 1, - overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, style: context.textTheme.labelMedium?.copyWith( color: context.colors.onSurface, ), @@ -764,51 +708,10 @@ class _FastActionTile extends StatelessWidget { ], ), ), - ], - ), - ); - } -} - -/// The emoji shown are the user's own frequently-used set (desktop's -/// `useQuickReactionEmojis` behaviour), topped up with [defaultQuickEmojis] so -/// the row is full on a fresh install. -class _ReactionItemReveal extends StatelessWidget { - final Animation? animation; - final int index; - final Widget child; - - const _ReactionItemReveal({ - super.key, - required this.animation, - required this.index, - required this.child, - }); - - @override - Widget build(BuildContext context) { - final parent = animation; - if (parent == null) return child; - - final start = 0.06 + (index * 0.11); - final opacityEnd = math.min(1.0, start + 0.20); - final scaleEnd = math.min(1.0, start + 0.30); - final opacity = CurvedAnimation( - parent: parent, - curve: Interval(start, opacityEnd, curve: Curves.easeOutCubic), - ); - final scale = Tween(begin: 0.86, end: 1).animate( - CurvedAnimation( - parent: parent, - curve: Interval(start, scaleEnd, curve: _reactionSpringCurve), + ), ), - ); - - return FadeTransition( - opacity: opacity, - child: ScaleTransition(scale: scale, child: child), - ); - } + ), + ); } /// Renders a quick-reaction value: a Unicode glyph as text, a `:shortcode:` as @@ -845,33 +748,48 @@ class _QuickReactionCircle extends StatelessWidget { final VoidCallback onTap; final Widget child; final double size; + final String label; + final bool? selected; const _QuickReactionCircle({ required this.onTap, required this.child, required this.size, + required this.label, + this.selected, }); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onTap(); - }, - child: Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration( - color: Color.lerp( - context.colors.surface, - context.colors.primaryContainer, - 0.78, + return Semantics( + button: true, + label: label, + selected: selected, + excludeSemantics: true, + onTap: onTap, + child: GestureDetector( + excludeFromSemantics: true, + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onTap(); + }, + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected == true + ? context.colors.primary.withValues(alpha: 0.10) + : context.colors.surfaceContainerHighest, + border: selected == true + ? Border.all( + color: context.colors.primary.withValues(alpha: 0.40), + ) + : null, + shape: BoxShape.circle, ), - shape: BoxShape.circle, + child: child, ), - child: child, ), ); } diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart deleted file mode 100644 index 7a41f72a0c8..00000000000 --- a/mobile/lib/features/channels/message_actions/message_action_popover.dart +++ /dev/null @@ -1,777 +0,0 @@ -part of '../message_actions.dart'; - -const _messageActionRowHeight = 48.0; -const _messageActionRowVerticalPadding = Grid.xxs; -const _messageActionSeparatorHeight = 0.5; -const _messageActionVerticalInset = Grid.half; -const _messageActionMenuMaxWidth = 288.0; -const _messageActionPreviewMaxWidth = 358.0; -const _messageActionPreviewInset = Grid.xxs; -const _messageActionGap = Grid.twelve; -const _messageActionReactionSelection = '__reaction__'; -const _messageActionTransitionDuration = _reactionPopoverDuration; -const _iosMessageActionTransitionDuration = Duration(milliseconds: 220); -const _iosNativeMessageActionSurfaceChannel = MethodChannel( - 'buzz/native_message_action_surface', -); - -bool _messageActionPresentationInFlight = false; -bool? _iosNativeMessageActionSurfaceSupported; - -Future _supportsIosNativeMessageActionSurface() async { - if (!Platform.isIOS) return false; - final cached = _iosNativeMessageActionSurfaceSupported; - if (cached != null) return cached; - - try { - final supported = - await _iosNativeMessageActionSurfaceChannel.invokeMethod( - 'isSupported', - ) ?? - false; - _iosNativeMessageActionSurfaceSupported = supported; - return supported; - } on MissingPluginException { - _iosNativeMessageActionSurfaceSupported = false; - return false; - } on PlatformException { - _iosNativeMessageActionSurfaceSupported = false; - return false; - } -} - -bool _tryShowMessageActionsPopover({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool canManageMessage, - required List? allMessages, - required String? currentPubkey, - required bool isMember, - required bool isArchived, - required Rect? anchorRect, - required Future Function()? captureAnchorSnapshot, - required ValueChanged? onPopoverPreviewVisibilityChanged, - required VoidCallback? onPopoverDismissed, - required FocusNode? composerFocusNode, - required VoidCallback? restoreComposerFocus, -}) { - if (anchorRect == null || captureAnchorSnapshot == null) return false; - final shouldRestoreComposerFocus = composerFocusNode?.hasFocus ?? false; - unawaited( - _showMessageActionsPopover( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - anchorRect: anchorRect, - captureAnchorSnapshot: captureAnchorSnapshot, - onPopoverPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - onPopoverDismissed: onPopoverDismissed, - composerFocusNode: composerFocusNode, - restoreComposerFocus: restoreComposerFocus, - shouldRestoreComposerFocus: shouldRestoreComposerFocus, - ).then((shown) { - if (shown || !context.mounted) return; - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ); - }), - ); - return true; -} - -Future _showMessageActionsPopover({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool canManageMessage, - required List? allMessages, - required String? currentPubkey, - required bool isMember, - required bool isArchived, - required Rect anchorRect, - required Future Function() captureAnchorSnapshot, - required ValueChanged? onPopoverPreviewVisibilityChanged, - required VoidCallback? onPopoverDismissed, - required FocusNode? composerFocusNode, - required VoidCallback? restoreComposerFocus, - required bool shouldRestoreComposerFocus, -}) async { - if (_messageActionPresentationInFlight) return true; - _messageActionPresentationInFlight = true; - - try { - final actions = _buildPopoverMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ); - if (actions.isEmpty) return false; - final nativeActionSurfaceSupport = _supportsIosNativeMessageActionSurface(); - final isIos = defaultTargetPlatform == TargetPlatform.iOS; - - unawaited(HapticFeedback.mediumImpact()); - - final ui.Image snapshot; - try { - snapshot = await captureAnchorSnapshot(); - } catch (_) { - return false; - } - if (!context.mounted) { - snapshot.dispose(); - return false; - } - final useIosNativeActionSurface = await nativeActionSurfaceSupport; - if (!context.mounted) { - snapshot.dispose(); - return false; - } - - final reduceMotion = MediaQuery.disableAnimationsOf(context); - if (shouldRestoreComposerFocus) composerFocusNode!.unfocus(); - - messageActionBackdropActive.value = true; - // Give the timeline one frame to replace UIKit glass platform views with - // composable Flutter stand-ins before the full-screen blur is presented. - await WidgetsBinding.instance.endOfFrame; - if (!context.mounted) { - messageActionBackdropActive.value = false; - snapshot.dispose(); - return false; - } - - String? selectedActionId; - final dialogRoute = RawDialogRoute( - barrierDismissible: true, - barrierLabel: 'Dismiss message actions', - barrierColor: Colors.transparent, - transitionDuration: reduceMotion - ? Duration.zero - : isIos - ? _iosMessageActionTransitionDuration - : _messageActionTransitionDuration, - transitionBuilder: (context, animation, secondaryAnimation, child) => - child, - pageBuilder: (dialogContext, animation, secondaryAnimation) => - _MessageActionsPopover( - anchorRect: anchorRect, - anchorSnapshot: snapshot, - animation: animation, - message: message, - pageContext: context, - pageRef: ref, - actions: actions, - useIosNativeActionSurface: useIosNativeActionSurface, - onPreviewVisibilityChanged: onPopoverPreviewVisibilityChanged, - ), - ); - var routePushed = false; - try { - final popResult = Navigator.of( - context, - rootNavigator: true, - ).push(dialogRoute); - routePushed = true; - selectedActionId = await popResult; - } finally { - if (routePushed) await dialogRoute.completed; - messageActionBackdropActive.value = false; - snapshot.dispose(); - if (context.mounted) onPopoverDismissed?.call(); - } - - for (final action in actions) { - if (action.id != selectedActionId) continue; - await Future.sync(action.onSelected); - break; - } - if (selectedActionId == null && - shouldRestoreComposerFocus && - context.mounted) { - restoreComposerFocus?.call(); - } - return true; - } finally { - _messageActionPresentationInFlight = false; - } -} - -List<_PopoverMessageAction> _buildPopoverMessageActions({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool canManageMessage, - required List? allMessages, - required String? currentPubkey, - required bool isMember, - required bool isArchived, -}) { - final actions = <_PopoverMessageAction>[]; - final messages = allMessages; - final canRemind = ref.read(reminderServiceProvider) != null; - - if (!message.isSystem) { - if (messages != null) { - actions.add( - _PopoverMessageAction( - id: 'reply', - title: 'Reply', - icon: BuzzIcons.messageSquareReply, - group: _PopoverMessageActionGroup.primary, - onSelected: () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: messages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - ), - ); - } - actions.add( - _PopoverMessageAction( - id: 'copyLink', - title: 'Copy link', - icon: BuzzIcons.link2, - group: _PopoverMessageActionGroup.utility, - onSelected: () { - if (!context.mounted) return; - copyToClipboard( - context, - messageLinkFor(message: message, channelId: channelId), - message: 'Message link copied', - ); - }, - ), - ); - if (canRemind) { - actions.add( - _PopoverMessageAction( - id: 'remind', - title: 'Remind me', - icon: BuzzIcons.clock, - group: _PopoverMessageActionGroup.utility, - onSelected: () { - if (!context.mounted) return; - showRemindMeLaterSheet( - context: Navigator.of(context, rootNavigator: true).context, - ref: ref, - target: ReminderTarget( - eventId: message.id, - channelId: channelId, - preview: message.content.characters - .take(_reminderPreviewLength) - .toString(), - authorPubkey: message.pubkey, - ), - ); - }, - ), - ); - } - - final readState = ref.read(readStateProvider); - if (readState.isReady) { - final unread = messageActionShowsUnread( - ref, - readState, - channelId: channelId, - message: message, - ); - actions.add( - _PopoverMessageAction( - id: unread ? 'markRead' : 'markUnread', - title: unread ? 'Mark read' : 'Mark unread', - icon: unread ? BuzzIcons.mailCheck : BuzzIcons.mailOpen, - group: _PopoverMessageActionGroup.primary, - onSelected: () { - final notifier = ref.read(readStateProvider.notifier); - if (unread) { - notifier.markContextRead( - msgContextKey(message.id), - message.createdAt, - ); - } else { - notifier.markContextUnread( - msgContextKey(message.id), - channelId: channelId, - ); - } - }, - ), - ); - } - - final rootId = message.rootId ?? message.id; - final following = ref.read(threadFollowsProvider).isFollowing(rootId); - actions.add( - _PopoverMessageAction( - id: following ? 'unfollowThread' : 'followThread', - title: following ? 'Unfollow thread' : 'Follow thread', - icon: following ? BuzzIcons.bellOff : BuzzIcons.bellRing, - group: _PopoverMessageActionGroup.utility, - onSelected: () { - final notifier = ref.read(threadFollowsProvider.notifier); - if (following) { - notifier.unfollowThread(rootId); - } else { - notifier.followThread(rootId); - } - }, - ), - ); - actions.add( - _PopoverMessageAction( - id: 'copyText', - title: 'Copy text', - icon: BuzzIcons.copy, - group: _PopoverMessageActionGroup.utility, - onSelected: () => - Clipboard.setData(ClipboardData(text: message.content)), - ), - ); - } - - if (canManageMessage) { - actions.add( - _PopoverMessageAction( - id: 'edit', - title: 'Edit message', - icon: BuzzIcons.pencil, - group: _PopoverMessageActionGroup.primary, - onSelected: () { - if (!context.mounted) return; - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }, - ), - ); - actions.add( - _PopoverMessageAction( - id: 'delete', - title: 'Delete message', - icon: BuzzIcons.trash2, - group: _PopoverMessageActionGroup.destructive, - destructive: true, - onSelected: () { - if (!context.mounted) return; - _confirmDelete( - context: context, - ref: ref, - channelId: channelId, - messageId: message.id, - ); - }, - ), - ); - } - - const actionOrder = { - 'reply': 0, - 'markRead': 1, - 'markUnread': 1, - 'edit': 2, - 'copyText': 3, - 'copyLink': 4, - 'remind': 5, - 'followThread': 6, - 'unfollowThread': 6, - 'delete': 7, - }; - actions.sort( - (left, right) => actionOrder[left.id]!.compareTo(actionOrder[right.id]!), - ); - return actions; -} - -enum _PopoverMessageActionGroup { primary, utility, destructive } - -class _PopoverMessageAction { - final String id; - final String title; - final IconData icon; - final _PopoverMessageActionGroup group; - final bool destructive; - final FutureOr Function() onSelected; - - const _PopoverMessageAction({ - required this.id, - required this.title, - required this.icon, - required this.group, - required this.onSelected, - this.destructive = false, - }); - - String get iosSymbol => switch (id) { - 'reply' => 'arrowshape.turn.up.left', - 'markRead' => 'envelope.open', - 'markUnread' => 'envelope.badge', - 'edit' => 'pencil', - 'copyText' => 'doc.on.doc', - 'copyLink' => 'link', - 'remind' => 'clock', - 'followThread' => 'bell', - 'unfollowThread' => 'bell.slash', - 'delete' => 'trash', - _ => 'ellipsis', - }; - - Map toPlatformArguments() => { - 'id': id, - 'title': title, - 'symbol': iosSymbol, - 'group': group.name, - 'destructive': destructive, - }; -} - -class _IosNativeMessageActionSurface extends HookWidget { - final List<_PopoverMessageAction> actions; - final double rowHeight; - final ValueChanged onSelected; - - const _IosNativeMessageActionSurface({ - required this.actions, - required this.rowHeight, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final viewId = useState(null); - useNativeMessageActionSelection(viewId.value, onSelected); - - return UiKitView( - key: const ValueKey('ios-native-message-action-surface'), - viewType: 'buzz/native_message_action_surface', - creationParams: { - 'actions': [for (final action in actions) action.toPlatformArguments()], - 'surfaceColor': context.colors.surface.toARGB32(), - 'foregroundColor': context.colors.onSurface.toARGB32(), - 'separatorColor': context.colors.outlineVariant.toARGB32(), - 'errorColor': context.colors.error.toARGB32(), - 'interfaceStyle': context.colors.brightness.name, - 'rowHeight': rowHeight, - }, - creationParamsCodec: const StandardMessageCodec(), - onPlatformViewCreated: (id) => viewId.value = id, - ); - } -} - -class _MessageActionsPopover extends HookWidget { - final Rect anchorRect; - final ui.Image anchorSnapshot; - final Animation animation; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - final List<_PopoverMessageAction> actions; - final bool useIosNativeActionSurface; - final ValueChanged? onPreviewVisibilityChanged; - - const _MessageActionsPopover({ - required this.anchorRect, - required this.anchorSnapshot, - required this.animation, - required this.message, - required this.pageContext, - required this.pageRef, - required this.actions, - required this.useIosNativeActionSurface, - required this.onPreviewVisibilityChanged, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final selectionStarted = useRef(false); - - void select(Object? result, [VoidCallback? effect]) { - if (selectionStarted.value) return; - selectionStarted.value = true; - Navigator.of(context).pop(result); - effect?.call(); - } - - void selectAction(String actionId) => select(actionId); - return LayoutBuilder( - builder: (context, constraints) { - final safeLeft = mediaQuery.padding.left + Grid.xxs; - final safeRight = - constraints.maxWidth - mediaQuery.padding.right - Grid.xxs; - final safeTop = mediaQuery.padding.top + Grid.xxs; - final safeBottom = - constraints.maxHeight - - mediaQuery.padding.bottom - - mediaQuery.viewInsets.bottom - - Grid.xxs; - final availableWidth = math.max(1.0, safeRight - safeLeft); - final availableHeight = math.max(1.0, safeBottom - safeTop); - final trayWidth = math.min(_reactionTrayMaxWidth, availableWidth); - final menuWidth = math.min(_messageActionMenuMaxWidth, availableWidth); - final menuLayout = _MessageActionSurfaceLayout.from(context, actions); - final preferredMenuHeight = menuLayout.preferredHeight; - final minimumMenuHeight = math.min( - menuLayout.rowHeight, - availableHeight, - ); - final showReactionTray = - availableHeight >= - _reactionTrayMaxHeight + _messageActionGap + minimumMenuHeight; - final trayHeight = showReactionTray ? _reactionTrayMaxHeight : 0.0; - final trayGap = showReactionTray ? _messageActionGap : 0.0; - final heightAfterTray = availableHeight - trayHeight - trayGap; - final showPreview = - showReactionTray && - heightAfterTray >= minimumMenuHeight + _messageActionGap + 48.0; - final previewMinimumHeight = showPreview ? 48.0 : 0.0; - final previewGap = showPreview ? _messageActionGap : 0.0; - final menuBudget = math.max( - minimumMenuHeight, - availableHeight - - trayHeight - - trayGap - - previewMinimumHeight - - previewGap, - ); - final menuHeight = math.min(preferredMenuHeight, menuBudget); - final previewMaximumHeight = showPreview - ? math.max( - previewMinimumHeight, - availableHeight - - trayHeight - - trayGap - - menuHeight - - previewGap, - ) - : 0.0; - final previewInsetExtent = _messageActionPreviewInset * 2; - final previewSize = showPreview - ? () { - final previewWidthRatio = - (math.min(_messageActionPreviewMaxWidth, availableWidth) - - previewInsetExtent) / - math.max(anchorRect.width, 1); - final previewHeightRatio = - (previewMaximumHeight - previewInsetExtent) / - math.max(anchorRect.height, 1); - final previewScale = math.min( - 1.0, - math.max( - 0.0, - math.min(previewWidthRatio, previewHeightRatio), - ), - ); - return Size( - (anchorRect.width * previewScale) + previewInsetExtent, - (anchorRect.height * previewScale) + previewInsetExtent, - ); - }() - : Size.zero; - final totalHeight = - trayHeight + trayGap + previewSize.height + previewGap + menuHeight; - final contentTop = math.max(safeTop, safeBottom - totalHeight); - final surfaceLeft = - safeLeft + ((availableWidth - math.max(trayWidth, menuWidth)) / 2); - final trayRect = Rect.fromLTWH( - surfaceLeft, - contentTop, - trayWidth, - trayHeight, - ); - final trayHostWidth = math.min( - trayWidth + _reactionTraySpringAllowance, - safeRight - surfaceLeft, - ); - final previewRect = Rect.fromLTWH( - surfaceLeft, - trayRect.bottom + trayGap, - previewSize.width, - previewSize.height, - ); - final menuRect = Rect.fromLTWH( - surfaceLeft, - previewRect.bottom + previewGap, - menuWidth, - menuHeight, - ); - return _MessageActionPreviewVisibility( - visible: showPreview, - onChanged: onPreviewVisibilityChanged, - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - key: const ValueKey('message-actions-backdrop-filter'), - filter: _messageActionBackdropFilter, - child: AnimatedBuilder( - animation: animation, - builder: (context, child) { - final opacity = Curves.easeOutCubic.transform( - animation.value, - ); - return ColoredBox( - key: const ValueKey('message-actions-background'), - color: context.colors.inverseSurface.withValues( - alpha: _messageActionBackdropTintOpacity * opacity, - ), - ); - }, - ), - ), - ), - Positioned.fill( - child: GestureDetector( - key: const ValueKey('message-actions-backdrop'), - behavior: HitTestBehavior.opaque, - onTap: () => select(null), - ), - ), - if (showPreview) - Positioned.fromRect( - rect: previewRect, - child: AnimatedBuilder( - animation: animation, - child: RepaintBoundary( - child: _LiftedMessagePreview( - anchorSnapshot: anchorSnapshot, - ), - ), - builder: (context, child) { - final movement = - (defaultTargetPlatform == TargetPlatform.iOS - ? Curves.easeOutCubic - : Curves.easeInOutCubic) - .transform(animation.value); - final sourceRect = anchorRect.inflate( - _messageActionPreviewInset, - ); - final translation = Offset( - ui.lerpDouble( - sourceRect.left - previewRect.left, - 0, - movement, - )!, - ui.lerpDouble( - sourceRect.top - previewRect.top, - 0, - movement, - )!, - ); - final scaleX = ui.lerpDouble( - sourceRect.width / previewRect.width, - 1, - movement, - )!; - final scaleY = ui.lerpDouble( - sourceRect.height / previewRect.height, - 1, - movement, - )!; - return Transform.translate( - offset: translation, - child: Transform( - alignment: Alignment.topLeft, - transform: Matrix4.diagonal3Values(scaleX, scaleY, 1), - child: child, - ), - ); - }, - ), - ), - if (showReactionTray) - Positioned( - left: trayRect.left, - top: trayRect.top, - width: trayHostWidth, - height: trayRect.height, - child: _MessageReactionTray( - animation: animation, - trayWidth: trayWidth, - message: message, - pageContext: pageContext, - pageRef: pageRef, - popResult: _messageActionReactionSelection, - onSelected: (result, effect) => select(result, effect), - ), - ), - Positioned.fromRect( - rect: menuRect, - child: AnimatedBuilder( - animation: animation, - child: useIosNativeActionSurface - ? _IosNativeMessageActionSurface( - actions: actions, - rowHeight: menuLayout.rowHeight, - onSelected: selectAction, - ) - : _MessageActionSurface( - actions: actions, - onSelected: selectAction, - ), - builder: (context, child) { - final appearance = const Interval( - 0.08, - 0.82, - curve: Curves.easeOutCubic, - ).transform(animation.value); - final fadedChild = Opacity( - opacity: appearance, - child: child, - ); - if (defaultTargetPlatform == TargetPlatform.iOS) { - return fadedChild; - } - return Transform.scale( - alignment: Alignment.topLeft, - scale: ui.lerpDouble(0.96, 1, appearance)!, - child: fadedChild, - ); - }, - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart b/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart deleted file mode 100644 index 8c1bb7a5d50..00000000000 --- a/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart +++ /dev/null @@ -1,223 +0,0 @@ -part of '../message_actions.dart'; - -class _MessageActionPreviewVisibility extends HookWidget { - final bool visible; - final ValueChanged? onChanged; - final Widget child; - - const _MessageActionPreviewVisibility({ - required this.visible, - required this.onChanged, - required this.child, - }); - - @override - Widget build(BuildContext context) { - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) onChanged?.call(visible); - }); - return null; - }, [visible, onChanged]); - return child; - } -} - -class _LiftedMessagePreview extends StatelessWidget { - final ui.Image anchorSnapshot; - - const _LiftedMessagePreview({required this.anchorSnapshot}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - key: const ValueKey('message-action-preview'), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.7), - width: 0.5, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(_messageActionPreviewInset), - child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.xs), - child: RawImage( - image: anchorSnapshot, - fit: BoxFit.fill, - filterQuality: FilterQuality.medium, - ), - ), - ), - ); - } -} - -class _MessageActionSurface extends StatelessWidget { - final List<_PopoverMessageAction> actions; - final ValueChanged onSelected; - - const _MessageActionSurface({ - required this.actions, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final menuLayout = _MessageActionSurfaceLayout.from(context, actions); - return Material( - key: const ValueKey('message-action-surface'), - color: context.colors.surface, - surfaceTintColor: Colors.transparent, - elevation: 10, - shadowColor: Colors.black.withValues(alpha: 0.22), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.popover), - side: BorderSide( - color: context.colors.outlineVariant.withValues(alpha: 0.55), - width: 0.5, - ), - ), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: _messageActionVerticalInset, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (var index = 0; index < actions.length; index++) ...[ - if (index > 0 && - actions[index - 1].group != actions[index].group) - Divider( - key: ValueKey( - 'message-action-divider-${actions[index].group.name}', - ), - height: _messageActionSeparatorHeight, - thickness: _messageActionSeparatorHeight, - indent: Grid.xs, - endIndent: Grid.xs, - ), - _MessageActionRow( - action: actions[index], - height: menuLayout.rowHeight, - onSelected: onSelected, - ), - ], - ], - ), - ), - ), - ); - } -} - -class _MessageActionRow extends StatelessWidget { - final _PopoverMessageAction action; - final double height; - final ValueChanged onSelected; - - const _MessageActionRow({ - required this.action, - required this.height, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final foreground = action.destructive - ? context.colors.error - : context.colors.onSurface; - return Semantics( - button: true, - label: action.title, - excludeSemantics: true, - child: InkWell( - key: ValueKey('message-action-${action.id}'), - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onSelected(action.id); - }, - child: SizedBox( - height: height, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Row( - children: [ - SizedBox( - width: 32, - child: Center( - child: Icon(action.icon, size: 22, color: foreground), - ), - ), - const SizedBox(width: Grid.twelve), - Expanded( - child: Text( - action.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge?.copyWith( - color: foreground, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _MessageActionSurfaceLayout { - final double rowHeight, preferredHeight; - - const _MessageActionSurfaceLayout({ - required this.rowHeight, - required this.preferredHeight, - }); - - factory _MessageActionSurfaceLayout.from( - BuildContext context, - List<_PopoverMessageAction> actions, - ) { - final textPainter = TextPainter( - text: TextSpan( - text: 'Message action', - style: context.textTheme.bodyLarge, - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - maxLines: 1, - )..layout(); - final rowHeight = math.max( - _messageActionRowHeight, - textPainter.height + (_messageActionRowVerticalPadding * 2), - ); - textPainter.dispose(); - - var separatorCount = 0; - for (var index = 1; index < actions.length; index++) { - if (actions[index - 1].group != actions[index].group) separatorCount += 1; - } - final preferredHeight = - (_messageActionVerticalInset * 2) + - (actions.length * rowHeight) + - (separatorCount * _messageActionSeparatorHeight); - return _MessageActionSurfaceLayout( - rowHeight: rowHeight, - preferredHeight: preferredHeight, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/message_reaction_tray.dart b/mobile/lib/features/channels/message_actions/message_reaction_tray.dart deleted file mode 100644 index 21281789045..00000000000 --- a/mobile/lib/features/channels/message_actions/message_reaction_tray.dart +++ /dev/null @@ -1,36 +0,0 @@ -part of '../message_actions.dart'; - -class _MessageReactionTray extends StatelessWidget { - final Animation animation; - final double trayWidth; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - final Object popResult; - final void Function(Object? result, VoidCallback effect) onSelected; - - const _MessageReactionTray({ - required this.animation, - required this.trayWidth, - required this.message, - required this.pageContext, - required this.pageRef, - required this.popResult, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return _AnimatedReactionTray( - trayKey: const ValueKey('message-action-reaction-tray'), - animation: animation, - trayWidth: trayWidth, - scaleAlignment: Alignment.bottomLeft, - message: message, - pageContext: pageContext, - pageRef: pageRef, - popResult: popResult, - onSelected: onSelected, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/native_actions.dart b/mobile/lib/features/channels/message_actions/native_actions.dart deleted file mode 100644 index f64e658fec0..00000000000 --- a/mobile/lib/features/channels/message_actions/native_actions.dart +++ /dev/null @@ -1,242 +0,0 @@ -part of '../message_actions.dart'; - -Future _showNativeMessageActions({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required String channelId, - required bool canManageMessage, - required List? allMessages, - required String? currentPubkey, - required bool isMember, - required bool isArchived, - required Rect anchorRect, - required Future Function()? captureAnchorSnapshot, - required FocusNode? composerFocusNode, - required VoidCallback? restoreComposerFocus, - required ValueChanged? onPopoverPreviewVisibilityChanged, - required VoidCallback? onPopoverDismissed, -}) async { - if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS) return false; - return NativeMessagePresentation.withLease(() async { - if (captureAnchorSnapshot == null) return false; - final hadComposerFocus = composerFocusNode?.hasFocus ?? false; - final support = await NativeMessagePresentation.present( - 'supportsMessage', - {}, - ); - if (support?['supported'] != true) return false; - if (!context.mounted) return true; - final community = ref.read(relayConfigProvider); - Uint8List? previewBytes; - try { - final snapshot = await captureAnchorSnapshot(); - try { - final bytes = await snapshot.toByteData(format: ui.ImageByteFormat.png); - previewBytes = bytes?.buffer.asUint8List(); - } finally { - snapshot.dispose(); - } - } catch (_) { - return false; - } - if (!context.mounted || ref.read(relayConfigProvider) != community) { - return true; - } - if (previewBytes == null) return false; - final callbacks = {}; - final actions = >[]; - void action( - String id, - String title, - String symbol, - VoidCallback callback, { - bool destructive = false, - }) { - callbacks[id] = callback; - actions.add({ - 'id': id, - 'title': title, - 'symbol': symbol, - 'destructive': destructive, - }); - } - - if (!message.isSystem) { - if (allMessages != null) { - action('reply', 'Reply', 'arrowshape.turn.up.left', () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }); - } - action('copyLink', 'Copy link', 'link', () { - copyToClipboard( - context, - messageLinkFor(message: message, channelId: channelId), - message: 'Message link copied', - ); - }); - if (ref.read(reminderServiceProvider) != null) { - action('remind', 'Remind me', 'clock', () { - showRemindMeLaterSheet( - context: context, - ref: ref, - target: ReminderTarget( - eventId: message.id, - channelId: channelId, - preview: message.content.characters - .take(_reminderPreviewLength) - .toString(), - authorPubkey: message.pubkey, - ), - ); - }); - } - final readState = ref.read(readStateProvider); - if (readState.isReady) { - final unread = messageActionShowsUnread( - ref, - readState, - channelId: channelId, - message: message, - ); - action('read', unread ? 'Mark read' : 'Mark unread', 'envelope', () { - final notifier = ref.read(readStateProvider.notifier); - if (unread) { - notifier.markContextRead( - msgContextKey(message.id), - message.createdAt, - ); - } else { - notifier.markContextUnread( - msgContextKey(message.id), - channelId: channelId, - ); - } - }); - } - final rootId = message.rootId ?? message.id; - final following = ref.read(threadFollowsProvider).isFollowing(rootId); - action( - 'follow', - following ? 'Unfollow thread' : 'Follow thread', - following ? 'bell.slash' : 'bell', - () { - final notifier = ref.read(threadFollowsProvider.notifier); - if (following) { - notifier.unfollowThread(rootId); - } else { - notifier.followThread(rootId); - } - }, - ); - action('copy', 'Copy text', 'doc.on.doc', () { - Clipboard.setData(ClipboardData(text: message.content)); - }); - } - if (canManageMessage) { - action('edit', 'Edit message', 'pencil', () { - _showEditSheet( - context: context, - ref: ref, - message: message, - channelId: channelId, - ); - }); - action('delete', 'Delete message', 'trash', () { - _confirmDelete( - context: context, - ref: ref, - channelId: channelId, - messageId: message.id, - ); - }, destructive: true); - } - final palette = ref.read(customEmojiListProvider); - final emojis = quickReactionEmoji( - ref.read(recentEmojiProvider), - customShortcodes: {for (final e in palette) e.shortcode.toLowerCase()}, - ); - final dataset = ref.read(emojiDatasetOrEmptyProvider); - Map? response; - var ownsPreview = false; - if (hadComposerFocus) composerFocusNode!.unfocus(); - try { - response = await NativeMessagePresentation.present( - 'message', - { - 'x': anchorRect.left, - 'y': anchorRect.top, - 'width': anchorRect.width, - 'height': anchorRect.height, - 'dark': Theme.of(context).brightness == Brightness.dark, - 'previewLabel': message.content, - 'previewBytes': previewBytes, - 'actions': actions, - 'reactions': [ - for (final emoji in emojis) - { - 'emoji': emoji, - 'label': dataset.displayName(emoji), - 'selected': message.reactions.any( - (r) => r.emoji == emoji && r.reactedByCurrentUser, - ), - if (reactionEmojiUrl(emoji, palette) case final String url) ...{ - 'url': url, - 'headers': mediaGetHeadersFor(ref, url), - }, - }, - ], - }, - onPresented: () { - if (!context.mounted || ownsPreview) return; - ownsPreview = true; - onPopoverPreviewVisibilityChanged?.call(true); - }, - ); - } finally { - if (ownsPreview) { - onPopoverPreviewVisibilityChanged?.call(false); - if (context.mounted) onPopoverDismissed?.call(); - } - if (hadComposerFocus && - response?['busy'] != true && - response?['action'] == null && - context.mounted) { - restoreComposerFocus?.call(); - } - } - if (response == null) return false; - // A native controller can outlive the Flutter page that opened it. - if (!context.mounted || ref.read(relayConfigProvider) != community) { - return true; - } - final selected = response['action']; - if (selected == 'reaction' && emojis.contains(response['emoji'])) { - final emoji = response['emoji']! as String; - final existing = message.reactions.where((r) => r.emoji == emoji); - if (existing.isNotEmpty && existing.first.reactedByCurrentUser) { - toggleReaction(ref, message, emoji); - } else { - ref.read(recentEmojiProvider.notifier).record(emoji); - armReactionBurst(ref, message, emoji); - ref.read(channelActionsProvider).addReaction(message.id, emoji); - } - } else if (selected == 'more') { - showAddReactionPicker(context: context, ref: ref, message: message); - } else { - callbacks[selected]?.call(); - } - return true; - }); -} diff --git a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart index 18dacae27de..1673086e73e 100644 --- a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart +++ b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart @@ -14,28 +14,16 @@ class _QuickReactionRow extends ConsumerWidget { /// bottom sheet, so it must not read through the sheet's disposed ref. final WidgetRef pageRef; - /// Drives the staged glyph reveal when this row is shown in the popover. - /// The bottom sheet leaves this null and retains its existing static row. - final Animation? presentationAnimation; - - final Object? popResult; - - /// Selects exactly one popover result and side effect. Bottom sheets leave - /// this null and retain their existing local dismissal behavior. - final void Function(Object? result, VoidCallback effect)? onSelected; - const _QuickReactionRow({ required this.message, required this.sheetContext, required this.pageContext, required this.pageRef, - this.presentationAnimation, - this.popResult, - this.onSelected, }); @override Widget build(BuildContext context, WidgetRef ref) { + final dataset = ref.watch(emojiDatasetOrEmptyProvider); final customEmoji = ref.watch(customEmojiListProvider); final emoji = quickReactionEmoji( ref.watch(recentEmojiProvider), @@ -48,6 +36,12 @@ class _QuickReactionRow extends ConsumerWidget { }; void react(String value) { + if (message.reactions.any( + (r) => r.emoji == value && r.reactedByCurrentUser, + )) { + toggleReaction(pageRef, message, value); + return; + } // The generic picker is also used for composing and statuses. Record // recency here, at the reaction call site, so only reactions drive the // quick-reaction row. @@ -74,22 +68,17 @@ class _QuickReactionRow extends ConsumerWidget { .toDouble(); final circles = [ for (var index = 0; index < emoji.length; index++) - _ReactionItemReveal( + KeyedSubtree( key: ValueKey('quick-reaction-${emoji[index]}'), - animation: presentationAnimation, - index: index, child: _QuickReactionCircle( size: circleSize, + label: dataset.displayName(emoji[index]), + selected: message.reactions.any( + (r) => r.emoji == emoji[index] && r.reactedByCurrentUser, + ), onTap: () { - void effect() => react(emoji[index]); - - final select = onSelected; - if (select != null) { - select(popResult, effect); - } else { - Navigator.of(sheetContext).pop(popResult); - effect(); - } + if (!_selectMessageAction(context)) return; + react(emoji[index]); }, child: _QuickReactionGlyph( value: emoji[index], @@ -97,23 +86,14 @@ class _QuickReactionRow extends ConsumerWidget { ), ), ), - _ReactionItemReveal( + KeyedSubtree( key: const ValueKey('quick-reaction-more'), - animation: presentationAnimation, - index: emoji.length, child: _QuickReactionCircle( size: circleSize, + label: 'Add reaction', onTap: () { - void effect() => - showEmojiPicker(context: pageContext, onSelect: react); - - final select = onSelected; - if (select != null) { - select(popResult, effect); - } else { - Navigator.of(sheetContext).pop(popResult); - effect(); - } + if (!_selectMessageAction(context)) return; + showEmojiPicker(context: pageContext, onSelect: react); }, child: Icon( BuzzIcons.plus, diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart deleted file mode 100644 index b5b3e5a0c97..00000000000 --- a/mobile/lib/features/channels/message_actions/reaction_popover.dart +++ /dev/null @@ -1,345 +0,0 @@ -part of '../message_actions.dart'; - -const _reactionTrayMaxWidth = (52.0 * 6) + (Grid.twelve * 5) + (Grid.xxs * 2); -const _reactionTrayMaxHeight = 52.0 + (Grid.xxs * 2); -const _reactionTraySpringAllowance = 16.0; -const _reactionPopoverDuration = Duration(milliseconds: 320); -final _reactionSpringCurve = _SpringEaseOutCurve( - duration: const Duration(milliseconds: 260), - bounce: 0.22, -); - -/// Presents the frequently-used reaction tray over a long-pressed message. -/// -/// The conversation is frosted around [anchorRect] so the selected message -/// remains visually connected to the tray. -void _showMessageReactionPopover({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required Rect anchorRect, - required EdgeInsets spotlightPadding, -}) { - unawaited( - _presentMessageReactionPopover( - context: context, - ref: ref, - message: message, - anchorRect: anchorRect, - spotlightPadding: spotlightPadding, - ), - ); -} - -Future _presentMessageReactionPopover({ - required BuildContext context, - required WidgetRef ref, - required TimelineMessage message, - required Rect anchorRect, - required EdgeInsets spotlightPadding, -}) async { - if (_messageActionPresentationInFlight) return; - _messageActionPresentationInFlight = true; - - try { - unawaited(HapticFeedback.mediumImpact()); - final reduceMotion = MediaQuery.disableAnimationsOf(context); - final dialogRoute = RawDialogRoute( - barrierDismissible: true, - barrierLabel: 'Dismiss reaction picker', - barrierColor: Colors.transparent, - transitionDuration: reduceMotion - ? Duration.zero - : _reactionPopoverDuration, - transitionBuilder: (context, animation, secondaryAnimation, child) => - child, - pageBuilder: (dialogContext, animation, secondaryAnimation) => - _MessageReactionPopover( - anchorRect: anchorRect, - spotlightPadding: spotlightPadding, - animation: animation, - message: message, - pageContext: context, - pageRef: ref, - ), - ); - var routePushed = false; - messageActionBackdropActive.value = true; - try { - // Remove UIKit glass views from Flutter's platform-view overlay before the - // backdrop filter paints, otherwise they leave sharp rectangular holes. - await WidgetsBinding.instance.endOfFrame; - if (!context.mounted) return; - final popResult = Navigator.of( - context, - rootNavigator: true, - ).push(dialogRoute); - routePushed = true; - await popResult; - } finally { - if (routePushed) await dialogRoute.completed; - messageActionBackdropActive.value = false; - } - } finally { - _messageActionPresentationInFlight = false; - } -} - -class _MessageReactionPopover extends HookWidget { - final Rect anchorRect; - final EdgeInsets spotlightPadding; - final Animation animation; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - - const _MessageReactionPopover({ - required this.anchorRect, - required this.spotlightPadding, - required this.animation, - required this.message, - required this.pageContext, - required this.pageRef, - }); - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - final selectionStarted = useRef(false); - - void select(Object? result, [VoidCallback? effect]) { - if (selectionStarted.value) return; - selectionStarted.value = true; - Navigator.of(context).pop(result); - effect?.call(); - } - - return LayoutBuilder( - builder: (context, constraints) { - final safeTop = mediaQuery.padding.top + mediaQuery.viewInsets.top; - final safeBottom = - constraints.maxHeight - - mediaQuery.padding.bottom - - mediaQuery.viewInsets.bottom; - final availableAbove = anchorRect.top - Grid.xxs - safeTop; - final availableBelow = safeBottom - anchorRect.bottom - Grid.xxs; - final showAbove = - availableAbove >= _reactionTrayMaxHeight || - (availableBelow < _reactionTrayMaxHeight && - availableAbove >= availableBelow); - final proposedTop = showAbove - ? anchorRect.top - _reactionTrayMaxHeight - Grid.xxs - : anchorRect.bottom + Grid.xxs; - final maxTop = math.max(safeTop, safeBottom - _reactionTrayMaxHeight); - final top = proposedTop.clamp(safeTop, maxTop).toDouble(); - final trayScaleAlignment = showAbove - ? Alignment.bottomLeft - : Alignment.topLeft; - final trayWidth = math.min( - _reactionTrayMaxWidth, - constraints.maxWidth - (Grid.xxs * 2), - ); - final maxLeft = constraints.maxWidth - trayWidth - Grid.xxs; - final left = (anchorRect.center.dx - (trayWidth / 2)) - .clamp(Grid.xxs, maxLeft) - .toDouble(); - - return Stack( - children: [ - Positioned.fill( - child: ClipPath( - key: const ValueKey('reaction-popover-background'), - clipper: _OutsideAnchorClipper(anchorRect, spotlightPadding), - child: BackdropFilter( - key: const ValueKey('reaction-popover-backdrop-filter'), - filter: _messageActionBackdropFilter, - child: AnimatedBuilder( - animation: animation, - builder: (context, child) { - final opacity = const Interval( - 0, - 0.30, - curve: Curves.easeOutCubic, - ).transform(animation.value); - return ColoredBox( - key: const ValueKey('reaction-popover-background-tint'), - color: context.colors.inverseSurface.withValues( - alpha: _messageActionBackdropTintOpacity * opacity, - ), - ); - }, - ), - ), - ), - ), - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => select(null), - ), - ), - Positioned( - top: top, - left: left, - width: trayWidth + _reactionTraySpringAllowance, - height: _reactionTrayMaxHeight, - child: _AnimatedReactionTray( - trayKey: const ValueKey('reaction-popover-tray'), - animation: animation, - trayWidth: trayWidth, - scaleAlignment: trayScaleAlignment, - message: message, - pageContext: pageContext, - pageRef: pageRef, - onSelected: (result, effect) => select(result, effect), - ), - ), - ], - ); - }, - ); - } -} - -class _AnimatedReactionTray extends StatelessWidget { - final Key trayKey; - final Animation animation; - final double trayWidth; - final AlignmentGeometry scaleAlignment; - final TimelineMessage message; - final BuildContext pageContext; - final WidgetRef pageRef; - final Object? popResult; - final void Function(Object? result, VoidCallback effect)? onSelected; - - const _AnimatedReactionTray({ - required this.trayKey, - required this.animation, - required this.trayWidth, - required this.scaleAlignment, - required this.message, - required this.pageContext, - required this.pageRef, - this.popResult, - this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: animation, - child: SizedBox( - width: trayWidth, - height: _reactionTrayMaxHeight, - child: Padding( - padding: const EdgeInsets.all(Grid.xxs), - child: _QuickReactionRow( - message: message, - sheetContext: context, - pageContext: pageContext, - pageRef: pageRef, - presentationAnimation: animation, - popResult: popResult, - onSelected: onSelected, - ), - ), - ), - builder: (context, child) { - final appearance = const Interval( - 0.04, - 0.23, - curve: Curves.easeOutCubic, - ).transform(animation.value); - final expansion = const Interval(0.16, 0.92).transform(animation.value); - final springExpansion = _reactionSpringCurve.transform(expansion); - final width = lerpDouble( - _reactionTrayMaxHeight, - trayWidth, - springExpansion, - )!; - - return Opacity( - opacity: appearance, - child: Transform.scale( - alignment: scaleAlignment, - scale: lerpDouble(0.95, 1, appearance)!, - child: Align( - alignment: Alignment.centerLeft, - child: SizedBox( - width: width, - height: _reactionTrayMaxHeight, - child: Material( - key: trayKey, - color: context.colors.surface, - surfaceTintColor: Colors.transparent, - elevation: 8, - shadowColor: Colors.black.withValues(alpha: 0.2), - shape: const StadiumBorder(), - clipBehavior: Clip.antiAlias, - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: trayWidth, - maxWidth: trayWidth, - minHeight: _reactionTrayMaxHeight, - maxHeight: _reactionTrayMaxHeight, - child: child, - ), - ), - ), - ), - ), - ); - }, - ); - } -} - -class _OutsideAnchorClipper extends CustomClipper { - final Rect anchorRect; - final EdgeInsets spotlightPadding; - - const _OutsideAnchorClipper(this.anchorRect, this.spotlightPadding); - - @override - Path getClip(Size size) { - final spotlightRect = Rect.fromLTRB( - anchorRect.left - spotlightPadding.left, - anchorRect.top - spotlightPadding.top, - anchorRect.right + spotlightPadding.right, - anchorRect.bottom + spotlightPadding.bottom, - ); - return Path() - ..fillType = PathFillType.evenOdd - ..addRect(Offset.zero & size) - ..addRRect( - RRect.fromRectAndRadius(spotlightRect, const Radius.circular(Radii.lg)), - ); - } - - @override - bool shouldReclip(_OutsideAnchorClipper oldClipper) => - oldClipper.anchorRect != anchorRect || - oldClipper.spotlightPadding != spotlightPadding; -} - -class _SpringEaseOutCurve extends Curve { - final double _durationSeconds; - final SpringSimulation _simulation; - - _SpringEaseOutCurve({required Duration duration, required double bounce}) - : _durationSeconds = - duration.inMicroseconds / Duration.microsecondsPerSecond, - _simulation = SpringSimulation( - SpringDescription.withDurationAndBounce( - duration: duration, - bounce: bounce, - ), - 0, - 1, - 0, - snapToEnd: true, - ); - - @override - double transformInternal(double t) => _simulation.x(t * _durationSeconds); -} diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 929dd5bfc02..367a92e455c 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -49,8 +49,9 @@ const _pillGlyphSize = 16.0; /// Used by channel detail, thread detail, and system message rows to avoid /// duplicating the toggle wiring. void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) { + final reaction = message.reactions.where((r) => r.emoji == emoji).firstOrNull; + if (reaction == null) return; final actions = ref.read(channelActionsProvider); - final reaction = message.reactions.firstWhere((r) => r.emoji == emoji); if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) { actions.removeReaction(reaction.currentUserReactionId!, emoji); } else { @@ -95,7 +96,7 @@ void showAddReactionPicker({ ); } -class ReactionRow extends StatelessWidget { +class ReactionRow extends HookWidget { /// The message these reactions belong to. Used to match a pending burst to /// the right pill — the same emoji can be pending on a different message. final String messageId; @@ -126,6 +127,23 @@ class ReactionRow extends StatelessWidget { @override Widget build(BuildContext context) { + final liveReactions = useMemoized(() => ValueNotifier(reactions)); + useEffect( + () => () { + scheduleMicrotask(() { + liveReactions.value = []; + liveReactions.dispose(); + }); + }, + [liveReactions], + ); + final latestToggle = useRef(onToggle)..value = onToggle; + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) liveReactions.value = reactions; + }); + return null; + }, [reactions]); // With the add button on, an empty row still renders — that affordance is // the point. Without it, an empty row would be dead space. if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink(); @@ -144,8 +162,10 @@ class ReactionRow extends StatelessWidget { onLongPress: () => showReactionDetailSheet( context: context, channelId: channelId, - reactions: reactions, - initialEmoji: reaction.emoji, + reactions: liveReactions, + onToggle: (emoji) { + if (context.mounted) latestToggle.value(emoji); + }, ), ), if (showAddButton && onAddReaction != null) @@ -344,199 +364,66 @@ class _ReactionEmoji extends StatelessWidget { Future showReactionDetailSheet({ required BuildContext context, required String channelId, - required List reactions, - required String initialEmoji, + required ValueListenable> reactions, + required ValueChanged onToggle, }) async { - if (reactions.isEmpty) return; + if (reactions.value.isEmpty) return; if (await _showNativeReactionDetails( context, channelId, reactions, - initialEmoji, + onToggle, )) { return; } if (!context.mounted) return; - if (defaultTargetPlatform == TargetPlatform.android) { - await showModalBottomSheet( - context: context, - isScrollControlled: true, - useSafeArea: true, - showDragHandle: true, - backgroundColor: context.colors.surfaceContainerHighest, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(Radii.dialog)), - ), - builder: (_) => _AndroidReactionDetailSheet( - channelId: channelId, - reactions: reactions, - initialEmoji: initialEmoji, - ), - ); - return; - } - showBuzzModalBottomSheet( + await showBuzzModalBottomSheet( context: context, title: 'Reactions', isScrollControlled: true, showDragHandle: true, - builder: (sheetContext) => _ReactionDetailSheet( - channelId: channelId, - reactions: reactions, - initialEmoji: initialEmoji, + builder: (_) => ValueListenableBuilder>( + valueListenable: reactions, + builder: (sheetContext, value, _) { + if (value.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (sheetContext.mounted && + ModalRoute.of(sheetContext)?.isCurrent == true) { + Navigator.of(sheetContext).pop(); + } + }); + return const SizedBox.shrink(); + } + return _ReactionDetailSheet( + channelId: channelId, + reactions: value, + onToggle: onToggle, + ); + }, ), ); } -class _AndroidReactionDetailSheet extends HookConsumerWidget { - const _AndroidReactionDetailSheet({ - required this.channelId, - required this.reactions, - required this.initialEmoji, - }); - - final String channelId; - final List reactions; - final String initialEmoji; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final cache = ref.watch(userCacheProvider); - final names = ref.watch(channelIdentityNamesProvider(channelId)); - final dataset = ref.watch(emojiDatasetOrEmptyProvider); - final pubkeys = reactions - .expand((reaction) => reaction.userPubkeys) - .toSet() - .toList(); - useEffect(() { - if (pubkeys.isNotEmpty) { - ref.read(userCacheProvider.notifier).preload(pubkeys); - } - return null; - }, [pubkeys.join(',')]); - final total = reactions.fold( - 0, - (sum, reaction) => sum + reaction.count, - ); - final maxHeight = MediaQuery.sizeOf(context).height * 0.65; - final desiredHeight = - 48.0 + pubkeys.length * 56.0 + MediaQuery.viewPaddingOf(context).bottom; - return DefaultTabController( - length: reactions.length + 1, - initialIndex: reactions.indexWhere((r) => r.emoji == initialEmoji) + 1, - child: SizedBox( - key: const ValueKey('reaction-details-sheet'), - height: desiredHeight.clamp( - maxHeight < 340 ? maxHeight : 340, - maxHeight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TabBar( - isScrollable: true, - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - tabAlignment: TabAlignment.start, - dividerHeight: 0, - indicatorSize: TabBarIndicatorSize.tab, - indicatorPadding: const EdgeInsets.symmetric(vertical: Grid.xxs), - indicator: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.full), - ), - labelColor: context.colors.onSurface, - unselectedLabelColor: context.colors.onSurfaceVariant, - tabs: [ - Tab( - key: const ValueKey('reaction-filter-all'), - text: 'All $total', - ), - for (final reaction in reactions) - Tab( - key: ValueKey('reaction-filter-${reaction.emoji}'), - child: Tooltip( - message: dataset.displayName(reaction.emoji), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ReactionEmoji(reaction: reaction, size: 20), - const SizedBox(width: Grid.quarter), - Text('${reaction.count}'), - ], - ), - ), - ), - ], - ), - Expanded( - child: TabBarView( - children: [ - for (var tab = 0; tab <= reactions.length; tab++) - Builder( - builder: (context) { - final filtered = tab == 0 - ? reactions - : [reactions[tab - 1]]; - final rows = [ - for (final reaction in filtered) - for (final pubkey in reaction.userPubkeys.toSet()) - (pubkey: pubkey, reaction: reaction), - ]; - return ListView.builder( - key: PageStorageKey('reaction-page-$tab'), - padding: EdgeInsets.only( - bottom: MediaQuery.viewPaddingOf(context).bottom, - ), - itemCount: rows.length, - itemBuilder: (_, index) { - final row = rows[index]; - return _ReactorTile( - profile: cache[row.pubkey.toLowerCase()], - pubkey: row.pubkey, - displayName: names.labelFor(row.pubkey), - reaction: row.reaction, - reactionLabel: dataset.displayName( - row.reaction.emoji, - ), - compact: true, - ); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - class _ReactionDetailSheet extends HookConsumerWidget { final String channelId; final List reactions; - final String initialEmoji; + final ValueChanged onToggle; const _ReactionDetailSheet({ required this.channelId, required this.reactions, - required this.initialEmoji, + required this.onToggle, }); @override Widget build(BuildContext context, WidgetRef ref) { - final selectedEmoji = useState(initialEmoji); final userCache = ref.watch(userCacheProvider); final identityNames = ref.watch(channelIdentityNamesProvider(channelId)); final dataset = ref.watch(emojiDatasetOrEmptyProvider); final rows = [ for (final reaction in reactions) - if (selectedEmoji.value == null || - reaction.emoji == selectedEmoji.value) - for (final pubkey in reaction.userPubkeys.toSet()) - (pubkey: pubkey, reaction: reaction), + for (final pubkey in reaction.userPubkeys.toSet()) + (pubkey: pubkey, reaction: reaction), ]; final pubkeys = rows.map((row) => row.pubkey).toSet().toList(); useEffect(() { @@ -545,11 +432,6 @@ class _ReactionDetailSheet extends HookConsumerWidget { } return null; }, [pubkeys.join(',')]); - final total = reactions.fold( - 0, - (sum, reaction) => sum + reaction.count, - ); - return ConstrainedBox( key: const ValueKey('reaction-details-sheet'), constraints: BoxConstraints( @@ -564,31 +446,40 @@ class _ReactionDetailSheet extends HookConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - Padding( - padding: const EdgeInsets.only(right: Grid.half), - child: ChoiceChip( - key: const ValueKey('reaction-filter-all'), - label: Text('All $total'), - selected: selectedEmoji.value == null, - onSelected: (_) => selectedEmoji.value = null, - ), - ), for (final reaction in reactions) Padding( padding: const EdgeInsets.only(right: Grid.half), - child: ChoiceChip( - key: ValueKey('reaction-filter-${reaction.emoji}'), - tooltip: dataset.displayName(reaction.emoji), - label: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _ReactionEmoji(reaction: reaction, size: 20), - const SizedBox(width: Grid.quarter), - Text('${reaction.count}'), - ], + child: Builder( + builder: (chipContext) => FilterChip( + key: ValueKey('reaction-toggle-${reaction.emoji}'), + tooltip: dataset.displayName(reaction.emoji), + side: BorderSide( + color: reaction.reactedByCurrentUser + ? context.colors.primary.withValues(alpha: 0.40) + : context.colors.onSurface.withValues( + alpha: 0.25, + ), + ), + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ReactionEmoji(reaction: reaction, size: 20), + const SizedBox(width: Grid.quarter), + Text('${reaction.count}'), + ], + ), + selected: reaction.reactedByCurrentUser, + onSelected: (_) { + if (!reaction.reactedByCurrentUser) { + burstEmojiFromContext( + ref, + chipContext, + reaction.emoji, + ); + } + onToggle(reaction.emoji); + }, ), - selected: reaction.emoji == selectedEmoji.value, - onSelected: (_) => selectedEmoji.value = reaction.emoji, ), ), ], @@ -618,6 +509,7 @@ class _ReactionDetailSheet extends HookConsumerWidget { displayName: identityNames.labelFor(row.pubkey), reaction: row.reaction, reactionLabel: dataset.displayName(row.reaction.emoji), + compact: defaultTargetPlatform == TargetPlatform.android, ), ); }, @@ -719,20 +611,22 @@ class _ReactorAvatar extends StatelessWidget { Future _showNativeReactionDetails( BuildContext context, String channelId, - List reactions, - String initialEmoji, + ValueListenable> reactions, + ValueChanged onToggle, ) async { if (!NativeMessagePresentation.isSupportedPlatform) return false; return NativeMessagePresentation.withLease(() async { final container = ProviderScope.containerOf(context, listen: false); final requestId = DateTime.now().microsecondsSinceEpoch.toString(); - final pubkeys = reactions.expand((r) => r.userPubkeys).toSet().toList(); + List pubkeys() => + reactions.value.expand((r) => r.userPubkeys).toSet().toList(); final sent = {}; Map profiles({bool onlyChanges = false}) { + if (reactions.value.isEmpty) return {}; final cache = container.read(userCacheProvider); final names = container.read(channelIdentityNamesProvider(channelId)); final result = {}; - for (final pubkey in pubkeys) { + for (final pubkey in pubkeys()) { final value = ( name: names.labelFor(pubkey), avatarUrl: cache[pubkey.toLowerCase()]?.avatarUrl, @@ -804,38 +698,59 @@ Future _showNativeReactionDetails( channelIdentityNamesProvider(channelId), (_, _) => scheduleProfiles(), ); + final dataset = container.read(emojiDatasetOrEmptyProvider); + List> reactionData() => [ + for (final reaction in reactions.value) + { + 'emoji': reaction.emoji, + 'label': dataset.displayName(reaction.emoji), + 'count': reaction.count, + 'selected': reaction.reactedByCurrentUser, + 'burst': isPositiveEmojiParticle(reaction.emoji), + 'users': reaction.userPubkeys.toSet().toList(), + if (reaction.emojiUrl case final String url) ...{ + 'url': url, + 'headers': container + .read(mediaGetAuthServiceProvider) + .headersFor(url), + }, + }, + ]; + void updateReactions() { + if (disposed) return; + unawaited( + NativeMessagePresentation.present('updateReactions', { + 'requestId': requestId, + 'reactions': reactionData(), + }), + ); + if (reactions.value.isNotEmpty) { + container.read(userCacheProvider.notifier).preload(pubkeys()); + scheduleProfiles(); + } + } + + reactions.addListener(updateReactions); try { - final dataset = container.read(emojiDatasetOrEmptyProvider); final sheetColors = utilitySurfaceThemeData( Theme.of(context), ).colorScheme; final pending = NativeMessagePresentation.present('reactions', { 'requestId': requestId, - 'initialEmoji': initialEmoji, 'sheetColor': sheetColors.surface.toARGB32(), 'foregroundColor': sheetColors.primary.toARGB32(), + 'outlineColor': Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(), 'dark': Theme.of(context).brightness == Brightness.dark, 'profiles': profiles(), - 'reactions': [ - for (final reaction in reactions) - { - 'emoji': reaction.emoji, - 'label': dataset.displayName(reaction.emoji), - 'count': reaction.count, - 'users': reaction.userPubkeys.toSet().toList(), - if (reaction.emojiUrl case final String url) ...{ - 'url': url, - 'headers': container - .read(mediaGetAuthServiceProvider) - .headersFor(url), - }, - }, - ], - }); - container.read(userCacheProvider.notifier).preload(pubkeys); + 'reactions': reactionData(), + }, onReaction: onToggle); + container.read(userCacheProvider.notifier).preload(pubkeys()); return await pending != null; } finally { disposed = true; + reactions.removeListener(updateReactions); timer?.cancel(); subscription.close(); namesSubscription.close(); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index eaaa8cdac6c..e3b6f10c621 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -832,6 +832,21 @@ class ThreadDetailPage extends HookConsumerWidget { }); return FrostedScaffold( + foreground: threadViewportVisible + ? Stack( + children: [ + Positioned( + left: 0, + right: 0, + top: frostedAppBarHeight(context) + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('thread-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), + ], + ) + : null, resizeToAvoidBottomInset: !usesFixedAndroidImeViewport, appBar: _threadAppBar(context, ref, channel, currentPubkey), body: Stack( @@ -913,16 +928,6 @@ class ThreadDetailPage extends HookConsumerWidget { ), ], ), - if (threadViewportVisible) - Positioned( - left: 0, - right: 0, - top: frostedAppBarHeight(context) + Grid.twelve, - child: StickyDateHeader( - key: const ValueKey('thread-sticky-date-header'), - state: stickyDateHeaderState, - ), - ), if (isMember && !isArchived) AndroidImeLift( child: Align( diff --git a/mobile/lib/shared/widgets/app_list_card.dart b/mobile/lib/shared/widgets/app_list_card.dart index eb4b72a0cff..44e1aac2c20 100644 --- a/mobile/lib/shared/widgets/app_list_card.dart +++ b/mobile/lib/shared/widgets/app_list_card.dart @@ -56,7 +56,7 @@ class AppListCard extends StatelessWidget { // which lands them within a few levels of the card fill — invisible. // Tinting with the text color instead keeps the hairline readable on // the card in both brightnesses. - color: context.colors.onSurface.withValues(alpha: 0.12), + color: context.colors.onSurface.withValues(alpha: 0.07), ), ); } diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 98996f0cf72..160d9ab127c 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -21,6 +21,10 @@ class FrostedScaffold extends HookWidget { /// using [frostedAppBarHeight] — the scaffold does NOT add automatic padding. final Widget body; + /// Non-scrolling content painted above the navigation bar and its blur. + /// Position this child within the scaffold's full-screen stack. + final Widget? foreground; + /// Optional floating action button, passed through to [Scaffold]. final Widget? floatingActionButton; @@ -45,6 +49,7 @@ class FrostedScaffold extends HookWidget { super.key, required this.appBar, required this.body, + this.foreground, this.floatingActionButton, this.resizeToAvoidBottomInset, this.backgroundColor, @@ -248,7 +253,13 @@ class FrostedScaffold extends HookWidget { // only samples pixels that were already painted behind it. This is the // same composition as channel navigation, so top-level headers blur their // content rather than only the fixed gradient. - return [...backdrop, bodyMotion, appBar]; + return [ + ...backdrop, + bodyMotion, + appBar, + if (foreground != null) + Positioned.fill(child: DirectionalTransitionMotion(child: foreground!)), + ]; } } diff --git a/mobile/lib/shared/widgets/native_message_presentation.dart b/mobile/lib/shared/widgets/native_message_presentation.dart index 5c76d3f789e..3d8bbc28584 100644 --- a/mobile/lib/shared/widgets/native_message_presentation.dart +++ b/mobile/lib/shared/widgets/native_message_presentation.dart @@ -32,12 +32,20 @@ class NativeMessagePresentation { String method, Map arguments, { VoidCallback? onPresented, + ValueChanged? onReaction, }) async { if (!isSupportedPlatform) return null; - final requestId = 'message-${_nextRequest++}'; - if (onPresented != null) { - _presentedCallbacks[requestId] = onPresented; + final requestId = + arguments['requestId'] as String? ?? 'message-${_nextRequest++}'; + if (onPresented != null || onReaction != null) { + if (onPresented != null) _presentedCallbacks[requestId] = onPresented; channel.setMethodCallHandler((call) async { + if (call.method == 'toggleReaction') { + final data = call.arguments as Map; + if (data['requestId'] == requestId && data['emoji'] is String) { + onReaction?.call(data['emoji'] as String); + } + } if (call.method == 'messagePresented') { _presentedCallbacks[call.arguments]?.call(); } @@ -46,7 +54,7 @@ class NativeMessagePresentation { try { return await channel.invokeMapMethod(method, { ...arguments, - if (onPresented != null) 'requestId': requestId, + 'requestId': requestId, }); } on MissingPluginException { return null; @@ -54,6 +62,9 @@ class NativeMessagePresentation { return null; } finally { _presentedCallbacks.remove(requestId); + if (onPresented != null || onReaction != null) { + channel.setMethodCallHandler(null); + } } } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index ab6bc9e50da..f7685643911 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -45,7 +45,6 @@ import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; import 'package:buzz/features/channels/message_action_backdrop_state.dart'; -import 'package:buzz/features/channels/message_actions.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; import 'package:buzz/features/channels/reaction_row.dart'; @@ -3296,9 +3295,7 @@ void main() { ); }); - testWidgets('long press opens the anchored reaction popover', ( - tester, - ) async { + testWidgets('long press opens the reaction sheet', (tester) async { final hapticCalls = []; tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, @@ -3349,376 +3346,13 @@ void main() { ), findsNWidgets(6), ); - expect(find.byType(BottomSheet), findsNothing); + expect(find.byType(BottomSheet), findsOneWidget); expect(find.text('Copy text'), findsNothing); expect(findRichText('joined the channel'), findsOneWidget); expect(hapticCalls, hasLength(1)); expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact'); - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - testWidgets('reaction popover leaves existing reactions in the blur', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'reacted-system-message', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - _reaction( - id: 'existing-reaction', - targetId: 'reacted-system-message', - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - final messageCenter = tester.getCenter( - findRichText('joined the channel'), - ); - final reactionRect = tester.getRect(find.byType(ReactionRow)); - final reactionCenter = reactionRect.center; - final reactionPillTop = Offset( - reactionRect.center.dx, - reactionRect.top + Grid.half + Grid.quarter, - ); - await tester.longPress( - find.byKey(const ValueKey('system-message-row-reacted-system-message')), - ); - await tester.pumpAndSettle(); - - final backgroundFinder = find.byKey( - const ValueKey('reaction-popover-background'), - ); - final background = tester.widget(backgroundFinder); - final backgroundOrigin = tester.getTopLeft(backgroundFinder); - final blurPath = background.clipper!.getClip( - tester.getSize(backgroundFinder), - ); - - expect(blurPath.contains(messageCenter - backgroundOrigin), isFalse); - expect(blurPath.contains(reactionPillTop - backgroundOrigin), isTrue); - expect(blurPath.contains(reactionCenter - backgroundOrigin), isTrue); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - for (final thread in [false, true]) { - for (final reactionOnly in [false, true]) { - testWidgets( - 'native header restores after backdrop thread=$thread reactionOnly=$reactionOnly', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() { - debugDefaultTargetPlatformOverride = null; - messageActionBackdropActive.value = false; - }); - // Exercise the composable fallback when native menus are unavailable. - const presentation = MethodChannel( - 'buzz/native_message_presentation', - ); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - presentation, - (_) async => {'supported': false}, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger - .setMockMethodCallHandler(presentation, null), - ); - final event = reactionOnly - ? _systemMsg( - id: 'native-backdrop', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ) - : _textMsg( - id: 'native-backdrop', - pubkey: 'alice', - content: 'Native backdrop target', - ); - final timeline = formatTimeline([event]); - await tester.pumpWidget( - _buildTestable( - messages: [event], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - home: thread - ? ThreadDetailPage( - threadHead: timeline.single, - allMessages: timeline, - channelId: _testChannel.id, - currentPubkey: null, - isMember: true, - isArchived: false, - ) - : null, - ), - ); - await tester.pumpAndSettle(); - final header = find.byWidgetPredicate( - (widget) => - widget is UiKitView && - widget.viewType == 'buzz/ios_navigation_bar', - ); - expect(header, findsOneWidget); - final key = thread - ? 'thread-message-row-native-backdrop' - : reactionOnly - ? 'system-message-row-native-backdrop' - : 'message-row-native-backdrop'; - await tester.longPress(find.byKey(ValueKey(key))); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isTrue); - expect(header, findsNothing); - expect( - find.descendant( - of: find.byType(FrostedAppBar), - matching: find.byType(UiKitView), - ), - findsNothing, - reason: 'Every header control must participate in Flutter blur', - ); - final overlay = find.byKey( - ValueKey( - reactionOnly - ? 'reaction-popover-tray' - : 'message-action-surface', - ), - ); - expect(overlay, findsOneWidget); - Navigator.of(tester.element(overlay)).pop(); - await tester.pump(); - expect( - header, - findsNothing, - reason: - 'Keep native view suppressed through the dismissal transition', - ); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - expect(header, findsOneWidget); - await tester.pumpWidget(const SizedBox()); - debugDefaultTargetPlatformOverride = null; - }, - ); - } - } - - testWidgets('reaction-only and full actions share the stronger backdrop', ( - tester, - ) async { - addTearDown(() => messageActionBackdropActive.value = false); - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'reaction-only-blur', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - _textMsg( - id: 'full-action-blur', - pubkey: 'alice', - content: 'Full action target', - createdAt: 1100, - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - await tester.longPress( - find.byKey(const ValueKey('system-message-row-reaction-only-blur')), - ); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isTrue); - final reactionBackdrop = tester.widget( - find.byKey(const ValueKey('reaction-popover-backdrop-filter')), - ); - final reactionTint = tester.widget( - find.byKey(const ValueKey('reaction-popover-background-tint')), - ); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - - await tester.longPress( - find.byKey(const ValueKey('message-row-full-action-blur')), - ); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isTrue); - final fullBackdrop = tester.widget( - find.byKey(const ValueKey('message-actions-backdrop-filter')), - ); - final fullTint = tester.widget( - find.byKey(const ValueKey('message-actions-background')), - ); - - expect(fullBackdrop.filter, same(reactionBackdrop.filter)); - expect(fullTint.color, reactionTint.color); - - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - testWidgets('reaction-only popovers serialize concurrent requests', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: const [], - home: Scaffold( - body: Consumer( - builder: (context, ref, _) => TextButton( - key: const ValueKey('concurrent-reaction-launcher'), - onPressed: () { - final message = formatTimeline([ - _systemMsg( - id: 'concurrent-reaction', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - ]).single; - const anchorRect = Rect.fromLTWH(32, 260, 300, 72); - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: _channelId, - canManageMessage: false, - anchorRect: anchorRect, - ); - showMessageActions( - context: context, - ref: ref, - message: message, - channelId: _channelId, - canManageMessage: false, - anchorRect: anchorRect, - ); - }, - child: const Text('Open concurrent reactions'), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap( - find.byKey(const ValueKey('concurrent-reaction-launcher')), - ); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('reaction-popover-tray')), - findsOneWidget, - ); - expect(messageActionBackdropActive.value, isTrue); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - - await tester.tap( - find.byKey(const ValueKey('concurrent-reaction-launcher')), - ); - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('reaction-popover-tray')), - findsOneWidget, - reason: 'The presentation latch must release after dismissal.', - ); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); - await tester.pumpAndSettle(); - expect(messageActionBackdropActive.value, isFalse); - }); - - testWidgets('reaction popover grows right from a fixed left edge', ( - tester, - ) async { - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'left-growth-target', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'alice', - }, - ), - ], - users: const { - 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - await tester.longPress( - find.byKey(const ValueKey('system-message-row-left-growth-target')), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - final earlyRect = tester.getRect( - find.byKey(const ValueKey('reaction-popover-tray')), - ); - await tester.pump(const Duration(milliseconds: 80)); - final laterRect = tester.getRect( - find.byKey(const ValueKey('reaction-popover-tray')), - ); - - expect(laterRect.left, moreOrLessEquals(earlyRect.left)); - expect(laterRect.width, greaterThan(earlyRect.width)); - await tester.pumpAndSettle(); - - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); expect(messageActionBackdropActive.value, isFalse); }); @@ -3759,14 +3393,9 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('reaction-popover-tray')), - findsOneWidget, - ); + expect(find.byType(BottomSheet), findsOneWidget); - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); expect(messageActionBackdropActive.value, isFalse); }); @@ -3809,24 +3438,15 @@ void main() { ); await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsOneWidget, - ); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); expect( find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, + findsNothing, ); - expect(find.byType(BottomSheet), findsNothing); + expect(find.byType(BottomSheet), findsOneWidget); expect(find.text('Copy text'), findsOneWidget); - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); expect(messageActionBackdropActive.value, isFalse); }); @@ -5377,6 +4997,30 @@ void main() { final stickyHeader = find.byKey( const ValueKey('channel-sticky-date-header'), ); + final scaffold = tester.widget( + find + .ancestor( + of: stickyHeader, + matching: find.byType(FrostedScaffold), + ) + .first, + ); + expect(scaffold.foreground, isNotNull); + expect( + find.descendant( + of: find.byWidget(scaffold.foreground!), + matching: stickyHeader, + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byWidget(scaffold.body), + matching: stickyHeader, + ), + findsNothing, + ); + final stickySurface = find.byKey( const ValueKey('channel-sticky-date-header-surface'), ); @@ -12281,6 +11925,27 @@ void main() { final stickyHeader = find.byKey( const ValueKey('thread-sticky-date-header'), ); + final scaffold = tester.widget( + find + .ancestor(of: stickyHeader, matching: find.byType(FrostedScaffold)) + .first, + ); + expect(scaffold.foreground, isNotNull); + expect( + find.descendant( + of: find.byWidget(scaffold.foreground!), + matching: stickyHeader, + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byWidget(scaffold.body), + matching: stickyHeader, + ), + findsNothing, + ); + expect( find.descendant( of: stickyHeader, diff --git a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart index 0364ff6f0b0..3e5919cec4e 100644 --- a/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/action_row_tests.dart @@ -175,9 +175,7 @@ void actionRowTests() { ); await tester.pumpAndSettle(); expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + Navigator.of(tester.element(find.byType(BottomSheet))).pop(); await tester.pumpAndSettle(); }); diff --git a/mobile/test/features/channels/message_actions_test.dart b/mobile/test/features/channels/message_actions_test.dart index cd5bad08219..ef9a9a01e9b 100644 --- a/mobile/test/features/channels/message_actions_test.dart +++ b/mobile/test/features/channels/message_actions_test.dart @@ -1,6 +1,5 @@ import 'package:buzz/shared/widgets/sheet_action_section.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; -import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/services.dart'; @@ -8,7 +7,6 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_actions.dart'; -import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/thread_follows/thread_follows_provider.dart'; @@ -18,7 +16,6 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:buzz/shared/widgets/native_message_presentation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -33,6 +30,7 @@ TimelineMessage _message({ bool isSystem = false, String? rootId, List> tags = const [], + List reactions = const [], }) => TimelineMessage( id: id, pubkey: pubkey, @@ -41,8 +39,22 @@ TimelineMessage _message({ isSystem: isSystem, rootId: rootId, tags: tags, + reactions: reactions, ); +class _RecordingReactionActions implements ChannelActions { + final added = []; + final removed = []; + @override + Future addReaction(String eventId, String emoji) async => + added.add(emoji); + @override + Future removeReaction(String eventId, String emoji) async => + removed.add(eventId); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + final _listedChannel = Channel( id: _channelId, name: 'general', @@ -381,34 +393,74 @@ Future<_MessageActionsPopoverHarness> _pumpMessageActionsPopover( ); } -Future _dismissMessageActionsPopover(WidgetTester tester) async { - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); - await tester.pumpAndSettle(); -} - -class _FakeChannelActions extends ChannelActions { - final reactions = <({String eventId, String emoji})>[]; - - _FakeChannelActions(Ref ref) - : super( - ref: ref, - session: ref.read(relaySessionProvider.notifier), - signedEventRelay: SignedEventRelay( - session: ref.read(relaySessionProvider.notifier), - nsec: null, +void main() { + testWidgets( + 'quick reactions show selection, label controls, and remove our reaction', + (tester) async { + final semantics = tester.ensureSemantics(); + final actions = _RecordingReactionActions(); + await _pumpMessageActionsPopover( + tester, + message: _message( + reactions: [ + const TimelineReaction( + emoji: '👍', + count: 1, + reactedByCurrentUser: true, + userPubkeys: ['self'], + currentUserReactionId: 'our-reaction', + ), + ], ), - currentPubkey: 'self', - ); + prefs: await _mockPrefs(), + createChannelActions: (_) => actions, + ); + final emoji = find.byKey(const ValueKey('quick-reaction-👍')); + final owner = tester.widget( + find.descendant(of: emoji, matching: find.byType(Semantics)).first, + ); + expect(owner.properties.button, isTrue); + expect(owner.properties.selected, isTrue); + expect(owner.properties.label, isNotEmpty); + expect(owner.properties.onTap, isNotNull); + expect(find.bySemanticsLabel('Add reaction'), findsOneWidget); + await tester.tap(emoji); + await tester.pumpAndSettle(); + expect(actions.removed, ['our-reaction']); + expect(actions.added, isEmpty); + semantics.dispose(); + }, + ); - @override - Future addReaction(String eventId, String emoji) async { - reactions.add((eventId: eventId, emoji: emoji)); - } -} + testWidgets('two active pointers select only one sheet action', ( + tester, + ) async { + final actions = _RecordingReactionActions(); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: await _mockPrefs(), + launcherOnNestedRoute: true, + createChannelActions: (_) => actions, + ); + final first = await tester.startGesture( + tester.getCenter(find.byKey(const ValueKey('quick-reaction-👍'))), + pointer: 1, + ); + final second = await tester.startGesture( + tester.getCenter(find.text('Copy text')), + pointer: 2, + ); + await first.up(); + await second.up(); + await tester.pumpAndSettle(); + expect(actions.added, ['👍']); + expect( + find.byKey(const ValueKey('message-actions-underlying-page')), + findsOneWidget, + ); + }); -void main() { // Some sheets build the app lifecycle, which listens for network changes. // That listen is asynchronous, so a missing plugin fails whichever test // happens to be running. Flutter documents mock handlers as cleared after @@ -652,1056 +704,201 @@ void main() { expect(snapshot.height, 80); }); - testWidgets( - 'native menu respects ownership and dispatches the target read action', - (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - Map? payload; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - payload = call.arguments as Map; - return {'action': 'read'}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - final notifier = _FakeReadStateNotifier( - _readState(const {_channelId: 100000}), - ); - await _pumpSheet( + group('showMessageActions', () { + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('uses sectioned sheet on $platform even with a snapshot', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + var captures = 0; + final harness = await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: await _mockPrefs(), + canManageMessage: true, + allMessages: [_message()], + reminderService: _stubReminderService(), + captureAnchorSnapshot: () async { + captures++; + return _testMessageSnapshot(); + }, + ); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byType(SheetActionSection), findsNWidgets(3)); + expect(find.byType(AppListCard), findsNWidgets(3)); + expect(captures, 0); + expect(harness.sourceHidden.value, isFalse); + for (final label in [ + 'Mark unread', + 'Follow thread', + 'Copy text', + 'Edit message', + 'Delete message', + ]) { + expect( + find.ancestor( + of: find.text(label), + matching: find.byType(SheetActionSection), + ), + findsOneWidget, + ); + } + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }); + } + + testWidgets('sheet actions grow with large text and remain scrollable', ( + tester, + ) async { + tester.view.physicalSize = const Size(375, 667); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await _pumpMessageActionsPopover( tester, message: _message(), prefs: await _mockPrefs(), - readStateOverride: () => notifier, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, + canManageMessage: true, + allMessages: [_message()], + reminderService: _stubReminderService(), + textScaler: TextScaler.linear(2), ); - expect(payload?['previewBytes'], isA()); - expect(notifier.markedUnread, ['msg:msg-1']); - final actions = (payload!['actions'] as List) - .cast>(); - expect(actions.map((a) => a['id']), isNot(contains('edit'))); - expect(actions.map((a) => a['id']), isNot(contains('delete'))); - expect(find.text('Copy text'), findsNothing); - debugDefaultTargetPlatformOverride = null; - }, - ); - - testWidgets('the native menu uses channel catch-up without a profile', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - Map? payload; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - payload = call.arguments as Map; - return {}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(createdAt: 900), - prefs: await _mockPrefs(), - readStateOverride: () => _FakeReadStateNotifier( - _readState(const {_channelId: 500, 'activity:$_channelId': 2000}), - ), - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - currentPubkey: null, - listChannel: true, - ); - final actions = (payload!['actions'] as List).cast>(); - expect( - actions.firstWhere((a) => a['id'] == 'read')['title'], - 'Mark unread', - ); - debugDefaultTargetPlatformOverride = null; - }); + await tester.ensureVisible(find.text('Delete message')); + expect(find.text('Delete message').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); - testWidgets('system messages use the native tray without action rows', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - Map? payload; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - payload = call.arguments as Map; - return {}; - }, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(isSystem: true), - prefs: await _mockPrefs(), - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - ); - expect(payload?['actions'], isEmpty); - expect(payload?['reactions'], isNotEmpty); - expect(payload?['previewBytes'], isA()); - expect(find.byType(BottomSheet), findsNothing); - debugDefaultTargetPlatformOverride = null; - }); + testWidgets('shows parity actions for a regular message', (tester) async { + final prefs = await _mockPrefs(); + await _pumpSheet(tester, message: _message(), prefs: prefs); - for (final selectedAction in [ - null, - 'follow', - 'unmount', - 'unmountFailure', - 'overlap', - ]) { - testWidgets('native focus and source lifecycle for $selectedAction', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final focus = FocusNode(); - late Completer ready; - late Completer> result; - await tester.runAsync(() async { - ready = Completer(); - result = Completer>(); - }); - final visibility = []; - var dismissed = 0; - var focusRestores = 0; - late BuildContext pageContext; - late WidgetRef pageRef; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') return {'supported': true}; - expect(visibility, isEmpty); - final args = call.arguments as Map; - await tester.binding.defaultBinaryMessenger.handlePlatformMessage( - NativeMessagePresentation.channel.name, - const StandardMethodCodec().encodeMethodCall( - MethodCall('messagePresented', args['requestId']), - ), - (_) {}, - ); - ready.complete(); - return result.future; - }, - ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(await _mockPrefs()), - myPubkeyProvider.overrideWithValue('self'), - readStateProvider.overrideWith( - () => _FakeReadStateNotifier( - _readState(const {_channelId: 100000}), + expect(find.text('Copy text'), findsOneWidget); + expect(find.text('Copy link'), findsOneWidget); + expect(find.text('Mark unread'), findsOneWidget); + expect(find.text('Follow thread'), findsOneWidget); + // No thread context → no Reply fast action. + expect(find.text('Reply'), findsNothing); + // No signing identity → no reminders; no manage rights → no edit/delete. + expect(find.text('Remind me'), findsNothing); + expect(find.text('Edit message'), findsNothing); + expect(find.text('Delete message'), findsNothing); + expect(find.byTooltip('Close sheet'), findsNothing); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'quick-reaction-', ), - ), - reminderServiceProvider.overrideWithValue(null), - ], - child: MaterialApp( - home: Consumer( - builder: (context, ref, _) { - pageContext = context; - pageRef = ref; - return Scaffold(body: TextField(focusNode: focus)); - }, - ), - ), ), + findsNWidgets(6), ); - focus.requestFocus(); - await tester.pump(); - late Future presentation; - Future openMenu() => showMessageActions( - context: pageContext, - ref: pageRef, - message: _message(), - channelId: _channelId, - canManageMessage: false, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - captureAnchorSnapshot: _testMessageSnapshot, - composerFocusNode: focus, - restoreComposerFocus: () { - focusRestores++; - focus.requestFocus(); - }, - onPopoverPreviewVisibilityChanged: visibility.add, - onPopoverDismissed: () => dismissed++, - ); - await tester.runAsync(() async { - presentation = openMenu(); - if (selectedAction == 'overlap') { - // A is awaiting preflight/capture, so both gestures could see focus. - await openMenu(); - expect(focusRestores, 0); - } - await ready.future; - }); - await tester.pump(); - expect(focus.hasFocus, isFalse); - expect(visibility, [true]); - if (selectedAction == 'overlap') { - await tester.runAsync(openMenu); - await tester.pump(); - expect(focus.hasFocus, isFalse); - expect(focusRestores, 0); - } - if (selectedAction?.startsWith('unmount') == true) { - await tester.pumpWidget(const SizedBox.shrink()); - } - await tester.runAsync(() async { - if (selectedAction == 'unmountFailure') { - result.completeError(PlatformException(code: 'presentation-failed')); - } else { - result.complete({ - if (selectedAction == 'follow') 'action': selectedAction, - }); - } - await presentation; - }); - await tester.pump(); - final restores = selectedAction == null || selectedAction == 'overlap'; - expect(focus.hasFocus, restores); - expect(focusRestores, restores ? 1 : 0); - expect(visibility, [true, false]); - expect(dismissed, selectedAction?.startsWith('unmount') == true ? 0 : 1); - await tester.pumpWidget(const SizedBox.shrink()); - focus.dispose(); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, + expect( + tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))), + const Size.square(52), ); - debugDefaultTargetPlatformOverride = null; }); - } - for (final (reactionFirst, missingSnapshot) in [ - (false, false), - (true, false), - (true, true), - ]) { testWidgets( - 'cross-surface native ownership reactionFirst=$reactionFirst missingSnapshot=$missingSnapshot', + 'places three quick action tiles between reactions and sections', (tester) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final focus = FocusNode(); - late Completer> result; - late Completer> support; - late Completer entered; - late Completer ready; - await tester.runAsync(() async { - result = Completer(); - support = Completer(); - entered = Completer(); - ready = Completer(); - }); - var messages = 0; - var reactions = 0; - var restores = 0; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async { - if (call.method == 'supportsMessage') { - entered.complete(); - return support.future; - } - if (call.method == 'message') messages++; - if (call.method == 'reactions') reactions++; - ready.complete(); - return result.future; - }, - ); - late BuildContext pageContext; - late WidgetRef pageRef; - await tester.pumpWidget( - ProviderScope( - overrides: [ - savedPrefsProvider.overrideWithValue(await _mockPrefs()), - myPubkeyProvider.overrideWithValue('self'), - readStateProvider.overrideWith( - () => _FakeReadStateNotifier( - _readState(const {_channelId: 100000}), - ), - ), - reminderServiceProvider.overrideWithValue(null), - ], - child: MaterialApp( - home: Consumer( - builder: (context, ref, _) { - pageContext = context; - pageRef = ref; - return Scaffold( - body: TextField(focusNode: focus, showCursor: false), - ); - }, - ), - ), - ), - ); - focus.requestFocus(); - await tester.pump(); - Future message() => showMessageActions( - context: pageContext, - ref: pageRef, + final prefs = await _mockPrefs(); + await _pumpSheet( + tester, message: _message(), - channelId: _channelId, - canManageMessage: false, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - captureAnchorSnapshot: missingSnapshot ? null : _testMessageSnapshot, - composerFocusNode: focus, - restoreComposerFocus: () { - restores++; - focus.requestFocus(); - }, + prefs: prefs, + allMessages: [_message()], + reminderService: _stubReminderService(), ); - Future reaction() => showReactionDetailSheet( - context: pageContext, - channelId: _channelId, - reactions: const [ - TimelineReaction( - emoji: '❤️', - count: 1, - reactedByCurrentUser: false, - userPubkeys: [], - ), - ], - initialEmoji: '❤️', + + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Copy link'), findsOneWidget); + expect(find.text('Remind me'), findsOneWidget); + final reply = tester.getCenter(find.text('Reply')); + final link = tester.getCenter(find.text('Copy link')); + final remind = tester.getCenter(find.text('Remind me')); + expect(reply.dy, link.dy); + expect(link.dy, remind.dy); + expect(reply.dx, lessThan(link.dx)); + expect(link.dx, lessThan(remind.dx)); + expect( + tester + .getBottomLeft(find.byKey(const ValueKey('quick-reaction-more'))) + .dy, + lessThan(reply.dy), ); - late Future owner; - await tester.runAsync(() async { - if (reactionFirst) { - owner = reaction(); - await ready.future; - await message(); - } else { - owner = message(); - await entered.future; - await reaction(); - expect(reactions, 0); - support.complete({'supported': true}); - await ready.future; - } - }); - await tester.pump(); - expect(messages, reactionFirst ? 0 : 1); - expect(reactions, reactionFirst ? 1 : 0); - expect(restores, 0); - expect(focus.hasFocus, reactionFirst); - expect(find.byType(BottomSheet), findsNothing); - await tester.runAsync(() async { - result.complete({}); - await owner; - }); - await tester.pumpAndSettle(); - expect(focus.hasFocus, isTrue); - expect(restores, reactionFirst ? 0 : 1); - await tester.pumpWidget(const SizedBox.shrink()); - focus.dispose(); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, + expect( + remind.dy, + lessThan(tester.getTopLeft(find.byType(SheetActionSection).first).dy), ); - debugDefaultTargetPlatformOverride = null; + for (final label in ['Reply', 'Copy link', 'Remind me']) { + expect( + find.ancestor( + of: find.text(label), + matching: find.byType(SheetActionSection), + ), + findsNothing, + ); + } + // Keep the existing labels. + expect(find.text('Reply in thread'), findsNothing); + expect(find.text('Remind me later'), findsNothing); }, ); - } - - testWidgets('native dismissal does not open the Flutter sheet', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (call) async => call.method == 'supportsMessage' - ? {'supported': true} - : {}, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(), - prefs: await _mockPrefs(), - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - nativePresentation: true, - ); - expect(find.text('Copy text'), findsNothing); - expect(find.text('open'), findsOneWidget); - debugDefaultTargetPlatformOverride = null; - }); - testWidgets('unavailable native presentation falls back to message actions', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (_) async => null, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - await _pumpSheet( - tester, - message: _message(), - prefs: await _mockPrefs(), - canManageMessage: true, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - ); - expect(find.text('Edit message'), findsOneWidget); - expect(find.text('Delete message'), findsOneWidget); - debugDefaultTargetPlatformOverride = null; - }); + for (final action in [ + null, + 'Copy link', + 'Copy text', + 'Follow thread', + 'Mark unread', + ]) { + testWidgets('composer focus after sheet dismissal: $action', ( + tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await _pumpMessageActionsPopover( + tester, + message: _message(), + prefs: await _mockPrefs(), + composerFocusNode: focusNode, + composerInitiallyFocused: true, + ); + expect(focusNode.hasFocus, isFalse); + if (action == null) { + await tester.tapAt(const Offset(10, 10)); + } else { + await tester.tap(find.text(action)); + } + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, action == null); + }); + } - group('showMessageActions', () { - testWidgets('composes the tray, lifted preview, and compact actions', ( - tester, - ) async { + testWidgets('hides utility actions for system messages', (tester) async { final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); + await _pumpSheet(tester, message: _message(isSystem: true), prefs: prefs); - expect(harness.sourceHidden.value, isTrue); - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsOneWidget, - ); + expect(find.text('Copy text'), findsNothing); + expect(find.text('Copy link'), findsNothing); + expect(find.text('Mark unread'), findsNothing); + expect(find.text('Follow thread'), findsNothing); + expect(find.text('Reply'), findsNothing); + expect(find.text('Remind me'), findsNothing); + expect(find.byTooltip('Close sheet'), findsNothing); expect( - find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - expect(find.byType(BottomSheet), findsNothing); - expect(find.text('Reply'), findsOneWidget); - expect(find.text('Copy link'), findsOneWidget); - expect(find.text('Remind me'), findsOneWidget); - expect(find.text('Follow thread'), findsOneWidget); - - final trayRect = tester.getRect( - find.byKey(const ValueKey('message-action-reaction-tray')), - ); - final previewRect = tester.getRect( - find.byKey(const ValueKey('message-action-preview')), - ); - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - final trayMaterial = tester.widget( - find.byKey(const ValueKey('message-action-reaction-tray')), - ); - final actionMaterial = tester.widget( - find.byKey(const ValueKey('message-action-surface')), - ); - expect(previewRect.top, greaterThan(trayRect.bottom)); - expect(actionRect.top, greaterThan(previewRect.bottom)); - expect(previewRect.left, trayRect.left); - expect(actionRect.left, trayRect.left); - expect(actionRect.width, 288); - expect(actionMaterial.color, trayMaterial.color); - - await _dismissMessageActionsPopover(tester); - expect(harness.sourceHidden.value, isFalse); - }); - - for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { - testWidgets( - '${platform.name} composition keeps the action menu near the safe bottom', - (tester) async { - debugDefaultTargetPlatformOverride = platform; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (_) async => null, - ); - addTearDown( - () => - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - try { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); - - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - final logicalHeight = - tester.view.physicalSize.height / tester.view.devicePixelRatio; - expect(actionRect.bottom, closeTo(logicalHeight - Grid.xxs, 0.1)); - - await _dismissMessageActionsPopover(tester); - } finally { - debugDefaultTargetPlatformOverride = null; - } - }, - ); - } - - testWidgets('keeps the action menu above the software keyboard', ( - tester, - ) async { - const keyboardInset = 300.0; - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - viewInsets: const EdgeInsets.only(bottom: keyboardInset), - ); - - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - final logicalHeight = - tester.view.physicalSize.height / tester.view.devicePixelRatio; - expect( - actionRect.bottom, - closeTo(logicalHeight - keyboardInset - Grid.xxs, 0.1), - ); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('collapses fixed sections in a short keyboard viewport', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 220); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - const keyboardInset = 100.0; - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - viewInsets: const EdgeInsets.only(bottom: keyboardInset), - ); - - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsNothing, - ); - expect( - find.byKey(const ValueKey('message-action-preview')), - findsNothing, - ); - expect(harness.sourceHidden.value, isFalse); - final actionRect = tester.getRect( - find.byKey(const ValueKey('message-action-surface')), - ); - expect(actionRect.top, greaterThanOrEqualTo(Grid.xxs)); - expect( - actionRect.bottom, - lessThanOrEqualTo(220 - keyboardInset - Grid.xxs), - ); - expect(tester.takeException(), isNull); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('keeps tall message previews within the visible viewport', ( - tester, - ) async { - const keyboardInset = 300.0; - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(pubkey: 'self'), - prefs: prefs, - canManageMessage: true, - allMessages: [_message(pubkey: 'self')], - reminderService: _stubReminderService(), - viewInsets: const EdgeInsets.only(bottom: keyboardInset), - anchorRect: const Rect.fromLTWH(32, 40, 300, 2000), - ); - - final logicalHeight = - tester.view.physicalSize.height / tester.view.devicePixelRatio; - final visibleBottom = logicalHeight - keyboardInset - Grid.xxs; - expect( - tester - .getRect(find.byKey(const ValueKey('message-action-preview'))) - .top, - greaterThanOrEqualTo(Grid.xxs), - ); - expect( - tester - .getRect(find.byKey(const ValueKey('message-action-surface'))) - .bottom, - lessThanOrEqualTo(visibleBottom), - ); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('restores composer focus only after a dismissed popover', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - composerFocusNode: focusNode, - composerInitiallyFocused: true, - ); - - expect(focusNode.hasFocus, isFalse); - await _dismissMessageActionsPopover(tester); - expect(focusNode.hasFocus, isTrue); - }); - - testWidgets('leaves an initially unfocused composer unfocused', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - composerFocusNode: focusNode, - ); - - expect(focusNode.hasFocus, isFalse); - await _dismissMessageActionsPopover(tester); - expect(focusNode.hasFocus, isFalse); - }); - - testWidgets('does not restore composer focus after opening reactions', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - composerFocusNode: focusNode, - composerInitiallyFocused: true, - ); - - await tester.tap(find.byKey(const ValueKey('quick-reaction-more'))); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - expect(focusNode.hasFocus, isFalse); - }); - - testWidgets('does not restore composer focus after selecting an action', ( - tester, - ) async { - final focusNode = FocusNode(); - addTearDown(focusNode.dispose); - final prefs = await _mockPrefs(); - - await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - composerFocusNode: focusNode, - composerInitiallyFocused: true, - ); - - await tester.tap( - find.byKey(const ValueKey('message-action-followThread')), - ); - await tester.pumpAndSettle(); - expect(focusNode.hasFocus, isFalse); - }); - - testWidgets('runs an action after dismissal and can reopen', ( - tester, - ) async { - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - ); - - await tester.tap( - find.byKey(const ValueKey('message-action-followThread')), - ); - await tester.pumpAndSettle(); - - expect(harness.sourceHidden.value, isFalse); - expect(harness.container.read(threadFollowsProvider).followedRootIds, { - 'root-9', - }); - - await tester.tap( - find.byKey(const ValueKey('open-message-actions-popover')), - ); - await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('ignores repeat action taps once dismissal starts', ( - tester, - ) async { - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - launcherOnNestedRoute: true, - ); - final action = find.byKey(const ValueKey('message-action-followThread')); - final actionWidget = tester.widget(action); - - actionWidget.onTap!.call(); - actionWidget.onTap!.call(); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('message-actions-underlying-page')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-actions-root-page')), - findsNothing, - ); - expect(harness.container.read(threadFollowsProvider).followedRootIds, { - 'root-9', - }); - expect(tester.takeException(), isNull); - }); - - testWidgets('ignores repeat backdrop taps once dismissal starts', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - launcherOnNestedRoute: true, - ); - final backdrop = tester.widget( - find.byKey(const ValueKey('message-actions-backdrop')), - ); - - backdrop.onTap!.call(); - backdrop.onTap!.call(); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('message-actions-underlying-page')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-actions-root-page')), - findsNothing, - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('ignores repeat quick reactions once dismissal starts', ( - tester, - ) async { - final prefs = await _mockPrefs(); - late _FakeChannelActions actions; - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - launcherOnNestedRoute: true, - createChannelActions: (ref) => actions = _FakeChannelActions(ref), - ); - final reaction = find.byKey(const ValueKey('quick-reaction-\u{1F44D}')); - final detector = tester.widget( - find.descendant(of: reaction, matching: find.byType(GestureDetector)), - ); - - detector.onTap!.call(); - detector.onTap!.call(); - await tester.pumpAndSettle(); - - expect( - find.byKey(const ValueKey('message-actions-underlying-page')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-actions-root-page')), - findsNothing, - ); - expect(actions.reactions, [(eventId: 'msg-1', emoji: '\u{1F44D}')]); - expect(tester.takeException(), isNull); - }); - - testWidgets('fallback action rows grow with accessibility text', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(rootId: 'root-9'), - prefs: prefs, - textScaler: const TextScaler.linear(3), - ); - - final rowFinder = find.byKey( - const ValueKey('message-action-followThread'), - ); - expect(tester.getSize(rowFinder).height, greaterThan(48)); - expect(tester.takeException(), isNull); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('orders primary, utility, and destructive action groups', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - canManageMessage: true, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); - - const actionIds = [ - 'reply', - 'markUnread', - 'edit', - 'copyText', - 'copyLink', - 'remind', - 'followThread', - 'delete', - ]; - final actionTops = [ - for (final actionId in actionIds) - tester - .getTopLeft(find.byKey(ValueKey('message-action-$actionId'))) - .dy, - ]; - expect(actionTops, orderedEquals([...actionTops]..sort())); - expect( - find.byKey(const ValueKey('message-action-divider-utility')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-divider-destructive')), - findsOneWidget, - ); - - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('reduced motion presents the complete surface immediately', ( - tester, - ) async { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - disableAnimations: true, - ); - - expect( - find.byKey(const ValueKey('message-action-reaction-tray')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, - ); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - await _dismissMessageActionsPopover(tester); - }); - - testWidgets('keeps the snapshot alive through the reverse transition', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - (_) async => null, - ); - addTearDown( - () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - NativeMessagePresentation.channel, - null, - ), - ); - ui.Image? snapshot; - try { - final prefs = await _mockPrefs(); - await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - captureAnchorSnapshot: () async { - snapshot = await _testMessageSnapshot(); - return snapshot!; - }, - ); - - final image = snapshot!; - expect(image.debugDisposed, isFalse); - Navigator.of( - tester.element(find.byKey(const ValueKey('message-action-surface'))), - ).pop(); - await tester.pump(); - - expect( - find.byKey(const ValueKey('message-action-preview')), - findsOneWidget, - ); - expect(image.debugDisposed, isFalse); - await tester.pump(const Duration(milliseconds: 110)); - expect(image.debugDisposed, isFalse); - - await tester.pumpAndSettle(); - expect(image.debugDisposed, isTrue); - expect(tester.takeException(), isNull); - } finally { - final image = snapshot; - if (image != null && !image.debugDisposed) image.dispose(); - debugDefaultTargetPlatformOverride = null; - } - }); - - testWidgets('shows parity actions for a regular message', (tester) async { - final prefs = await _mockPrefs(); - await _pumpSheet(tester, message: _message(), prefs: prefs); - - expect(find.text('Copy text'), findsOneWidget); - expect(find.text('Copy link'), findsOneWidget); - expect(find.text('Mark unread'), findsOneWidget); - expect(find.text('Follow thread'), findsOneWidget); - // No thread context → no Reply fast action. - expect(find.text('Reply'), findsNothing); - // No signing identity → no reminders; no manage rights → no edit/delete. - expect(find.text('Remind me'), findsNothing); - expect(find.text('Edit message'), findsNothing); - expect(find.text('Delete message'), findsNothing); - expect(find.byTooltip('Close sheet'), findsNothing); - expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); - expect( - find.byWidgetPredicate( - (widget) => - widget.key is ValueKey && - (widget.key! as ValueKey).value.startsWith( - 'quick-reaction-', - ), - ), - findsNWidgets(6), - ); - expect( - tester.getSize(find.byKey(const ValueKey('quick-reaction-\u{1F44D}'))), - const Size.square(52), - ); - }); - - testWidgets('promotes Reply, Copy link, and Remind me to the fast-actions ' - 'row', (tester) async { - final prefs = await _mockPrefs(); - await _pumpSheet( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); - - expect(find.text('Reply'), findsOneWidget); - expect(find.text('Copy link'), findsOneWidget); - expect(find.text('Remind me'), findsOneWidget); - // Promoted actions no longer appear under their old list-row labels. - expect(find.text('Reply in thread'), findsNothing); - expect(find.text('Remind me later'), findsNothing); - }); - - testWidgets('hides utility actions for system messages', (tester) async { - final prefs = await _mockPrefs(); - await _pumpSheet(tester, message: _message(isSystem: true), prefs: prefs); - - expect(find.text('Copy text'), findsNothing); - expect(find.text('Copy link'), findsNothing); - expect(find.text('Mark unread'), findsNothing); - expect(find.text('Follow thread'), findsNothing); - expect(find.text('Reply'), findsNothing); - expect(find.text('Remind me'), findsNothing); - expect(find.byTooltip('Close sheet'), findsNothing); - expect( - find.byWidgetPredicate( - (widget) => - widget.key is ValueKey && - (widget.key! as ValueKey).value.startsWith( - 'quick-reaction-', - ), - ), - findsNWidgets(6), + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'quick-reaction-', + ), + ), + findsNWidgets(6), ); }); @@ -1735,71 +932,20 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('keeps the reaction popover on-screen when neither side fits', ( + testWidgets('system messages use a dismissible reaction sheet', ( tester, ) async { - tester.view.physicalSize = const Size(320, 240); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - const anchorRect = Rect.fromLTWH(40, 60, 240, 100); - const safeTop = 24.0; - const visibleBottom = 200.0; - const trayHeight = 68.0; - const trayGap = Grid.xxs; - final prefs = await _mockPrefs(); - expect(anchorRect.top - trayGap - trayHeight, lessThan(safeTop)); - expect( - anchorRect.bottom + trayGap + trayHeight, - greaterThan(visibleBottom), - ); - - await tester.pumpWidget( - ProviderScope( - overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: MaterialApp( - theme: AppTheme.light(), - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: const EdgeInsets.only(top: safeTop), - viewInsets: const EdgeInsets.only(bottom: 40), - ), - child: child!, - ), - home: Scaffold( - body: Consumer( - builder: (context, ref, _) => TextButton( - onPressed: () => showMessageActions( - context: context, - ref: ref, - message: _message(isSystem: true), - channelId: _channelId, - canManageMessage: false, - anchorRect: anchorRect, - ), - child: const Text('open popover'), - ), - ), - ), - ), - ), - ); - await tester.tap(find.text('open popover')); - await tester.pumpAndSettle(); - - final trayRect = tester.getRect( - find.byKey(const ValueKey('reaction-popover-tray')), + await _pumpSheet( + tester, + message: _message(isSystem: true), + prefs: await _mockPrefs(), + anchorRect: const Rect.fromLTWH(20, 20, 100, 100), ); - expect(trayRect.top, greaterThanOrEqualTo(safeTop)); - expect(trayRect.bottom, lessThanOrEqualTo(visibleBottom)); - - // Close the popover so its presentation guard does not block later - // tests. - Navigator.of( - tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), - ).pop(); + expect(find.byType(BottomSheet), findsOneWidget); + expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget); + await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); }); testWidgets('shows Edit/Delete only with manage rights', (tester) async { @@ -1959,7 +1105,7 @@ void main() { }); } - testWidgets('the popover uses channel catch-up without a profile', ( + testWidgets('the anchored sheet uses channel catch-up without a profile', ( tester, ) async { await _pumpMessageActionsPopover( diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 1670268a060..3fc32577308 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/shared/emoji/emoji_data_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'package:buzz/shared/widgets/native_message_presentation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -111,6 +112,130 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { } void main() { + testWidgets('removing the sole reaction closes the sheet', (tester) async { + final reactions = ValueNotifier>([ + _reaction(reactedByCurrentUser: true), + ]); + addTearDown(reactions.dispose); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + ], + child: Builder( + builder: (context) => TextButton( + onPressed: () => showReactionDetailSheet( + context: context, + channelId: 'channel', + reactions: reactions, + onToggle: (_) => reactions.value = [], + ), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('reaction-details-sheet')), findsNothing); + expect(find.text('Open'), findsOneWidget); + }); + + testWidgets('a stale toggle for an absent emoji does nothing', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + child: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => toggleReaction( + ref, + TimelineMessage( + id: 'message', + pubkey: _alice, + createdAt: 1, + content: '', + ), + _fire, + ), + child: const Text('Toggle'), + ), + ), + ), + ); + await tester.tap(find.text('Toggle')); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'native toggles use the current callback and stream updated counts', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final completion = Completer>(); + Map? payload; + Map? updated; + final toggles = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + return completion.future; + } + if (call.method == 'updateReactions') updated = call.arguments as Map; + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpRow( + tester, + reactions: [_reaction()], + onToggle: (emoji) => toggles.add('old:$emoji'), + ); + await tester.longPress( + find.byKey(const ValueKey('reaction-pill-$_fire')), + ); + await tester.pumpAndSettle(); + await _pumpRow( + tester, + reactions: [_reaction(count: 2, reactedByCurrentUser: true)], + onToggle: (emoji) => toggles.add('new:$emoji'), + ); + expect((updated!['reactions'] as List).single, containsPair('count', 2)); + expect( + (updated!['reactions'] as List).single, + containsPair('selected', true), + ); + expect(updated!['requestId'], payload!['requestId']); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + NativeMessagePresentation.channel.name, + const StandardMethodCodec().encodeMethodCall( + MethodCall('toggleReaction', { + 'requestId': payload!['requestId'], + 'emoji': _fire, + }), + ), + (_) {}, + ); + await tester.pump(); + expect(toggles, ['new:$_fire']); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + expect(updated!['reactions'], isEmpty); + completion.complete({}); + await tester.pumpAndSettle(); + debugDefaultTargetPlatformOverride = null; + }, + ); + testWidgets( 'native reactor labels retain channel qualifications and live updates', (tester) async { @@ -304,7 +429,7 @@ void main() { ); testWidgets( - 'holding a pill opens native membership with the selected emoji without toggling', + 'holding a pill opens native membership with all reactions without toggling', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -341,8 +466,14 @@ void main() { await tester.pumpAndSettle(); expect(toggles, 0); expect(messageHolds, 0); - expect(payload!['initialEmoji'], _eyes); + expect(payload!.containsKey('initialEmoji'), isFalse); + expect( + ((payload!['reactions'] as List).first as Map)['selected'], + isFalse, + ); expect((payload!['reactions'] as List).length, 2); + expect(((payload!['reactions'] as List).first as Map)['burst'], isTrue); + expect(((payload!['reactions'] as List).last as Map)['burst'], isFalse); final profiles = payload!['profiles'] as Map; expect((profiles[_alice] as Map)['name'], 'Alice'); expect(find.byType(BottomSheet), findsNothing); @@ -371,54 +502,39 @@ void main() { find.descendant(of: pill, matching: find.text('1')), ); await tester.pumpAndSettle(); - expect(find.text('Reactions'), findsNothing); - expect(find.text('All 2'), findsOneWidget); - final controller = DefaultTabController.of( - tester.element(find.byType(TabBar)), - ); - expect(controller.index, 2); - expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsOneWidget, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsNothing, - ); - await tester.tap(find.byKey(const ValueKey('reaction-filter-all'))); - await tester.pumpAndSettle(); - expect(controller.index, 0); - expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsOneWidget, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsOneWidget, - ); - await tester.tap(find.byKey(const ValueKey('reaction-filter-$_fire'))); - await tester.pumpAndSettle(); - expect(controller.index, 1); + expect(find.text('Reactions'), findsOneWidget); + expect(find.text('All 2'), findsNothing); + for (final emoji in [_fire, _eyes]) { + expect( + find.byKey(ValueKey('reactor-$_alice-$emoji')).hitTestable(), + findsOneWidget, + ); + } expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsNothing, + tester + .widget( + find.byKey(const ValueKey('reaction-toggle-$_fire')), + ) + .selected, + isTrue, ); expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsOneWidget, - ); - await tester.drag(find.byType(TabBarView), const Offset(-500, 0)); + tester + .widget( + find.byKey(const ValueKey('reaction-toggle-$_eyes')), + ) + .selected, + isFalse, + ); + await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire'))); await tester.pumpAndSettle(); - expect(controller.index, 2); - expect( - find.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsOneWidget, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsNothing, - ); - expect(toggles, 0); + expect(toggles, 1); + for (final emoji in [_fire, _eyes]) { + expect( + find.byKey(ValueKey('reactor-$_alice-$emoji')).hitTestable(), + findsOneWidget, + ); + } expect(messageHolds, 0); await tester.tapAt(const Offset(20, 20)); await tester.pumpAndSettle(); @@ -533,12 +649,42 @@ void main() { await tester.pumpAndSettle(); expect(find.byTooltip(':fire:'), findsOneWidget); - expect(find.byType(TabBar), findsOneWidget); - expect(find.text('All 1'), findsOneWidget); + expect(find.byType(FilterChip), findsOneWidget); + expect(find.text('All 1'), findsNothing); expect(find.text('Alice'), findsOneWidget); }); }); + for (final entry in [ + (emoji: _fire, selected: false, bursts: true), + (emoji: _fire, selected: true, bursts: false), + (emoji: _eyes, selected: false, bursts: false), + ]) { + testWidgets('sheet burst for ${entry.emoji}, selected=${entry.selected}', ( + tester, + ) async { + final container = await _pumpRow( + tester, + reactions: [ + _reaction(emoji: entry.emoji, reactedByCurrentUser: entry.selected), + ], + ); + final controller = container.read(emojiBurstControllerProvider); + await tester.longPress( + find.byKey(ValueKey('reaction-pill-${entry.emoji}')), + ); + await tester.pumpAndSettle(); + final chip = find.byKey(ValueKey('reaction-toggle-${entry.emoji}')); + expect(tester.widget(chip).side!.width, 1); + await tester.tap(chip); + await tester.pump(); + expect(controller.hasParticles, entry.bursts); + if (entry.bursts) { + expect(controller.debugLastBurstOrigin, tester.getCenter(chip)); + } + }); + } + group('reaction bursts', () { testWidgets('adding a positive reaction bursts from the pill', ( tester, diff --git a/mobile/test/shared/widgets/app_list_test.dart b/mobile/test/shared/widgets/app_list_test.dart index db493a3f9b3..d0a2b7a4a75 100644 --- a/mobile/test/shared/widgets/app_list_test.dart +++ b/mobile/test/shared/widgets/app_list_test.dart @@ -93,7 +93,7 @@ void main() { final scheme = AppTheme.light().colorScheme; // Derived from the text color, not the scheme's border tokens — those sit // within a few levels of the card fill and vanish. - expect(divider.color, scheme.onSurface.withValues(alpha: 0.12)); + expect(divider.color, scheme.onSurface.withValues(alpha: 0.07)); // The indent is painted inside the divider's box, so the line starts at // the box's left edge plus the indent — level with the row titles. diff --git a/mobile/test/shared/widgets/frosted_scaffold_test.dart b/mobile/test/shared/widgets/frosted_scaffold_test.dart index 998febfdec5..c77322ff950 100644 --- a/mobile/test/shared/widgets/frosted_scaffold_test.dart +++ b/mobile/test/shared/widgets/frosted_scaffold_test.dart @@ -12,6 +12,43 @@ Border? _appBarBorder(WidgetTester tester) { } void main() { + testWidgets('foreground paints above header and leaves body interactive', ( + tester, + ) async { + var taps = 0; + const marker = Text('Foreground date'); + const bar = FrostedAppBar(title: Text('Header')); + await tester.pumpWidget( + MaterialApp( + home: FrostedScaffold( + appBar: bar, + body: Center( + child: TextButton( + onPressed: () => taps++, + child: const Text('Body action'), + ), + ), + foreground: const Stack( + children: [Positioned(top: 80, left: 0, child: marker)], + ), + ), + ), + ); + final stack = tester.widget( + find.ancestor(of: find.byWidget(bar), matching: find.byType(Stack)).first, + ); + expect(stack.children.indexOf(bar), lessThan(stack.children.length - 1)); + expect( + find.descendant( + of: find.byWidget(stack.children.last), + matching: find.byWidget(marker), + ), + findsOneWidget, + ); + await tester.tap(find.text('Body action')); + expect(taps, 1); + }); + testWidgets('utility pages expose the inverted surface hierarchy', ( tester, ) async { From 40c1c474e74686cbd3218866ee926211cb3365db Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 13 Oct 2026 14:45:59 -0400 Subject: [PATCH 06/13] fix(relay): hide deletions of author-only events from other readers (#8197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Opened by an agent (Hayt) for Will Pfleger. The relay keeps `AUTHOR_ONLY_KINDS` (event reminders `30300`, push leases `30350`, private managed agents `30179`) readable only by their author, but a NIP-09 deletion request (`kind:5`) for one of those events was readable by any community reader. The deletion carries no `h` tag, so ingest stores it with its target's channel (none), and every read gate classified it by its own kind, `5`. Another reader could read and count it by author, by id, live, or over HTTP, learning the deleted ids, how many, and when. A deletion is now exactly as private as the event it deletes: - **Classification by the deletion's own `k` tag.** `buzz_core::kind::is_author_only_event_kind` treats a `kind:5` that names an author-only kind in a `k` tag as author-only. Only the canonical decimal spelling counts (`"30300"`, not `"030300"` or `"+30300"`), so Rust classification, ingest and the admin SQL (which matches the tag text) always agree. Because it reads the deletion, not a target lookup, the deletion stays private after its target is soft-deleted or gone. It is generic over `AUTHOR_ONLY_KINDS`, so a kind added to the list later (for example `44300`) is covered automatically. - **Every read surface.** The shared per-event gate (`is_author_only_event`, used by WS REQ historical and lookup by id, COUNT fallbacks, HTTP `/query`, `/count`, search hydration and the thread window), live fan-out in `handlers/event.rs`, the COUNT fast-path guard (`filter_can_match_author_only_kinds` now treats any filter that can match `kind:5`, including unfiltered and id-only filters, as needing per-event filtering), the channel-window aux closure in the bridge, and admin event preview plus report list and detail (SQL suppression of `kind:5` rows with a tag whose first two elements are `k` and an author-only kind, matching the Rust classifier; soft-deleted rows included). - **Enforcement still reaches the author.** The admin report query takes the private-deletion `k` list from its caller next to the hidden kinds. HTTP-facing reads pass both; `admin_get_report_for_enforcement` passes neither, so report-directed bans, timeouts and action recovery still resolve the stored author of a reported private deletion. - **Ingest agreement.** `validate_standard_deletion_event` rejects a deletion of a stored (including soft-deleted) author-only event, by `e` or by `a` coordinate, unless it carries the matching canonical `["k",""]`. It also rejects an author-only `k` on a deletion of any other kind, so a public deletion can't hide itself from the readers who need it. A deletion whose target is itself a private deletion is rejected with `invalid: cannot delete a private deletion request`: it would otherwise be stored as a public event revealing the private deletion's id and timing, and NIP-09 gives a deletion of a deletion no effect. A deletion of a public deletion is unchanged. The `k` tag only narrows who can read the request; it grants no one new deletion authority. - **Unauthorized deletions learn nothing about the target.** On every delete-by-id path, no target-dependent error (its kind, the artifact and `k`-marker errors, the artifact-removal-marker error, or where it lives) is returned until every access check that could block the deletion has passed. A sender who fails any of them gets `invalid: deletion target not found or not deletable by you`, the same text and status as for a missing target. Errors that depend only on the request, never on the target, are unchanged: for example a `kind:9005` whose own `h` channel is archived still gets `channel is archived`, for a missing target and a real one alike. "May not delete" includes an author who has since been removed from the target's private channel, an author whose token is scoped to a different channel, and anyone deleting in an archived channel. The single-target rule (exactly one `e` or `a` tag) now runs before any target lookup, so every target faces the gates. For `kind:5` with an `e` target, ingest decides authority (effective author or the owning human of the authoring agent) first, then takes the channel from that same lookup, soft-deleted targets included, so tombstones face the same gates; an authorized re-deletion of a soft-deleted target now keeps the target's stored channel instead of being treated as global, so historical access checks, `#h` filtering and live routing all use that channel: readers outside a private channel no longer see it, and that channel's members and subscribers do. The token channel scope, scoped-token global-event, membership and archived checks then reject with the shared denial, all before `validate_standard_deletion_event` returns its artifact and `k`-marker errors. Other kinds, and `kind:5` with only `a` coordinates, keep their existing errors. For NIP-29 `kind:9005`, a target outside the `h` channel is denied like a missing one, so the relay no longer returns "different channel" or "no channel"; `may_delete_channel_event` (the author while still a member or the channel is open, a channel owner/admin, or the owning human of the author agent) is then checked in the `h` channel before the artifact-removal-marker error. No access restriction is removed and nothing becomes newly deletable. An authorized sender who names the wrong `h` channel for a `kind:9005` now gets the shared denial. Whether kind:5 content is full-text indexed depends on how the database was built. The desired-state schema (`schema/schema.sql`, which CI applies) and databases created before migration `0008` index it; an empty database initialized through `0008` gets a positive allowlist that doesn't. Where it is indexed, the per-event gate on search hydration is what keeps a private deletion out of other readers' search results. No current client is affected by the new rejection. `buzz-app`'s signer only allows `kind:5` for channel messages (with `h`) and managed-agent `30177` coordinates, and neither `buzz-app` nor this repo's desktop or CLI publishes deletions for `30300`, `30350` or `30179` today. NIP-ER already requires `k=30300` on reminder deletions. Coverage: - `crates/buzz-test-client/tests/e2e_author_only_deletion.rs` (Backend Integration job): a non-author gets nothing from WS REQ, WS COUNT, HTTP `/query` and `/count` (by author, mixed kinds, and id), search, or live delivery, while the author gets the deletion. Search runs WS and HTTP against a private deletion and a public control deletion sharing a reason word; with `BUZZ_TEST_SEARCH_INDEXES_DELETIONS=1` (set by the CI step) the author must get exactly both and another reader exactly the control; ingest rejects a missing, wrong, non-canonical, or public-target marker, and an untagged deletion of an already soft-deleted target. - `e2e_author_only_deletion.rs` also sends one non-owner's deletions over HTTP and WS for a missing, live, soft-deleted, private-channel and global target, plus the `kind:9005` equivalents including a target in another channel and one with no channel, and requires the identical rejection text and HTTP status in every case; another member's deletion of a private event never names its kind. - `e2e_author_only_deletion.rs` `removed_author_deletion_does_not_reveal_target`: an author removed from a private channel gets the missing-target rejection over HTTP and WS for `kind:5` and `kind:9005` (with `h` set to a channel they can write to) against their retained message; the channel owner can still delete the removed author's message, and the author can still delete in a channel they can write to. - `e2e_author_only_deletion.rs` `archived_target_deletion_does_not_reveal_target`: over HTTP and WS, `kind:5` against a message, the same message with a wrong `k`, and an artifact in an archived channel, and `kind:9005` against the archived-channel message with `h` set to an active channel, all match the missing-target rejection; a `kind:9005` for an artifact-removal marker in the archived channel matches a missing target with the same `h`; in an active channel the owner still gets the `k`-mismatch and artifact errors. - `e2e_author_only_deletion.rs` `deletion_of_private_deletion_rejected`: over HTTP and WS, the author's deletion of their private deletion, with no `k` and with `k=5`, gets the rejection and stores nothing; a non-author's gets the missing-target rejection; a `kind:9005` against it gets the missing-target rejection; the private deletion stays hidden from another reader in WS REQ and COUNT, HTTP `/query` and `/count`, and live delivery; a deletion of a public deletion is still accepted. - `e2e_author_only_deletion.rs` `soft_deleted_target_keeps_its_channel`: an author removed from a private channel, and an author in an archived channel, re-deleting their own soft-deleted message get the missing-target rejection; a distinct successful re-deletion is readable by a channel member and not by an outsider, by id and by `#h`, and is delivered live to the member's `#h` subscription but not to the outsider's. - `e2e_author_only_deletion.rs` `multi_target_deletion_error_ignores_targets`: deletions with two `e` tags, or an `e` and an `a` tag, get the identical single-target error whether the first target is missing, live, in another private channel, a private reminder or an artifact. - `handlers::ingest::postgres_tests::scoped_token_deletion_rejection_does_not_reveal_target`: a non-owner and the target's author, each with a token scoped to another channel, get the same rejection for a missing, live, soft-deleted and global target, and the author's `kind:9005` naming the token's channel gets it for a missing target, a live target in another channel and a global target; a token scoped to the target's channel still deletes, and its re-deletion of the soft-deleted target is stored with that target's channel. - `thread_window::postgres_tests`: stored private deletions never reach a non-signer through the thread-window or channel-window aux closures, or by id once their target is gone. - `api::admin::postgres_tests`: ban and stranded-ban recovery on a reported private deletion reach the stored author while report detail hides it; `admin_moderation` tests treat non-canonical markers as public. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- .github/workflows/_ci-relay.yml | 5 +- Justfile | 2 +- crates/buzz-core/src/kind.rs | 64 + crates/buzz-db/src/store/admin_moderation.rs | 194 ++- crates/buzz-relay/src/api/admin/mod.rs | 61 +- crates/buzz-relay/src/api/bridge.rs | 9 +- .../bridge/thread_window/postgres_tests.rs | 61 + crates/buzz-relay/src/handlers/event.rs | 7 +- crates/buzz-relay/src/handlers/ingest.rs | 297 ++++- crates/buzz-relay/src/handlers/req.rs | 32 +- .../buzz-relay/src/handlers/side_effects.rs | 211 +-- .../tests/e2e_author_only_deletion.rs | 1128 +++++++++++++++++ scripts/run-tests.sh | 3 +- 13 files changed, 1878 insertions(+), 196 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_author_only_deletion.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index b6cab48aaf1..dff24c5f75d 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -110,6 +110,7 @@ jobs: -p buzz-test-client \ --lib \ --test e2e_event_reminder \ + --test e2e_author_only_deletion \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Build PostgreSQL test archive if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact == 'postgres-tests' @@ -580,10 +581,12 @@ jobs: run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder)' \ + -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion)' \ --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + # schema/schema.sql (applied above) indexes kind:5 content. + BUZZ_TEST_SEARCH_INDEXES_DELETIONS: "1" - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/Justfile b/Justfile index 04c6e46e700..3e7ead15284 100644 --- a/Justfile +++ b/Justfile @@ -528,7 +528,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 332e97d9589..db05ea922f1 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -132,6 +132,35 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ KIND_PRIVATE_MANAGED_AGENT, ]; +/// Returns `true` if the event is readable only by its author: an +/// [`AUTHOR_ONLY_KINDS`] event, or a NIP-09 deletion whose `k` tag names one. +/// +/// A deletion request reveals its targets' ids and deletion timing, so it +/// inherits the target's privacy. It is classified by its own `k` tag, not by +/// a target lookup, so it stays private after the target is gone; ingest +/// rejects a deletion of a stored author-only event without that tag. +pub fn is_author_only_event_kind(event: &nostr::Event) -> bool { + let kind = event_kind_u32(event); + if AUTHOR_ONLY_KINDS.contains(&kind) { + return true; + } + kind == KIND_DELETION && k_tag_kinds(event).any(|k| AUTHOR_ONLY_KINDS.contains(&k)) +} + +/// Kinds named by an event's well-formed `["k", ""]` tags. +/// +/// Only canonical decimal values count (`"30300"`, not `"030300"` or +/// `"+30300"`), so this agrees with SQL that matches the tag value as text. +pub fn k_tag_kinds(event: &nostr::Event) -> impl Iterator + '_ { + event.tags.iter().filter_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "k" => value + .parse::() + .ok() + .filter(|kind| kind.to_string() == *value), + _ => None, + }) +} + /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's /// `#p` tag to receive the event. This closes the kindless `{ids:[…]}` read @@ -972,6 +1001,41 @@ mod tests { .unwrap() } + #[test] + fn deletion_privacy_follows_its_k_tag() { + for &kind in AUTHOR_ONLY_KINDS { + let k = kind.to_string(); + assert!(is_author_only_event_kind(&make_event_of_kind(kind, &[]))); + assert!(is_author_only_event_kind(&make_event_of_kind( + KIND_DELETION, + &[&["e", &"ab".repeat(32)], &["k", &k]] + ))); + assert!(is_author_only_event_kind(&make_event_of_kind( + KIND_DELETION, + &[&["k", "1"], &["k", &k]] + ))); + } + for tags in [ + &[][..], + &[&["k", "1"][..]][..], + &[&["k", "not-a-kind"][..]][..], + // Non-canonical spellings are not markers; SQL matches text. + &[&["k", "030300"][..]][..], + &[&["k", "+30300"][..]][..], + &[&["k", " 30300"][..]][..], + ] { + assert!(!is_author_only_event_kind(&make_event_of_kind( + KIND_DELETION, + tags + ))); + } + // A `k` tag classifies only deletions. + assert!(!is_author_only_event_kind(&make_event_of_kind( + 1, + &[&["k", &KIND_EVENT_REMINDER.to_string()]] + ))); + } + fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { make_event_of_kind(KIND_PERSONA, tags) } diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs index 1c7de25cc0f..7ff6eabdfd7 100644 --- a/crates/buzz-db/src/store/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -31,6 +31,13 @@ fn admin_hidden_kinds() -> Vec { .collect() } +/// `k` values that make a NIP-09 deletion as private as the author-only event +/// it deletes (see `buzz_core::kind::is_author_only_event_kind`). Admin reads +/// hide such deletions with `NOT (kind = 5 AND tags name one of these)`. +fn private_deletion_k_values() -> Vec { + AUTHOR_ONLY_KINDS.iter().map(u32::to_string).collect() +} + fn bounded_limit(limit: i64) -> i64 { limit.clamp(1, MAX_PAGE_SIZE) } @@ -225,6 +232,9 @@ pub async fn list_reports( AND e.community_id = r.community_id AND e.id = r.target_event_id AND e.kind <> ALL($10) + AND NOT (e.kind = 5 AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(e.tags) t + WHERE t->>0 = 'k' AND t->>1 = ANY($11::text[]))) ORDER BY e.created_at DESC LIMIT 1 ) target ON TRUE @@ -249,17 +259,20 @@ pub async fn list_reports( .bind(cursor_id) .bind(bounded_limit(limit)) .bind(admin_hidden_kinds()) + .bind(private_deletion_k_values()) .fetch_all(pool) .await?; rows.into_iter().map(row_to_report).collect() } /// Fetch one report globally by its row id, including its event target content. -/// Targets whose kind is in `hidden_kinds` come back as `message: None`. +/// Targets whose kind is in `hidden_kinds`, and deletions whose `k` tag names +/// one of `private_deletion_ks`, come back as `message: None`. async fn get_report( pool: &PgPool, report_id: Uuid, hidden_kinds: &[i32], + private_deletion_ks: &[String], ) -> Result> { let row = sqlx::query( r#" @@ -294,6 +307,9 @@ async fn get_report( AND e.community_id = r.community_id AND e.id = r.target_event_id AND e.kind <> ALL($2) + AND NOT (e.kind = 5 AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(e.tags) t + WHERE t->>0 = 'k' AND t->>1 = ANY($3::text[]))) ORDER BY e.created_at DESC LIMIT 1 ) target ON TRUE @@ -314,6 +330,7 @@ async fn get_report( ) .bind(report_id) .bind(hidden_kinds) + .bind(private_deletion_ks) .fetch_optional(pool) .await?; row.map(|row| { @@ -535,6 +552,9 @@ pub async fn get_event_preview( SELECT id, pubkey, kind, content, created_at, deleted_at, channel_id FROM events WHERE community_id = $1 AND id = $2 AND kind <> ALL($3) + AND NOT (kind = 5 AND EXISTS ( + SELECT 1 FROM jsonb_array_elements(tags) t + WHERE t->>0 = 'k' AND t->>1 = ANY($4::text[]))) ORDER BY created_at DESC LIMIT 1 "#, @@ -542,6 +562,7 @@ pub async fn get_event_preview( .bind(community_id) .bind(id) .bind(admin_hidden_kinds()) + .bind(private_deletion_k_values()) .fetch_optional(pool) .await?; row.map(|row| { @@ -591,7 +612,13 @@ impl Db { /// back as `message: None`, exactly like a missing event. #[datastore_span(name = "admin_get_report", system = "postgresql")] pub async fn admin_get_report(&self, id: Uuid) -> Result> { - get_report(&self.pool, id, &admin_hidden_kinds()).await + get_report( + &self.pool, + id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await } /// Fetch one report with its target unfiltered, for report enforcement and @@ -602,7 +629,7 @@ impl Db { &self, id: Uuid, ) -> Result> { - get_report(&self.pool, id, &[]).await + get_report(&self.pool, id, &[], &[]).await } /// List feedback for the deployment-global read-only admin plane. @@ -787,10 +814,15 @@ mod postgres_tests { .await; let report_id = insert_event_report(&pool, report_community, &event_id).await; - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); let message = detail.message.expect("reported message exists"); assert_eq!(message.content, "reported message"); assert_eq!(message.author_pubkey, hex::encode([5_u8; 32])); @@ -912,6 +944,52 @@ mod postgres_tests { insert_event_report(&pool, community_id, &event_id).await, )); } + // Deletions of author-only events are private by their `k` tag alone, + // live or soft-deleted; a deletion with a public or non-canonical `k` + // stays readable, matching `buzz_core::kind::k_tag_kinds`. + // An unrelated tag that merely contains `"k"` and an author-only kind + // is not a marker: matching is positional, as in Rust. + let mut public_deletions = Vec::new(); + let cases = AUTHOR_ONLY_KINDS + .iter() + .map(|k| (format!(r#"[["k","{k}"]]"#), true)) + .chain( + [ + r#"[["k","1"]]"#, + r#"[["k","030300"]]"#, + r#"[["k","+30300"]]"#, + r#"[["k","1"],["meta","k","30300"]]"#, + ] + .map(|tags| (tags.to_string(), false)), + ); + for (i, (tags, private)) in cases.enumerate() { + let event_id = vec![0x60 + i as u8; 32]; + sqlx::query( + r#" + INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at) + VALUES ($1, $2, $3, now(), 5, $4::jsonb, '', $5, CASE WHEN $6 THEN now() END) + "#, + ) + .bind(community_id) + .bind(&event_id) + .bind(author) + .bind(&tags) + .bind(vec![3_u8; 64]) + .bind(i % 2 == 1) + .execute(&pool) + .await + .expect("insert deletion event"); + let report = insert_event_report(&pool, community_id, &event_id).await; + let preview = get_event_preview(&pool, community_id, &event_id) + .await + .expect("preview"); + assert_eq!(preview.is_none(), private, "preview, deletion tags {tags}"); + if private { + hidden.push((5, report)); + } else { + public_deletions.push((tags, report)); + } + } let visible_id = vec![0x3f_u8; 32]; insert_event(&pool, community_id, &visible_id, &author, "public", None).await; let visible = insert_event_report(&pool, community_id, &visible_id).await; @@ -939,17 +1017,44 @@ mod postgres_tests { }; for &(kind, report_id) in &hidden { assert_eq!(author_of(report_id), None, "list, kind {kind}"); - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); assert!(detail.message.is_none(), "detail, kind {kind}"); } assert_eq!(author_of(visible), Some(hex::encode(author))); - let detail = get_report(&pool, visible, &admin_hidden_kinds()) + for (tags, report_id) in public_deletions { + assert_eq!( + author_of(report_id), + Some(hex::encode(author)), + "list, deletion tags {tags}" + ); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) .await .expect("query report") .expect("report exists"); + assert!(detail.message.is_some(), "detail, deletion tags {tags}"); + } + let detail = get_report( + &pool, + visible, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); assert_eq!(detail.message.expect("message shown").content, "public"); sqlx::query("DELETE FROM events WHERE community_id = $1") @@ -967,10 +1072,15 @@ mod postgres_tests { let community_id = insert_community(&pool, "pubkey-target").await; let report_id = insert_pubkey_report(&pool, community_id).await; - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); assert_eq!(detail.report.target_kind, "pubkey"); assert!(detail.message.is_none()); @@ -985,10 +1095,15 @@ mod postgres_tests { let missing_event_id = vec![8_u8; 32]; let report_id = insert_event_report(&pool, community_id, &missing_event_id).await; - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); assert_eq!(detail.report.target_kind, "event"); assert_eq!(detail.report.target, hex::encode(missing_event_id)); assert!(detail.message.is_none()); @@ -1052,10 +1167,15 @@ mod postgres_tests { .await; set_report_status(&pool, community_id, report_id, "dismissed").await; - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); assert_eq!(detail.report.status, "dismissed"); let action = detail .active_action @@ -1092,10 +1212,15 @@ mod postgres_tests { .await; set_report_status(&pool, community_id, report_id, "resolved").await; - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); let action = detail.active_action.expect("an action surfaces"); assert_eq!( action.id, @@ -1126,10 +1251,15 @@ mod postgres_tests { ) .await; - let detail = get_report(&pool, report_id, &admin_hidden_kinds()) - .await - .expect("query report") - .expect("report exists"); + let detail = get_report( + &pool, + report_id, + &admin_hidden_kinds(), + &private_deletion_k_values(), + ) + .await + .expect("query report") + .expect("report exists"); assert!( detail.active_action.is_none(), "reopen audit row must not surface as an enforcement action" diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 20935e9fed5..b5cfa1ef490 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -7308,9 +7308,30 @@ mod postgres_tests { assert!(banned, "the stored author must be banned"); } - /// Seed an event report in the `admin.example` community whose target is a - /// hidden (shared-gated) kind authored by `author`. Returns the report id. - async fn seed_hidden_kind_event_report(pool: &sqlx::PgPool, author: &[u8]) -> Uuid { + /// Targets admin reads must hide but enforcement must still reach: a + /// hidden (shared-gated) kind, and a deletion of an author-only event, + /// which is private by its `k` tag alone. + fn hidden_targets() -> [(i32, serde_json::Value); 2] { + [ + ( + buzz_core::kind::SHARED_GATED_KINDS[0] as i32, + serde_json::json!([["shared", "true"]]), + ), + ( + buzz_core::kind::KIND_DELETION as i32, + serde_json::json!([["k", buzz_core::kind::KIND_EVENT_REMINDER.to_string()]]), + ), + ] + } + + /// Seed an event report in the `admin.example` community whose target is + /// authored by `author` and stored with `kind` and `tags`. Returns the + /// report id. + async fn seed_hidden_kind_event_report( + pool: &sqlx::PgPool, + author: &[u8], + (kind, tags): &(i32, serde_json::Value), + ) -> Uuid { let host_report = seed_admin_host_report(pool, "open").await; let community_id: Uuid = sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1") @@ -7321,16 +7342,14 @@ mod postgres_tests { cleanup_admin_host_report(pool, host_report).await; let (report_id, _channel_id, target_event_id) = e2e_event_report_with_author(pool, community_id, author).await; - sqlx::query( - r#"UPDATE events SET kind = $3, tags = '[["shared","true"]]'::jsonb - WHERE community_id = $1 AND id = $2"#, - ) - .bind(community_id) - .bind(target_event_id.as_slice()) - .bind(buzz_core::kind::SHARED_GATED_KINDS[0] as i32) - .execute(pool) - .await - .expect("make target a hidden kind"); + sqlx::query("UPDATE events SET kind = $3, tags = $4 WHERE community_id = $1 AND id = $2") + .bind(community_id) + .bind(target_event_id.as_slice()) + .bind(kind) + .bind(tags) + .execute(pool) + .await + .expect("make target a hidden kind"); report_id } @@ -7339,11 +7358,17 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres — resolve ban on a hidden-kind target bans the author, detail stays hidden"] async fn resolve_ban_on_hidden_kind_target_bans_author_without_revealing_it() { + for target in hidden_targets() { + resolve_ban_on_hidden_target(&target).await; + } + } + + async fn resolve_ban_on_hidden_target(target: &(i32, serde_json::Value)) { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; let pool = e2e_pool().await; let author = nostr::Keys::generate().public_key().to_bytes().to_vec(); - let report_id = seed_hidden_kind_event_report(&pool, &author).await; + let report_id = seed_hidden_kind_event_report(&pool, &author, target).await; let path = format!("/reports/{report_id}/resolve"); let body = serde_json::json!({ "action": "ban", "requestId": Uuid::new_v4() }).to_string(); @@ -7424,9 +7449,15 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres — stranded ban on a hidden-kind target recovers"] async fn stranded_ban_on_hidden_kind_target_recovers_via_worker() { + for target in hidden_targets() { + stranded_ban_on_hidden_target_recovers(&target).await; + } + } + + async fn stranded_ban_on_hidden_target_recovers(target: &(i32, serde_json::Value)) { let pool = e2e_pool().await; let author = nostr::Keys::generate().public_key().to_bytes().to_vec(); - let report_id = seed_hidden_kind_event_report(&pool, &author).await; + let report_id = seed_hidden_kind_event_report(&pool, &author, target).await; let (community_id, channel_id): (Uuid, Option) = sqlx::query_as("SELECT community_id, channel_id FROM moderation_reports WHERE id = $1") .bind(report_id) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index d631df75003..bd39ada46e2 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -686,6 +686,7 @@ async fn handle_channel_window_filter( raw: &Value, filter: &nostr::Filter, accessible_channels: &[uuid::Uuid], + reader_pubkey_bytes: &[u8], events: &mut Vec, ) -> Result<(), (StatusCode, Json)> { use buzz_core::kind::{KIND_THREAD_SUMMARY, KIND_WINDOW_BOUNDS}; @@ -790,7 +791,12 @@ async fn handle_channel_window_filter( } // Deletions can be stored channel-less; access-check instead // of channel-constraining so they aren't silently dropped. - if !event_in_accessible_channel(&se, accessible_channels) { + if !event_in_accessible_channel(&se, accessible_channels) + || !crate::handlers::req::event_visible_to_reader( + &se.event, + reader_pubkey_bytes, + ) + { continue; } hop_ids.push(se.event.id.to_hex()); @@ -1501,6 +1507,7 @@ async fn query_events_authed( raw, filter, &accessible_channels, + &pubkey_bytes, &mut events, ) .await?; diff --git a/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs b/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs index 9cee67ea3bd..02ef106cdae 100644 --- a/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs +++ b/crates/buzz-relay/src/api/bridge/thread_window/postgres_tests.rs @@ -532,3 +532,64 @@ async fn thread_window_router_aux_row_cap_and_corrupt_page() { mod failure_postgres_tests; mod review_postgres_tests; + +/// A deletion of an author-only event is private by its `k` tag alone. Neither +/// window's aux closure returns one to anyone but its signer, and a read by id +/// stays author-only after its target is physically gone. Ordinary ingest can +/// build neither case, so both use stored fixtures. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn private_deletions_reach_only_their_signer_through_windows_and_id_reads() { + let f = Fixture::new().await; + let reply = f.reply(0).await; + let reply_reaction = f.aux(7, &reply, Some(f.channel)).await; + let root_reaction = f.aux(7, &f.root, Some(f.channel)).await; + let signer = Keys::generate(); + let deletion = |target: &str, k: &str| { + EventBuilder::new(Kind::EventDeletion, "") + .tags([ + Tag::parse(["e", target]).unwrap(), + Tag::parse(["k", k]).unwrap(), + ]) + .custom_created_at(Timestamp::from(f.root.created_at.as_secs() + 120)) + .sign_with_keys(&signer) + .unwrap() + }; + let reminder = buzz_core::kind::KIND_EVENT_REMINDER.to_string(); + let thread_private = deletion(&reply_reaction.id.to_hex(), &reminder); + let thread_public = deletion(&reply_reaction.id.to_hex(), "7"); + let channel_private = deletion(&root_reaction.id.to_hex(), &reminder); + let channel_public = deletion(&root_reaction.id.to_hex(), "7"); + let orphan_private = deletion(&"ab".repeat(32), &reminder); + for event in [ + &thread_private, + &thread_public, + &channel_private, + &channel_public, + &orphan_private, + ] { + f.state + .db + .insert_event(f.community, event, None) + .await + .unwrap(); + } + + let thread = f.query(&f.filter()).await; + let thread = ids(&thread, Some(5)); + assert!(thread.contains(&thread_public.id.to_hex().as_str())); + assert!(!thread.contains(&thread_private.id.to_hex().as_str())); + + let window = json!({"top_level":true,"#h":[f.channel],"kinds":[9],"include_aux":true}); + let window = f.query(&window).await; + let window = ids(&window, Some(5)); + assert!(window.contains(&channel_public.id.to_hex().as_str())); + assert!(!window.contains(&channel_private.id.to_hex().as_str())); + + let by_id = json!([{"ids":[orphan_private.id.to_hex()]}]); + for (reader, visible) in [(&f.keys, false), (&signer, true)] { + let (status, body) = f.post(reader, "/query", by_id.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(ids(&body, Some(5)).len(), usize::from(visible), "{body}"); + } +} diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a937ff0e4f2..f57c10b150b 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,9 +7,8 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, - KIND_AGENT_OBSERVER_FRAME, KIND_AGENT_PRESENCE, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, - KIND_TYPING_INDICATOR, + event_kind_u32, is_ephemeral, is_unshared_gated_event, KIND_AGENT_OBSERVER_FRAME, + KIND_AGENT_PRESENCE, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -137,7 +136,7 @@ pub async fn filter_fanout_by_access( // only paths that route author-only kinds — so no such delivery can bypass // it. It runs before (and independent of) the channel-membership filter // below because author-only kinds are stored globally (channel_id = None). - let matches = if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&stored_event.event)) { + let matches = if buzz_core::kind::is_author_only_event_kind(&stored_event.event) { let author = stored_event.event.pubkey.to_bytes(); matches .into_iter() diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 564ad3d00dd..5aad9689aa8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2597,6 +2597,34 @@ async fn ingest_event_inner( }); } + // NIP-09: kind:5 may reference targets via `e` tag (regular events) OR + // `a` tag (addressable/parameterized-replaceable events like kind:30620). + // Checked before any target lookup so every target faces the gates below. + if kind_u32 == KIND_NIP29_DELETE_EVENT || kind_u32 == KIND_DELETION { + let e_count = count_e_tags(&event); + let a_count = event + .tags + .iter() + .filter(|t| t.kind().to_string() == "a") + .count(); + if (e_count + a_count) != 1 { + return Err(IngestError::Rejected(format!( + "invalid: deletion events must reference exactly one target via e or a tag (got e={e_count}, a={a_count})" + ))); + } + } + + // A kind 5 targeting an event derives its channel from that target, so + // every channel gate below would reveal the target's existence or + // location. Their rejections all collapse to the shared denial. + let deletion_targets_event = kind_u32 == KIND_DELETION + && !crate::handlers::side_effects::extract_target_event_ids(&event).is_empty(); + let deletion_denied = || { + IngestError::Rejected(format!( + "invalid: {}", + crate::handlers::side_effects::DELETION_TARGET_DENIED + )) + }; let mut channel_id = if kind_u32 == KIND_REACTION { match derive_reaction_channel(tenant.community(), &state.db, &event).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), @@ -2620,43 +2648,25 @@ async fn ingest_event_inner( } else if is_gift_wrap { None } else if kind_u32 == KIND_DELETION { - // Standard deletion (kind:5): derive channel from the target event. - // kind:5 events don't carry an h-tag, so we look up the target event - // and use its channel_id. This ensures token-channel, membership, and - // archived checks run against the correct channel. - let target_hex = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "e" { - t.content().and_then(|v| { - if v.len() == 64 && v.chars().all(|c| c.is_ascii_hexdigit()) { - Some(v.to_string()) - } else { - None - } - }) - } else { - None - } - }); - match target_hex { - Some(hex) => { - let target_bytes = hex::decode(&hex).map_err(|_| { - IngestError::Rejected("invalid: malformed deletion target id".into()) - })?; - match state - .db - .get_event_by_id_for_event_write(tenant.community(), &target_bytes) - .await - { - Ok(Some(target)) => target.channel_id, - Ok(None) => None, // target not found — validate_standard_deletion will catch this - Err(e) => { - return Err(IngestError::Internal(format!( - "error: looking up deletion target: {e}" - ))); - } - } + // Standard deletion (kind:5): decide authority over the target before + // the channel gates below, whose errors would otherwise reveal whether + // the target exists and where. The target's channel, soft-deleted or + // not (kind:5 carries no h-tag), then faces the token-channel, + // membership, and archived checks. + match crate::handlers::side_effects::extract_target_event_ids(&event).first() { + Some(target_id) => { + let actor = effective_message_author(&event, &state.relay_keypair.public_key()); + crate::handlers::side_effects::authorized_standard_deletion_target( + tenant, state, &actor, target_id, + ) + .await + .map_err(|e| { + IngestError::Internal(format!("error: looking up deletion target: {e}")) + })? + .ok_or_else(deletion_denied)? + .channel_id } - None => None, // no e-tag — will be caught by single-target enforcement (step 12) + None => None, } } else { extract_channel_id(&event) @@ -2673,7 +2683,15 @@ async fn ingest_event_inner( } if let Some(ch_id) = channel_id { - check_token_channel_access(&auth, ch_id).map_err(IngestError::AuthFailed)?; + check_token_channel_access(&auth, ch_id).map_err(|e| { + if deletion_targets_event { + deletion_denied() + } else { + IngestError::AuthFailed(e) + } + })?; + } else if deletion_targets_event && auth.channel_ids().is_some() { + return Err(deletion_denied()); } else if auth.channel_ids().is_some() { // Channel-scoped tokens cannot publish global events — that would bypass // the token's channel restriction. This covers kind:1 (global text notes), @@ -2756,7 +2774,13 @@ async fn ingest_event_inner( }, state_for_request(tenant, auth.pubkey()), ); - auth_result.map_err(IngestError::Rejected)?; + auth_result.map_err(|e| { + if deletion_targets_event { + deletion_denied() + } else { + IngestError::Rejected(e) + } + })?; } } @@ -2894,6 +2918,15 @@ async fn ingest_event_inner( } if kind_u32 == KIND_DELETION { + // The target's archived channel blocks the deletion, so it must + // reject before the validator's target-kind diagnostics. + if deletion_targets_event + && channel_row + .as_ref() + .is_some_and(|ch| ch.archived_at.is_some()) + { + return Err(deletion_denied()); + } crate::handlers::side_effects::validate_standard_deletion_event(tenant, &event, state) .await .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; @@ -2934,22 +2967,6 @@ async fn ingest_event_inner( return Ok(result); } - // NIP-09: kind:5 may reference targets via `e` tag (regular events) OR - // `a` tag (addressable/parameterized-replaceable events like kind:30620). - if kind_u32 == KIND_NIP29_DELETE_EVENT || kind_u32 == KIND_DELETION { - let e_count = count_e_tags(&event); - let a_count = event - .tags - .iter() - .filter(|t| t.kind().to_string() == "a") - .count(); - if (e_count + a_count) != 1 { - return Err(IngestError::Rejected(format!( - "invalid: deletion events must reference exactly one target via e or a tag (got e={e_count}, a={a_count})" - ))); - } - } - if kind_u32 == KIND_STREAM_MESSAGE_EDIT { validate_edit_ownership(tenant.community(), &event, state) .await @@ -6728,6 +6745,178 @@ mod postgres_tests { .expect("untagged canvas write must append unconditionally"); } + /// A channel-scoped token gets the same kind 5 rejection for a target it + /// has no authority over whether that target is missing, soft-deleted + /// (no live channel), or live in a channel outside the token's scope. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_deletion_rejection_does_not_reveal_target() { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use nostr::{Keys, Kind, Tag}; + + let db_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()); // sadscan:disable np.postgres.1 + let pool = sqlx::PgPool::connect(&db_url) + .await + .expect("connect test Postgres"); + let state = build_canvas_ingest_state(&db_url, &pool).await; + let host = format!("scoped-deletion-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + let tracer: Arc = Arc::new(VecTracer::default()); + + let author = Keys::generate(); + let sender = Keys::generate(); + let mut channels = Vec::new(); + for creator in [&author, &sender] { + let channel_id = Uuid::new_v4(); + state + .db + .create_channel_with_id( + community, + channel_id, + &format!("scoped-deletion-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + channels.push(channel_id); + } + let author_auth = || IngestAuth::Http { + pubkey: author.public_key(), + scopes: vec![Scope::MessagesWrite], + auth_method: HttpAuthMethod::Nip98, + }; + let publish = |kind: u16, tags: Vec, keys: &Keys| { + nostr::EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .tags(tags) + .sign_with_keys(keys) + .expect("sign") + }; + let h = Tag::parse(["h", &channels[0].to_string()]).unwrap(); + let live = publish(9, vec![h.clone()], &author); + let deleted = publish(9, vec![h.clone()], &author); + let global = publish(1, vec![], &author); + let in_scope = publish(9, vec![h], &author); + for event in [ + live.clone(), + deleted.clone(), + global.clone(), + in_scope.clone(), + ] { + ingest_event_inner(&state, &tracer, &tenant, event, author_auth()) + .await + .expect("store message"); + } + let delete = |target: &str, keys: &Keys| { + publish( + KIND_DELETION as u16, + vec![Tag::parse(["e", target]).unwrap()], + keys, + ) + }; + ingest_event_inner( + &state, + &tracer, + &tenant, + delete(&deleted.id.to_hex(), &author), + author_auth(), + ) + .await + .expect("soft-delete message"); + + let scoped_auth = |keys: &Keys, channel: Uuid| IngestAuth::Nip42 { + pubkey: keys.public_key(), + scopes: vec![Scope::MessagesWrite], + channel_ids: Some(vec![channel]), + conn_id: Uuid::new_v4(), + }; + // Neither a non-owner nor the author holding a token scoped to the + // sender's channel may delete; both get the missing-target rejection. + for keys in [&sender, &author] { + for target in [ + "a".repeat(64), + live.id.to_hex(), + deleted.id.to_hex(), + global.id.to_hex(), + ] { + let auth = scoped_auth(keys, channels[1]); + match ingest_event_inner(&state, &tracer, &tenant, delete(&target, keys), auth) + .await + { + Err(IngestError::Rejected(msg)) => assert_eq!( + msg, "invalid: deletion target not found or not deletable by you", + "target {target}" + ), + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: deletion accepted"), + } + } + } + // Kind 9005 from the author's out-of-scope token, naming the channel + // the token covers, cannot reveal where the target lives either. + let redact = |target: &str| { + publish( + KIND_NIP29_DELETE_EVENT as u16, + vec![ + Tag::parse(["e", target]).unwrap(), + Tag::parse(["h", &channels[1].to_string()]).unwrap(), + ], + &author, + ) + }; + for target in ["a".repeat(64), live.id.to_hex(), global.id.to_hex()] { + match ingest_event_inner( + &state, + &tracer, + &tenant, + redact(&target), + scoped_auth(&author, channels[1]), + ) + .await + { + Err(IngestError::Rejected(msg)) => assert_eq!( + msg, "invalid: deletion target not found or not deletable by you", + "9005 target {target}" + ), + Err(other) => panic!("9005 target {target}: {other:?}"), + Ok(_) => panic!("9005 target {target}: deletion accepted"), + } + } + // A token scoped to the target's channel still deletes. + ingest_event_inner( + &state, + &tracer, + &tenant, + delete(&in_scope.id.to_hex(), &author), + scoped_auth(&author, channels[0]), + ) + .await + .expect("in-scope token deletes the author's message"); + // Re-deleting a soft-deleted message stores the deletion in its channel. + let redeletion = delete(&deleted.id.to_hex(), &author); + ingest_event_inner(&state, &tracer, &tenant, redeletion.clone(), author_auth()) + .await + .expect("author re-deletes a soft-deleted message"); + let stored = state + .db + .get_event_by_id(community, redeletion.id.as_bytes()) + .await + .expect("read re-deletion") + .expect("re-deletion stored"); + assert_eq!(stored.channel_id, Some(channels[0])); + } + // ── Owner-aware ban/timeout coverage ───────────────────────────────────── /// Fresh community plus an agent owned by `owner` (users.agent_owner_pubkey). diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index c2296ee717d..7572774b3ac 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -8,7 +8,7 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, P_GATED_KINDS, RESULT_GATED_KINDS, + KIND_DELETION, KIND_DM_VISIBILITY, KIND_HUDDLE_LIVENESS, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; @@ -1607,8 +1607,11 @@ pub(crate) fn engram_filters_authorized(filters: &[Filter], authed_pubkey_hex: & /// author-only events from the aggregate count. pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { filter.kinds.as_ref().is_none_or(|ks| { - ks.iter() - .any(|k| AUTHOR_ONLY_KINDS.contains(&(k.as_u16() as u32))) + ks.iter().any(|k| { + let k = k.as_u16() as u32; + // Deletions of author-only events inherit their privacy. + k == KIND_DELETION || AUTHOR_ONLY_KINDS.contains(&k) + }) }) } @@ -1667,12 +1670,13 @@ pub(crate) fn result_gated_count_safe_for_pushdown( .is_some_and(|values| !values.is_empty() && values.iter().all(|v| v == authed_pubkey_hex)) } -/// Returns `true` if the event is an author-only kind and the requester is NOT -/// the author. Used as a per-event filter during historical delivery and fan-out +/// Returns `true` if the event is author-only (see +/// [`buzz_core::kind::is_author_only_event_kind`]) and the requester is NOT the +/// author. Used as a per-event filter during historical delivery and fan-out /// to silently omit unauthorized events from mixed-kind result sets. pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { - let kind_u32 = event.kind.as_u16() as u32; - AUTHOR_ONLY_KINDS.contains(&kind_u32) && event.pubkey.to_bytes() != requester_pubkey_bytes + buzz_core::kind::is_author_only_event_kind(event) + && event.pubkey.to_bytes() != requester_pubkey_bytes } /// Combined per-event result-visibility check for all gated event classes. @@ -3009,6 +3013,20 @@ mod tests { (agent, owner, attacker) } + #[test] + fn count_fallback_covers_filters_that_can_match_private_deletions() { + assert!(filter_can_match_author_only_kinds(&Filter::new())); + assert!(filter_can_match_author_only_kinds( + &Filter::new().id(nostr::EventId::all_zeros()) + )); + assert!(filter_can_match_author_only_kinds( + &Filter::new().kinds([nostr::Kind::EventDeletion, nostr::Kind::TextNote]) + )); + assert!(!filter_can_match_author_only_kinds( + &Filter::new().kind(nostr::Kind::TextNote) + )); + } + #[test] fn push_lease_requires_self_author_filter_and_count_fallback() { let (owner, other, _) = three_pubkeys(); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 250b289f446..8cc7d3abc6b 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -7,11 +7,11 @@ use tracing::{info, warn}; use uuid::Uuid; use buzz_core::kind::{ - event_kind_u32, is_parameterized_replaceable, KIND_AGENT_PROFILE, KIND_DM_VISIBILITY, - KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, - KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + event_kind_u32, is_parameterized_replaceable, AUTHOR_ONLY_KINDS, KIND_AGENT_PROFILE, + KIND_DM_VISIBILITY, KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, + KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, + KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; @@ -358,6 +358,70 @@ pub async fn handle_side_effects( } } +/// Rejection for a deletion whose sender may not delete the target. A missing +/// target gets the same text, so the error never reveals whether, or where, +/// the target exists. +pub(crate) const DELETION_TARGET_DENIED: &str = "deletion target not found or not deletable by you"; + +/// The kind 5 target `target_id`, including a soft-deleted one, when `actor` +/// is its effective author or that author's owning human; `None` when the +/// target is missing or `actor` has no authority over it. +pub(crate) async fn authorized_standard_deletion_target( + tenant: &TenantContext, + state: &Arc, + actor: &[u8], + target_id: &[u8], +) -> anyhow::Result> { + let Some(target) = state + .db + .get_event_by_id_including_deleted_for_event_write(tenant.community(), target_id) + .await? + else { + return Ok(None); + }; + let author = effective_message_author(&target.event, &state.relay_keypair.public_key()); + let authorized = author == actor + || state + .db + .is_agent_owner(tenant.community(), &author, actor) + .await?; + Ok(authorized.then_some(target)) +} + +/// Whether `actor` may delete `author`'s event in `channel_id` with kind 9005: +/// the author while still a member or the channel is open (a removed member +/// of a private channel loses this), a channel owner/admin, or the owning +/// human of the author agent, even when that human is not a channel member. +async fn may_delete_channel_event( + tenant: &TenantContext, + state: &Arc, + event: &Event, + channel_id: Uuid, + author: &[u8], + actor: &[u8], +) -> anyhow::Result { + if author_delete_can_use_self_delete_path(author, actor, event) + && (state + .is_member_cached(tenant.community(), channel_id, actor) + .await? + || state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await + .map(|ch| ch.visibility == "open") + .unwrap_or(false)) + { + return Ok(true); + } + Ok(actor_is_channel_owner_or_admin( + &state.db.get_members(tenant.community(), channel_id).await?, + actor, + ) || state + .db + .is_agent_owner(tenant.community(), author, actor) + .await?) +} + /// Validate a standard NIP-09 deletion event before it is stored. /// /// Buzz accepts standard deletions for self-authored events, plus the owning @@ -383,6 +447,9 @@ pub async fn validate_standard_deletion_event( if parts.len() < 2 { return Err(anyhow::anyhow!("invalid a-tag format")); } + if let Ok(target_kind) = parts[0].parse() { + check_deletion_privacy_k_tag(event, target_kind)?; + } let target_pubkey_bytes = hex::decode(parts[1]).map_err(|_| anyhow::anyhow!("invalid pubkey in a-tag"))?; if target_pubkey_bytes != actor_bytes @@ -391,38 +458,57 @@ pub async fn validate_standard_deletion_event( .is_agent_owner(tenant.community(), &target_pubkey_bytes, &actor_bytes) .await? { - return Err(anyhow::anyhow!("must be event author")); + return Err(anyhow::anyhow!(DELETION_TARGET_DENIED)); } return Ok(()); } for target_id in target_ids { - let target_event = state - .db - .get_event_by_id_including_deleted_for_event_write(tenant.community(), &target_id) - .await? - .ok_or_else(|| anyhow::anyhow!("target event not found"))?; - + // Authority first: every later error depends on the target's kind, + // which a sender without authority must not learn. + let target_event = + authorized_standard_deletion_target(tenant, state, &actor_bytes, &target_id) + .await? + .ok_or_else(|| anyhow::anyhow!(DELETION_TARGET_DENIED))?; if matches!(target_event.event.kind.as_u16(), 45010 | 45011) { anyhow::bail!( "artifacts cannot be deleted with kind 5; use op=delete or kind 9005 redaction" ); } - let target_author = - effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - if target_author != actor_bytes - && !state - .db - .is_agent_owner(tenant.community(), &target_author, &actor_bytes) - .await? + // A deletion of a private deletion would be stored publicly and reveal + // the private one's id and timing; NIP-09 gives it no effect anyway. + if target_event.event.kind.as_u16() == 5 + && buzz_core::kind::is_author_only_event_kind(&target_event.event) { - return Err(anyhow::anyhow!("must be event author")); + anyhow::bail!("cannot delete a private deletion request"); } + check_deletion_privacy_k_tag(event, event_kind_u32(&target_event.event))?; } Ok(()) } +/// A deletion's read privacy follows its `k` tag (see +/// `buzz_core::kind::is_author_only_event_kind`), so the tag must agree with +/// the target: required when the target is author-only, so the deletion cannot +/// leak its existence, and otherwise forbidden, so a public deletion cannot be +/// hidden from the readers who need it. +fn check_deletion_privacy_k_tag(event: &Event, target_kind: u32) -> anyhow::Result<()> { + let target_is_author_only = AUTHOR_ONLY_KINDS.contains(&target_kind); + let mut names_target = false; + for k in buzz_core::kind::k_tag_kinds(event) { + if k == target_kind { + names_target = true; + } else if AUTHOR_ONLY_KINDS.contains(&k) { + anyhow::bail!("k tag {k} does not match the deletion target's kind {target_kind}"); + } + } + if target_is_author_only && !names_target { + anyhow::bail!("deletion of an author-only kind {target_kind} event requires [\"k\",\"{target_kind}\"]"); + } + Ok(()) +} + /// Returns `true` if `actor_bytes` is the NIP-OA owner of **any** active owner-role /// member in `members`. Used by kind:9002 and kind:9008 to authorize the owning /// human of a channel's agent-owner(s) even when the human is not a channel member. @@ -724,77 +810,42 @@ pub async fn validate_admin_event( }) .ok_or_else(|| anyhow::anyhow!("missing e tag for target event"))?; - // Verify the target event exists and belongs to the h-tag channel - // BEFORE storage. Fail closed: missing target → reject. + // Decide authority before any error that depends on the target: + // a missing target and one the actor may not delete share one + // rejection. Tombstoned targets read as missing. + let denied = || anyhow::anyhow!(DELETION_TARGET_DENIED); let target_event = state .db .get_event_by_id_for_event_write(tenant.community(), &target_id) .await .map_err(|e| anyhow::anyhow!("db error looking up target: {e}"))? - .ok_or_else(|| anyhow::anyhow!("target event not found"))?; + .ok_or_else(denied)?; + // For relay-signed REST messages, the real author is in the p tag. + let author = + effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - match target_event.channel_id { - Some(target_ch) if target_ch != channel_id => { - return Err(anyhow::anyhow!( - "target event belongs to a different channel" - )); - } - None => { - return Err(anyhow::anyhow!("target event has no channel")); - } - _ => {} // Same channel — OK + // A target outside the `h` channel is denied like a missing one: + // telling where it lives would need every access gate of a second + // channel. The `h` channel's token and archive gates already ran. + if target_event.channel_id != Some(channel_id) + || !may_delete_channel_event( + tenant, + state, + event, + channel_id, + &author, + &actor_bytes, + ) + .await? + { + return Err(denied()); } if target_event.event.kind.as_u16() == 45011 { return Err(anyhow::anyhow!( "artifact removal markers cannot be deleted" )); } - - // Check if actor is the event author. - // For relay-signed REST messages, the real author is in the p tag. - let author = - effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - if author_delete_can_use_self_delete_path(&author, &actor_bytes, event) { - // Author deleting their own message: re-gate on membership/open visibility so that - // a removed private-channel member cannot mutate old messages after access is revoked. - let is_member = state - .is_member_cached(tenant.community(), channel_id, &actor_bytes) - .await?; - if is_member { - return Ok(()); - } - let is_open = state - .db - .get_channel_for_event_write(tenant.community(), channel_id) - .await - .map(|ch| ch.visibility == "open") - .unwrap_or(false); - if is_open { - return Ok(()); - } - // Not a member and channel is private — fall through to owner/admin/owner-of-agent check. - } - - // Not the author, or author who is no longer a member of a private channel — - // must be owner/admin or the owning human of the message's agent-author. - let members = state.db.get_members(tenant.community(), channel_id).await?; - if actor_is_channel_owner_or_admin(&members, &actor_bytes) { - Ok(()) - } else { - // Allow the owning human of the agent that authored the target message, - // even when the human is not a channel member. - if state - .db - .is_agent_owner(tenant.community(), &author, &actor_bytes) - .await? - { - Ok(()) - } else { - Err(anyhow::anyhow!( - "must be event author or channel owner/admin" - )) - } - } + Ok(()) } 9008 => { // DELETE_GROUP: owner only, or the owning human of the channel's agent-owner. @@ -2563,7 +2614,7 @@ fn has_e_tag(event: &Event) -> bool { event.tags.iter().any(|t| t.kind().to_string() == "e") } -fn extract_target_event_ids(event: &Event) -> Vec> { +pub(crate) fn extract_target_event_ids(event: &Event) -> Vec> { event .tags .iter() diff --git a/crates/buzz-test-client/tests/e2e_author_only_deletion.rs b/crates/buzz-test-client/tests/e2e_author_only_deletion.rs new file mode 100644 index 00000000000..e820aabd135 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_author_only_deletion.rs @@ -0,0 +1,1128 @@ +//! End-to-end tests: NIP-09 deletion requests (kind:5) for author-only kinds +//! are exactly as private as the events they delete. +//! +//! A deletion request names its target ids and reveals when they were +//! deleted. For a kind in `AUTHOR_ONLY_KINDS` (here the NIP-ER reminder, +//! kind:30300) the relay must hide that deletion from everyone but its author +//! on every read surface, and must reject one that omits the matching `k` tag. +//! A deletion from anyone without authority over its target is rejected +//! before any of that, with one message whether the target is missing, live, +//! soft-deleted, or in any channel, so the rejection reveals nothing about it. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_author_only_deletion -- --ignored +//! ``` +//! +//! On a database built from the desired-state schema (`schema/schema.sql`), +//! which indexes kind:5 content, also set `BUZZ_TEST_SEARCH_INDEXES_DELETIONS=1`. +//! Without it, the search test only checks that results stay within the +//! permitted sets, not that search actually returns them. + +use std::collections::BTreeSet; +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError}; +use nostr::{EventBuilder, EventId, Filter, Keys, Kind, Tag}; +use reqwest::Client; +use serde_json::Value; + +const KIND_EVENT_REMINDER: u16 = 30300; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn sub_id(name: &str) -> String { + format!("e2e-aod-{name}-{}", uuid::Uuid::new_v4()) +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.to_vec()).expect("valid tag") +} + +fn build_reminder(keys: &Keys) -> nostr::Event { + EventBuilder::new( + Kind::Custom(KIND_EVENT_REMINDER), + "nip44-ciphertext-placeholder", + ) + .tags(vec![ + tag(&["d", &uuid::Uuid::new_v4().to_string()]), + tag(&["alt", "Encrypted reminder"]), + ]) + .sign_with_keys(keys) + .unwrap() +} + +fn build_deletion(keys: &Keys, tags: Vec) -> nostr::Event { + build_deletion_with_reason(keys, tags, "") +} + +fn build_deletion_with_reason(keys: &Keys, tags: Vec, reason: &str) -> nostr::Event { + EventBuilder::new(Kind::EventDeletion, reason) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(event) + .send() + .await + .expect("submit event"); + let ok = resp.status().is_success(); + let body: Value = resp.json().await.expect("parse response"); + if ok { + ( + body["accepted"].as_bool().unwrap_or(false), + body["message"].as_str().unwrap_or("").to_string(), + ) + } else { + (false, body["error"].as_str().unwrap_or("").to_string()) + } +} + +async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) { + let (accepted, msg) = submit(client, keys, event).await; + assert!(accepted, "setup event rejected: {msg}"); +} + +/// Store a reminder as `author`, then delete it with a `k`-tagged kind:5 +/// whose reason is a unique search term. Returns the reminder and deletion. +async fn store_deleted_reminder(client: &Client, author: &Keys) -> (nostr::Event, nostr::Event) { + let reminder = build_reminder(author); + submit_ok(client, author, &reminder).await; + let deletion = build_deletion_with_reason( + author, + vec![ + tag(&["e", &reminder.id.to_hex()]), + tag(&["k", &KIND_EVENT_REMINDER.to_string()]), + ], + &search_term(author), + ); + submit_ok(client, author, &deletion).await; + let reminder_reads = http_query_ids(client, author, &Filter::new().id(reminder.id)).await; + assert!(reminder_reads.is_empty(), "the reminder must be deleted"); + (reminder, deletion) +} + +/// A search term unique to `author`'s deletion reason. +fn search_term(author: &Keys) -> String { + format!("aodreason{}", &author.public_key().to_hex()[..16]) +} + +/// Filters that each match only the deletion `store_deleted_reminder` left: +/// by kind, mixed kinds, and id. The id filter is the kindless COUNT case; +/// broader kindless filters can match `#p`-gated kinds, so the relay closes +/// them before any read runs. +fn deletion_filters(author: &Keys, deletion: EventId) -> Vec { + vec![ + Filter::new() + .kind(Kind::EventDeletion) + .author(author.public_key()), + Filter::new() + .kinds([Kind::EventDeletion, Kind::TextNote]) + .author(author.public_key()), + Filter::new().id(deletion), + ] +} + +async fn http_query_ids(client: &Client, reader: &Keys, filter: &Filter) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") + .iter() + .filter_map(|e| e["id"].as_str().map(str::to_string)) + .collect() +} + +async fn http_count(client: &Client, reader: &Keys, filter: &Filter) -> u64 { + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .expect("count events"); + assert!( + resp.status().is_success(), + "count failed: {}", + resp.status() + ); + let body: Value = resp.json().await.expect("parse count response"); + body["count"].as_u64().expect("count field") +} + +async fn ws_query_ids(ws: &mut BuzzTestClient, filter: &Filter) -> Vec { + let sid = sub_id("req"); + ws.subscribe(&sid, vec![filter.clone()]).await.expect("REQ"); + let events = ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + ws.close_subscription(&sid).await.expect("CLOSE"); + events.into_iter().map(|e| e.id).collect() +} + +async fn ws_count(ws: &mut BuzzTestClient, filter: &Filter) -> u64 { + let sid = sub_id("count"); + ws.send_raw(&serde_json::json!(["COUNT", sid, filter])) + .await + .expect("COUNT"); + loop { + match ws + .recv_event(Duration::from_secs(5)) + .await + .expect("COUNT reply") + { + RelayMessage::Count { + subscription_id, + count, + } if subscription_id == sid => return count, + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == sid => panic!("COUNT closed: {message}"), + _ => {} + } + } +} + +/// Every historical read surface returns the deletion to `reader` iff +/// `visible`: WS REQ, WS COUNT, HTTP `/query` and HTTP `/count`, by author +/// and by id. +async fn assert_deletion_visibility( + reader: &Keys, + author: &Keys, + deletion: EventId, + visible: bool, +) { + let client = http_client(); + let mut ws = BuzzTestClient::connect(&relay_url(), reader) + .await + .expect("connect reader"); + let expected = u64::from(visible); + let mut mismatches = Vec::new(); + for filter in deletion_filters(author, deletion) { + let observed = [ + ("WS REQ", ws_query_ids(&mut ws, &filter).await.len() as u64), + ("WS COUNT", ws_count(&mut ws, &filter).await), + ( + "HTTP /query", + http_query_ids(&client, reader, &filter).await.len() as u64, + ), + ("HTTP /count", http_count(&client, reader, &filter).await), + ]; + for (surface, n) in observed { + if n != expected { + mismatches.push(format!("{surface} {filter:?}: {n}, expected {expected}")); + } + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); + ws.disconnect().await.expect("disconnect"); +} + +/// Whether the relay's `search_tsv` indexes kind:5 content. The desired +/// schema (`schema/schema.sql`, which CI applies) does; an empty database +/// initialized through migration 0008 gets a positive allowlist that doesn't. +fn search_indexes_deletions() -> bool { + std::env::var("BUZZ_TEST_SEARCH_INDEXES_DELETIONS").is_ok_and(|v| v == "1") +} + +/// Search results for `filter` over WS REQ and HTTP `/query`, as hex ids. +async fn search_ids(reader: &Keys, filter: &Filter) -> [(&'static str, BTreeSet); 2] { + let mut ws = BuzzTestClient::connect(&relay_url(), reader) + .await + .expect("connect reader"); + let ws_ids = ws_query_ids(&mut ws, filter) + .await + .iter() + .map(EventId::to_hex) + .collect(); + ws.disconnect().await.expect("disconnect"); + let http_ids = http_query_ids(&http_client(), reader, filter) + .await + .into_iter() + .collect(); + [("WS REQ search", ws_ids), ("HTTP /query search", http_ids)] +} + +#[tokio::test] +#[ignore] +async fn author_only_deletion_search_reaches_only_author() { + let client = http_client(); + let author = Keys::generate(); + let (_, private) = store_deleted_reminder(&client, &author).await; + // Public control: an ordinary deletion carrying the same reason word. + let note = EventBuilder::text_note("public note") + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, ¬e).await; + let public = build_deletion_with_reason( + &author, + vec![tag(&["e", ¬e.id.to_hex()]), tag(&["k", "1"])], + &search_term(&author), + ); + submit_ok(&client, &author, &public).await; + + let search = Filter::new() + .kind(Kind::EventDeletion) + .search(search_term(&author)); + let (private, public) = (private.id.to_hex(), public.id.to_hex()); + let strict = search_indexes_deletions(); + let mut mismatches = Vec::new(); + for (reader, name, allowed) in [ + ( + &author, + "author", + BTreeSet::from([private.clone(), public.clone()]), + ), + (&Keys::generate(), "other", BTreeSet::from([public.clone()])), + ] { + for (surface, ids) in search_ids(reader, &search).await { + let ok = if strict { + ids == allowed + } else { + ids.is_subset(&allowed) + }; + if !ok { + mismatches.push(format!( + "{surface} as {name}: {ids:?}, expected {allowed:?} (strict={strict})" + )); + } + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +#[tokio::test] +#[ignore] +async fn author_only_deletion_hidden_from_other_readers() { + let client = http_client(); + let author = Keys::generate(); + let (_, deletion) = store_deleted_reminder(&client, &author).await; + assert_deletion_visibility(&Keys::generate(), &author, deletion.id, false).await; +} + +#[tokio::test] +#[ignore] +async fn author_only_deletion_visible_to_author() { + let client = http_client(); + let author = Keys::generate(); + let (_, deletion) = store_deleted_reminder(&client, &author).await; + assert_deletion_visibility(&author, &author, deletion.id, true).await; +} + +#[tokio::test] +#[ignore] +async fn author_only_deletion_live_fanout_reaches_only_author() { + let client = http_client(); + let author = Keys::generate(); + let reminder = build_reminder(&author); + submit_ok(&client, &author, &reminder).await; + + let live = Filter::new() + .kind(Kind::EventDeletion) + .author(author.public_key()); + let mut subscribers = Vec::new(); + for keys in [author.clone(), Keys::generate()] { + let mut ws = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect"); + let sid = sub_id("live"); + ws.subscribe(&sid, vec![live.clone()]).await.expect("REQ"); + assert!(ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE") + .is_empty()); + subscribers.push((ws, sid)); + } + + let deletion = build_deletion( + &author, + vec![ + tag(&["e", &reminder.id.to_hex()]), + tag(&["k", &KIND_EVENT_REMINDER.to_string()]), + ], + ); + submit_ok(&client, &author, &deletion).await; + + for (i, (mut ws, sid)) in subscribers.into_iter().enumerate() { + let delivered = live_event_ids(&mut ws, &sid).await.contains(&deletion.id); + let is_author = i == 0; + assert_eq!(delivered, is_author, "live delivery to subscriber {i}"); + ws.disconnect().await.expect("disconnect"); + } +} + +#[tokio::test] +#[ignore] +async fn deletion_of_author_only_event_requires_matching_k_tag() { + let client = http_client(); + let author = Keys::generate(); + let reminder = build_reminder(&author); + submit_ok(&client, &author, &reminder).await; + let target = tag(&["e", &reminder.id.to_hex()]); + let coordinate = format!( + "{KIND_EVENT_REMINDER}:{}:{}", + author.public_key().to_hex(), + reminder.tags.identifier().expect("d tag") + ); + let coordinate = tag(&["a", &coordinate]); + + for tags in [ + vec![target.clone()], + vec![target.clone(), tag(&["k", "1"])], + vec![coordinate.clone()], + ] { + let (accepted, msg) = + submit(&client, &author, &build_deletion(&author, tags.clone())).await; + assert!( + !accepted, + "deletion {tags:?} must be rejected without k=30300 (got: {msg})" + ); + } + submit_ok( + &client, + &author, + &build_deletion( + &author, + vec![coordinate, tag(&["k", &KIND_EVENT_REMINDER.to_string()])], + ), + ) + .await; +} + +#[tokio::test] +#[ignore] +async fn untagged_deletion_of_soft_deleted_author_only_event_rejected() { + let client = http_client(); + let author = Keys::generate(); + let (reminder, _) = store_deleted_reminder(&client, &author).await; + let untagged = build_deletion(&author, vec![tag(&["e", &reminder.id.to_hex()])]); + let (accepted, msg) = submit(&client, &author, &untagged).await; + assert!( + !accepted, + "an untagged deletion of a soft-deleted reminder must be rejected (got: {msg})" + ); +} + +#[tokio::test] +#[ignore] +async fn non_canonical_k_tag_is_not_a_private_marker() { + let client = http_client(); + let author = Keys::generate(); + let reminder = build_reminder(&author); + submit_ok(&client, &author, &reminder).await; + for k in ["030300", "+30300"] { + let deletion = build_deletion( + &author, + vec![tag(&["e", &reminder.id.to_hex()]), tag(&["k", k])], + ); + let (accepted, msg) = submit(&client, &author, &deletion).await; + assert!( + !accepted, + "k={k} must not satisfy the k=30300 rule (got: {msg})" + ); + } +} + +#[tokio::test] +#[ignore] +async fn public_deletion_cannot_claim_author_only_kind() { + let client = http_client(); + let author = Keys::generate(); + let note = EventBuilder::text_note("public note") + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, ¬e).await; + let deletion = build_deletion( + &author, + vec![ + tag(&["e", ¬e.id.to_hex()]), + tag(&["k", &KIND_EVENT_REMINDER.to_string()]), + ], + ); + let (accepted, msg) = submit(&client, &author, &deletion).await; + assert!( + !accepted, + "a public deletion must not hide behind k=30300 (got: {msg})" + ); +} + +/// The one rejection a sender without authority over a deletion target gets, +/// whether the target exists, is soft-deleted, or lives in any channel. +const DENIED: &str = "invalid: deletion target not found or not deletable by you"; + +/// Submit `deletion` as `sender` over HTTP and WS and return how each +/// rejected it: the HTTP status and error, and the WS `OK` message. +async fn rejection( + client: &Client, + sender: &Keys, + deletion: nostr::Event, +) -> (u16, String, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", sender.public_key().to_hex()) + .json(&deletion) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + let http_msg = body["error"].as_str().unwrap_or("").to_string(); + let mut ws = BuzzTestClient::connect(&relay_url(), sender) + .await + .expect("connect"); + let ok = ws.send_event(deletion).await.expect("OK"); + ws.disconnect().await.expect("disconnect"); + assert!(!ok.accepted, "WS must reject the deletion"); + (status, http_msg, ok.message) +} + +/// Submit `deletion` as `sender` over HTTP and WS; both must give the +/// generic denial, which cannot reveal the target's kind. +async fn assert_rejected_as_non_author(client: &Client, sender: &Keys, deletion: nostr::Event) { + let (status, http_msg, ws_msg) = rejection(client, sender, deletion).await; + assert!(status >= 400, "HTTP must reject the deletion"); + for msg in [http_msg, ws_msg] { + assert_eq!(msg, DENIED); + } +} + +async fn create_channel(client: &Client, owner: &Keys, visibility: &str) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + tag(&["h", &id]), + tag(&["name", &format!("aod-{id}")]), + tag(&["channel_type", "stream"]), + tag(&["visibility", visibility]), + ]) + .sign_with_keys(owner) + .unwrap(); + submit_ok(client, owner, &event).await; + id +} + +async fn post_message(client: &Client, author: &Keys, channel: &str) -> nostr::Event { + let message = EventBuilder::new(Kind::Custom(9), uuid::Uuid::new_v4().to_string()) + .tags(vec![tag(&["h", channel])]) + .sign_with_keys(author) + .unwrap(); + submit_ok(client, author, &message).await; + message +} + +/// A sender without authority over a deletion target gets one rejection, +/// identical in HTTP status, HTTP error and WS `OK` message, for kind 5 and +/// kind 9005 alike, whether the target is missing, live, soft-deleted, in a +/// private channel the sender has not joined, or in another channel. +#[tokio::test] +#[ignore] +async fn unauthorized_deletion_does_not_reveal_target_existence() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let open = create_channel(&client, &author, "open").await; + let private = create_channel(&client, &author, "private").await; + // A channel `other` owns, so 9005 there passes as owner/admin authority. + let others = create_channel(&client, &other, "open").await; + let live = post_message(&client, &author, &open).await; + let deleted = post_message(&client, &author, &open).await; + submit_ok( + &client, + &author, + &build_deletion(&author, vec![tag(&["e", &deleted.id.to_hex()])]), + ) + .await; + let hidden = post_message(&client, &author, &private).await; + let reminder = build_reminder(&author); + submit_ok(&client, &author, &reminder).await; + let missing = "a".repeat(64); + + assert_ne!(live.id, deleted.id); + let delete = |target: &str| build_deletion(&other, vec![tag(&["e", target])]); + let redact = |target: &str, channel: &str| { + EventBuilder::new(Kind::Custom(9005), "") + .tags(vec![tag(&["e", target]), tag(&["h", channel])]) + .sign_with_keys(&other) + .unwrap() + }; + let cases = [ + ("kind 5, missing", delete(&missing)), + ("kind 5, live", delete(&live.id.to_hex())), + ("kind 5, soft-deleted", delete(&deleted.id.to_hex())), + ("kind 5, private channel", delete(&hidden.id.to_hex())), + ("kind 5, global reminder", delete(&reminder.id.to_hex())), + ("9005, missing", redact(&missing, &others)), + ("9005, live", redact(&live.id.to_hex(), &open)), + ("9005, soft-deleted", redact(&deleted.id.to_hex(), &open)), + ( + "9005, private channel", + redact(&hidden.id.to_hex(), &private), + ), + ("9005, other channel", redact(&live.id.to_hex(), &others)), + ("9005, no channel", redact(&reminder.id.to_hex(), &others)), + ]; + let mut expected = None; + for (case, deletion) in cases { + let (status, http_msg, ws_msg) = rejection(&client, &other, deletion).await; + assert_eq!(http_msg, DENIED, "{case}: HTTP"); + assert_eq!(ws_msg, DENIED, "{case}: WS"); + assert_eq!( + *expected.get_or_insert(status), + status, + "{case}: HTTP status" + ); + } +} + +#[tokio::test] +#[ignore] +async fn non_author_deletion_does_not_reveal_target_kind() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let live = build_reminder(&author); + submit_ok(&client, &author, &live).await; + let (deleted, _) = store_deleted_reminder(&client, &author).await; + + for target in [&live, &deleted] { + for k in [None, Some("30350"), Some("30300")] { + let mut tags = vec![tag(&["e", &target.id.to_hex()])]; + tags.extend(k.map(|k| tag(&["k", k]))); + assert_rejected_as_non_author(&client, &other, build_deletion(&other, tags)).await; + } + } +} + +/// An author removed from a private channel can no longer delete their +/// messages there, so kind 5 and kind 9005 (sent from a channel they still +/// write to) must reject exactly as for a missing target, while the channel +/// owner and the author's own still-writable messages keep working. +#[tokio::test] +#[ignore] +async fn removed_author_deletion_does_not_reveal_target() { + let client = http_client(); + let owner = Keys::generate(); + let author = Keys::generate(); + let private = create_channel(&client, &owner, "private").await; + let own = create_channel(&client, &author, "open").await; + let membership = |kind: u16| { + EventBuilder::new(Kind::Custom(kind), "") + .tags(vec![ + tag(&["h", &private]), + tag(&["p", &author.public_key().to_hex()]), + ]) + .sign_with_keys(&owner) + .unwrap() + }; + submit_ok(&client, &owner, &membership(9000)).await; + let retained = post_message(&client, &author, &private).await; + let moderated = post_message(&client, &author, &private).await; + submit_ok(&client, &owner, &membership(9001)).await; + let missing = "a".repeat(64); + + let delete = |target: &str| build_deletion(&author, vec![tag(&["e", target])]); + let redact = |keys: &Keys, target: &str, channel: &str| { + EventBuilder::new(Kind::Custom(9005), "") + .tags(vec![tag(&["e", target]), tag(&["h", channel])]) + .sign_with_keys(keys) + .unwrap() + }; + let cases = [ + ("kind 5, missing", delete(&missing)), + ("kind 5, retained", delete(&retained.id.to_hex())), + ("9005, missing", redact(&author, &missing, &own)), + ( + "9005, retained", + redact(&author, &retained.id.to_hex(), &own), + ), + ]; + let mut expected = None; + for (case, deletion) in cases { + let (status, http_msg, ws_msg) = rejection(&client, &author, deletion).await; + assert_eq!(http_msg, DENIED, "{case}: HTTP"); + assert_eq!(ws_msg, DENIED, "{case}: WS"); + assert_eq!( + *expected.get_or_insert(status), + status, + "{case}: HTTP status" + ); + } + + submit_ok( + &client, + &owner, + &redact(&owner, &moderated.id.to_hex(), &private), + ) + .await; + let still_writable = post_message(&client, &author, &own).await; + submit_ok(&client, &author, &delete(&still_writable.id.to_hex())).await; +} + +/// An NIP-AR artifact revision by `author` homed in `channel`. +fn artifact_revision( + author: &Keys, + artifact: &str, + channel: &str, + op: &str, + prev: Option<&EventId>, +) -> nostr::Event { + let mut tags = vec![ + tag(&["ar", "1"]), + tag(&["d", artifact]), + tag(&["h", channel]), + tag(&["type", "buzz.task"]), + tag(&["op", op]), + tag(&["title", "Task"]), + ]; + tags.extend(prev.map(|p| tag(&["prev", &p.to_hex()]))); + EventBuilder::new(Kind::Custom(45010), "") + .tags(tags) + .sign_with_keys(author) + .unwrap() +} + +/// A live artifact by `author` homed in `channel`. +async fn post_artifact(client: &Client, author: &Keys, channel: &str) -> nostr::Event { + let create = artifact_revision( + author, + &uuid::Uuid::new_v4().to_string(), + channel, + "create", + None, + ); + submit_ok(client, author, &create).await; + create +} + +/// An archived channel blocks every deletion of its events, so kind 5 and +/// kind 9005 must reject as for a missing target before any error about the +/// target's kind or location, even for the author who owns the channel. +#[tokio::test] +#[ignore] +async fn archived_target_deletion_does_not_reveal_target() { + let client = http_client(); + let owner = Keys::generate(); + let archived = create_channel(&client, &owner, "open").await; + let active = create_channel(&client, &owner, "open").await; + let message = post_message(&client, &owner, &archived).await; + let artifact = post_artifact(&client, &owner, &archived).await; + // Moving an artifact out of a channel leaves a removal marker there. + let moved = post_artifact(&client, &owner, &archived).await; + let d = moved.tags.identifier().unwrap().to_string(); + submit_ok( + &client, + &owner, + &artifact_revision(&owner, &d, &active, "move", Some(&moved.id)), + ) + .await; + let marker = http_query_ids( + &client, + &owner, + &Filter::new().kind(Kind::Custom(45011)).custom_tag( + nostr::SingleLetterTag::lowercase(nostr::Alphabet::H), + archived.clone(), + ), + ) + .await + .pop() + .expect("artifact removal marker"); + let archive = EventBuilder::new(Kind::Custom(9002), "") + .tags(vec![tag(&["h", &archived]), tag(&["archived", "true"])]) + .sign_with_keys(&owner) + .unwrap(); + submit_ok(&client, &owner, &archive).await; + let missing = "a".repeat(64); + + let delete = |target: &str, k: Option<&str>| { + let mut tags = vec![tag(&["e", target])]; + tags.extend(k.map(|k| tag(&["k", k]))); + build_deletion(&owner, tags) + }; + let redact = |target: &str, channel: &str| { + EventBuilder::new(Kind::Custom(9005), "") + .tags(vec![tag(&["e", target]), tag(&["h", channel])]) + .sign_with_keys(&owner) + .unwrap() + }; + let message_id = message.id.to_hex(); + let cases = [ + ("kind 5, missing", delete(&missing, None)), + ("kind 5, message", delete(&message_id, None)), + ("kind 5, wrong k", delete(&message_id, Some("30300"))), + ("kind 5, artifact", delete(&artifact.id.to_hex(), None)), + ("9005, missing", redact(&missing, &active)), + ("9005, archived target", redact(&message_id, &active)), + ]; + let mut expected = None; + for (case, deletion) in cases { + let (status, http_msg, ws_msg) = rejection(&client, &owner, deletion).await; + assert_eq!(http_msg, DENIED, "{case}: HTTP"); + assert_eq!(ws_msg, DENIED, "{case}: WS"); + assert_eq!( + *expected.get_or_insert(status), + status, + "{case}: HTTP status" + ); + } + // A 9005 naming the archived channel is refused for that channel before + // the target is read, so the removal marker matches a missing target. + assert_eq!( + rejection(&client, &owner, redact(&marker, &archived)).await, + rejection(&client, &owner, redact(&missing, &archived)).await, + ); + + // In an active channel the owner still gets the target diagnostics. + let live = post_message(&client, &owner, &active).await; + let (_, http_msg, _) = + rejection(&client, &owner, delete(&live.id.to_hex(), Some("30300"))).await; + assert!( + http_msg.contains("does not match the deletion target's kind"), + "{http_msg}" + ); + let live_artifact = post_artifact(&client, &owner, &active).await; + let (_, http_msg, _) = + rejection(&client, &owner, delete(&live_artifact.id.to_hex(), None)).await; + assert!( + http_msg.contains("artifacts cannot be deleted with kind 5"), + "{http_msg}" + ); +} + +/// Whether `reader`'s open subscription `sid` receives any event within 2s. +async fn receives_any(ws: &mut BuzzTestClient, sid: &str) -> bool { + !live_event_ids(ws, sid).await.is_empty() +} + +/// IDs of the events subscription `sid` receives until 2s pass with no +/// message. A dropped connection or a `CLOSED` for `sid` fails the test, so +/// a dead subscription can never pass for one that saw nothing. +async fn live_event_ids(ws: &mut BuzzTestClient, sid: &str) -> Vec { + let mut ids = Vec::new(); + loop { + match ws.recv_event(Duration::from_secs(2)).await { + Ok(RelayMessage::Event { + subscription_id, + event, + }) if subscription_id == sid => ids.push(event.id), + Ok(RelayMessage::Closed { + subscription_id, + message, + }) if subscription_id == sid => panic!("{sid} closed: {message}"), + Ok(_) => {} + Err(TestClientError::Timeout) => return ids, + Err(e) => panic!("{sid} receive failed: {e}"), + } + } +} + +/// Deleting a private deletion would store a public event naming it, so the +/// author is refused (NIP-09 gives such a deletion no effect), everyone else +/// gets the shared denial, and no reader but the author ever sees either. +#[tokio::test] +#[ignore] +async fn deletion_of_private_deletion_rejected() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let (_, private) = store_deleted_reminder(&client, &author).await; + let own = create_channel(&client, &author, "open").await; + + let live = Filter::new() + .kind(Kind::EventDeletion) + .author(author.public_key()); + let mut watcher = BuzzTestClient::connect(&relay_url(), &other) + .await + .expect("connect"); + let sid = sub_id("chain"); + watcher.subscribe(&sid, vec![live]).await.expect("REQ"); + assert!(watcher + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE") + .is_empty()); + + let target = private.id.to_hex(); + for k in [None, Some("5")] { + let mut tags = vec![tag(&["e", &target])]; + tags.extend(k.map(|k| tag(&["k", k]))); + let (status, http_msg, ws_msg) = + rejection(&client, &author, build_deletion(&author, tags)).await; + assert!(status >= 400, "k={k:?}: HTTP must reject"); + for msg in [http_msg, ws_msg] { + assert_eq!( + msg, "invalid: cannot delete a private deletion request", + "k={k:?}" + ); + } + } + // A 9005 cannot reach the global private deletion from any channel. + let redact = EventBuilder::new(Kind::Custom(9005), "") + .tags(vec![tag(&["e", &target]), tag(&["h", &own])]) + .sign_with_keys(&author) + .unwrap(); + let (_, http_msg, ws_msg) = rejection(&client, &author, redact).await; + assert_eq!((http_msg.as_str(), ws_msg.as_str()), (DENIED, DENIED)); + + let missing = "a".repeat(64); + assert_eq!( + rejection( + &client, + &other, + build_deletion(&other, vec![tag(&["e", &target])]) + ) + .await, + rejection( + &client, + &other, + build_deletion(&other, vec![tag(&["e", &missing])]) + ) + .await, + ); + assert_rejected_as_non_author( + &client, + &other, + build_deletion(&other, vec![tag(&["e", &target])]), + ) + .await; + + // Only the private deletion exists: the author alone reads it. + assert_deletion_visibility(&author, &author, private.id, true).await; + assert_deletion_visibility(&other, &author, private.id, false).await; + assert!( + !receives_any(&mut watcher, &sid).await, + "no deletion may reach another reader live" + ); + watcher.disconnect().await.expect("disconnect"); + + // Deleting a public deletion is unchanged. + let note = EventBuilder::text_note("public note") + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, ¬e).await; + let public = build_deletion(&author, vec![tag(&["e", ¬e.id.to_hex()])]); + submit_ok(&client, &author, &public).await; + submit_ok( + &client, + &author, + &build_deletion(&author, vec![tag(&["e", &public.id.to_hex()])]), + ) + .await; +} + +/// A soft-deleted target keeps its channel: re-deleting it faces the same +/// membership and archive gates as a live one, and an accepted re-deletion is +/// readable by that channel's members only. Re-deletions now retain the +/// target's stored channel, so historical access, `#h` filtering and live +/// routing use that channel rather than treating a new re-deletion as global. +#[tokio::test] +#[ignore] +async fn soft_deleted_target_keeps_its_channel() { + let client = http_client(); + let owner = Keys::generate(); + let author = Keys::generate(); + let outsider = Keys::generate(); + let private = create_channel(&client, &owner, "private").await; + let archived = create_channel(&client, &author, "open").await; + let membership = |kind: u16| { + EventBuilder::new(Kind::Custom(kind), "") + .tags(vec![ + tag(&["h", &private]), + tag(&["p", &author.public_key().to_hex()]), + ]) + .sign_with_keys(&owner) + .unwrap() + }; + let delete = + |target: &nostr::Event| build_deletion(&author, vec![tag(&["e", &target.id.to_hex()])]); + let soft_deleted = |channel: String| { + let client = client.clone(); + let author = author.clone(); + async move { + let message = post_message(&client, &author, &channel).await; + submit_ok(&client, &author, &delete(&message)).await; + message + } + }; + + submit_ok(&client, &owner, &membership(9000)).await; + let redeletable = soft_deleted(private.clone()).await; + let redeletion = build_deletion_with_reason( + &author, + vec![tag(&["e", &redeletable.id.to_hex()])], + "re-deletion", + ); + assert_ne!(redeletion.id, delete(&redeletable).id); + + // Subscribe before the re-deletion: the member by channel, the outsider + // by author. A global control deletion proves the outsider's is alive. + let h = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let mut watchers = Vec::new(); + for (reader, filter) in [ + ( + &owner, + Filter::new() + .kind(Kind::EventDeletion) + .custom_tag(h, private.clone()), + ), + ( + &outsider, + Filter::new() + .kind(Kind::EventDeletion) + .author(author.public_key()), + ), + ] { + let mut ws = BuzzTestClient::connect(&relay_url(), reader) + .await + .expect("connect"); + let sid = sub_id("redeletion"); + ws.subscribe(&sid, vec![filter]).await.expect("REQ"); + ws.collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + watchers.push((ws, sid)); + } + let (accepted, msg) = submit(&client, &author, &redeletion).await; + assert!( + accepted && !msg.starts_with("duplicate:"), + "re-deletion: {msg}" + ); + let note = EventBuilder::text_note("control") + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, ¬e).await; + let control = delete(¬e); + submit_ok(&client, &author, &control).await; + let [member_ids, outsider_ids] = { + let mut out = [Vec::new(), Vec::new()]; + for (slot, (mut ws, sid)) in out.iter_mut().zip(watchers) { + *slot = live_event_ids(&mut ws, &sid).await; + ws.disconnect().await.expect("disconnect"); + } + out + }; + assert_eq!(member_ids, vec![redeletion.id], "member live delivery"); + assert_eq!(outsider_ids, vec![control.id], "outsider live delivery"); + + let removed = soft_deleted(private.clone()).await; + submit_ok(&client, &owner, &membership(9001)).await; + let in_archive = soft_deleted(archived.clone()).await; + let archive = EventBuilder::new(Kind::Custom(9002), "") + .tags(vec![tag(&["h", &archived]), tag(&["archived", "true"])]) + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, &archive).await; + + let missing = build_deletion(&author, vec![tag(&["e", &"a".repeat(64)])]); + let expected = rejection(&client, &author, missing).await; + assert_eq!(expected.1, DENIED); + for (case, target) in [("removed", &removed), ("archived", &in_archive)] { + assert_eq!( + rejection(&client, &author, delete(target)).await, + expected, + "{case}" + ); + } + + for (reader, name, visible) in [(&owner, "member", true), (&outsider, "outsider", false)] { + for filter in [ + Filter::new().id(redeletion.id), + Filter::new() + .kind(Kind::EventDeletion) + .custom_tag(h, private.clone()), + ] { + let ids = http_query_ids(&client, reader, &filter).await; + assert_eq!( + ids.contains(&redeletion.id.to_hex()), + visible, + "{name} {filter:?}: {ids:?}" + ); + } + } +} + +/// The single-target rule depends only on the request, so a deletion naming +/// two targets gets the same error whatever those targets are. +#[tokio::test] +#[ignore] +async fn multi_target_deletion_error_ignores_targets() { + let client = http_client(); + let author = Keys::generate(); + let owner = Keys::generate(); + let open = create_channel(&client, &author, "open").await; + let elsewhere = create_channel(&client, &owner, "private").await; + let foreign = post_message(&client, &owner, &elsewhere).await; + let live = post_message(&client, &author, &open).await; + let reminder = build_reminder(&author); + submit_ok(&client, &author, &reminder).await; + let artifact = post_artifact(&client, &author, &open).await; + let coordinate = format!( + "{KIND_EVENT_REMINDER}:{}:{}", + author.public_key().to_hex(), + reminder.tags.identifier().expect("d tag") + ); + let missing = "a".repeat(64); + let targets = [ + ("missing", missing.clone()), + ("live", live.id.to_hex()), + ("other channel", foreign.id.to_hex()), + ("private kind", reminder.id.to_hex()), + ("artifact", artifact.id.to_hex()), + ]; + let shapes = [ + ("two e", "(got e=2, a=0)", "e", missing.as_str()), + ("e plus a", "(got e=1, a=1)", "a", coordinate.as_str()), + ]; + for (shape, counts, second, value) in shapes { + let expected = format!( + "invalid: deletion events must reference exactly one target via e or a tag {counts}" + ); + let mut status = None; + for (case, target) in &targets { + let deletion = + build_deletion(&author, vec![tag(&["e", target]), tag(&[second, value])]); + let (code, http_msg, ws_msg) = rejection(&client, &author, deletion).await; + assert_eq!(http_msg, expected, "{shape}, {case}: HTTP"); + assert_eq!(ws_msg, expected, "{shape}, {case}: WS"); + assert_eq!(*status.get_or_insert(code), code, "{shape}, {case}: status"); + } + } +} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 4f5ed92ba36..b51e5759737 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -239,7 +239,8 @@ run_unit_tests() { handlers::req::tests::revoke_then_replacement_keeps_replacement_whole \ handlers::req::tests::claims_after_connection_cleanup_are_refused \ handlers::req::tests::dropped_terminal_frame_cancels_connection \ - handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection + handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection \ + handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions run_test_step "buzz-relay readiness tests" \ cargo test -p buzz-relay --lib readiness::tests:: -- --nocapture From d6dc813290680231e90cf7954d99ec60055750dd Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Tue, 13 Oct 2026 15:47:53 -0400 Subject: [PATCH 07/13] ci(docker): publish a sortable main-- relay tag (#8240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Each push to main adds one more tag to the relay image manifest that `docker.yml` already publishes: - `:main--` (UTC commit time) - `:debug-main--` on the debug manifest `:main`, `:sha-<7>`, and the release tags don't change. **Why:** `:sha-<7>` has no ordering, so a deploy tool that wants "the newest main build" has to fetch metadata for every tag before it can sort. Our staging promotion warehouse (Kargo, `NewestBuild`) does that on every discovery cycle, against about 1,700 `sha-*` tags (4,800 total) in `ghcr.io/block/buzz`. That's slow, it hits registry rate limits, and one failed fetch aborts the whole cycle. The timestamp prefix makes the tag sort lexically in build order. A `Lexical` selector with `^main-\d{14}-[0-9a-f]{7}$` can then pick the newest build from the tag list alone and fetch only the tags it keeps. Details, checked against the pinned `docker/metadata-action` v6.1.0 (`80c7e94d`): - `{{sha}}` is the 7-char short sha (`defaultShortShaLength = 7`), the same value as `:sha-<7>`. - `{{commit_date}}` formats in UTC by default. On a push it uses the event's `commits[].timestamp`, so rerunning a commit produces the same tag. - `type=raw` inherits the flavor prefix, so the merge job's debug leg emits `debug-main-…`. That's the same mechanism that produces `debug-main` today. - `enable=` limits the tag to `push` on `refs/heads/main`. Release tags, rescue dispatches, and PRs never get it. On main's last 40 commits, author time and committer time are the same instant, and both increase monotonically. A commit pushed with a backdated timestamp would sort below newer builds and just wouldn't be auto-picked; the next push supersedes it. **What got simpler:** this adds one tag line in each of the two places that already mirror the tag matrix, plus header docs. In return, downstream tooling no longer needs per-tag metadata fetches to find the newest build. ### Related issue N/A (staging rollout tooling). The downstream warehouse and tag-guard changes land separately and don't switch over until this tag has been published. ### Testing - `scripts/test-relay-image-eligibility-workflow.sh` and `scripts/test-release-ref-contract.sh` pass at `9ba1d8b7f`. - PR runs don't push images (`push: false`), so the first real tag appears on the first main push after merge. I'll check the registry for `main-<14 digits>-` and `debug-main-…` on that manifest. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- .github/workflows/docker.yml | 12 ++++++++++-- deploy/charts/buzz/README.md | 4 ++-- scripts/test-release-ref-contract.sh | 8 ++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3d824bfc565..596c247eeee 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,8 +16,8 @@ name: Docker image # the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. # # Triggers: -# - push to main → :main + :sha-<7> -# + :debug-main + :debug-sha-<7> +# - push to main → :main + :sha-<7> + :main--<7> +# + the matching :debug-* tags # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # + matching :debug-* tags # (+ :latest/:debug-latest for stable releases) @@ -34,6 +34,12 @@ name: Docker image # intact) and the bare version flows straight to the semver parser. On a # real push event value= is empty and the match strips relay-v from the ref. # +# :main--<7> names the same main build as :sha-<7>, prefixed +# with its UTC commit time so the tag sorts by build order. Kargo's Lexical +# strategy picks the newest from the tag list alone; :sha-<7> cannot be +# ordered without fetching every image's metadata. It uses the commit date, +# not the run date, so a rerun of the same commit re-pushes the same tag. +# # The :latest tag tracks the latest STABLE relay release: metadata-action's # `flavor.latest=auto` (its default) emits :latest only for non-prerelease # semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest, @@ -162,6 +168,7 @@ jobs: type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} labels: | org.opencontainers.image.title=Buzz org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform @@ -371,6 +378,7 @@ jobs: type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - name: Create and push manifest list id: manifest diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index eb9e3bdb32f..5704a7622b8 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -480,8 +480,8 @@ Every Pod the chart renders — relay, pairing relay, storage accounting, and th deletion drain operator job — carries a chart-owned `tags.datadoghq.com/version` label derived from the deployed image: `image.tag` when set, otherwise `image.digest`, otherwise `Chart.AppVersion`. -The tag is preferred because it is the readable build name (`sha-`) -dashboards already use; promotion tooling writes it together with the digest +The tag is preferred because it is the readable build name (`sha-`, or +`main--` for Kargo-promoted main builds) dashboards use; promotion tooling writes it together with the digest that actually pins the image. ```yaml diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 73e37e7955c..5b11a710875 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -52,6 +52,14 @@ fi grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/release.yml" grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/docker.yml" +# Kargo's buzz-image warehouse selects Lexical over ^main-[0-9]{14}-[0-9a-f]{7}$; +# a drifted format stops Freight discovery silently, so pin both image legs. +sortable_main_tag=" type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=\${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}" +sortable_count="$(grep -Fxc "$sortable_main_tag" "$repo_root/.github/workflows/docker.yml" || true)" +if [ "$sortable_count" != 2 ]; then + echo "docker.yml must publish the sortable main-- tag on both image legs (found $sortable_count)" >&2 + exit 1 +fi grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml" "$repo_root/scripts/test-signed-canary-contract.sh" "$repo_root/scripts/test-desktop-release-cache-key.sh" From 5dc693aeafc780565d1fa6da3882090aadb8b06c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 13 Oct 2026 16:24:27 -0400 Subject: [PATCH 08/13] Extract shared Huddle audio client for desktop consumers (#8129) Huddle audio was embedded in the v0 desktop client, forcing other clients to duplicate its protocol and codec behavior. This extracts `buzz-audio-client` so v1 can depend on a pinned library revision without building the v0 application. The crate owns the bounded v2 audio handshake, roster parsing, wire format, Opus sender, and per-peer NetEQ jitter buffer. The v0 human and agent senders use it, and the desktop retains room admission, identity/signing, TLS policy, device routing, mute, and teardown. Receive decoding preserves v1's support for 120 ms Opus packets. No relay protocol changes, speech models, or model downloads are included. Docker builder stages include CMake because cargo-chef compiles the workspace dependency graph. Dependency security: Opus 0.4 replaces the unmaintained `audiopus_sys` backend with `opusic-sys`. NetEQ is pinned exactly to 0.8.3 because its published Rust sources never reference its declared ringbuf dependency. RUSTSEC-2026-0293 is documented as unreachable for this audited source, and a wrapper restriction rejects every other consumer of vulnerable ringbuf versions. Independent review confirmed the scope; re-audit/remove the exception when changing NetEQ. NetEQ 0.9.1 still declares ringbuf 0.3, so a version bump alone does not remove it. Local `cargo deny check` passes with this constrained exception. Validation: - All 21 shared-crate tests pass, including real loopback WebSocket authentication/cancellation, real Opus-to-NetEQ playback, malformed input, and 120 ms decoding. These tests are included in `just test-unit`. - Shared-crate, root workspace, and desktop Tauri Clippy pass; `just desktop-tauri-check` passes. - The desktop Tauri test workspace passes; native two-person listening/device checks remain separate. - All mandatory push checks pass at `d06bac5dc`, including the complete Rust unit gate and desktop workspace checks. Existing agent timing and relay log-capture tests failed under concurrent load, passed separately, and passed in the full serial test run; no tests or hooks were skipped. - One test-only fixture change allows cold Perl startup before the escaped-process test's inner timeout (2 seconds instead of 300 ms). Its independent 5-second watchdog, 300-second surviving pipe writer, and all assertions remain intact. All eight process-helper tests pass; independent review confirmed this still tests bounded drain completion. - Dependency inspection confirms the library brings in no desktop application, Tauri, identity store, relay server, or speech-model crates. - Independent source review found no correctness blockers. Draft: a native two-person call, mute/leave/cancellation, and microphone/speaker switching still need a runtime tryout and human review. Full `just ci` was stopped during the unrelated mobile toolchain download because of disk pressure; it is not a passing gate. Consumer integration is tracked in block/buzz-app#489. --------- Signed-off-by: kenny lopez --- Cargo.lock | 53 +++ Cargo.toml | 1 + Dockerfile | 1 + Dockerfile.push-gateway | 2 +- Justfile | 2 + crates/buzz-acp/src/acp.rs | 122 +++--- crates/buzz-agent/tests/fake_llm.rs | 39 +- crates/buzz-audio-client/Cargo.toml | 25 ++ crates/buzz-audio-client/README.md | 42 +++ crates/buzz-audio-client/src/connection.rs | 165 +++++++++ crates/buzz-audio-client/src/encoder.rs | 65 ++++ crates/buzz-audio-client/src/jitter.rs | 285 ++++++++++++++ crates/buzz-audio-client/src/lib.rs | 11 + crates/buzz-audio-client/src/wire.rs | 346 +++++++++++++++++ crates/buzz-audio-client/tests/codec.rs | 56 +++ crates/buzz-audio-client/tests/connection.rs | 133 +++++++ deny.toml | 9 + desktop/src-tauri/Cargo.lock | 45 ++- desktop/src-tauri/Cargo.toml | 3 +- desktop/src-tauri/src/huddle/jitter.rs | 269 +------------- desktop/src-tauri/src/huddle/relay_api.rs | 214 ++--------- desktop/src-tauri/src/huddle/wire.rs | 348 +----------------- .../discovery/bounded_command.rs | 5 +- 23 files changed, 1356 insertions(+), 885 deletions(-) create mode 100644 crates/buzz-audio-client/Cargo.toml create mode 100644 crates/buzz-audio-client/README.md create mode 100644 crates/buzz-audio-client/src/connection.rs create mode 100644 crates/buzz-audio-client/src/encoder.rs create mode 100644 crates/buzz-audio-client/src/jitter.rs create mode 100644 crates/buzz-audio-client/src/lib.rs create mode 100644 crates/buzz-audio-client/src/wire.rs create mode 100644 crates/buzz-audio-client/tests/codec.rs create mode 100644 crates/buzz-audio-client/tests/connection.rs diff --git a/Cargo.lock b/Cargo.lock index b4986b67b40..0a924f4e36f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1040,6 +1040,18 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-audio-client" +version = "0.1.0" +dependencies = [ + "futures-util", + "neteq", + "opus", + "serde_json", + "tokio", + "tokio-tungstenite 0.29.0", +] + [[package]] name = "buzz-audit" version = "0.1.0" @@ -6244,6 +6256,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "neteq" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fbe9601485d95750187c92344fa056e75055027b00e375c53d9f4b8b883112" +dependencies = [ + "log", + "ringbuf", + "serde", + "thiserror 1.0.69", + "web-time", +] + [[package]] name = "netlink-packet-core" version = "0.8.1" @@ -7000,6 +7025,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opus" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" +dependencies = [ + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" +dependencies = [ + "cmake", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -8577,6 +8620,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ringbuf" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79abed428d1fd2a128201cec72c5f6938e2da607c6f3745f769fabea399d950a" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "rmcp" version = "1.8.0" @@ -10708,6 +10760,7 @@ dependencies = [ "futures-util", "log", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index d27c63d234c..df77ab7cb3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/buzz-mesh-smoke", "crates/buzz-dev-mcp", "crates/buzz-voice", + "crates/buzz-audio-client", "crates/buzz-backend-kubernetes", "crates/buzz-datastore-tracing", "examples/countdown-bot", diff --git a/Dockerfile b/Dockerfile index 2c1e8adbb5a..1f10b0f260a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,7 @@ FROM chef AS builder RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ + cmake \ pkg-config \ libssl-dev \ ca-certificates \ diff --git a/Dockerfile.push-gateway b/Dockerfile.push-gateway index 5b0aca8bd3e..2b899c89e5b 100644 --- a/Dockerfile.push-gateway +++ b/Dockerfile.push-gateway @@ -13,7 +13,7 @@ RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder ARG BUZZ_PUSH_CARGO_FEATURES="" RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential pkg-config libssl-dev ca-certificates \ + && apt-get install -y --no-install-recommends build-essential cmake pkg-config libssl-dev ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=planner /build/recipe.json recipe.json RUN cargo chef cook --release --recipe-path recipe.json --features "$BUZZ_PUSH_CARGO_FEATURES" diff --git a/Justfile b/Justfile index 3e7ead15284..8d107af69ee 100644 --- a/Justfile +++ b/Justfile @@ -397,6 +397,8 @@ test-unit: #!/usr/bin/env bash set -euo pipefail ./scripts/test-ensure-local-relay-key.sh + # Shared Huddle transport, codec, and loopback authentication regressions. + cargo test -p buzz-audio-client if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-audit --lib diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 985b29bee70..026b2344a04 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -3384,29 +3384,85 @@ mod tests { #[tokio::test] async fn idle_resets_on_stdout_activity() { - // Send valid JSON (session/update notifications) to reset the idle timer. - // Non-JSON lines no longer reset idle — only valid JSON notifications do. - let mut client = spawn_script( - r#"for i in {1..10}; do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"agent_thought_chunk","content":{"text":"thinking"}}}}'; sleep 0.05; done; sleep 10"#, - ) + assert_activity_resets_idle(serde_json::json!({ + "sessionUpdate": "agent_thought_chunk", "content": { "text": "thinking" } + })) .await; - let max_dur = std::time::Duration::from_secs(10); - let hard_deadline = tokio::time::Instant::now() + max_dur; - let start = std::time::Instant::now(); - let result = client - .read_until_response_with_idle_timeout( + } + + /// Exercise the real stdout reader with a paused clock. Advance only after + /// each notification is observed, so OS scheduling cannot consume the idle + /// window. Each update must extend the deadline; silence must still expire. + async fn assert_activity_resets_idle(update: serde_json::Value) { + use tokio::io::AsyncWriteExt; + + let mut client = + spawn_script(r#"while IFS= read -r line; do printf '%s\n' "$line"; done"#).await; + let mut stdin = client.stdin.take().expect("test stdin"); + let observer = ObserverHandle::in_process(); + let mut events = observer.subscribe(); + client.set_observer(Some(observer), 0); + // Prevent Tokio from auto-advancing while waiting for real pipe I/O. + let clock_guard = tokio::spawn(async { + loop { + tokio::task::yield_now().await; + } + }); + tokio::time::pause(); + let idle = std::time::Duration::from_secs(1); + let hard = std::time::Duration::from_secs(10); + let hard_deadline = tokio::time::Instant::now() + hard; + let mut frame = serde_json::json!({ + "jsonrpc": "2.0", "method": "session/update", "params": { "update": update } + }) + .to_string(); + frame.push('\n'); + { + let read = client.read_until_response_with_idle_timeout( "test", 999, - std::time::Duration::from_millis(200), + idle, hard_deadline, - max_dur, - ) - .await; - let elapsed = start.elapsed(); - // 10 messages × 50ms = ~500ms of activity, then idle timeout fires after 200ms more - assert!(elapsed >= std::time::Duration::from_millis(400)); - assert!(elapsed < std::time::Duration::from_secs(3)); - assert!(matches!(result, Err(AcpError::IdleTimeout(_)))); + hard, + ); + tokio::pin!(read); + assert!(futures_util::poll!(&mut read).is_pending()); + for _ in 0..3 { + tokio::time::advance(std::time::Duration::from_millis(750)).await; + stdin + .write_all(frame.as_bytes()) + .await + .expect("write activity"); + let watchdog = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + assert!( + futures_util::poll!(&mut read).is_pending(), + "activity must extend the idle deadline" + ); + if let Ok(event) = events.try_recv() { + assert_eq!(event.kind, "acp_read"); + break; + } + assert!( + std::time::Instant::now() < watchdog, + "activity was not read from the pipe" + ); + tokio::task::yield_now().await; + } + } + tokio::time::advance(idle - std::time::Duration::from_millis(1)).await; + assert!( + futures_util::poll!(&mut read).is_pending(), + "the last activity must reset the full idle window" + ); + tokio::time::advance(std::time::Duration::from_millis(1)).await; + // Pipe I/O is done. Let Tokio drive the timer wheel, whose wakeup + // can round the deadline up to the next millisecond. + clock_guard.abort(); + assert!(matches!(read.await, Err(AcpError::IdleTimeout(duration)) if duration == idle)); + } + tokio::time::resume(); + client.shutdown().await; } #[tokio::test] @@ -3581,33 +3637,7 @@ mod tests { #[tokio::test] async fn keepalive_resets_idle_past_deadline() { - // Keepalive session/update lines every 50ms against a 250ms idle deadline. - // The turn should survive well past the idle deadline (proves the fix). - let mut client = spawn_script( - r#"for i in {1..20}; do echo '{"jsonrpc":"2.0","method":"session/update","params":{"update":{"sessionUpdate":"keepalive"}}}'; sleep 0.05; done; sleep 10"#, - ) - .await; - let max_dur = std::time::Duration::from_secs(10); - let hard_deadline = tokio::time::Instant::now() + max_dur; - let start = std::time::Instant::now(); - let result = client - .read_until_response_with_idle_timeout( - "test", - 999, - std::time::Duration::from_millis(250), - hard_deadline, - max_dur, - ) - .await; - let elapsed = start.elapsed(); - // 20 keepalives × 50ms = ~1000ms of activity, then idle fires after 250ms more. - // Must survive well past the 250ms deadline. - assert!( - elapsed >= std::time::Duration::from_millis(750), - "keepalive should reset idle past the deadline; elapsed only {elapsed:?}" - ); - assert!(elapsed < std::time::Duration::from_secs(5)); - assert!(matches!(result, Err(AcpError::IdleTimeout(_)))); + assert_activity_resets_idle(serde_json::json!({ "sessionUpdate": "keepalive" })).await; } #[tokio::test] diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index 2451deae088..86c469f982c 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -956,10 +956,18 @@ async fn steer_rejected_when_no_active_run() { async fn steer_rejected_on_run_id_mismatch() { // A live run, but the caller targets a stale/wrong run id → invalid_params, // so the client falls back to cancel+merge instead of injecting blind. - let (url, _captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_x", "fake__noop", json!({})), - openai_text("done"), - ]) + // Hold the provider response until the rejection is observed. Otherwise + // the turn can finish before the client sends or receives the steer. + let (gate_tx, gate_rx) = tokio::sync::oneshot::channel(); + let responses = vec![CannedResponse { + status: 200, + body: openai_text("done"), + }]; + let (url, _) = spawn_gated_capturing_fake_llm( + responses, + Arc::new(Mutex::new(Vec::new())), + Arc::new(Mutex::new(Some(gate_rx))), + ) .await; let mut h = Harness::spawn(&url).await; let sid = init_session(&mut h).await; @@ -983,21 +991,14 @@ async fn steer_rejected_on_run_id_mismatch() { ) .await; - let mut saw_reject = false; - for _ in 0..40 { - let v = h.recv().await; - if v["id"] == json!(s_id) { - assert_eq!( - v["error"]["code"], -32602, - "mismatched runId must be rejected" - ); - saw_reject = true; - } else if v["id"] == json!(p_id) { - // Turn finishes normally regardless of the rejected steer. - break; - } - } - assert!(saw_reject, "run-id mismatch was not rejected"); + let rejected = h.recv_until(|v| v["id"] == json!(s_id)).await; + assert_eq!( + rejected["error"]["code"], -32602, + "mismatched runId must be rejected" + ); + gate_tx.send(()).expect("provider gate should remain live"); + let completed = h.recv_until(|v| v["id"] == json!(p_id)).await; + assert_eq!(completed["result"]["stopReason"], "end_turn"); h.shutdown().await; } diff --git a/crates/buzz-audio-client/Cargo.toml b/crates/buzz-audio-client/Cargo.toml new file mode 100644 index 00000000000..8b0a5919256 --- /dev/null +++ b/crates/buzz-audio-client/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "buzz-audio-client" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Shared Huddle audio transport and codecs without a desktop application" + +[features] +default = ["native-roots"] +native-roots = ["tokio-tungstenite/rustls-tls-native-roots"] +webpki-roots = ["tokio-tungstenite/rustls-tls-webpki-roots"] + +[dependencies] +futures-util = { version = "0.3", features = ["sink"] } +# Keep the audited unused ringbuf dependency pinned until NetEQ removes it. +neteq = { version = "=0.8.3", default-features = false } +opus = "0.4" +serde_json = "1" +tokio = { version = "1", features = ["net", "time"] } +tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "handshake"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt", "sync", "test-util"] } diff --git a/crates/buzz-audio-client/README.md b/crates/buzz-audio-client/README.md new file mode 100644 index 00000000000..f847c985760 --- /dev/null +++ b/crates/buzz-audio-client/README.md @@ -0,0 +1,42 @@ +# Shared Huddle audio client + +`buzz-audio-client` contains the Huddle v2 audio handshake, wire format, Opus +sender, and per-peer NetEQ jitter buffer used by Buzz desktop clients. It does +not depend on Tauri, the v0 desktop application, a relay server, identity stores, +or speech models. A consumer can select this package from a pinned `block/buzz` +Git revision without building the rest of the workspace. + +The host validates the destination and supplies an asynchronous NIP-42 signer +to `connection::connect`. The crate bounds connection and admission, validates +the initial roster, and returns a socket owned by the host. Dropping the pending +future or socket releases it; the crate creates no detached tasks. Hosts retain +room creation, membership checks, mute, device routing, connection supervision, +and application teardown. Audio admission alone does not grant chat access. + +`AudioEncoder` accepts up to 960 finite mono samples at 48 kHz and produces a +20 ms v2 packet. `PeerJitterBuffer` consumes the sequence/timestamp/Opus fields +from `wire::parse_relay_frame` and produces 480 samples per 10 ms playout tick. +Hosts maintain one receiver per peer, discard its state on leave or roster +reassignment, bound playback queues, and stop idle peers after buffered audio +drains. V2 has no occupancy epoch in media, so a delayed packet from a previous +occupant cannot be distinguished after index reuse. + +The default `native-roots` feature selects system TLS trust. Hosts that use +WebPKI roots select `default-features = false, features = ["webpki-roots"]`; +hosts can also pass their configured TLS connector. Signing and trust policy +remain host-owned. + +Opus 0.4 uses the maintained `opusic-sys` backend. NetEQ is pinned to 0.8.3: +its published source does not use its declared `ringbuf` dependency, which has +advisory RUSTSEC-2026-0293. The root security policy documents that unreachable +path and forbids other consumers of vulnerable ringbuf versions. Re-audit or +remove that exception when updating NetEQ; 0.9.1 still declares the old ringbuf. + +Run `cargo test -p buzz-audio-client` and +`cargo clippy -p buzz-audio-client --all-targets -- -D warnings` from the root +with Hermit active. Tests use real loopback WebSockets and Opus audio, without a +live community or microphone. Client integration still requires a native +two-person call, mute, leave, cancellation, and device-switching tryout. + +STT/TTS providers, model installation, and speech HTTP endpoints are intentionally +outside this transport crate. diff --git a/crates/buzz-audio-client/src/connection.rs b/crates/buzz-audio-client/src/connection.rs new file mode 100644 index 00000000000..193a32c5fe1 --- /dev/null +++ b/crates/buzz-audio-client/src/connection.rs @@ -0,0 +1,165 @@ +//! Bounded NIP-42 audio handshake. Signing and TLS policy belong to the host. + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{collections::BTreeSet, future::Future, time::Duration}; +use tokio_tungstenite::{ + connect_async_tls_with_config, + tungstenite::{protocol::WebSocketConfig, Message}, + Connector, MaybeTlsStream, WebSocketStream, +}; + +/// Authenticated audio socket; dropping it closes the connection. +pub type AudioSocket = WebSocketStream>; + +/// Relay roster entry. Epoch is optional on released v2 relays. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Peer { + /// Relay-assigned media index. + pub index: u8, + /// Hex Nostr public key. + pub pubkey: String, + /// Control-plane occupancy epoch, zero on legacy relays. + pub epoch: u8, +} + +/// Successful audio admission, without granting message read/write authority. +pub struct Joined { + /// Host-owned connection, ready for media and control messages. + pub socket: AudioSocket, + /// Index assigned to this publisher. + pub index: u8, + /// Initial room roster. + pub peers: Vec, +} + +/// Parse a bounded roster without truncating out-of-range indices or epochs. +pub fn parse_peers(value: &Value) -> Result, String> { + let peers = value["peers"].as_array().ok_or("Invalid Huddle roster")?; + if peers.len() > 256 { + return Err("Huddle roster exceeds protocol capacity".into()); + } + let mut indices = BTreeSet::new(); + peers + .iter() + .map(|p| { + let index = p["peer_index"] + .as_u64() + .and_then(|i| u8::try_from(i).ok()) + .ok_or("Invalid Huddle peer index")?; + let pubkey = p["pubkey"] + .as_str() + .filter(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit())) + .ok_or("Invalid Huddle peer key")?; + let epoch = match p.get("epoch") { + None => 0, + Some(e) => e + .as_u64() + .and_then(|i| u8::try_from(i).ok()) + .ok_or("Invalid Huddle peer epoch")?, + }; + if !indices.insert(index) { + return Err("Duplicate Huddle peer".into()); + } + Ok(Peer { + index, + pubkey: pubkey.into(), + epoch, + }) + }) + .collect() +} + +async fn control(socket: &mut AudioSocket) -> Result { + loop { + match socket.next().await { + Some(Ok(Message::Text(text))) => { + let value: Value = + serde_json::from_str(&text).map_err(|_| "Invalid Huddle response")?; + if value["type"] == "error" { + return Err(value["message"] + .as_str() + .unwrap_or("Huddle rejected by relay") + .chars() + .take(240) + .collect()); + } + return Ok(value); + } + Some(Ok(Message::Ping(data))) => socket + .send(Message::Pong(data)) + .await + .map_err(|_| "Huddle disconnected")?, + Some(Ok(Message::Pong(_))) => {} + _ => return Err("Huddle disconnected during admission".into()), + } + } +} + +/// Connect and authenticate using a host-provided asynchronous signer. +/// +/// The host validates the destination before calling, and constructs the NIP-42 +/// event (including its exact relay tag) from the challenge. No private key is +/// accepted. Dropping this future cancels connection/admission; the entire +/// operation is bounded to 15 seconds, including signing. +pub async fn connect( + audio_url: &str, + parent: Option<&str>, + connector: Option, + sign: F, +) -> Result +where + F: FnOnce(String) -> Fut, + Fut: Future>, +{ + tokio::time::timeout(Duration::from_secs(15), async { + let config = WebSocketConfig::default() + .max_message_size(Some(65_536)) + .max_frame_size(Some(65_536)); + let (mut socket, _) = + connect_async_tls_with_config(audio_url, Some(config), false, connector) + .await + .map_err(|e| format!("audio WS connect failed: {e}"))?; + let challenge = tokio::time::timeout(Duration::from_secs(5), control(&mut socket)) + .await + .map_err(|_| "Huddle challenge timed out")??; + if challenge["type"] != "challenge" { + return Err("Invalid Huddle handshake".into()); + } + let challenge = challenge["challenge"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 4096) + .ok_or("Invalid Huddle challenge")? + .to_owned(); + let event = sign(challenge).await?; + socket + .send(Message::Text( + json!({ + "type": "auth", "event": event, "parent_channel_id": parent, + "protocol_version": crate::wire::PROTOCOL_VERSION, + }) + .to_string() + .into(), + )) + .await + .map_err(|_| "Huddle authentication failed")?; + let joined = tokio::time::timeout(Duration::from_secs(5), control(&mut socket)) + .await + .map_err(|_| "Huddle admission timed out")??; + if joined["type"] != "joined" { + return Err("Huddle admission was not confirmed".into()); + } + let index = joined["peer_index"] + .as_u64() + .and_then(|i| u8::try_from(i).ok()) + .ok_or("Invalid Huddle publisher index")?; + let peers = parse_peers(&joined)?; + Ok(Joined { + socket, + index, + peers, + }) + }) + .await + .map_err(|_| "Huddle connection timed out")? +} diff --git a/crates/buzz-audio-client/src/encoder.rs b/crates/buzz-audio-client/src/encoder.rs new file mode 100644 index 00000000000..342187b07c9 --- /dev/null +++ b/crates/buzz-audio-client/src/encoder.rs @@ -0,0 +1,65 @@ +//! Stateful 48 kHz mono Opus sender shared by human and agent publishers. + +use crate::wire::{audio_level_dbov, FrameHeader, FLAG_DTX, V2_HEADER_LEN}; + +/// Samples in one 20 ms, 48 kHz mono frame. +pub const FRAME_SAMPLES: usize = 960; + +/// Opus encoder and v2 sequence/media clock for one connection. +pub struct AudioEncoder { + opus: opus::Encoder, + sequence: u16, + timestamp: u32, + output: Vec, +} + +impl AudioEncoder { + /// Create the standard 32 kbit/s VoIP encoder with discontinuous transmission. + pub fn new() -> Result { + let mut opus = opus::Encoder::new(48_000, opus::Channels::Mono, opus::Application::Voip) + .map_err(|e| format!("opus encoder: {e}"))?; + opus.set_bitrate(opus::Bitrate::Bits(32_000)) + .map_err(|e| format!("opus bitrate: {e}"))?; + opus.set_dtx(true).map_err(|e| format!("opus dtx: {e}"))?; + Ok(Self { + opus, + sequence: 0, + timestamp: 0, + output: vec![0; 4_000], + }) + } + + /// Encode up to 20 ms of finite mono PCM; pad the final short frame with silence. + /// + /// Returns the eight-byte v2 header followed by Opus. Empty/oversized or + /// non-finite input is rejected without advancing the media clock. + pub fn encode(&mut self, samples: &[f32]) -> Result, String> { + if samples.is_empty() + || samples.len() > FRAME_SAMPLES + || samples.iter().any(|s| !s.is_finite()) + { + return Err("Invalid Huddle audio frame".into()); + } + let mut padded = [0.0; FRAME_SAMPLES]; + padded[..samples.len()].copy_from_slice(samples); + let n = self + .opus + .encode_float(&padded, &mut self.output) + .map_err(|e| format!("opus encode: {e}"))?; + if n == 0 { + return Err("Opus produced an empty frame".into()); + } + let header = FrameHeader { + seq: self.sequence, + ts_48k: self.timestamp, + level_dbov: audio_level_dbov(samples), + flags: if n <= 2 { FLAG_DTX } else { 0 }, + }; + let mut frame = Vec::with_capacity(V2_HEADER_LEN + n); + frame.extend_from_slice(&header.encode()); + frame.extend_from_slice(&self.output[..n]); + self.sequence = self.sequence.wrapping_add(1); + self.timestamp = self.timestamp.wrapping_add(FRAME_SAMPLES as u32); + Ok(frame) + } +} diff --git a/crates/buzz-audio-client/src/jitter.rs b/crates/buzz-audio-client/src/jitter.rs new file mode 100644 index 00000000000..ff1c36077a1 --- /dev/null +++ b/crates/buzz-audio-client/src/jitter.rs @@ -0,0 +1,285 @@ +//! Per-peer NetEQ-style jitter buffer for huddle remote audio. +//! +//! Wraps the [`neteq`] crate's `NetEq` state machine and registers a thin +//! `AudioDecoder` impl over our `opus` dependency. Default +//! features on `neteq` are disabled so we don't pull in axum/cpal/clap; this +//! module owns the Opus side of the trait instead. +//! +//! ## Lifecycle +//! +//! One [`PeerJitterBuffer`] per active remote peer. It owns: +//! * a `NetEq` configured for 48 kHz mono with conservative jitter bounds, +//! * a stable synthetic RTP SSRC (the relay-assigned `peer_index`), and +//! * a 16-bit sequence number that wraps with the wire protocol's `seq`. +//! +//! On `joined` / first packet, construct one. On `left` / rejoin, drop and +//! recreate so NetEq's internal delay state doesn't carry over. +//! +//! ## Decode path +//! +//! Each inbound Opus frame becomes a `neteq::AudioPacket` with: +//! * sequence number from the protocol v2 header, +//! * 48 kHz RTP-style media timestamp (frame_index * 960 for 20 ms frames), +//! * a synthetic SSRC == peer_index, +//! * payload type [`OPUS_PAYLOAD_TYPE`] (any value, only NetEq uses it to +//! dispatch to its registered decoder), +//! * the raw Opus bytes as `payload`, +//! * `duration_ms = 20` (our encoder frame size). +//! +//! The playout side calls [`PeerJitterBuffer::get_audio`] on a 10 ms tick; +//! NetEq returns 10 ms of decoded PCM with adaptive-delay / expand / accelerate +//! decisions already applied. For 20 ms input frames at 48 kHz mono, that's +//! 480 f32 samples per call. + +use neteq::{ + codec::AudioDecoder, neteq::SpeechType, AudioPacket, NetEq, NetEqConfig, NetEqError, RtpHeader, +}; + +/// RTP payload type used internally. NetEq only needs it to look up the decoder +/// registered via `register_decoder`; the relay forwards Opus payloads opaquely +/// so the wire never carries a payload-type byte. +pub const OPUS_PAYLOAD_TYPE: u8 = 111; + +/// Audio sample rate for huddle (Opus VoIP, mono). +pub const SAMPLE_RATE_HZ: u32 = 48_000; +/// One channel (mono). +pub const CHANNELS: u8 = 1; +/// Opus encoder frame size in milliseconds. The wire format is one Opus packet +/// per 20 ms frame; the receiver still drains NetEq at 10 ms granularity. +pub const FRAME_DURATION_MS: u32 = 20; +/// 48 kHz media-time increment per encoder frame. +pub const FRAME_TIMESTAMP_DELTA: u32 = SAMPLE_RATE_HZ / 1000 * FRAME_DURATION_MS; // 960 +/// NetEq returns 10 ms frames; this is the sample count per `get_audio` call. +/// Kept as a documented constant for the protocol-v2 follow-up and consumers +/// that want to size their own buffers around the playout contract. +#[allow(dead_code)] +pub const PLAYOUT_SAMPLES: usize = (SAMPLE_RATE_HZ as usize / 1000) * 10; // 480 + +/// Minimum jitter delay NetEq is allowed to converge to (ms). +const MIN_DELAY_MS: u32 = 40; +/// Maximum jitter delay NetEq is allowed to converge to (ms). +const MAX_DELAY_MS: u32 = 200; +/// Buffer cap in packets — generous, but bounded so a runaway sender can't +/// drive memory growth. +const MAX_PACKETS_IN_BUFFER: usize = 50; + +/// Thin `AudioDecoder` over `opus::Decoder` so `neteq` can drive Opus payloads. +/// +/// NetEq's `decode` trait is sample-based, not FEC-aware (no `fec` bool). The +/// stock `neteq` `NativeOpusDecoder` (gated behind the `native` feature, which +/// pulls in cpal/axum) calls `decode_float(..., false)` unconditionally; this +/// matches that behavior so we don't need the feature. +/// +/// Receive-side FEC ("decode this frame's redundant copy on a known-lost prior +/// frame") would require either a trait change upstream or NetEq calling decode +/// twice with different `fec` values — out of scope for the initial 10-person +/// fix. Tracked as a follow-up alongside encoder-side `set_inband_fec`. +struct OpusFrameDecoder { + inner: opus::Decoder, + sample_rate: u32, + channels: u8, + /// Scratch buffer reused across calls to avoid per-frame allocation. + scratch: Vec, +} + +impl OpusFrameDecoder { + fn new(sample_rate: u32, channels: u8) -> Result { + let opus_channels = match channels { + 1 => opus::Channels::Mono, + 2 => opus::Channels::Stereo, + n => return Err(format!("unsupported channel count: {n}")), + }; + let inner = opus::Decoder::new(sample_rate, opus_channels) + .map_err(|e| format!("opus decoder init: {e}"))?; + // Opus can carry 120 ms even though our own sender uses 20 ms mono. + // Keep the decoder bounded while accepting other compatible senders. + let cap = (sample_rate as usize / 1000) * 120 * channels as usize; + Ok(Self { + inner, + sample_rate, + channels, + scratch: vec![0.0; cap], + }) + } +} + +impl AudioDecoder for OpusFrameDecoder { + fn sample_rate(&self) -> u32 { + self.sample_rate + } + + fn channels(&self) -> u8 { + self.channels + } + + fn decode(&mut self, encoded: &[u8]) -> Result, NetEqError> { + match self.inner.decode_float(encoded, &mut self.scratch, false) { + Ok(samples_per_channel) => { + let total = samples_per_channel * self.channels as usize; + Ok(self.scratch[..total].to_vec()) + } + Err(e) => Err(NetEqError::DecoderError(format!("opus decode: {e}"))), + } + } +} + +/// One peer's jitter buffer + decoder pair. +pub struct PeerJitterBuffer { + neteq: NetEq, + ssrc: u32, +} + +impl PeerJitterBuffer { + /// Construct a new jitter buffer for the peer identified by `peer_index`. + /// + /// `peer_index` is the relay-assigned 0..=254 stable index for the peer's + /// lifetime in the room; we promote it to a 32-bit synthetic SSRC so NetEq + /// has a stable stream identity for its delay-manager state. + pub fn new(peer_index: u8) -> Result { + let config = NetEqConfig { + sample_rate: SAMPLE_RATE_HZ, + channels: CHANNELS, + max_packets_in_buffer: MAX_PACKETS_IN_BUFFER, + min_delay_ms: MIN_DELAY_MS, + max_delay_ms: MAX_DELAY_MS, + ..Default::default() + }; + let mut neteq = NetEq::new(config)?; + let decoder = + OpusFrameDecoder::new(SAMPLE_RATE_HZ, CHANNELS).map_err(NetEqError::DecoderError)?; + neteq.register_decoder(OPUS_PAYLOAD_TYPE, Box::new(decoder)); + Ok(Self { + neteq, + ssrc: peer_index as u32, + }) + } + + /// Insert a received Opus packet. + /// + /// `seq` and `ts_48k` come from the protocol v2 header (sender-authored, + /// 16-bit wrapping sequence + 48 kHz RTP-style media time). + pub fn insert_packet( + &mut self, + seq: u16, + ts_48k: u32, + opus_payload: &[u8], + ) -> Result<(), NetEqError> { + let header = RtpHeader::new(seq, ts_48k, self.ssrc, OPUS_PAYLOAD_TYPE, false); + let packet = AudioPacket::new( + header, + opus_payload.to_vec(), + SAMPLE_RATE_HZ, + CHANNELS, + FRAME_DURATION_MS, + ); + self.neteq.insert_packet(packet) + } + + /// Drain one 10 ms playout frame. Always succeeds: NetEq emits PLC / + /// comfort-noise / silence rather than an error when there's nothing to + /// play. + /// + /// Returns `(samples, vad_active)`. `samples.len() == PLAYOUT_SAMPLES`. + pub fn get_audio(&mut self) -> Result<(Vec, bool), NetEqError> { + let frame = self.neteq.get_audio()?; + let vad = frame.vad_activity && !matches!(frame.speech_type, SpeechType::Expand); + Ok((frame.samples, vad)) + } + + /// True when NetEq's buffer has no packets queued and the next + /// `get_audio` call would emit expand/silence. Used by + /// `huddle::playout` as part of the idle-peer gate — if a peer has + /// gone quiet (`last_packet_at` is stale) *and* there's nothing + /// buffered to drain, we stop appending frames to that peer's rodio + /// Player. The check is conservative: a peer who just sent a burst + /// then disconnected may have buffered audio that should still play + /// out, so this method drives that decision. + pub fn is_empty(&self) -> bool { + self.neteq.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_a_repacketized_120_ms_opus_packet() { + let mut encoder = + opus::Encoder::new(48_000, opus::Channels::Mono, opus::Application::Voip).unwrap(); + let mut packet = vec![0; 4000]; + let size = encoder.encode_float(&[0.0; 2880], &mut packet).unwrap(); + packet.truncate(size); + let mut combined = vec![0; 8000]; + let size = opus::Repacketizer::new() + .unwrap() + .combine(&[&packet, &packet], &mut combined) + .unwrap(); + let mut decoder = OpusFrameDecoder::new(48_000, 1).unwrap(); + let samples = decoder.decode(&combined[..size]).unwrap(); + assert_eq!(samples.len(), 5760); + assert!(samples.iter().all(|s| s.is_finite())); + } + + fn opus_silence_frame() -> Vec { + // Encode 20 ms of silence at 48 kHz mono so the test doesn't rely on + // synthetic Opus byte sequences. + let mut encoder = opus::Encoder::new( + SAMPLE_RATE_HZ, + opus::Channels::Mono, + opus::Application::Voip, + ) + .expect("opus encoder"); + let pcm = vec![0.0_f32; FRAME_TIMESTAMP_DELTA as usize]; + let mut out = vec![0u8; 4000]; + let n = encoder + .encode_float(&pcm, &mut out) + .expect("opus encode silence"); + out.truncate(n); + out + } + + #[test] + fn jitter_buffer_construction_succeeds_for_each_peer_index() { + for idx in [0u8, 1, 7, 42, 254] { + let jb = PeerJitterBuffer::new(idx).expect("construct jitter buffer"); + assert!(jb.is_empty(), "peer {idx} should start empty"); + // SSRC is the peer_index as u32 — verifies the stable-identity contract. + assert_eq!(jb.ssrc, idx as u32); + } + } + + #[test] + fn insert_packet_then_get_audio_returns_playout_frame() { + let mut jb = PeerJitterBuffer::new(3).expect("jitter buffer"); + let payload = opus_silence_frame(); + + // Insert a few sequential packets so NetEq has enough buffer to start + // emitting Normal-decoded frames instead of pure Expand/silence. + for i in 0..6u16 { + let ts = (i as u32) * FRAME_TIMESTAMP_DELTA; + jb.insert_packet(i, ts, &payload).expect("insert"); + } + + // Pull a playout frame — should be 10 ms = 480 mono samples. + let (samples, _vad) = jb.get_audio().expect("get_audio"); + assert_eq!( + samples.len(), + PLAYOUT_SAMPLES, + "NetEq must emit exactly one 10 ms frame per get_audio call", + ); + } + + #[test] + fn empty_buffer_get_audio_still_produces_a_frame() { + // Even with no packets inserted, NetEq's contract is to always emit + // 10 ms — as Expand/silence — so the playout clock keeps ticking. + let mut jb = PeerJitterBuffer::new(0).expect("jitter buffer"); + let (samples, vad) = jb.get_audio().expect("get_audio"); + assert_eq!(samples.len(), PLAYOUT_SAMPLES); + assert!( + !vad, + "empty buffer should not report voice activity (Expand frame)", + ); + } +} diff --git a/crates/buzz-audio-client/src/lib.rs b/crates/buzz-audio-client/src/lib.rs new file mode 100644 index 00000000000..14917b9500c --- /dev/null +++ b/crates/buzz-audio-client/src/lib.rs @@ -0,0 +1,11 @@ +//! Huddle audio primitives shared by desktop clients. +//! +//! The host retains identity, room admission, device routing, UI, and speech models. +//! This crate owns the v2 handshake, wire format, Opus encoding and receive jitter +//! buffering. It neither loads private keys nor starts background tasks. +#![deny(unsafe_code)] + +pub mod connection; +pub mod encoder; +pub mod jitter; +pub mod wire; diff --git a/crates/buzz-audio-client/src/wire.rs b/crates/buzz-audio-client/src/wire.rs new file mode 100644 index 00000000000..bcf9c007c2d --- /dev/null +++ b/crates/buzz-audio-client/src/wire.rs @@ -0,0 +1,346 @@ +//! Huddle audio wire protocol — versioning + frame header. +//! +//! ## v1 (legacy) +//! +//! Client → relay: `` +//! Relay → client: `` +//! +//! No per-frame metadata; receiver synthesizes sequence/timestamp on arrival. +//! Kept for backward compatibility — relay still admits v1 clients into +//! v1-pinned rooms — but new clients speak v2 while deployed relays remain +//! capped at the released v2 contract. +//! +//! ## v2 (compatibility contract) +//! +//! Client → relay: `` +//! Relay → client: `` +//! +//! Protocol v2 does not carry v3's occupancy epoch in media frames. The +//! control-plane roster still resets decoder and playout state when an index is +//! reassigned, but v2 cannot fence a delayed packet from the previous occupant +//! after that reassignment. +//! +//! Header layout (8 bytes, network byte order, big-endian): +//! +//! ```text +//! byte 0..=1 : seq u16 wrapping sequence number, +1 per packet +//! byte 2..=5 : ts_48k u32 sender RTP-style 48 kHz media time, +//! +960 per 20 ms encoded frame +//! byte 6 : level_dbov i8 audio level in dBov, range [-127, 0] +//! byte 7 : flags u8 bit 0 = DTX/comfort frame; other bits +//! MUST be ignored on decode +//! ``` +//! +//! Notes for reviewers (Max's spec): +//! * Header is fixed-size. No extension mechanism in v2 — future fields go +//! into a v3 with a new pinned version. +//! * `level_dbov` is client-authored telemetry. The relay parses it for +//! logging/active-speaker hints, clamps invalid values into range, and +//! **never** uses it for trust decisions (admission, moderation, etc.). +//! * Negotiation lives in the WS auth message (`protocol_version: 2`), not +//! in any bit of `flags`. Mixed-version rooms are rejected at the relay +//! with `upgrade_required`. + +/// Wire protocol version this client speaks. Bumped only when the frame +/// layout itself changes; the relay tracks pinned per-room. +pub const PROTOCOL_VERSION: u8 = 2; + +/// Length of the v2 per-frame header in bytes. +pub const V2_HEADER_LEN: usize = 8; + +/// Flag bit indicating a DTX / comfort-noise frame. Receivers MAY skip +/// arrival accounting for these; encoders MUST set it for any Opus packet +/// they tag as DTX. Unset on normal speech frames. +pub const FLAG_DTX: u8 = 0x01; + +/// Reserved bit-mask. Any bits outside this set MUST be ignored by the +/// receiver — they're available for the next minor protocol bump within v2. +#[allow(dead_code)] // Public constant for diagnostics / future flag additions. +pub const RESERVED_FLAG_MASK: u8 = !FLAG_DTX; + +/// Parsed view of one v2 header. Cheap (Copy). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameHeader { + /// Sender-authored sequence number; wraps every 2^16 frames (~21.8 min + /// at 50 Hz). + pub seq: u16, + /// Sender-authored 48 kHz RTP-style media timestamp. + pub ts_48k: u32, + /// Audio level in dBov. Always parsed into the canonical `-127..=0` + /// range — out-of-range inputs are clamped to `-127` (silence). + pub level_dbov: i8, + /// Raw flags byte. Use [`Self::is_dtx`] / explicit masks; bits outside + /// `FLAG_DTX | reserved` are not inspected. + pub flags: u8, +} + +impl FrameHeader { + /// Encode `self` into 8 bytes, network byte order. Suitable for direct + /// concatenation onto an Opus payload. + pub fn encode(self) -> [u8; V2_HEADER_LEN] { + let mut out = [0u8; V2_HEADER_LEN]; + out[0..2].copy_from_slice(&self.seq.to_be_bytes()); + out[2..6].copy_from_slice(&self.ts_48k.to_be_bytes()); + out[6] = self.level_dbov as u8; + out[7] = self.flags; + out + } + + /// Parse a v2 header from the leading 8 bytes of `bytes`. Returns + /// `(header, remainder)` so callers can pass the remainder straight to + /// the Opus decoder. + /// + /// `level_dbov` is clamped into `-127..=0`; values outside that range + /// are coerced to `-127`. Per Max's spec, malformed VU metadata must + /// *not* cause the audio frame to be dropped — only the metric is + /// suppressed. + pub fn parse(bytes: &[u8]) -> Option<(Self, &[u8])> { + if bytes.len() < V2_HEADER_LEN { + return None; + } + let seq = u16::from_be_bytes([bytes[0], bytes[1]]); + let ts_48k = u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]); + let raw_level = bytes[6] as i8; + let level_dbov = if (-127..=0).contains(&raw_level) { + raw_level + } else { + // Out-of-range telemetry is coerced to "silent floor"; the + // audio packet is still delivered. This matches Max's "don't + // drop audio on bad VU" guidance. + -127 + }; + let flags = bytes[7]; + Some(( + Self { + seq, + ts_48k, + level_dbov, + flags, + }, + &bytes[V2_HEADER_LEN..], + )) + } + + /// True if [`FLAG_DTX`] is set. + #[allow(dead_code)] + pub fn is_dtx(&self) -> bool { + (self.flags & FLAG_DTX) != 0 + } +} + +/// Parse a complete relay-to-client v2 frame. +/// +/// The released v2 contract has exactly one relay-authored prefix byte: the +/// sender's peer index. A non-empty Opus payload must follow the fixed header. +pub fn parse_relay_frame(bytes: &[u8]) -> Option<(u8, FrameHeader, &[u8])> { + let (&peer_index, framed_audio) = bytes.split_first()?; + let (header, opus_payload) = FrameHeader::parse(framed_audio)?; + if opus_payload.is_empty() { + return None; + } + Some((peer_index, header, opus_payload)) +} + +/// Compute a dBov audio level for a normalized f32 PCM frame. +/// +/// "dBov" is RMS expressed in dB relative to full scale (where full scale = +/// peak amplitude 1.0), clamped into the v2 header's canonical `-127..=0` +/// range. A silent frame returns `-127`; full-scale ±1.0 returns `0`. +/// +/// This is cheap (one pass over the samples, one log) and runs once per +/// 20 ms frame, so it's nowhere near the audio fast-path budget. +pub fn audio_level_dbov(samples: &[f32]) -> i8 { + if samples.is_empty() { + return -127; + } + let mean_square: f64 = samples + .iter() + .map(|&s| { + let v = s as f64; + v * v + }) + .sum::() + / samples.len() as f64; + if mean_square <= 0.0 { + return -127; + } + let rms = mean_square.sqrt(); + let db = 20.0 * rms.log10(); + // Clamp into the canonical range. -127 = silence floor, 0 = full scale. + if !db.is_finite() || db <= -127.0 { + -127 + } else if db >= 0.0 { + 0 + } else { + db.round() as i8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_preserves_fields() { + let h = FrameHeader { + seq: 0xABCD, + ts_48k: 0x12_34_56_78, + level_dbov: -40, + flags: FLAG_DTX, + }; + let bytes = h.encode(); + let (parsed, tail) = FrameHeader::parse(&bytes).expect("parse"); + assert_eq!(parsed, h); + assert!(tail.is_empty()); + assert!(parsed.is_dtx()); + } + + /// Header is fixed 8 bytes; anything shorter is rejected. + #[test] + fn parse_rejects_short_input() { + for len in 0..V2_HEADER_LEN { + let buf = vec![0u8; len]; + assert!( + FrameHeader::parse(&buf).is_none(), + "{len} bytes must be rejected", + ); + } + } + + /// Trailing bytes pass through as the remainder, which is what the + /// receive path hands to the Opus decoder. + #[test] + fn parse_returns_payload_remainder() { + let mut buf = FrameHeader { + seq: 1, + ts_48k: 960, + level_dbov: -20, + flags: 0, + } + .encode() + .to_vec(); + buf.extend_from_slice(b"opus-bytes"); + let (_h, tail) = FrameHeader::parse(&buf).expect("parse"); + assert_eq!(tail, b"opus-bytes"); + } + + #[test] + fn relay_frame_uses_the_v2_one_byte_peer_prefix() { + let header = FrameHeader { + seq: 0x0102, + ts_48k: 960, + level_dbov: -20, + flags: 0, + }; + let mut frame = vec![7]; + frame.extend_from_slice(&header.encode()); + frame.extend_from_slice(b"opus"); + + let (peer_index, parsed_header, opus_payload) = + parse_relay_frame(&frame).expect("valid v2 relay frame"); + assert_eq!(peer_index, 7); + assert_eq!(parsed_header, header); + assert_eq!(opus_payload, b"opus"); + } + + #[test] + fn relay_frame_rejects_a_missing_opus_payload() { + let mut frame = vec![7]; + frame.extend_from_slice( + &FrameHeader { + seq: 1, + ts_48k: 960, + level_dbov: -20, + flags: 0, + } + .encode(), + ); + + assert!(parse_relay_frame(&frame).is_none()); + } + + /// Bytes in big-endian network order, matching Max's spec. This pins + /// the byte layout against accidental endianness changes. + #[test] + fn encoded_byte_order_is_network_byte_order() { + let h = FrameHeader { + seq: 0x0102, + ts_48k: 0x0304_0506, + level_dbov: -1, + flags: 0, + }; + let bytes = h.encode(); + assert_eq!(bytes[0..2], [0x01, 0x02]); + assert_eq!(bytes[2..6], [0x03, 0x04, 0x05, 0x06]); + assert_eq!(bytes[6], 0xFF); // -1 as i8 -> 0xFF + assert_eq!(bytes[7], 0x00); + } + + /// Per Max: malformed `level_dbov` must not drop the frame; only the + /// metric is suppressed. We coerce to -127 (silence floor) and keep + /// the payload available. + #[test] + fn out_of_range_level_dbov_is_clamped_not_rejected() { + // i8(127) is +127 which is outside [-127, 0]. + let mut bytes = FrameHeader { + seq: 7, + ts_48k: 7 * 960, + level_dbov: 0, + flags: 0, + } + .encode(); + bytes[6] = 0x7F; // raw +127 — out of range + let (parsed, _) = FrameHeader::parse(&bytes).expect("parse must succeed"); + assert_eq!(parsed.level_dbov, -127, "clamped to silence floor"); + assert_eq!(parsed.seq, 7, "other fields preserved"); + } + + #[test] + fn audio_level_dbov_clamps_silence_to_minus_127() { + assert_eq!(audio_level_dbov(&[]), -127); + assert_eq!(audio_level_dbov(&[0.0_f32; 960]), -127); + } + + #[test] + fn audio_level_dbov_full_scale_returns_zero() { + let frame: Vec = (0..960).map(|_| 1.0_f32).collect(); + assert_eq!(audio_level_dbov(&frame), 0); + } + + #[test] + fn audio_level_dbov_is_in_canonical_range_for_realistic_speech() { + // A 1 kHz sine at amplitude 0.3 — typical normalized speech peak. + // RMS ≈ 0.3 / sqrt(2) ≈ 0.212 → ≈ -13 dBov. + let frame: Vec = (0..960) + .map(|i| { + let t = i as f32 / 48_000.0; + 0.3 * (2.0 * std::f32::consts::PI * 1_000.0 * t).sin() + }) + .collect(); + let db = audio_level_dbov(&frame); + assert!( + (-20..=-8).contains(&db), + "expected -20..=-8 dBov for 0.3-amp sine, got {db}", + ); + } + + /// Reserved flag bits MUST be ignored on parse (no error, just preserved + /// for forward-compat). The contract is that *receivers* don't inspect + /// them; the parse path keeps them so introspection still works. + #[test] + fn reserved_flag_bits_are_preserved_for_inspection() { + let mut bytes = FrameHeader { + seq: 0, + ts_48k: 0, + level_dbov: 0, + flags: 0, + } + .encode(); + bytes[7] = 0b1010_1010; // FLAG_DTX bit clear, reserved bits set + let (parsed, _) = FrameHeader::parse(&bytes).expect("parse"); + assert_eq!(parsed.flags, 0b1010_1010); + assert!(!parsed.is_dtx()); + // Caller can still check which reserved bits were set if needed. + assert_eq!(parsed.flags & RESERVED_FLAG_MASK, 0b1010_1010); + } +} diff --git a/crates/buzz-audio-client/tests/codec.rs b/crates/buzz-audio-client/tests/codec.rs new file mode 100644 index 00000000000..5f15e39699d --- /dev/null +++ b/crates/buzz-audio-client/tests/codec.rs @@ -0,0 +1,56 @@ +use buzz_audio_client::{ + encoder::{AudioEncoder, FRAME_SAMPLES}, + jitter::PeerJitterBuffer, + wire::{parse_relay_frame, FrameHeader, V2_HEADER_LEN}, +}; + +#[test] +fn encoded_media_plays_through_the_shared_receiver() { + let mut encoder = AudioEncoder::new().unwrap(); + let mut receiver = PeerJitterBuffer::new(7).unwrap(); + let samples: Vec = (0..FRAME_SAMPLES) + .map(|n| (n as f32 * 0.1).sin() * 0.1) + .collect(); + for seq in 0..8 { + let payload = encoder.encode(&samples).unwrap(); + let frame = [vec![7], payload].concat(); + let (peer, header, opus) = parse_relay_frame(&frame).unwrap(); + assert_eq!(peer, 7); + assert_eq!(header.seq, seq); + assert_eq!(header.ts_48k, u32::from(seq) * 960); + receiver + .insert_packet(header.seq, header.ts_48k, opus) + .unwrap(); + } + let mut energy = 0.0_f32; + for _ in 0..20 { + let (pcm, _) = receiver.get_audio().unwrap(); + assert_eq!(pcm.len(), 480); + assert!(pcm.iter().all(|s| s.is_finite())); + energy += pcm.iter().map(|s| s * s).sum::(); + } + assert!( + energy > 0.01, + "voice must survive actual Opus and jitter decoding" + ); +} + +#[test] +fn invalid_input_does_not_advance_clock_and_short_frames_are_padded() { + let mut encoder = AudioEncoder::new().unwrap(); + for samples in [vec![], vec![0.; 961], vec![f32::NAN], vec![f32::INFINITY]] { + assert!(encoder.encode(&samples).is_err()); + } + let encoded = encoder.encode(&[0.1; 120]).unwrap(); + let (header, _) = FrameHeader::parse(&encoded).unwrap(); + assert_eq!(header.seq, 0); + assert_eq!(header.ts_48k, 0); + let mut decoder = opus::Decoder::new(48000, opus::Channels::Mono).unwrap(); + let mut pcm = [0.0; 5760]; + assert_eq!( + decoder + .decode_float(&encoded[V2_HEADER_LEN..], &mut pcm, false) + .unwrap(), + 960 + ); +} diff --git a/crates/buzz-audio-client/tests/connection.rs b/crates/buzz-audio-client/tests/connection.rs new file mode 100644 index 00000000000..c67c24b7645 --- /dev/null +++ b/crates/buzz-audio-client/tests/connection.rs @@ -0,0 +1,133 @@ +use buzz_audio_client::connection::{connect, parse_peers}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::{net::TcpListener, sync::oneshot}; +use tokio_tungstenite::{accept_async, tungstenite::Message}; + +#[tokio::test] +async fn authenticates_with_host_signer_and_preserves_v2_media() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}/huddle/room/audio", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + socket.send(Message::Ping(vec![7].into())).await.unwrap(); + socket + .send(Message::Text( + json!({"type":"challenge","challenge":"host-challenge"}) + .to_string() + .into(), + )) + .await + .unwrap(); + let auth = loop { + match socket.next().await.unwrap().unwrap() { + Message::Text(text) => break serde_json::from_str::(&text).unwrap(), + Message::Pong(bytes) => assert_eq!(bytes.as_ref(), &[7]), + other => panic!("unexpected {other:?}"), + } + }; + assert_eq!( + auth, + json!({"type":"auth","event":{"signed":"host-challenge"},"parent_channel_id":"parent","protocol_version":2}) + ); + socket.send(Message::Text(json!({"type":"joined","peer_index":0,"peers":[{"peer_index":1,"pubkey":"a".repeat(64)}]}).to_string().into())).await.unwrap(); + socket + .send(Message::Binary(vec![1, 2, 3].into())) + .await + .unwrap(); + assert_eq!( + socket.next().await.unwrap().unwrap(), + Message::Binary(vec![4, 5, 6].into()) + ); + }); + let mut joined = connect(&url, Some("parent"), None, |challenge| async move { + assert_eq!(challenge, "host-challenge"); + Ok(json!({"signed":challenge})) + }) + .await + .unwrap(); + assert_eq!(joined.index, 0); + assert_eq!(joined.peers[0].epoch, 0); + assert_eq!(joined.peers[0].pubkey, "a".repeat(64)); + assert_eq!( + joined.socket.next().await.unwrap().unwrap(), + Message::Binary(vec![1, 2, 3].into()) + ); + joined + .socket + .send(Message::Binary(vec![4, 5, 6].into())) + .await + .unwrap(); + server.await.unwrap(); +} + +#[tokio::test] +async fn invalid_challenge_never_reaches_signer() { + for challenge in [ + json!({"type":"joined","challenge":"wrong"}), + json!({"type":"challenge","challenge":""}), + json!({"type":"challenge","challenge":"x".repeat(4097)}), + ] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + socket + .send(Message::Text(challenge.to_string().into())) + .await + .unwrap(); + assert!(socket + .next() + .await + .is_none_or(|r| r.is_err() || matches!(r, Ok(Message::Close(_))))); + }); + assert!(connect(&url, None, None, |_| async { + panic!("must not sign an invalid challenge") + }) + .await + .is_err()); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn cancelling_admission_drops_the_socket() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let (ready, received) = oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(stream).await.unwrap(); + ready.send(()).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(2), socket.next()) + .await + .unwrap(); + assert!(result.is_none_or(|r| r.is_err() || matches!(r, Ok(Message::Close(_))))); + }); + let client = tokio::spawn(async move { + connect(&url, None, None, |_| async { panic!("no challenge") }).await + }); + received.await.unwrap(); + client.abort(); + assert!(matches!(client.await, Err(e) if e.is_cancelled())); + server.await.unwrap(); +} + +#[test] +fn rejects_invalid_rosters_without_truncation() { + for peers in [ + json!([{"peer_index":256,"pubkey":"a".repeat(64)}]), + json!([{"peer_index":1,"pubkey":"bad"}]), + json!([{"peer_index":1,"pubkey":"a".repeat(64),"epoch":256}]), + json!([{"peer_index":1,"pubkey":"a".repeat(64)},{"peer_index":1,"pubkey":"b".repeat(64)}]), + ] { + assert!(parse_peers(&json!({"peers":peers})).is_err()); + } + assert!(parse_peers( + &json!({"peers":vec![json!({"peer_index":1,"pubkey":"a".repeat(64)});257]}) + ) + .is_err()); +} diff --git a/deny.toml b/deny.toml index 74e83fb3518..00b4f66ae41 100644 --- a/deny.toml +++ b/deny.toml @@ -20,6 +20,12 @@ ignore = [ # fork uses the same crate, so removing it means migrating YAML parsing in # buzz-agent, buzz-persona, buzz-relay, buzz-workflow and desktop. { id = "RUSTSEC-2024-0449", reason = "transitive via serde_yaml; unmaintained, not vulnerable; removal needs a YAML parser migration" }, + # NetEQ =0.8.3 declares ringbuf but its published Rust sources never use it, + # so Consumer::skip/clear and their panic-unsafe Drop path are unreachable. + # NetEQ 0.9.1 still declares ringbuf 0.3; ringbuf itself is fixed in 0.5.2. + # Keep the exact NetEQ pin and the wrapper ban below together. Re-audit or + # remove this exception when NetEQ removes/updates the unused dependency. + { id = "RUSTSEC-2026-0293", reason = "unused dependency of audited, exactly pinned neteq 0.8.3; no Rust references to ringbuf; other consumers forbidden by bans.deny" }, ] [licenses] @@ -95,3 +101,6 @@ ignore = true [bans] multiple-versions = "warn" wildcards = "allow" +deny = [ + { name = "ringbuf", version = "<0.5.2", wrappers = ["neteq"], reason = "only audited NetEQ may declare this unused vulnerable dependency; all other consumers must use a patched release" }, +] diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index cab1aa500e5..7ba160f8c8c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -598,17 +598,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "audiopus_sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62314a1546a2064e033665d658e88c620a62904be945f8147e6b16c3db9f8651" -dependencies = [ - "cmake", - "log", - "pkg-config", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -1095,6 +1084,18 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "buzz-audio-client" +version = "0.1.0" +dependencies = [ + "futures-util", + "neteq", + "opus", + "serde_json", + "tokio", + "tokio-tungstenite 0.29.0", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -1128,6 +1129,7 @@ dependencies = [ "base64 0.22.1", "block2", "buzz-agent", + "buzz-audio-client", "buzz-core", "buzz-media", "buzz-persona", @@ -1157,14 +1159,12 @@ dependencies = [ "mesh-llm-node", "mesh-llm-sdk", "mesh-llm-system", - "neteq", "nostr 0.44.7", "notify-rust", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-user-notifications", - "opus", "percent-encoding", "plist", "png 0.18.1", @@ -2387,7 +2387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -7174,11 +7174,20 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "opus" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3809943dff6fbad5f0484449ea26bdb9cb7d8efdf26ed50d3c7f227f69eb5c" +checksum = "33718946cc77d4032911d4efe03a66dbcbfbd2bb16c3da06aaeadcc637c32216" dependencies = [ - "audiopus_sys", + "opusic-sys", +] + +[[package]] +name = "opusic-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d1ecdf206421bc74343ab3bb2f30ad2abbfee41fa341f7181fecbaf957769a" +dependencies = [ + "cmake", ] [[package]] @@ -11114,7 +11123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b7b31117f7c..fdff759d0f6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -93,8 +93,6 @@ tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } tokio-util = { version = "0.7", features = ["rt"] } bytes = "1" futures-util = "0.3" -opus = "0.3" -neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -112,6 +110,7 @@ buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } +buzz_audio_client = { package = "buzz-audio-client", path = "../../crates/buzz-audio-client", default-features = false, features = ["webpki-roots"] } buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } buzz_ws_client_pkg = { package = "buzz-ws-client", path = "../../crates/buzz-ws-client" } diff --git a/desktop/src-tauri/src/huddle/jitter.rs b/desktop/src-tauri/src/huddle/jitter.rs index ba10a5b4fcd..44302db1c21 100644 --- a/desktop/src-tauri/src/huddle/jitter.rs +++ b/desktop/src-tauri/src/huddle/jitter.rs @@ -1,267 +1,2 @@ -//! Per-peer NetEQ-style jitter buffer for huddle remote audio. -//! -//! Wraps the [`neteq`] crate's `NetEq` state machine and registers a thin -//! `AudioDecoder` impl over our existing `opus = "0.3"` dependency. Default -//! features on `neteq` are disabled so we don't pull in axum/cpal/clap; this -//! module owns the Opus side of the trait instead. -//! -//! ## Lifecycle -//! -//! One [`PeerJitterBuffer`] per active remote peer. It owns: -//! * a `NetEq` configured for 48 kHz mono with conservative jitter bounds, -//! * a stable synthetic RTP SSRC (the relay-assigned `peer_index`), and -//! * a 16-bit sequence number that wraps with the wire protocol's `seq`. -//! -//! On `joined` / first packet, construct one. On `left` / rejoin, drop and -//! recreate so NetEq's internal delay state doesn't carry over. -//! -//! ## Decode path -//! -//! Each inbound Opus frame becomes a `neteq::AudioPacket` with: -//! * sequence number from the protocol v2 header, -//! * 48 kHz RTP-style media timestamp (frame_index * 960 for 20 ms frames), -//! * a synthetic SSRC == peer_index, -//! * payload type [`OPUS_PAYLOAD_TYPE`] (any value, only NetEq uses it to -//! dispatch to its registered decoder), -//! * the raw Opus bytes as `payload`, -//! * `duration_ms = 20` (our encoder frame size). -//! -//! The playout side calls [`PeerJitterBuffer::get_audio`] on a 10 ms tick; -//! NetEq returns 10 ms of decoded PCM with adaptive-delay / expand / accelerate -//! decisions already applied. For 20 ms input frames at 48 kHz mono, that's -//! 480 f32 samples per call. - -use neteq::{ - codec::AudioDecoder, neteq::SpeechType, AudioPacket, NetEq, NetEqConfig, NetEqError, RtpHeader, -}; - -/// RTP payload type used internally. NetEq only needs it to look up the decoder -/// registered via `register_decoder`; the relay forwards Opus payloads opaquely -/// so the wire never carries a payload-type byte. -pub const OPUS_PAYLOAD_TYPE: u8 = 111; - -/// Audio sample rate for huddle (Opus VoIP, mono). -pub const SAMPLE_RATE_HZ: u32 = 48_000; -/// One channel (mono). -pub const CHANNELS: u8 = 1; -/// Opus encoder frame size in milliseconds. The wire format is one Opus packet -/// per 20 ms frame; the receiver still drains NetEq at 10 ms granularity. -pub const FRAME_DURATION_MS: u32 = 20; -/// 48 kHz media-time increment per encoder frame. -pub const FRAME_TIMESTAMP_DELTA: u32 = SAMPLE_RATE_HZ / 1000 * FRAME_DURATION_MS; // 960 -/// NetEq returns 10 ms frames; this is the sample count per `get_audio` call. -/// Kept as a documented constant for the protocol-v2 follow-up and consumers -/// that want to size their own buffers around the playout contract. -#[allow(dead_code)] -pub const PLAYOUT_SAMPLES: usize = (SAMPLE_RATE_HZ as usize / 1000) * 10; // 480 - -/// Minimum jitter delay NetEq is allowed to converge to (ms). -const MIN_DELAY_MS: u32 = 40; -/// Maximum jitter delay NetEq is allowed to converge to (ms). -const MAX_DELAY_MS: u32 = 200; -/// Buffer cap in packets — generous, but bounded so a runaway sender can't -/// drive memory growth. -const MAX_PACKETS_IN_BUFFER: usize = 50; - -/// Thin `AudioDecoder` over `opus::Decoder` so `neteq` can drive Opus payloads. -/// -/// NetEq's `decode` trait is sample-based, not FEC-aware (no `fec` bool). The -/// stock `neteq` `NativeOpusDecoder` (gated behind the `native` feature, which -/// pulls in cpal/axum) calls `decode_float(..., false)` unconditionally; this -/// matches that behavior so we don't need the feature. -/// -/// Receive-side FEC ("decode this frame's redundant copy on a known-lost prior -/// frame") would require either a trait change upstream or NetEq calling decode -/// twice with different `fec` values — out of scope for the initial 10-person -/// fix. Tracked as a follow-up alongside encoder-side `set_inband_fec`. -struct OpusFrameDecoder { - inner: opus::Decoder, - sample_rate: u32, - channels: u8, - /// Scratch buffer reused across calls to avoid per-frame allocation. - scratch: Vec, -} - -impl OpusFrameDecoder { - fn new(sample_rate: u32, channels: u8) -> Result { - let opus_channels = match channels { - 1 => opus::Channels::Mono, - 2 => opus::Channels::Stereo, - n => return Err(format!("unsupported channel count: {n}")), - }; - let inner = opus::Decoder::new(sample_rate, opus_channels) - .map_err(|e| format!("opus decoder init: {e}"))?; - // Largest plausible frame: 60 ms at 48 kHz stereo. We always use - // 20 ms mono in practice; this is just a one-shot allocation cap. - let cap = (sample_rate as usize / 1000) * 60 * channels as usize; - Ok(Self { - inner, - sample_rate, - channels, - scratch: vec![0.0; cap], - }) - } -} - -impl AudioDecoder for OpusFrameDecoder { - fn sample_rate(&self) -> u32 { - self.sample_rate - } - - fn channels(&self) -> u8 { - self.channels - } - - fn decode(&mut self, encoded: &[u8]) -> Result, NetEqError> { - match self.inner.decode_float(encoded, &mut self.scratch, false) { - Ok(samples_per_channel) => { - let total = samples_per_channel * self.channels as usize; - Ok(self.scratch[..total].to_vec()) - } - Err(e) => Err(NetEqError::DecoderError(format!("opus decode: {e}"))), - } - } -} - -/// One peer's jitter buffer + decoder pair. -pub struct PeerJitterBuffer { - neteq: NetEq, - ssrc: u32, -} - -impl PeerJitterBuffer { - /// Construct a new jitter buffer for the peer identified by `peer_index`. - /// - /// `peer_index` is the relay-assigned 0..=254 stable index for the peer's - /// lifetime in the room; we promote it to a 32-bit synthetic SSRC so NetEq - /// has a stable stream identity for its delay-manager state. - pub fn new(peer_index: u8) -> Result { - let config = NetEqConfig { - sample_rate: SAMPLE_RATE_HZ, - channels: CHANNELS, - max_packets_in_buffer: MAX_PACKETS_IN_BUFFER, - min_delay_ms: MIN_DELAY_MS, - max_delay_ms: MAX_DELAY_MS, - ..Default::default() - }; - let mut neteq = NetEq::new(config)?; - let decoder = - OpusFrameDecoder::new(SAMPLE_RATE_HZ, CHANNELS).map_err(NetEqError::DecoderError)?; - neteq.register_decoder(OPUS_PAYLOAD_TYPE, Box::new(decoder)); - Ok(Self { - neteq, - ssrc: peer_index as u32, - }) - } - - /// Insert a received Opus packet. - /// - /// `seq` and `ts_48k` come from the protocol v2 header (sender-authored, - /// 16-bit wrapping sequence + 48 kHz RTP-style media time). - pub fn insert_packet( - &mut self, - seq: u16, - ts_48k: u32, - opus_payload: &[u8], - ) -> Result<(), NetEqError> { - let header = RtpHeader::new(seq, ts_48k, self.ssrc, OPUS_PAYLOAD_TYPE, false); - let packet = AudioPacket::new( - header, - opus_payload.to_vec(), - SAMPLE_RATE_HZ, - CHANNELS, - FRAME_DURATION_MS, - ); - self.neteq.insert_packet(packet) - } - - /// Drain one 10 ms playout frame. Always succeeds: NetEq emits PLC / - /// comfort-noise / silence rather than an error when there's nothing to - /// play. - /// - /// Returns `(samples, vad_active)`. `samples.len() == PLAYOUT_SAMPLES`. - pub fn get_audio(&mut self) -> Result<(Vec, bool), NetEqError> { - let frame = self.neteq.get_audio()?; - let vad = frame.vad_activity && !matches!(frame.speech_type, SpeechType::Expand); - Ok((frame.samples, vad)) - } - - /// True when NetEq's buffer has no packets queued and the next - /// `get_audio` call would emit expand/silence. Used by - /// `huddle::playout` as part of the idle-peer gate — if a peer has - /// gone quiet (`last_packet_at` is stale) *and* there's nothing - /// buffered to drain, we stop appending frames to that peer's rodio - /// Player. The check is conservative: a peer who just sent a burst - /// then disconnected may have buffered audio that should still play - /// out, so this method drives that decision. - pub fn is_empty(&self) -> bool { - self.neteq.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn opus_silence_frame() -> Vec { - // Encode 20 ms of silence at 48 kHz mono so the test doesn't rely on - // synthetic Opus byte sequences. - let mut encoder = opus::Encoder::new( - SAMPLE_RATE_HZ, - opus::Channels::Mono, - opus::Application::Voip, - ) - .expect("opus encoder"); - let pcm = vec![0.0_f32; FRAME_TIMESTAMP_DELTA as usize]; - let mut out = vec![0u8; 4000]; - let n = encoder - .encode_float(&pcm, &mut out) - .expect("opus encode silence"); - out.truncate(n); - out - } - - #[test] - fn jitter_buffer_construction_succeeds_for_each_peer_index() { - for idx in [0u8, 1, 7, 42, 254] { - let jb = PeerJitterBuffer::new(idx).expect("construct jitter buffer"); - assert!(jb.is_empty(), "peer {idx} should start empty"); - // SSRC is the peer_index as u32 — verifies the stable-identity contract. - assert_eq!(jb.ssrc, idx as u32); - } - } - - #[test] - fn insert_packet_then_get_audio_returns_playout_frame() { - let mut jb = PeerJitterBuffer::new(3).expect("jitter buffer"); - let payload = opus_silence_frame(); - - // Insert a few sequential packets so NetEq has enough buffer to start - // emitting Normal-decoded frames instead of pure Expand/silence. - for i in 0..6u16 { - let ts = (i as u32) * FRAME_TIMESTAMP_DELTA; - jb.insert_packet(i, ts, &payload).expect("insert"); - } - - // Pull a playout frame — should be 10 ms = 480 mono samples. - let (samples, _vad) = jb.get_audio().expect("get_audio"); - assert_eq!( - samples.len(), - PLAYOUT_SAMPLES, - "NetEq must emit exactly one 10 ms frame per get_audio call", - ); - } - - #[test] - fn empty_buffer_get_audio_still_produces_a_frame() { - // Even with no packets inserted, NetEq's contract is to always emit - // 10 ms — as Expand/silence — so the playout clock keeps ticking. - let mut jb = PeerJitterBuffer::new(0).expect("jitter buffer"); - let (samples, vad) = jb.get_audio().expect("get_audio"); - assert_eq!(samples.len(), PLAYOUT_SAMPLES); - assert!( - !vad, - "empty buffer should not report voice activity (Expand frame)", - ); - } -} +//! Shared Huddle jitter implementation. +pub use buzz_audio_client::jitter::*; diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 190397aa054..53c757b986c 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -10,9 +10,10 @@ //! → recv loop: WS binary frame → Opus decode (per-peer) → rodio playback //! ``` +use buzz_audio_client::encoder::{AudioEncoder, FRAME_SAMPLES}; use futures_util::{SinkExt, StreamExt}; use std::sync::{atomic::AtomicBool, Arc}; -use tokio_tungstenite::{connect_async, tungstenite::Message as WsMsg}; +use tokio_tungstenite::tungstenite::Message as WsMsg; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -38,9 +39,6 @@ pub(crate) fn parse_channel_uuid(channel_id: &str) -> Result { Uuid::parse_str(channel_id).map_err(|_| format!("invalid channel UUID: {channel_id}")) } -/// Handshake timeout — matches the server's AUTH_TIMEOUT (5 s). -const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); - fn build_audio_auth_event( keys: &nostr::Keys, relay_url: &str, @@ -79,99 +77,24 @@ async fn connect_authenticated_audio_socket( use nostr::JsonUtil; let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); - let (ws_stream, _) = connect_async(&ws_url) - .await - .map_err(|e| format!("audio WS connect failed: {e}"))?; - let (mut ws_tx, mut ws_rx) = ws_stream.split(); - - let challenge = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { - loop { - match ws_rx.next().await { - Some(Ok(WsMsg::Text(text))) => { - let value: serde_json::Value = serde_json::from_str(&text) - .map_err(|e| format!("bad challenge JSON: {e}"))?; - if value["type"] == "challenge" { - break value["challenge"] - .as_str() - .ok_or_else(|| "missing challenge string".to_string()) - .map(str::to_string); - } - } - Some(Ok(WsMsg::Close(_))) | None => { - break Err("connection closed before challenge".into()); - } - _ => continue, - } - } - }) - .await - .map_err(|_| "timeout waiting for challenge from relay".to_string())??; - - let event = build_audio_auth_event(keys, relay_url, &challenge, auth_tag_json)?; - let event_json: serde_json::Value = serde_json::from_str(&event.as_json()) - .map_err(|e| format!("failed to serialize auth event: {e}"))?; - let auth_msg = serde_json::json!({ - "type": "auth", - "event": event_json, - "parent_channel_id": parent_channel_id, - // Use the released v2 contract while deployed relays remain capped at - // v2. Relay-to-client media therefore has a one-byte peer-index prefix; - // see huddle::wire for the compatibility tradeoff. - "protocol_version": super::wire::PROTOCOL_VERSION, - }); - ws_tx - .send(WsMsg::Text(auth_msg.to_string().into())) - .await - .map_err(|e| format!("send auth: {e}"))?; - - let (peer_index, initial_peers) = tokio::time::timeout(HANDSHAKE_TIMEOUT, async { - loop { - match ws_rx.next().await { - Some(Ok(WsMsg::Text(text))) => { - let value: serde_json::Value = serde_json::from_str(&text).unwrap_or_default(); - match value["type"].as_str() { - Some("joined") => { - let peers = value["peers"] - .as_array() - .map(|peers| { - peers - .iter() - .filter_map(|peer| { - Some(( - peer["peer_index"].as_u64()? as u8, - peer["pubkey"].as_str()?.to_string(), - // Absent `epoch` (legacy relay) degrades - // to 0 so the fence becomes a no-op rather - // than rejecting every frame. - peer["epoch"].as_u64().unwrap_or(0) as u8, - )) - }) - .collect() - }) - .unwrap_or_default(); - let peer_index = value["peer_index"] - .as_u64() - .and_then(|index| u8::try_from(index).ok()) - .ok_or_else(|| "joined message missing peer index".to_string())?; - break Ok((peer_index, peers)); - } - Some("error") => { - break Err(format!("audio relay auth error: {}", value["message"])); - } - _ => continue, - } - } - Some(Ok(WsMsg::Close(_))) | None => { - break Err("connection closed before joined".into()); - } - _ => continue, - } - } - }) - .await - .map_err(|_| "timeout waiting for joined from relay".to_string())??; - - Ok((ws_tx, ws_rx, peer_index, initial_peers)) + let joined = buzz_audio_client::connection::connect( + &ws_url, + parent_channel_id, + None, + |challenge| async move { + let event = build_audio_auth_event(keys, relay_url, &challenge, auth_tag_json)?; + serde_json::from_str(&event.as_json()) + .map_err(|e| format!("failed to serialize auth event: {e}")) + }, + ) + .await?; + let (tx, rx) = joined.socket.split(); + let peers = joined + .peers + .into_iter() + .map(|p| (p.index, p.pubkey, p.epoch)) + .collect(); + Ok((tx, rx, joined.index, peers)) } /// Connect to the relay's audio WebSocket and run the Opus encode/decode pipeline. @@ -364,21 +287,9 @@ async fn run_tts_audio_publisher( epoch: Arc, speaker_generation: Arc, ) -> Result<(), String> { - use super::wire::{audio_level_dbov, FrameHeader, V2_HEADER_LEN}; use std::sync::atomic::Ordering; - let mut encoder = opus::Encoder::new(48_000, opus::Channels::Mono, opus::Application::Voip) - .map_err(|error| format!("tts opus encoder: {error}"))?; - encoder - .set_bitrate(opus::Bitrate::Bits(32_000)) - .map_err(|error| format!("tts opus bitrate: {error}"))?; - encoder - .set_dtx(true) - .map_err(|error| format!("tts opus dtx: {error}"))?; - - let mut sequence = 0_u16; - let mut timestamp_48k = 0_u32; - let mut encoded = vec![0_u8; 4_000]; + let mut encoder = AudioEncoder::new()?; let mut queue = std::collections::VecDeque::::new(); let mut send_tick = tokio::time::interval(std::time::Duration::from_millis(20)); send_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -397,30 +308,11 @@ async fn run_tts_audio_publisher( queue.pop_front(); } let Some(frame) = queue.pop_front() else { continue }; - let level = audio_level_dbov(&frame.samples_48k); - let encoded_len = encoder - .encode_float(&frame.samples_48k, &mut encoded) - .map_err(|error| format!("tts opus encode: {error}"))?; - if encoded_len == 0 { - continue; - } - let flags = if encoded_len <= 2 { super::wire::FLAG_DTX } else { 0 }; - let header = FrameHeader { - seq: sequence, - ts_48k: timestamp_48k, - level_dbov: level, - flags, - } - .encode(); - let mut payload = Vec::with_capacity(V2_HEADER_LEN + encoded_len); - payload.extend_from_slice(&header); - payload.extend_from_slice(&encoded[..encoded_len]); + let payload = encoder.encode(&frame.samples_48k)?; ws_tx .send(WsMsg::Binary(payload.into())) .await .map_err(|error| format!("tts audio send: {error}"))?; - sequence = sequence.wrapping_add(1); - timestamp_48k = timestamp_48k.wrapping_add(super::jitter::FRAME_TIMESTAMP_DELTA); } message = ws_rx.next() => { match message { @@ -481,14 +373,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String output_device_name, } = args; - let mut encoder = opus::Encoder::new(48000, opus::Channels::Mono, opus::Application::Voip) - .map_err(|e| format!("opus encoder: {e}"))?; - encoder - .set_bitrate(opus::Bitrate::Bits(32000)) - .map_err(|e| format!("opus bitrate: {e}"))?; - encoder - .set_dtx(true) - .map_err(|e| format!("opus dtx: {e}"))?; + let encoder = AudioEncoder::new()?; let sink_handle = super::audio_output::open_output_sink_by_name(output_device_name.as_deref())?; @@ -498,16 +383,7 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String let cancel_send = cancel.clone(); let send_task = tokio::spawn(async move { - use super::wire::{audio_level_dbov, FrameHeader, V2_HEADER_LEN}; let mut encoder = encoder; // Move encoder into task. - const FRAME_SAMPLES: usize = 960; - let mut out_buf = vec![0u8; 4000]; - // Per-frame wire-protocol state. We send v2 frames now: each Opus - // payload is preceded by an 8-byte header carrying our own seq + - // 48 kHz timestamp + audio level + flags. - let mut seq: u16 = 0; - let mut ts_48k: u32 = 0; - loop { let pcm_bytes = { use futures_util::future::Either; @@ -530,47 +406,15 @@ async fn audio_relay_pipeline(args: AudioRelayPipelineArgs) -> Result<(), String let mut tx = ws_tx_send.lock().await; for chunk in samples.chunks(FRAME_SAMPLES) { - // dBov is computed from the pre-encode PCM. Opus DTX may - // produce a 1-2 byte comfort packet; computing level from - // the encoded payload would be meaningless. - let level = audio_level_dbov(chunk); - let encode_result = if chunk.len() == FRAME_SAMPLES { - encoder.encode_float(chunk, &mut out_buf) - } else { - let mut padded = chunk.to_vec(); - padded.resize(FRAME_SAMPLES, 0.0); - encoder.encode_float(&padded, &mut out_buf) - }; - let n = match encode_result { - Ok(n) => n, - Err(e) => { - eprintln!("buzz-desktop: opus encode error: {e}"); + let frame = match encoder.encode(chunk) { + Ok(frame) => frame, + Err(error) => { + eprintln!("buzz-desktop: opus encode error: {error}"); continue; } }; - if n > 0 { - // Opus DTX packets are very small (≤2 bytes). Flag them - // explicitly so the receiver can elide DTX from speaker - // detection without re-parsing the Opus payload. - let flags = if n <= 2 { super::wire::FLAG_DTX } else { 0 }; - let header = FrameHeader { - seq, - ts_48k, - level_dbov: level, - flags, - } - .encode(); - - // Build the v2 wire frame: 8-byte header + Opus payload. - let mut frame = Vec::with_capacity(V2_HEADER_LEN + n); - frame.extend_from_slice(&header); - frame.extend_from_slice(&out_buf[..n]); - if tx.send(WsMsg::Binary(frame.into())).await.is_err() { - return; // WS closed. - } - - seq = seq.wrapping_add(1); - ts_48k = ts_48k.wrapping_add(super::jitter::FRAME_TIMESTAMP_DELTA); + if tx.send(WsMsg::Binary(frame.into())).await.is_err() { + return; // WS closed. } } } diff --git a/desktop/src-tauri/src/huddle/wire.rs b/desktop/src-tauri/src/huddle/wire.rs index bcf9c007c2d..e872d70affd 100644 --- a/desktop/src-tauri/src/huddle/wire.rs +++ b/desktop/src-tauri/src/huddle/wire.rs @@ -1,346 +1,2 @@ -//! Huddle audio wire protocol — versioning + frame header. -//! -//! ## v1 (legacy) -//! -//! Client → relay: `` -//! Relay → client: `` -//! -//! No per-frame metadata; receiver synthesizes sequence/timestamp on arrival. -//! Kept for backward compatibility — relay still admits v1 clients into -//! v1-pinned rooms — but new clients speak v2 while deployed relays remain -//! capped at the released v2 contract. -//! -//! ## v2 (compatibility contract) -//! -//! Client → relay: `` -//! Relay → client: `` -//! -//! Protocol v2 does not carry v3's occupancy epoch in media frames. The -//! control-plane roster still resets decoder and playout state when an index is -//! reassigned, but v2 cannot fence a delayed packet from the previous occupant -//! after that reassignment. -//! -//! Header layout (8 bytes, network byte order, big-endian): -//! -//! ```text -//! byte 0..=1 : seq u16 wrapping sequence number, +1 per packet -//! byte 2..=5 : ts_48k u32 sender RTP-style 48 kHz media time, -//! +960 per 20 ms encoded frame -//! byte 6 : level_dbov i8 audio level in dBov, range [-127, 0] -//! byte 7 : flags u8 bit 0 = DTX/comfort frame; other bits -//! MUST be ignored on decode -//! ``` -//! -//! Notes for reviewers (Max's spec): -//! * Header is fixed-size. No extension mechanism in v2 — future fields go -//! into a v3 with a new pinned version. -//! * `level_dbov` is client-authored telemetry. The relay parses it for -//! logging/active-speaker hints, clamps invalid values into range, and -//! **never** uses it for trust decisions (admission, moderation, etc.). -//! * Negotiation lives in the WS auth message (`protocol_version: 2`), not -//! in any bit of `flags`. Mixed-version rooms are rejected at the relay -//! with `upgrade_required`. - -/// Wire protocol version this client speaks. Bumped only when the frame -/// layout itself changes; the relay tracks pinned per-room. -pub const PROTOCOL_VERSION: u8 = 2; - -/// Length of the v2 per-frame header in bytes. -pub const V2_HEADER_LEN: usize = 8; - -/// Flag bit indicating a DTX / comfort-noise frame. Receivers MAY skip -/// arrival accounting for these; encoders MUST set it for any Opus packet -/// they tag as DTX. Unset on normal speech frames. -pub const FLAG_DTX: u8 = 0x01; - -/// Reserved bit-mask. Any bits outside this set MUST be ignored by the -/// receiver — they're available for the next minor protocol bump within v2. -#[allow(dead_code)] // Public constant for diagnostics / future flag additions. -pub const RESERVED_FLAG_MASK: u8 = !FLAG_DTX; - -/// Parsed view of one v2 header. Cheap (Copy). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FrameHeader { - /// Sender-authored sequence number; wraps every 2^16 frames (~21.8 min - /// at 50 Hz). - pub seq: u16, - /// Sender-authored 48 kHz RTP-style media timestamp. - pub ts_48k: u32, - /// Audio level in dBov. Always parsed into the canonical `-127..=0` - /// range — out-of-range inputs are clamped to `-127` (silence). - pub level_dbov: i8, - /// Raw flags byte. Use [`Self::is_dtx`] / explicit masks; bits outside - /// `FLAG_DTX | reserved` are not inspected. - pub flags: u8, -} - -impl FrameHeader { - /// Encode `self` into 8 bytes, network byte order. Suitable for direct - /// concatenation onto an Opus payload. - pub fn encode(self) -> [u8; V2_HEADER_LEN] { - let mut out = [0u8; V2_HEADER_LEN]; - out[0..2].copy_from_slice(&self.seq.to_be_bytes()); - out[2..6].copy_from_slice(&self.ts_48k.to_be_bytes()); - out[6] = self.level_dbov as u8; - out[7] = self.flags; - out - } - - /// Parse a v2 header from the leading 8 bytes of `bytes`. Returns - /// `(header, remainder)` so callers can pass the remainder straight to - /// the Opus decoder. - /// - /// `level_dbov` is clamped into `-127..=0`; values outside that range - /// are coerced to `-127`. Per Max's spec, malformed VU metadata must - /// *not* cause the audio frame to be dropped — only the metric is - /// suppressed. - pub fn parse(bytes: &[u8]) -> Option<(Self, &[u8])> { - if bytes.len() < V2_HEADER_LEN { - return None; - } - let seq = u16::from_be_bytes([bytes[0], bytes[1]]); - let ts_48k = u32::from_be_bytes([bytes[2], bytes[3], bytes[4], bytes[5]]); - let raw_level = bytes[6] as i8; - let level_dbov = if (-127..=0).contains(&raw_level) { - raw_level - } else { - // Out-of-range telemetry is coerced to "silent floor"; the - // audio packet is still delivered. This matches Max's "don't - // drop audio on bad VU" guidance. - -127 - }; - let flags = bytes[7]; - Some(( - Self { - seq, - ts_48k, - level_dbov, - flags, - }, - &bytes[V2_HEADER_LEN..], - )) - } - - /// True if [`FLAG_DTX`] is set. - #[allow(dead_code)] - pub fn is_dtx(&self) -> bool { - (self.flags & FLAG_DTX) != 0 - } -} - -/// Parse a complete relay-to-client v2 frame. -/// -/// The released v2 contract has exactly one relay-authored prefix byte: the -/// sender's peer index. A non-empty Opus payload must follow the fixed header. -pub fn parse_relay_frame(bytes: &[u8]) -> Option<(u8, FrameHeader, &[u8])> { - let (&peer_index, framed_audio) = bytes.split_first()?; - let (header, opus_payload) = FrameHeader::parse(framed_audio)?; - if opus_payload.is_empty() { - return None; - } - Some((peer_index, header, opus_payload)) -} - -/// Compute a dBov audio level for a normalized f32 PCM frame. -/// -/// "dBov" is RMS expressed in dB relative to full scale (where full scale = -/// peak amplitude 1.0), clamped into the v2 header's canonical `-127..=0` -/// range. A silent frame returns `-127`; full-scale ±1.0 returns `0`. -/// -/// This is cheap (one pass over the samples, one log) and runs once per -/// 20 ms frame, so it's nowhere near the audio fast-path budget. -pub fn audio_level_dbov(samples: &[f32]) -> i8 { - if samples.is_empty() { - return -127; - } - let mean_square: f64 = samples - .iter() - .map(|&s| { - let v = s as f64; - v * v - }) - .sum::() - / samples.len() as f64; - if mean_square <= 0.0 { - return -127; - } - let rms = mean_square.sqrt(); - let db = 20.0 * rms.log10(); - // Clamp into the canonical range. -127 = silence floor, 0 = full scale. - if !db.is_finite() || db <= -127.0 { - -127 - } else if db >= 0.0 { - 0 - } else { - db.round() as i8 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trip_preserves_fields() { - let h = FrameHeader { - seq: 0xABCD, - ts_48k: 0x12_34_56_78, - level_dbov: -40, - flags: FLAG_DTX, - }; - let bytes = h.encode(); - let (parsed, tail) = FrameHeader::parse(&bytes).expect("parse"); - assert_eq!(parsed, h); - assert!(tail.is_empty()); - assert!(parsed.is_dtx()); - } - - /// Header is fixed 8 bytes; anything shorter is rejected. - #[test] - fn parse_rejects_short_input() { - for len in 0..V2_HEADER_LEN { - let buf = vec![0u8; len]; - assert!( - FrameHeader::parse(&buf).is_none(), - "{len} bytes must be rejected", - ); - } - } - - /// Trailing bytes pass through as the remainder, which is what the - /// receive path hands to the Opus decoder. - #[test] - fn parse_returns_payload_remainder() { - let mut buf = FrameHeader { - seq: 1, - ts_48k: 960, - level_dbov: -20, - flags: 0, - } - .encode() - .to_vec(); - buf.extend_from_slice(b"opus-bytes"); - let (_h, tail) = FrameHeader::parse(&buf).expect("parse"); - assert_eq!(tail, b"opus-bytes"); - } - - #[test] - fn relay_frame_uses_the_v2_one_byte_peer_prefix() { - let header = FrameHeader { - seq: 0x0102, - ts_48k: 960, - level_dbov: -20, - flags: 0, - }; - let mut frame = vec![7]; - frame.extend_from_slice(&header.encode()); - frame.extend_from_slice(b"opus"); - - let (peer_index, parsed_header, opus_payload) = - parse_relay_frame(&frame).expect("valid v2 relay frame"); - assert_eq!(peer_index, 7); - assert_eq!(parsed_header, header); - assert_eq!(opus_payload, b"opus"); - } - - #[test] - fn relay_frame_rejects_a_missing_opus_payload() { - let mut frame = vec![7]; - frame.extend_from_slice( - &FrameHeader { - seq: 1, - ts_48k: 960, - level_dbov: -20, - flags: 0, - } - .encode(), - ); - - assert!(parse_relay_frame(&frame).is_none()); - } - - /// Bytes in big-endian network order, matching Max's spec. This pins - /// the byte layout against accidental endianness changes. - #[test] - fn encoded_byte_order_is_network_byte_order() { - let h = FrameHeader { - seq: 0x0102, - ts_48k: 0x0304_0506, - level_dbov: -1, - flags: 0, - }; - let bytes = h.encode(); - assert_eq!(bytes[0..2], [0x01, 0x02]); - assert_eq!(bytes[2..6], [0x03, 0x04, 0x05, 0x06]); - assert_eq!(bytes[6], 0xFF); // -1 as i8 -> 0xFF - assert_eq!(bytes[7], 0x00); - } - - /// Per Max: malformed `level_dbov` must not drop the frame; only the - /// metric is suppressed. We coerce to -127 (silence floor) and keep - /// the payload available. - #[test] - fn out_of_range_level_dbov_is_clamped_not_rejected() { - // i8(127) is +127 which is outside [-127, 0]. - let mut bytes = FrameHeader { - seq: 7, - ts_48k: 7 * 960, - level_dbov: 0, - flags: 0, - } - .encode(); - bytes[6] = 0x7F; // raw +127 — out of range - let (parsed, _) = FrameHeader::parse(&bytes).expect("parse must succeed"); - assert_eq!(parsed.level_dbov, -127, "clamped to silence floor"); - assert_eq!(parsed.seq, 7, "other fields preserved"); - } - - #[test] - fn audio_level_dbov_clamps_silence_to_minus_127() { - assert_eq!(audio_level_dbov(&[]), -127); - assert_eq!(audio_level_dbov(&[0.0_f32; 960]), -127); - } - - #[test] - fn audio_level_dbov_full_scale_returns_zero() { - let frame: Vec = (0..960).map(|_| 1.0_f32).collect(); - assert_eq!(audio_level_dbov(&frame), 0); - } - - #[test] - fn audio_level_dbov_is_in_canonical_range_for_realistic_speech() { - // A 1 kHz sine at amplitude 0.3 — typical normalized speech peak. - // RMS ≈ 0.3 / sqrt(2) ≈ 0.212 → ≈ -13 dBov. - let frame: Vec = (0..960) - .map(|i| { - let t = i as f32 / 48_000.0; - 0.3 * (2.0 * std::f32::consts::PI * 1_000.0 * t).sin() - }) - .collect(); - let db = audio_level_dbov(&frame); - assert!( - (-20..=-8).contains(&db), - "expected -20..=-8 dBov for 0.3-amp sine, got {db}", - ); - } - - /// Reserved flag bits MUST be ignored on parse (no error, just preserved - /// for forward-compat). The contract is that *receivers* don't inspect - /// them; the parse path keeps them so introspection still works. - #[test] - fn reserved_flag_bits_are_preserved_for_inspection() { - let mut bytes = FrameHeader { - seq: 0, - ts_48k: 0, - level_dbov: 0, - flags: 0, - } - .encode(); - bytes[7] = 0b1010_1010; // FLAG_DTX bit clear, reserved bits set - let (parsed, _) = FrameHeader::parse(&bytes).expect("parse"); - assert_eq!(parsed.flags, 0b1010_1010); - assert!(!parsed.is_dtx()); - // Caller can still check which reserved bits were set if needed. - assert_eq!(parsed.flags & RESERVED_FLAG_MASK, 0b1010_1010); - } -} +//! Shared Huddle wire implementation. +pub use buzz_audio_client::wire::*; diff --git a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs index 18286d62b1e..16eb01231b0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/bounded_command.rs @@ -692,7 +692,10 @@ mod tests { ); let mut cmd = Command::new("/bin/sh"); cmd.args(["-c", &script]); - let result = run_watchdogged(cmd, Duration::from_millis(300), Duration::from_secs(5)); + // Allow cold Perl/POSIX startup under parallel test load. Startup speed is + // not the contract: an escaped writer holds the pipe for 300 seconds, while + // the independent five-second watchdog still bounds drain completion. + let result = run_watchdogged(cmd, Duration::from_secs(2), Duration::from_secs(5)); assert!( result.is_none(), "a timed-out probe must fail closed even when an escaped writer holds the pipe" From e12c4cb264ea6118c68691bef56dab1ac276d967 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 15:59:24 -0500 Subject: [PATCH 09/13] fix(buzz-acp): clean up key folders left by killed agents Each harness copies the agent's secret key into %TEMP%\buzz-acp-git-* inside a TempDir. Dreamforge stops agents by killing the process, so Drop never runs and the folder (the key plus two copies of the buzz-acp binary, about 28 MB per folder) stays behind. The harness now holds a lock on a `.lock` file in its folder for its whole life; the OS releases it when the process exits or is killed. At startup each harness removes buzz-acp-git-* folders whose lock it can take and that are over a minute old. A running agent's folder is never removed because its lock is held. Folders made before this change have no lock file and cannot be judged safely, so they are left alone. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/git.rs | 111 ++++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/lib.rs | 24 ++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/git.rs b/crates/buzz-acp/src/git.rs index e438e41cc13..76bec4a501f 100644 --- a/crates/buzz-acp/src/git.rs +++ b/crates/buzz-acp/src/git.rs @@ -4,14 +4,24 @@ use std::path::Path; use tempfile::TempDir; use zeroize::Zeroizing; +const DIR_PREFIX: &str = "buzz-acp-git-"; +/// Held locked for the harness's whole life. The OS drops the lock when the +/// process exits or is killed, which is how a later harness knows the folder +/// (and the key inside it) is no longer in use. +const LOCK_FILE: &str = ".lock"; + /// Keep this guard alive until the adapter pool has shut down. pub(crate) struct GitEnvironment { + // Declared first so it closes before the directory is removed. + _lock: std::fs::File, _dir: TempDir, pub(crate) env: Vec<(String, String)>, } impl GitEnvironment { pub(crate) fn for_config(config: &mut crate::config::Config) -> anyhow::Result { + // A killed harness never runs TempDir's Drop, so its key stays behind. + remove_stale_dirs(&std::env::temp_dir(), std::time::Duration::from_secs(60)); let environment = Self::install(&config.keys, &config.relay_url, &std::env::current_exe()?)?; config @@ -30,8 +40,10 @@ impl GitEnvironment { ) -> anyhow::Result { let agent = GitIdentityMode::from_value(std::env::var_os(GIT_IDENTITY_ENV).as_deref())? == GitIdentityMode::Agent; - let dir = tempfile::Builder::new().prefix("buzz-acp-git-").tempdir()?; + let dir = tempfile::Builder::new().prefix(DIR_PREFIX).tempdir()?; set_owner_only(dir.path())?; + let lock = std::fs::File::create(dir.path().join(LOCK_FILE))?; + lock.lock()?; symlink(executable, &dir.path().join("git-credential-nostr"))?; if agent { symlink(executable, &dir.path().join("git-sign-nostr"))?; @@ -104,7 +116,48 @@ impl GitEnvironment { GIT_IDENTITY_ENV.into(), if agent { "agent" } else { "user" }.into(), )); - Ok(Self { _dir: dir, env }) + Ok(Self { + _lock: lock, + _dir: dir, + env, + }) + } +} + +/// Remove `buzz-acp-git-*` folders whose harness is gone. +/// +/// A folder goes only when its lock is free (no live owner) and it is older +/// than `min_age`, which is far longer than an owner takes to lock a folder it +/// has just created. Folders without a lock file predate it; a live owner of +/// one cannot be told apart from a dead one, so they are left alone. +fn remove_stale_dirs(parent: &Path, min_age: std::time::Duration) { + let Ok(entries) = std::fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !entry.file_name().to_string_lossy().starts_with(DIR_PREFIX) || !path.is_dir() { + continue; + } + let Ok(lock) = std::fs::File::open(path.join(LOCK_FILE)) else { + continue; + }; + let old_enough = entry + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age >= min_age); + if !old_enough || lock.try_lock().is_err() { + continue; + } + drop(lock); + match std::fs::remove_dir_all(&path) { + Ok(()) => tracing::info!(path = %path.display(), "removed a stale Git key folder"), + Err(error) => { + tracing::warn!(path = %path.display(), %error, "could not remove a stale Git key folder") + } + } } } @@ -781,3 +834,57 @@ mod git_identity_mode_tests { } } } + +#[cfg(test)] +mod stale_dir_tests { + use super::{remove_stale_dirs, DIR_PREFIX, LOCK_FILE}; + use std::{fs::File, path::Path, time::Duration}; + + fn key_dir(parent: &Path, name: &str, with_lock: bool) -> std::path::PathBuf { + let dir = parent.join(format!("{DIR_PREFIX}{name}")); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join(".nostr-key"), "secret").unwrap(); + if with_lock { + File::create(dir.join(LOCK_FILE)).unwrap(); + } + dir + } + + #[test] + fn only_folders_whose_owner_is_gone_are_removed() { + let parent = tempfile::tempdir().unwrap(); + let running = key_dir(parent.path(), "running", true); + let killed = key_dir(parent.path(), "killed", true); + let legacy = key_dir(parent.path(), "legacy", false); + let unrelated = parent.path().join("other-killed"); + std::fs::create_dir(&unrelated).unwrap(); + File::create(unrelated.join(LOCK_FILE)).unwrap(); + // Stands in for a live harness: it holds its lock until it exits. + let held = File::open(running.join(LOCK_FILE)).unwrap(); + held.lock().unwrap(); + + // Too young: an owner may not have taken its lock yet. + remove_stale_dirs(parent.path(), Duration::from_secs(3600)); + assert!(killed.exists(), "a folder younger than min_age must stay"); + + remove_stale_dirs(parent.path(), Duration::ZERO); + assert!(!killed.exists(), "a dead harness's folder must be removed"); + assert!( + running.join(".nostr-key").exists(), + "a running harness keeps its key" + ); + assert!( + legacy.exists(), + "a folder without a lock cannot be judged and must stay" + ); + assert!( + unrelated.exists(), + "only buzz-acp-git-* folders are touched" + ); + + // Once the owner exits, its folder is fair game. + drop(held); + remove_stale_dirs(parent.path(), Duration::ZERO); + assert!(!running.exists()); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4001e486492..4b162d984ac 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -10546,6 +10546,30 @@ mod build_mcp_servers_tests { ) } + #[test] + fn git_key_folder_stays_locked_while_its_harness_runs() { + let _guard = ENV_LOCK.lock().unwrap(); + let git = install_git_with_env(&test_config(), &[]).unwrap(); + let keyfile = &git + .env + .windows(2) + .find(|pair| pair[0].1 == "nostr.keyfile") + .unwrap()[1] + .1; + let dir = std::path::Path::new(keyfile) + .parent() + .unwrap() + .to_path_buf(); + let lock = std::fs::File::open(dir.join(".lock")).unwrap(); + assert!( + lock.try_lock().is_err(), + "a running harness must hold its folder's lock, or a sweep would delete its key" + ); + drop(lock); + drop(git); + assert!(!dir.exists(), "a clean exit still removes the folder"); + } + #[test] fn invalid_git_identity_mode_fails_install() { let _guard = ENV_LOCK.lock().unwrap(); From 60c412555e28aa370b51e92796c06f8aa5deefd8 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 16:05:24 -0500 Subject: [PATCH 10/13] fix(buzz-acp): no "not set up to take requests" notice when the check failed The author gate's sibling check fails closed when the relay cannot be asked (a timeout, an HTTP error, or the retry backoff after one). Our fork's refusal notice could not tell that apart from a real refusal, so during a relay hiccup a same-owner agent was told "I'm not set up to take requests from you" and the owner was flagged, both wrongly. The check now reports "could not check" separately (`None`), the gate returns why it turned an event away (`GateRefusal::Policy` or `Unverified`), and the notice goes out only for a policy refusal. A failed lookup still drops the event and is still logged at warn, now with `unverified = true`. Behaviour for every caller is otherwise unchanged: an unchecked author is still refused. The same "could not check" result covers an author with no profile on the relay yet (a new agent's profile may not have replicated), so such an author now gets no notice either. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/lib.rs | 181 ++++++++++++++++++++++++------ crates/buzz-acp/src/setup_mode.rs | 4 +- 2 files changed, 146 insertions(+), 39 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4b162d984ac..7c05cbe430c 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -382,30 +382,33 @@ enum SiblingVerdict { /// current event and arms a bounded retry deadline, so a transient relay /// outage cannot poison a sibling for the process lifetime and a persistent /// one cannot turn every sibling event into an inline relay round-trip. -async fn is_owner_or_sibling( +/// +/// `None` means the author could not be checked (the lookup failed or is +/// throttled after one). Callers fail closed on it, but it is not a verdict. +async fn owner_or_sibling( author: &str, owner_cache: &OwnerCache, rest_client: &relay::RestClient, -) -> bool { +) -> Option { let my_owner = match owner_cache.get() { Some(o) => o, - None => return false, // no owner configured — fail closed + None => return Some(false), // no owner configured — fail closed }; // Direct owner check. if author == my_owner { - return true; + return Some(true); } // Check sibling cache. match owner_cache.sibling_state(author) { - SiblingCacheState::Proven(is_sibling) => return is_sibling, + SiblingCacheState::Proven(is_sibling) => return Some(is_sibling), SiblingCacheState::Throttled => { tracing::debug!( author, "sibling check throttled after an indeterminate lookup — denying without a relay query" ); - return false; + return None; } SiblingCacheState::Unknown => {} } @@ -414,11 +417,11 @@ async fn is_owner_or_sibling( match check_sibling_via_profile(author, my_owner, rest_client).await { SiblingVerdict::Sibling => { owner_cache.cache_sibling(author.to_string(), true); - true + Some(true) } SiblingVerdict::NotSibling => { owner_cache.cache_sibling(author.to_string(), false); - false + Some(false) } SiblingVerdict::Indeterminate => { let backoff = owner_cache.note_indeterminate(author); @@ -428,11 +431,23 @@ async fn is_owner_or_sibling( "sibling check indeterminate — relay profile query failed; \ dropping this event without a proven verdict, retrying after backoff" ); - false + None } } } +/// [`owner_or_sibling`] failing closed, as the tests below pin it. +#[cfg(test)] +async fn is_owner_or_sibling( + author: &str, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, +) -> bool { + owner_or_sibling(author, owner_cache, rest_client) + .await + .unwrap_or(false) +} + /// Return the workflow owner attributed by a relay-signed workflow message. /// /// `buzz:workflow-owner` alone is not authority: any ordinary event author can @@ -535,17 +550,29 @@ fn effective_prompt_author( /// dropping the load inside it fails the construction regressions. mod inbound_author_gate { use super::{ - effective_prompt_author, is_dm_channel, is_owner_or_sibling, pool, refresh_relay_self, - relay, OwnerCache, RespondTo, + effective_prompt_author, is_dm_channel, owner_or_sibling, pool, refresh_relay_self, relay, + OwnerCache, RespondTo, }; use std::collections::HashSet; pub(crate) struct InboundAuthorGateDecision { pub(crate) effective_author: String, pub(crate) allowed: bool, + /// Denied only because the author could not be checked, not by policy. + pub(crate) unverified: bool, pub(crate) is_dm: bool, } + /// Why the gate turned an event away. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum GateRefusal { + /// The configured policy does not accept this author. + Policy, + /// The author could not be checked (a relay lookup failed). Fails + /// closed, but says nothing about whether the author is accepted. + Unverified, + } + /// An event that passed the complete listener author boundary. /// /// The event is moved into the gate before policy evaluation and can only @@ -568,6 +595,7 @@ mod inbound_author_gate { /// /// This stays private to the gate module so neither listener can bypass /// workflow attribution by calling the raw-signer policy directly. + /// `None` means the author could not be checked. async fn author_allowed( respond_to: &RespondTo, allowlist: &HashSet, @@ -575,21 +603,19 @@ mod inbound_author_gate { is_dm: bool, owner_cache: &OwnerCache, rest_client: &relay::RestClient, - ) -> bool { + ) -> Option { if is_dm { return match respond_to { - RespondTo::Nobody => false, - _ => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::Nobody => Some(false), + _ => owner_or_sibling(author, owner_cache, rest_client).await, }; } match respond_to { - RespondTo::Anyone => true, - RespondTo::Nobody => false, - RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, - RespondTo::Allowlist => { - allowlist.contains(author) - || is_owner_or_sibling(author, owner_cache, rest_client).await - } + RespondTo::Anyone => Some(true), + RespondTo::Nobody => Some(false), + RespondTo::OwnerOnly => owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::Allowlist if allowlist.contains(author) => Some(true), + RespondTo::Allowlist => owner_or_sibling(author, owner_cache, rest_client).await, } } @@ -611,6 +637,7 @@ mod inbound_author_gate { rest_client, ) .await + .unwrap_or(false) } pub(crate) struct InboundAuthorGate { @@ -704,7 +731,7 @@ mod inbound_author_gate { ) -> InboundAuthorGateDecision { let effective_author = effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex); - let allowed = author_allowed( + let verdict = author_allowed( respond_to, allowlist, &effective_author, @@ -715,7 +742,8 @@ mod inbound_author_gate { .await; InboundAuthorGateDecision { effective_author, - allowed, + allowed: verdict == Some(true), + unverified: verdict.is_none(), is_dm, } } @@ -728,7 +756,7 @@ mod inbound_author_gate { owner_cache: &OwnerCache, channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, - ) -> Option { + ) -> Result { let decision = self .evaluate_listener_event( &buzz_event, @@ -749,11 +777,16 @@ mod inbound_author_gate { effective_author = %decision.effective_author, mode = %respond_to, is_dm = decision.is_dm, + unverified = decision.unverified, "inbound author gate — dropping event" ); - return None; + return Err(if decision.unverified { + GateRefusal::Unverified + } else { + GateRefusal::Policy + }); } - Some(AuthorizedListenerEvent { + Ok(AuthorizedListenerEvent { buzz_event, effective_author: decision.effective_author, }) @@ -782,7 +815,7 @@ mod inbound_author_gate { } } -use inbound_author_gate::{AuthorizedListenerEvent, InboundAuthorGate}; +use inbound_author_gate::{AuthorizedListenerEvent, GateRefusal, InboundAuthorGate}; struct AuthorizedNormalListenerEvent(AuthorizedListenerEvent); @@ -964,7 +997,7 @@ async fn authorize_normal_listener_event( owner_cache: &OwnerCache, channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, -) -> Option { +) -> Result { author_gate .authorize_listener_event( buzz_event, @@ -3740,7 +3773,7 @@ async fn run_harness( let inbound_kind = buzz_event.event.kind.as_u16(); let inbound_author = buzz_event.event.pubkey.to_hex(); let inbound_thread_tags = queue::parse_thread_tags(&buzz_event.event); - let Some(authorized_event) = authorize_normal_listener_event( + let authorized = authorize_normal_listener_event( &mut author_gate_ctx, buzz_event, &config.respond_to, @@ -3749,18 +3782,23 @@ async fn run_harness( &ctx.channel_info, &ctx.rest_client, ) - .await - else { + .await; + let refusal = authorized.as_ref().err().copied(); + let Ok(authorized_event) = authorized else { // The gate logs the drop at debug, which leaves // the sender waiting on an answer that will never // come and the owner unaware anyone tried. One // notice per (channel, author) says so out loud // without letting a noisy author turn the agent - // into a flooder. - if remember_gate_notice( - &mut gate_notified, - (inbound_channel_id, inbound_author), - ) { + // into a flooder. A failed lookup is not a + // refusal, so it gets no notice: "not set up to + // take requests from you" would be false. + if gate_notice_due(refusal) + && remember_gate_notice( + &mut gate_notified, + (inbound_channel_id, inbound_author), + ) + { let is_dm = is_dm_channel(inbound_channel_id, &ctx.channel_info).await; // Never mention the owner in a DM: that would @@ -5300,6 +5338,11 @@ fn remember_gate_notice(seen: &mut HashSet<(Uuid, String)>, key: (Uuid, String)) true } +/// Whether a gate refusal earns the "not set up to take requests" notice. +fn gate_notice_due(refusal: Option) -> bool { + refusal == Some(GateRefusal::Policy) +} + /// What someone outside the author gate is told. /// /// Says what happened, what it means for them, and what happens next — without @@ -7738,7 +7781,7 @@ mod author_gate_tests { .await } }; - let result = authorized.map(|event| event.into_parts().1); + let result = authorized.ok().map(|event| event.into_parts().1); server.abort(); let allowed = result.is_some(); (result, allowed) @@ -8688,6 +8731,70 @@ mod author_gate_tests { ); } + /// "I'm not set up to take requests from you" is a policy statement. When + /// the author could not be checked (the relay lookup failed), saying it + /// would be false — the sender may well be a same-owner agent. + #[tokio::test] + async fn test_gate_notice_only_for_a_checked_refusal() { + let owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let channel_id = Uuid::new_v4(); + let (profile_rest, server) = + sibling_query_server(serde_json::json!([{ "tags": [] }])).await; + // A fetched profile without the owner's attestation is a real refusal; + // an unreachable relay is not, on the lookup and while throttled after it. + let cases = [ + (profile_rest, GateRefusal::Policy, 1), + (dummy_rest_client(), GateRefusal::Unverified, 2), + ]; + for (rest, expected, events) in cases { + let mut gate = InboundAuthorGate::connect(&rest, &agent, "test").await; + let owner_cache = OwnerCache::new(Some(owner.clone())); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "project".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest.clone(), + ); + let author = nostr::Keys::generate(); + for _ in 0..events { + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), + "please review", + ) + .sign_with_keys(&author) + .unwrap(), + }; + let refusal = authorize_normal_listener_event( + &mut gate, + event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest, + ) + .await + .err(); + assert_eq!(refusal, Some(expected)); + assert_eq!( + gate_notice_due(refusal), + expected == GateRefusal::Policy, + "{expected:?}" + ); + } + } + server.abort(); + } + #[test] fn test_gate_notice_fires_once_per_person_per_channel() { let mut seen = HashSet::new(); diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 5d08c8b2acb..21e43d4dacf 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -431,7 +431,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let Some(authorized_event) = authorize_setup_listener_event( + let Ok(authorized_event) = authorize_setup_listener_event( &mut author_gate_ctx, buzz_event, &config.respond_to, @@ -542,7 +542,7 @@ pub(super) async fn authorize_setup_listener_event( owner_cache: &OwnerCache, channel_info: &crate::pool::ChannelInfoResolver, rest_client: &relay::RestClient, -) -> Option { +) -> Result { author_gate .authorize_listener_event( buzz_event, From 1e75fb96c600153b7ffebbcc832a61de0b192955 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 16:07:31 -0500 Subject: [PATCH 11/13] fix(buzz-acp): call the app Dreamforge in the agent base instructions The base instructions told every agent it was "operating inside Buzz" and pointed at "Buzz Desktop", which is not what the owner or guests see. Eight mentions that describe the app now say Dreamforge: - the two opening lines (what the agent is running inside) - "Dreamforge wraps each incoming turn" (the harness, part of the app) - duplicate project cards, channel requests, rich link previews and the Assignees rail: all things the desktop app shows (was "Buzz Desktop") - "display name as shown in Dreamforge" Left as is on purpose: the `buzz` CLI and its "## Buzz CLI" heading, the BUZZ_* env vars, `` tags, `buzz://` links, and "Buzz repository" / "Buzz-hosted repos", which name repos hosted on the relay service rather than the app. Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/base_prompt.md | 16 ++++++++-------- crates/buzz-acp/src/lib.rs | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 155bc7a8357..6f41d0d0a68 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -1,9 +1,9 @@ -You are an agent operating inside Buzz — a Nostr-based messaging platform for human-agent collaboration. -Buzz is a desktop and mobile collaboration app organized around channels, conversations, and shared work. +You are an agent operating inside Dreamforge — a Nostr-based messaging platform for human-agent collaboration. +Dreamforge is a desktop and mobile collaboration app organized around channels, conversations, and shared work. ## Incoming Turn Contract -Buzz wraps each incoming turn in semantic sections. Start with the `Content:` field in the current ``, or in each event inside ``; it contains the current request. When a turn is merged into work already in flight there is no ``: the current request arrives in `` or ``, and the paired prior section holds the earlier request. Use `` or `` to understand follow-ups and references, but do not mistake prior messages for the current request. Treat `` as authoritative routing and session metadata, especially for the channel and reply destination. `Event ID`, `From`, `Kind`, `Time`, `Tags`, and `Parsed` are supporting structured metadata; use them when routing, identity, mentions, or event semantics require it. +Dreamforge wraps each incoming turn in semantic sections. Start with the `Content:` field in the current ``, or in each event inside ``; it contains the current request. When a turn is merged into work already in flight there is no ``: the current request arrives in `` or ``, and the paired prior section holds the earlier request. Use `` or `` to understand follow-ups and references, but do not mistake prior messages for the current request. Treat `` as authoritative routing and session metadata, especially for the channel and reply destination. `Event ID`, `From`, `Kind`, `Time`, `Tags`, and `Parsed` are supporting structured metadata; use them when routing, identity, mentions, or event semantics require it. ## Buzz CLI @@ -21,16 +21,16 @@ When opening a pull request in response to channel work, always pass `--channel ## Projects -A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Buzz Desktop — never do that for work that already has a project. +A project is a named grouping (`kind:30621`) with a home channel. Creating a second project with the same name produces a duplicate card in Dreamforge — never do that for work that already has a project. - If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `` includes project fields when this channel is a project home — tasks, repositories, and files you create belong to that project. - To add a codebase: `buzz repos create --id --name "…" --channel `. `mkdir` in `REPOS/` is not a Buzz repository. - To add tasks: `buzz issues create --channel --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project. -- To add another channel to this project: `buzz projects add-channel --home-channel --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it. +- To add another channel to this project: `buzz projects add-channel --home-channel --name "…" [--template "…"]`. This opens an owner-reviewed request in Dreamforge and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it. -`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. +`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Dreamforge renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. -To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Buzz Desktop's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. +To assign an issue to someone, run `buzz issues assign --issue --repo-owner --repo-id --assignee --label ` after creating it. Remove an assignment with the matching `buzz issues unassign` arguments. Writing assignee names in the issue body or adding recipients with `issues create --to` is notification/presentation only — Dreamforge's Assignees rail and the "Assigned to me" filter read the signed assignment operations. Only operations signed by the issue author or repo owner are trusted for other people; anyone may assign or unassign themselves. ## Conversational Agent Creation @@ -42,7 +42,7 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. - Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7c05cbe430c..cb1883f4fa5 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6093,7 +6093,7 @@ mod agent_draft_prompt_tests { fn shared_base_prompt_teaches_portable_agent_drafts() { let prompt = include_str!("base_prompt.md"); assert!(prompt.starts_with( - "You are an agent operating inside Buzz — a Nostr-based messaging platform for human-agent collaboration.\nBuzz is a desktop and mobile collaboration app organized around channels, conversations, and shared work." + "You are an agent operating inside Dreamforge — a Nostr-based messaging platform for human-agent collaboration.\nDreamforge is a desktop and mobile collaboration app organized around channels, conversations, and shared work." )); assert!(prompt.contains("buzz agents draft-create")); assert!(prompt.contains("ask for at most two things")); @@ -6152,7 +6152,7 @@ mod agent_draft_prompt_tests { #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); - assert!(prompt.contains("use the person's **exact display name as shown in Buzz**")); + assert!(prompt.contains("use the person's **exact display name as shown in Dreamforge**")); assert!(prompt.contains("Do not expand a short display name, infer a surname")); assert!(prompt.contains("Preserve it exactly; do not infer, expand, or look up a surname")); assert!(prompt.contains("--mention ")); From d8478e6d2eeb25b75d0839b9f27d5c586eb1fb2e Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 16:29:36 -0500 Subject: [PATCH 12/13] fix(buzz-acp): lock the key folder with fs2 so clippy passes on the 1.88 MSRV std's File::lock and File::try_lock are stable only since Rust 1.89. The workspace declares rust-version 1.88.0, so CI's `cargo clippy --workspace --all-targets -- -D warnings` failed on clippy::incompatible_msrv in git.rs and in the lib test. buzz-agent already uses fs2 0.4 for the same reason, so buzz-acp now does too (flock on Unix, LockFileEx on Windows, the same locks as before). fs2 was already in Cargo.lock; only the buzz-acp dependency line is added. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/buzz-acp/Cargo.toml | 3 +++ crates/buzz-acp/src/git.rs | 6 +++--- crates/buzz-acp/src/lib.rs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a924f4e36f..9bd7adcb72e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -957,6 +957,7 @@ dependencies = [ "chrono", "clap", "evalexpr", + "fs2", "futures-util", "git-credential-nostr", "git-sign-nostr", diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index 244bae144ed..fd3c9effc49 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -25,6 +25,9 @@ buzz-persona = { path = "../buzz-persona" } git-credential-nostr = { path = "../git-credential-nostr" } git-sign-nostr = { path = "../git-sign-nostr" } tempfile = "3" +# Holds the key folder's .lock (flock / LockFileEx). std's File::lock is 1.89+, past the +# 1.88 MSRV; buzz-agent uses fs2 for the same reason. +fs2 = "0.4" zeroize = { workspace = true } # Nostr diff --git a/crates/buzz-acp/src/git.rs b/crates/buzz-acp/src/git.rs index 76bec4a501f..a5112f6d4e2 100644 --- a/crates/buzz-acp/src/git.rs +++ b/crates/buzz-acp/src/git.rs @@ -43,7 +43,7 @@ impl GitEnvironment { let dir = tempfile::Builder::new().prefix(DIR_PREFIX).tempdir()?; set_owner_only(dir.path())?; let lock = std::fs::File::create(dir.path().join(LOCK_FILE))?; - lock.lock()?; + fs2::FileExt::lock_exclusive(&lock)?; symlink(executable, &dir.path().join("git-credential-nostr"))?; if agent { symlink(executable, &dir.path().join("git-sign-nostr"))?; @@ -148,7 +148,7 @@ fn remove_stale_dirs(parent: &Path, min_age: std::time::Duration) { .ok() .and_then(|modified| modified.elapsed().ok()) .is_some_and(|age| age >= min_age); - if !old_enough || lock.try_lock().is_err() { + if !old_enough || fs2::FileExt::try_lock_exclusive(&lock).is_err() { continue; } drop(lock); @@ -861,7 +861,7 @@ mod stale_dir_tests { File::create(unrelated.join(LOCK_FILE)).unwrap(); // Stands in for a live harness: it holds its lock until it exits. let held = File::open(running.join(LOCK_FILE)).unwrap(); - held.lock().unwrap(); + fs2::FileExt::lock_exclusive(&held).unwrap(); // Too young: an owner may not have taken its lock yet. remove_stale_dirs(parent.path(), Duration::from_secs(3600)); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index cb1883f4fa5..1c3531f93df 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -10669,7 +10669,7 @@ mod build_mcp_servers_tests { .to_path_buf(); let lock = std::fs::File::open(dir.join(".lock")).unwrap(); assert!( - lock.try_lock().is_err(), + fs2::FileExt::try_lock_exclusive(&lock).is_err(), "a running harness must hold its folder's lock, or a sweep would delete its key" ); drop(lock); From 6489d428e99112c86dabeac2bbba066449c9a6e4 Mon Sep 17 00:00:00 2001 From: QuicksilverSlick Date: Tue, 13 Oct 2026 16:39:05 -0500 Subject: [PATCH 13/13] fix(buzz-acp): only sweep key folders we own, never through a symlink The stale key-folder sweep runs in the temp dir, which is shared on Linux. It now skips anything that is not a real directory (symlinks and junctions are never followed), not owned by us with mode 0700, or whose .lock is not a regular file, so a planted symlink or FIFO cannot redirect the sweep or block harness start (automated security review finding). Co-Authored-By: Claude Opus 5.5 --- crates/buzz-acp/src/git.rs | 60 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/git.rs b/crates/buzz-acp/src/git.rs index a5112f6d4e2..c4124253304 100644 --- a/crates/buzz-acp/src/git.rs +++ b/crates/buzz-acp/src/git.rs @@ -136,7 +136,7 @@ fn remove_stale_dirs(parent: &Path, min_age: std::time::Duration) { }; for entry in entries.flatten() { let path = entry.path(); - if !entry.file_name().to_string_lossy().starts_with(DIR_PREFIX) || !path.is_dir() { + if !entry.file_name().to_string_lossy().starts_with(DIR_PREFIX) || !is_own_key_dir(&entry) { continue; } let Ok(lock) = std::fs::File::open(path.join(LOCK_FILE)) else { @@ -161,6 +161,32 @@ fn remove_stale_dirs(parent: &Path, min_age: std::time::Duration) { } } +/// The temp dir can be shared (Linux /tmp), so only judge a real directory (not a +/// symlink) that only we can write, holding a regular `.lock` file: a planted +/// symlink or FIFO could otherwise redirect the sweep or block harness start. +fn is_own_key_dir(entry: &std::fs::DirEntry) -> bool { + // DirEntry::file_type does not follow symlinks (or Windows junctions). + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + return false; + } + let lock_is_file = std::fs::symlink_metadata(entry.path().join(LOCK_FILE)) + .is_ok_and(|meta| meta.file_type().is_file()); + lock_is_file && owned_by_us_only(&entry.path()) +} + +#[cfg(unix)] +fn owned_by_us_only(dir: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + std::fs::symlink_metadata(dir) + .is_ok_and(|meta| meta.uid() == nix::unistd::getuid().as_raw() && meta.mode() & 0o077 == 0) +} + +#[cfg(not(unix))] +fn owned_by_us_only(_: &Path) -> bool { + // %TEMP% is per-user on Windows. + true +} + struct KeyInfo { keyfile_path: String, pubkey_hex: String, @@ -837,12 +863,13 @@ mod git_identity_mode_tests { #[cfg(test)] mod stale_dir_tests { - use super::{remove_stale_dirs, DIR_PREFIX, LOCK_FILE}; + use super::{remove_stale_dirs, set_owner_only, DIR_PREFIX, LOCK_FILE}; use std::{fs::File, path::Path, time::Duration}; fn key_dir(parent: &Path, name: &str, with_lock: bool) -> std::path::PathBuf { let dir = parent.join(format!("{DIR_PREFIX}{name}")); std::fs::create_dir(&dir).unwrap(); + set_owner_only(&dir).unwrap(); std::fs::write(dir.join(".nostr-key"), "secret").unwrap(); if with_lock { File::create(dir.join(LOCK_FILE)).unwrap(); @@ -887,4 +914,33 @@ mod stale_dir_tests { remove_stale_dirs(parent.path(), Duration::ZERO); assert!(!running.exists()); } + + #[test] + fn a_lock_that_is_not_a_regular_file_is_left_alone() { + let parent = tempfile::tempdir().unwrap(); + let odd = key_dir(parent.path(), "odd", false); + std::fs::create_dir(odd.join(LOCK_FILE)).unwrap(); + remove_stale_dirs(parent.path(), Duration::ZERO); + assert!(odd.join(".nostr-key").exists()); + } + + #[cfg(unix)] + #[test] + fn symlinked_or_shared_folders_are_left_alone() { + use std::os::unix::fs::PermissionsExt; + let parent = tempfile::tempdir().unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let target = key_dir(elsewhere.path(), "target", true); + std::os::unix::fs::symlink(&target, parent.path().join(format!("{DIR_PREFIX}link"))) + .unwrap(); + let shared = key_dir(parent.path(), "shared", true); + std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o755)).unwrap(); + + remove_stale_dirs(parent.path(), Duration::ZERO); + assert!( + target.join(".nostr-key").exists(), + "a symlink is never followed" + ); + assert!(shared.exists(), "a folder others can write is not judged"); + } }