From 845fc16ebf5ec84f87a94ceaf69bb79c004b3a76 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sat, 10 Oct 2026 12:50:52 -0400 Subject: [PATCH 001/146] chore(hermit): share one CARGO_HOME across worktrees (#8250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Hermit defaults `CARGO_HOME` to each checkout's `.hermit/rust`, so every worktree sees dependency sources at a different absolute path. sccache keys Rust compiles on those paths, which means the same dependency built in two worktrees almost never hits the cache. This pins `CARGO_HOME` to `${HOME}/.cargo` in `bin/hermit.hcl`, the same setting buzz-app already uses. CI also lines up better: the workflows that activate Hermit and cache `~/.cargo/registry` / `~/.cargo/git` were caching a directory cargo wasn't using (cargo wrote to `$GITHUB_WORKSPACE/.hermit/rust`). With this change those cache paths are where cargo actually puts the registry and git checkouts. The `MESH_ROOT` lookups in `_ci-desktop-macos.yml`, `release.yml`, and `signed-macos-canary.yml` already read `${CARGO_HOME:-$HOME/.cargo}`, so they follow either way. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- bin/hermit.hcl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/hermit.hcl b/bin/hermit.hcl index b429ec9613..5b76c78ca4 100644 --- a/bin/hermit.hcl +++ b/bin/hermit.hcl @@ -1 +1,5 @@ manage-git = true + +env = { + "CARGO_HOME": "${HOME}/.cargo", +} From 30873ba46b5e08602ae61a2a0592ecedd37f7289 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 10 Oct 2026 21:39:21 -0700 Subject: [PATCH 002/146] docs(nest): replace commit attribution policy with the runtime facts (#8236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The nest `AGENTS.md` template (`desktop/src-tauri/src/managed_agents/nest_agents.md`) ships to every Buzz user's agents. Its "Git Commit Attribution" section is cut from a general attribution policy (separate claims, author "per applicable policy", hypothetical repos that require a human author, "stop and ask") down to what the runtime actually does: - `buzz-acp` sets a default git identity and signing. Agents use it, but change the author when a repository or their human needs a different one. The operator can opt out with `BUZZ_GIT_IDENTITY=user`. - Credit the other party with `Co-authored-by`: the human you work for when you commit as yourself, or yourself when you commit as them. - Follow each repository's own `AGENTS.md` for sign-off (DCO) and other attribution rules. The buzz-acp base prompt gets the same co-author default. `NEST_AGENTS_VERSION` goes from 6 to 7 so existing nests refresh, and the template test now asserts the new content. ## Why Agents read the old section as a puzzle to solve on every commit. They looked for an applicable policy, wondered whether the human should be the author, and stalled. DCO and sign-off rules are per-repository and already live in each repo's own `AGENTS.md` (this repo says `git commit -s`), so they don't belong in a template that every OSS user's agents read. Companion buzz-app change: block/buzz-app#833. ## Validation `cargo test --lib managed_agents::nest` in `desktop/src-tauri`: 55 passed, including the legacy-attribution upgrade test. I pushed with `--no-verify`: the change is Markdown plus a version constant and a test, and the nest tests above are the relevant suite. CI runs the full set. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Bradley Axen Co-authored-by: bundled00 Co-authored-by: Claude Opus 5.5 --- crates/buzz-acp/src/base_prompt.md | 2 +- desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest/tests.rs | 20 +++++-------------- .../src/managed_agents/nest_agents.md | 17 ++-------------- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 565173e8bb..155bc7a835 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -132,7 +132,7 @@ These are guidelines, not a fixed procedure — apply judgment to the task in fr - After selecting a repository or worktree, read its root `AGENTS.md` and any path-local `AGENTS.md` files that apply before planning or editing. The workspace-level file is team context; it does not replace repository-owned instructions. - Treat repository-owned product, architecture, and vision documents as design constraints, not optional background. Read the relevant documents before making non-trivial plans, and surface any intentional conflict with them. - Make file changes in a worktree, not on the default branch. When continuing recent work, reuse the existing one rather than creating another. -- The runtime sets your git commit identity and signing. Don't override them with `user.*` config, `-c user.*`, `--author` or another signing key. Add co-author or sign-off trailers only when the repository or the person you're working for requires them. If a repository requires a different commit author, tell the person running you instead of overriding the identity; they can switch your commits to their own git identity with `BUZZ_GIT_IDENTITY=user`. +- The runtime gives you a default git commit identity and signing key. Use it unless a repository or the person you're working for needs a different author, then change it. Credit the human you work for with a `Co-authored-by` trailer (or yourself, if you commit as them), and follow the repository's `AGENTS.md` for sign-off rules. ## Autonomy diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 8ce221b85b..9e6f9c8845 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -48,7 +48,7 @@ const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md"); /// Template content version for AGENTS.md static content (above managed markers). /// Bump this when changing `nest_agents.md` to trigger refresh on existing installs. /// Version 1 is implicitly "before this mechanism existed" (no version file). -const NEST_AGENTS_VERSION: u32 = 6; +const NEST_AGENTS_VERSION: u32 = 7; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index 8e27151b10..adc217b954 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -42,23 +42,13 @@ fn nest_skill_contains_safe_mention_workflow() { } #[test] -fn nest_agents_template_separates_commit_attribution_claims() { +fn nest_agents_template_defers_commit_policy_to_repositories() { assert_eq!(AGENTS_MD.matches("## Git Commit Attribution").count(), 1); - assert!(AGENTS_MD.contains( - "Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims" - )); - assert!(AGENTS_MD - .contains("Request, approval, review, or accountability alone is not co-authorship")); - assert!(AGENTS_MD.contains("A sign-off is not an approval marker")); - assert!(AGENTS_MD.contains("Never use another person's signing key")); - assert!(AGENTS_MD.contains("inspect every outgoing commit against the actual upstream or base")); - assert!(AGENTS_MD.contains("An agent-owned repository may use the agent as author")); + assert!(AGENTS_MD.contains("the runtime sets a default git identity and signing")); + assert!(AGENTS_MD.contains("`BUZZ_GIT_IDENTITY=user`")); + assert!(AGENTS_MD.contains("Credit the other party with a `Co-authored-by` trailer")); + assert!(AGENTS_MD.contains("Follow each repository's own `AGENTS.md` for sign-off (DCO)")); assert!(!AGENTS_MD.contains("every commit MUST include a `Signed-off-by`")); - // Managed-runtime delta: describes the default agent identity and the - // `user` opt-out without reintroducing mandatory human-trailer policy. - assert!(AGENTS_MD.contains("### Managed runtime default")); - assert!(AGENTS_MD.contains("`BUZZ_GIT_IDENTITY` unset or `agent`")); - assert!(AGENTS_MD.contains("The operator can opt out per-agent with `BUZZ_GIT_IDENTITY=user`")); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md index 50a6010739..e7dd0afeb9 100644 --- a/desktop/src-tauri/src/managed_agents/nest_agents.md +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -46,22 +46,9 @@ created: 2026-01-15 ## Git Commit Attribution -Git authorship, co-authorship, DCO sign-off, and cryptographic signing are separate claims. Follow repository-local rules and the authorizing human's explicit directions; do not infer attribution from repository ownership or from who requested, approved, or reviewed the work. +When Buzz runs you through its `buzz-acp` harness, the runtime sets a default git identity and signing: you commit as yourself (your display name and `@`), signed with your agent key. Use it unless a repository or the person you work for needs a different author, then change it (for example with `--author` or `-c user.*`). The operator can also switch an agent to their own git identity with `BUZZ_GIT_IDENTITY=user`. -- **Author:** use the person or agent required by the applicable policy. If no policy specifies an author, use the identity that actually authored the change. -- **Co-authors:** add `Co-authored-by` only for other people or agents who materially authored the change. Request, approval, review, or accountability alone is not co-authorship. -- **DCO:** add `Signed-off-by` only when repository policy requires that identity's DCO certification. A sign-off is not an approval marker. -- **Identity:** resolve required identities from trusted local configuration or explicit verified direction; never hard-code or guess them. A managed runtime may make effective `git config user.*` values identify the agent. Stop and ask if a required identity cannot be established. -- **Signing:** use only the signing key configured for the committing identity. Never use another person's signing key. -- **Verify before pushing:** inspect every outgoing commit against the actual upstream or base and confirm its attribution matches the applicable policy. - -A repository may require an accountable human as author and the implementing agent as co-author. An agent-owned repository may use the agent as author and require no human trailer. In both cases, repository-local policy controls. - -### Managed runtime default - -When Buzz runs you through its stock `buzz-acp` harness, by default (`BUZZ_GIT_IDENTITY` unset or `agent`) its `GIT_CONFIG_*` environment variables set your agent nostr identity and signing as the default for commits you create: `user.email` is `@`, `user.name` is your display name (or npub), and commits are signed with your agent key (NIP-GS). These outrank repo and global `user.*` config, but not repo `author.*`/`committer.*` config, and amend or cherry-pick keep the original author. Treat this as your identity under the rules above, and do not override it with `user.*` config, `-c user.*`, `--author` or another signing key. If a repository's policy requires a different author, tell the operator instead. - -The operator can opt out per-agent with `BUZZ_GIT_IDENTITY=user`. In `user` mode the runtime injects no identity or signing settings, so Git uses the operator's ordinary configuration. It also drops inherited `GIT_CONFIG_*` identity, signing, `author.*`, `committer.*` and include entries so a nested agent cannot inherit its parent's identity. Relay git authentication works in both modes. A custom agent harness gets only relay git authentication and controls its own commit attribution. +Credit the other party with a `Co-authored-by` trailer: when you commit as yourself, credit the human you work for (`git config --global user.name` and `user.email` give their identity); when you commit as them, credit yourself. Follow each repository's own `AGENTS.md` for sign-off (DCO) and other attribution rules. ## Active Agents From c09730e87466de572fb6218375aef2c3d915dd7d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 12 Oct 2026 09:39:53 -0700 Subject: [PATCH 003/146] 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 34b46a4cd0..23ba05a22f 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 847668fefe..49b05859ec 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 bcfaf06fa4..6688797993 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 0000000000..39e46ad9e8 --- /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 2a3752e9f8..6770ad546e 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 004/146] 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 24d59c2c93..da930dda45 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 07dc4cce55..0249f5be4d 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 005/146] 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 531b1ceee7..bab06526ed 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 cce70f2f77..cb1f20e84f 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 32d8959fb6..332e97d958 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 e6f9ad4cd5..ec4b897a61 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 93417e11c3..487ea9b630 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 a349022271..4de5fae8a3 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 b4df5bec17..a937ff0e4f 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 49b906ee3d..564ad3d00d 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 006/146] 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 211c201d60..985b29bee7 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 d3c5fcd4bc..74e83fb351 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 39e46ad9e8..19c80e2522 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 007/146] 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 | 302 ++-- .../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 | 1304 +++-------------- .../features/channels/reaction_row_test.dart | 240 ++- mobile/test/shared/widgets/app_list_test.dart | 2 +- .../shared/widgets/frosted_scaffold_test.dart | 37 + 22 files changed, 959 insertions(+), 3640 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 4a6d846c1f..4b7821dcd3 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 00657f133d..8d6361e92c 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 5549d6af63..b25edaa883 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 1a2c3c8211..2c18dcf786 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 883f456078..6aac44bfa6 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; - } + unawaited(HapticFeedback.mediumImpact()); + final restoreFocusOnCancel = composerFocusNode?.hasFocus ?? false; + composerFocusNode?.unfocus(); - 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; - } - - 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 7a41f72a0c..0000000000 --- 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 8c1bb7a5d5..0000000000 --- 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 2128178904..0000000000 --- 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 f64e658fec..0000000000 --- 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 18dacae27d..1673086e73 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 b5b3e5a0c9..0000000000 --- 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 929dd5bfc0..367a92e455 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 eaaa8cdac6..e3b6f10c62 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 eb4b72a0cf..44e1aac2c2 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 98996f0cf7..160d9ab127 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 5c76d3f789..3d8bbc2858 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 ab6bc9e50d..f768564391 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, + findsNothing, ); - expect( - find.byKey(const ValueKey('message-action-surface')), - findsOneWidget, - ); - 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 0364ff6f0b..3e5919cec4 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 cd5bad0821..ef9a9a01e9 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, - ), - currentPubkey: 'self', - ); - - @override - Future addReaction(String eventId, String emoji) async { - reactions.add((eventId: eventId, emoji: emoji)); - } -} - 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', + ), + ], + ), + 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(); + }, + ); + + 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, + ); + }); + // 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,983 +704,70 @@ 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, - ); - 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; - }); - - 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; - }); - - 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}), - ), - ), - reminderServiceProvider.overrideWithValue(null), - ], - child: MaterialApp( - home: Consumer( - builder: (context, ref, _) { - pageContext = context; - pageRef = ref; - return Scaffold(body: TextField(focusNode: focus)); - }, - ), - ), - ), - ); - 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, - ); - debugDefaultTargetPlatformOverride = null; - }); - } - - for (final (reactionFirst, missingSnapshot) in [ - (false, false), - (true, false), - (true, true), - ]) { - testWidgets( - 'cross-surface native ownership reactionFirst=$reactionFirst missingSnapshot=$missingSnapshot', - (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, - message: _message(), - channelId: _channelId, - canManageMessage: false, - anchorRect: const Rect.fromLTWH(20, 200, 300, 80), - captureAnchorSnapshot: missingSnapshot ? null : _testMessageSnapshot, - composerFocusNode: focus, - restoreComposerFocus: () { - restores++; - focus.requestFocus(); - }, - ); - Future reaction() => showReactionDetailSheet( - context: pageContext, - channelId: _channelId, - reactions: const [ - TimelineReaction( - emoji: '❤️', - count: 1, - reactedByCurrentUser: false, - userPubkeys: [], - ), - ], - initialEmoji: '❤️', - ); - 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, - ); - debugDefaultTargetPlatformOverride = null; - }, - ); - } - - 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; - }); - - group('showMessageActions', () { - testWidgets('composes the tray, lifted preview, and compact actions', ( - tester, - ) async { - final prefs = await _mockPrefs(); - final harness = await _pumpMessageActionsPopover( - tester, - message: _message(), - prefs: prefs, - allMessages: [_message()], - reminderService: _stubReminderService(), - ); - - expect(harness.sourceHidden.value, isTrue); - 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, - ); - 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(), + textScaler: TextScaler.linear(2), ); - - 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; - } + await tester.ensureVisible(find.text('Delete message')); + expect(find.text('Delete message').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); }); testWidgets('shows parity actions for a regular message', (tester) async { @@ -1663,24 +802,82 @@ void main() { ); }); - 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(), - ); + testWidgets( + 'places three quick action tiles between reactions and sections', + (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); - }); + 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), + ); + expect( + remind.dy, + lessThan(tester.getTopLeft(find.byType(SheetActionSection).first).dy), + ); + 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); + }, + ); + + 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); + }); + } testWidgets('hides utility actions for system messages', (tester) async { final prefs = await _mockPrefs(); @@ -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 _pumpSheet( + tester, + message: _message(isSystem: true), + prefs: await _mockPrefs(), + anchorRect: const Rect.fromLTWH(20, 20, 100, 100), ); - - 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')), - ); - 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 1670268a06..3fc3257730 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.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(), - findsOneWidget, + tester + .widget( + find.byKey(const ValueKey('reaction-toggle-$_fire')), + ) + .selected, + isTrue, ); expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsNothing, + tester + .widget( + find.byKey(const ValueKey('reaction-toggle-$_eyes')), + ) + .selected, + isFalse, ); - await tester.tap(find.byKey(const ValueKey('reaction-filter-all'))); + await tester.tap(find.byKey(const ValueKey('reaction-toggle-$_fire'))); 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.byKey(ValueKey('reactor-$_alice-$_eyes')).hitTestable(), - findsNothing, - ); - expect( - find.byKey(ValueKey('reactor-$_alice-$_fire')).hitTestable(), - findsOneWidget, - ); - await tester.drag(find.byType(TabBarView), const Offset(-500, 0)); - 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 db493a3f9b..d0a2b7a4a7 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 998febfdec..c77322ff95 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 008/146] 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 | 200 ++- 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, 1881 insertions(+), 199 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 b6cab48aaf..dff24c5f75 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 04c6e46e70..3e7ead1528 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 332e97d958..db05ea922f 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 1c7de25cc0..7ff6eabdfd 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"); - 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()) + 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))); + 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 20935e9fed..b5cfa1ef49 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 d631df7500..bd39ada46e 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 9cee67ea3b..02ef106cda 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 a937ff0e4f..f57c10b150 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 564ad3d00d..5aad9689aa 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 + // 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 } - }); - 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}" - ))); - } - } - } - 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 c2296ee717..7572774b3a 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 250b289f44..8cc7d3abc6 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 0000000000..e820aabd13 --- /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 4f5ed92ba3..b51e575973 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 009/146] 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 3d824bfc56..596c247eee 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 eb9e3bdb32..5704a7622b 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 73e37e7955..5b11a71087 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 010/146] 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 b4986b67b4..0a924f4e36 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 d27c63d234..df77ab7cb3 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 2c1e8adbb5..1f10b0f260 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 5b0aca8bd3..2b899c89e5 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 3e7ead1528..8d107af69e 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 985b29bee7..026b2344a0 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 2451deae08..86c469f982 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 0000000000..8b0a591925 --- /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 0000000000..f847c98576 --- /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 0000000000..193a32c5fe --- /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 0000000000..342187b07c --- /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 0000000000..ff1c36077a --- /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 0000000000..14917b9500 --- /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 0000000000..bcf9c007c2 --- /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 0000000000..5f15e39699 --- /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 0000000000..c67c24b764 --- /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 74e83fb351..00b4f66ae4 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 cab1aa500e..7ba160f8c8 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 b7b31117f7..fdff759d0f 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 ba10a5b4fc..44302db1c2 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 190397aa05..53c757b986 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 bcf9c007c2..e872d70aff 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 18286d62b1..16eb01231b 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 85b7331784804fbf9bb07df4199d4dc6a2b42ade Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Tue, 13 Oct 2026 13:57:54 -0700 Subject: [PATCH 011/146] fix(relay): partition audit observability follow-ups (#7994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!IMPORTANT] > **The alert rules changed in c3a314e71.** If you reviewed the alert guidance earlier, please look again. > - **The two range-based rules now apply only to live pods.** A range selector such as `[30m]` keeps reading a terminated pod's samples until they leave the window. So `BuzzPartitionAuditNotRunning` could fire for about 10 minutes after normal pod turnover, and `BuzzPartitionAuditFailing` could keep firing for up to an hour after the pod was gone. Each rule now adds `and `, which requires the counter to be exported right now: > ```yaml > expr: | > sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 > and sum without (outcome) (buzz_partition_audit_runs_total) > ``` > ```yaml > expr: | > increase(buzz_partition_audit_failures_total[1h]) >= 2 > and buzz_partition_audit_failures_total > ``` > - **The audit counters start at 0.** When the relay starts, it exports every `buzz_partition_audit_runs_total{table, outcome}` series and `buzz_partition_audit_failures_total` at 0, before the first audit. `increase()` ignores a new series' first sample. Before this change, it could miss the first periodic failure, or read a table's first `degraded` run as a stopped loop. This change is also what makes the `and ` gate work: the counters now exist for a relay's whole life. > > The doc's old claim that pod turnover can't leave an alert firing was true only of the instant-selector stale rule. The doc now explains the difference. ## Why These are the observability follow-ups deferred from #6515 ("fix(db): audit partition catalog before creation"): - **Timestamp-age alerts go silent** ([review comment](https://github.com/block/buzz/pull/6515#discussion_r4127212141)). Partition gauges are set only when a table audits successfully, and the exporter evicts idle gauges after 2,700 s by default. After sustained failures, `buzz_partition_audit_last_success_timestamp_seconds` disappears. A table that has never audited successfully never has it. An alert on `time() - last_success` then has no input. - **Startup fallback failures are not counted** ([review comment](https://github.com/block/buzz/pull/6515#discussion_r4127212158)). When the startup fallback audit fails, it logs an error but does not increment `buzz_partition_audit_failures_total`. Periodic failures do. - **Repaired environments are always `degraded`**. `PartitionTableAudit::degraded()` counts every legacy leaf, even one wholly in the past. The `bb-block-staging` audit of the repaired catalog reports `degraded` only because of `events_p2026_07_08_legacy` `[2026-07-01, 2026-09-01)`. With this rule, every repaired environment stays `degraded`, so nobody can alert on the outcome. - **Partition config tests can see a poisoned env** ([review comment](https://github.com/block/buzz/pull/6515#discussion_r4137416297)). Two partition config tests take only `ENV_MUTEX`. A NIP-FI test running in the same process can make them see its deliberately invalid environment. Partition health is still diagnostic only, through `/_status.partition_catalog` and metrics. Nothing here affects `/_readiness`. Handling concurrent creation (#6515 item 4) is out of scope and stays with the catch-all advancement work. ## What - **Success timestamp survives failures.** Each failed table audit refreshes `buzz_partition_audit_last_success_timestamp_seconds` with `increment(0.0)`, which leaves the value unchanged. It still resets the exporter's idle tracking, the same mechanism as `refresh_legacy_active_gauge_recency`. Sustained failures therefore keep the last success exported and its age rising. A table that has never succeeded exports `0`. A failed audit still never makes the last success look fresh. - **Alert guidance.** New `docs/partition-catalog-monitoring.md` covers the metrics, how long each series lives, and the audit outcomes. It notes that `runs_total` has no `unsafe` outcome. It gives three alert rules: - timestamp age, which also covers never-successful tables and sustained failures; - `sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0` for a stopped audit loop. Counters are never idle-evicted, and this rule fires before the stopped loop's timestamp is evicted, so there is no false resolve; - an aggregate failure-counter rule. Both range-based rules also require their counter to be exported right now (`and `), so a terminated pod's leftover samples can't raise or hold an alert. - **Zero counter baselines.** `try_install` exports each `buzz_partition_audit_runs_total{table, outcome}` series (`ok`, `degraded`, and `error` for both tables) and `buzz_partition_audit_failures_total` at 0 before the startup audit, the same way the auth series already start at 0. A failure in the startup audit itself can still happen before the first scrape. The doc says so; the stale rule covers that case. - **Failure counter.** `buzz_partition_audit_failures_total` now counts failed explicit audit attempts: the startup fallback audit and each periodic audit. Both paths go through one helper, `counted_partition_audit`. A failed `ensure_future_partitions` call, such as a DDL error that the fallback then recovers, is not counted by itself. A single attempt never counts twice. - **Historical legacy leaves.** A legacy leaf is historical when its finite upper bound is at or before the current month start. It is still reported under `children` as `legacy_leaf`, but it no longer degrades the table. A legacy leaf that still covers the current month or any lookahead month stays degraded. All other degradation causes are unchanged. The verdict now lives in `PartitionAuditReport::outcome()`, which `buzz-admin partition-audit` uses. The JSON outcome names and exit codes are the same as before. - **Test hygiene.** Both partition config tests now use `env_guards()`. ## How it was tested - `cargo fmt --all -- --check` and `cargo clippy -p buzz-db -p buzz-relay -p buzz-admin --all-targets -- -D warnings`. - New unit tests: - The repaired incident layout with an injected clock: `degraded` in August 2026 and `ok` on 2026-09-01T00:00Z, when the upper bound equals the current month start, and on 2026-09-15. The legacy leaf is still in the serialized report. The `outcome` label on the run counter follows the verdict. - A table test of the legacy-leaf predicate: the upper bound before, at, and one second after the month start; lookahead and beyond-horizon leaves; historical leaves with trigger drift; anomalous and `DEFAULT` children; no audited month. - Outcome precedence (error, then unsafe, then degraded) and the `buzz-admin` exit-code mapping. - Startup: a failed fallback counts once, a successful fallback after a DDL failure counts nothing, and a successful ensure never runs the fallback. Each periodic failure counts once. Against an unreachable database, startup counts one aggregate failure while each table still records two `error` runs. - On the production exporter, never-successful audits leave run counters and a `0` success timestamp for both tables. - On the production exporter, all six run-counter series and the failure counter read 0 before any audit. After one failed audit, `error` reads 1 and `ok`/`degraded` stay at 0. Removing the initialization fails this test. - PostgreSQL lane (`scripts/postgres-test-run.sh`) with new or extended tests: - The repaired layout is not degraded in September. The same catalog is degraded in August. - With a 1 s idle timeout, a failed audit leaves the success timestamp unchanged, and sustained failures keep it exported past the idle timeout. When audits stop, the timestamp is evicted but the run counters remain. The next success exports a fresh timestamp. - Mutation check: removing the failure-path refresh fails both alert fixtures. - These fixtures check the emitted series. They do not evaluate the rules themselves, including the `and ` gate. `promtool` isn't available locally or in CI, and the doc points to `promtool test rules` for that. - `scripts/test-postgres-test-discovery.sh`. - `config::` and `nip_fi_config::` tests run together in one process five times. ## Related This PR conflicts textually with #7993 (catch-all advancement) in `crates/buzz-db/src/store/partition.rs` and `crates/buzz-relay/src/main.rs`. Whichever PR merges second will be rebased onto the other. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Ian Oberst Co-authored-by: Ian Oberst Co-authored-by: Claude Opus 5.5 --- Justfile | 12 +- crates/buzz-admin/src/main.rs | 45 +- crates/buzz-db/src/store/partition.rs | 457 +++++++++++++++++- crates/buzz-relay/src/config.rs | 4 +- crates/buzz-relay/src/main.rs | 229 +++++++-- crates/buzz-relay/src/metrics.rs | 172 +++++++ crates/buzz-relay/src/state.rs | 8 +- docs/partition-catalog-monitoring.md | 150 ++++++ ...artition-catalog-monitoring.rules.test.yml | 122 +++++ docs/partition-catalog-monitoring.rules.yml | 20 + scripts/run-tests.sh | 6 + 11 files changed, 1156 insertions(+), 69 deletions(-) create mode 100644 docs/partition-catalog-monitoring.md create mode 100644 docs/partition-catalog-monitoring.rules.test.yml create mode 100644 docs/partition-catalog-monitoring.rules.yml diff --git a/Justfile b/Justfile index 8d107af69e..d73a9cc547 100644 --- a/Justfile +++ b/Justfile @@ -448,10 +448,11 @@ test-unit: # suites are otherwise absent from the infra-free unit lane. cargo nextest run -p buzz-media --lib \ -E 'test(=bucket_index::tests::bucket_snapshot_json_round_trip_preserves_community_keys)' - # buzz-admin: storage-snapshot worker plus the community archive - # command parser, validation, and propagation-evidence tests. + # buzz-admin: storage-snapshot worker, the community archive command + # parser, validation, and propagation-evidence tests, and the partition + # audit outcome contract. cargo nextest run -p buzz-admin \ - -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/)' + -E 'test(storage_snapshot) + test(/^communities::tests::/) + test(/^tests::communities_/) + test(=tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes)' # Multi-tenant conformance gate (buzz-conformance): the independent # replay checker + golden fixtures. No infra — pure in-process trace # replay — so it belongs in the unit job. Run all targets (lib + the @@ -539,7 +540,9 @@ test-unit: # DB-backed audio join tests use #[ignore] and run in the postgres lane. # NIP-FI (S3/S4) relay witnesses: the wholly-new nip_fi_upgrade and # api::nip_fi modules, - # the auth metrics contract module, plus the exact NIP-FI tests added, + # the auth metrics contract module, the DB-free partition alert series + # tests (their Postgres case is #[ignore]d and runs in the PostgreSQL + # lane), plus the exact NIP-FI tests added, # or whose assertions changed, in mixed modules (audio::room, # connection, handlers::*, state). nip_fi_config and router are # selected whole by the command above. Mixed modules are listed by exact @@ -550,6 +553,7 @@ test-unit: cargo nextest run -p buzz-relay --lib -E ' test(/^nip_fi_upgrade::/) + test(/^metrics::contract_tests::/) + + test(/^metrics::partition_alert_tests::/) + test(=audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative) + test(=connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge) + test(=audio::room::tests::b1_pending_peer_removed_before_commit_emits_no_delta) diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 66780e2041..33fc74b6d1 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -31,7 +31,10 @@ use std::time::Instant; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; use buzz_core::tenant::{relay_url_authority, TenantContext}; -use buzz_db::{partition::PartitionAuditReport, Db, DbConfig}; +use buzz_db::{ + partition::{PartitionAuditOutcome, PartitionAuditReport}, + Db, DbConfig, +}; use buzz_media::{BucketSnapshot, MediaConfig, MediaStorage, S3AddressingStyle, SweepError}; use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; @@ -358,7 +361,7 @@ struct PartitionAuditOutput { source_sha: &'static str, build_id: &'static str, build_url: &'static str, - outcome: &'static str, + outcome: PartitionAuditOutcome, months_ahead: u32, identity: PartitionAuditIdentity, report: PartitionAuditReport, @@ -399,20 +402,8 @@ async fn cmd_partition_audit(months_ahead: u32) -> Result { let report = Db::from_pool(pool) .audit_partitions_report(months_ahead) .await; - let outcome = if !report.errors.is_empty() { - "error" - } else if !report.serving_safe() { - "unsafe" - } else if report.tables.iter().any(|table| table.degraded()) { - "degraded" - } else { - "ok" - }; - let code = match outcome { - "error" => 5, - "unsafe" => 2, - _ => 0, - }; + let outcome = report.outcome(); + let code = partition_audit_exit_code(outcome); println!( "{}", serde_json::to_string_pretty(&PartitionAuditOutput { @@ -430,6 +421,15 @@ async fn cmd_partition_audit(months_ahead: u32) -> Result { Ok(code) } +/// Exit nonzero only when the audit is incomplete or a table cannot serve. +fn partition_audit_exit_code(outcome: PartitionAuditOutcome) -> i32 { + match outcome { + PartitionAuditOutcome::Error => 5, + PartitionAuditOutcome::Unsafe => 2, + PartitionAuditOutcome::Degraded | PartitionAuditOutcome::Ok => 0, + } +} + async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { if let Err(msg) = validate_role(&role) { eprintln!("error: {msg}"); @@ -1090,4 +1090,17 @@ mod tests { assert!(command.is_err()); } + + #[test] + fn partition_audit_outcomes_keep_their_json_names_and_exit_codes() { + for (outcome, name, code) in [ + (PartitionAuditOutcome::Ok, "ok", 0), + (PartitionAuditOutcome::Degraded, "degraded", 0), + (PartitionAuditOutcome::Unsafe, "unsafe", 2), + (PartitionAuditOutcome::Error, "error", 5), + ] { + assert_eq!(serde_json::to_value(outcome).unwrap(), name); + assert_eq!(partition_audit_exit_code(outcome), code, "{name}"); + } + } } diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index 02dfa4e708..8322313de3 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -186,7 +186,7 @@ pub struct PartitionTableAudit { pub children: Vec, /// Cached effective bounds for every routable leaf in the catalog tree. pub coverage_leaves: Vec, - /// Coverage of the current month and the requested future months. + /// Coverage of the current month, first, and the requested future months. pub months: Vec, /// Whether a parseable routable leaf covers the audit timestamp. pub serving_safe: bool, @@ -234,15 +234,18 @@ impl PartitionTableAudit { } /// Whether the table is serving but has state requiring operator attention. + /// + /// A legacy leaf that ends at or before the current month start is + /// historical: it stays in `children` but no longer degrades the table. pub fn degraded(&self) -> bool { !self.partition_key_valid || self.children.iter().any(|child| { matches!( child.kind, - PartitionChildKind::LegacyLeaf - | PartitionChildKind::Default - | PartitionChildKind::Anomalous - ) || !child.missing_triggers.is_empty() + PartitionChildKind::Default | PartitionChildKind::Anomalous + ) || (child.kind == PartitionChildKind::LegacyLeaf + && !self.ends_by_current_month(child)) + || !child.missing_triggers.is_empty() || !child.extra_triggers.is_empty() || child.catch_all_nonempty == Some(true) || child.default_nonempty == Some(true) @@ -253,6 +256,18 @@ impl PartitionTableAudit { .any(|month| month.kind != MonthCoverageKind::CoveredByMonthly) } + /// Whether `child` has a finite upper bound at or before the audited month start. + /// + /// Without an audited month, no child is treated as historical. + fn ends_by_current_month(&self, child: &PartitionChildAudit) -> bool { + self.months.first().is_some_and(|current_month| { + matches!( + &child.upper, + Some(PartitionBound::Finite(upper)) if *upper <= current_month.start + ) + }) + } + /// Whether the catalog is understood well enough to authorize automatic DDL. fn structurally_safe_for_creation(&self) -> bool { self.partition_key_valid @@ -297,6 +312,20 @@ pub struct PartitionAuditReport { pub errors: Vec, } +/// Operator-facing verdict for a best-effort catalog audit report. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PartitionAuditOutcome { + /// Every managed parent is serving safely without operator attention. + Ok, + /// Every managed parent is serving safely, but one needs operator attention. + Degraded, + /// The report is complete, but a managed parent is not serving safely. + Unsafe, + /// At least one managed parent could not be audited. + Error, +} + impl PartitionAuditReport { /// Whether the report is complete and every managed parent is serving safely. pub fn serving_safe(&self) -> bool { @@ -305,6 +334,19 @@ impl PartitionAuditReport { && self.tables.iter().all(|table| table.serving_safe) } + /// Most severe verdict across audit errors, serving safety, and degradation. + pub fn outcome(&self) -> PartitionAuditOutcome { + if !self.errors.is_empty() { + PartitionAuditOutcome::Error + } else if !self.serving_safe() { + PartitionAuditOutcome::Unsafe + } else if self.tables.iter().any(PartitionTableAudit::degraded) { + PartitionAuditOutcome::Degraded + } else { + PartitionAuditOutcome::Ok + } + } + fn into_complete_audit(self) -> Result { if self.errors.is_empty() { Ok(PartitionAudit { @@ -421,6 +463,7 @@ async fn audit_partition_catalog_report_at_with_timeout( "outcome" => "error" ) .increment(1); + refresh_last_success_recency(table); metrics::histogram!( "buzz_partition_audit_duration_seconds", "table" => table @@ -438,6 +481,7 @@ async fn audit_partition_catalog_report_at_with_timeout( "outcome" => "error" ) .increment(1); + refresh_last_success_recency(table); metrics::histogram!( "buzz_partition_audit_duration_seconds", "table" => table @@ -923,17 +967,7 @@ async fn audit_table_on( .map(|leaf| leaf.audit) .collect::>(); - let mut months = Vec::with_capacity(months_ahead as usize + 1); - for offset in 0..=months_ahead { - let (year, month) = add_months(now.year(), now.month(), offset)?; - let start = month_start(year, month)?; - let (end_year, end_month) = add_months(year, month, 1)?; - let end = month_start(end_year, end_month)?; - months.push(MonthCoverage { - start, - kind: coverage_for_range(&coverage_leaves, &start, &end), - }); - } + let months = month_coverage(&coverage_leaves, months_ahead, now)?; let serving_safe = coverage_leaves .iter() @@ -951,6 +985,26 @@ async fn audit_table_on( }) } +/// Classify the month containing `now`, then each lookahead month, in order. +fn month_coverage( + coverage_leaves: &[PartitionLeafAudit], + months_ahead: i32, + now: DateTime, +) -> Result> { + let mut months = Vec::with_capacity(months_ahead as usize + 1); + for offset in 0..=months_ahead { + let (year, month) = add_months(now.year(), now.month(), offset)?; + let start = month_start(year, month)?; + let (end_year, end_month) = add_months(year, month, 1)?; + let end = month_start(end_year, end_month)?; + months.push(MonthCoverage { + start, + kind: coverage_for_range(coverage_leaves, &start, &end), + }); + } + Ok(months) +} + fn validated_months_ahead(months_ahead: u32) -> Result { if months_ahead > MAX_PARTITION_MONTHS_AHEAD { return Err(DbError::InvalidData(format!( @@ -987,6 +1041,35 @@ fn qualified_relation_name(schema: &str, relation: &str) -> String { ) } +/// Export every `buzz_partition_audit_runs_total` series at zero. +/// +/// Prometheus `increase()` ignores the first sample of a new series, so an +/// outcome first seen mid-run would otherwise be invisible to rate-based +/// alerts. Call once at process start, before the first audit. +pub fn initialize_audit_metric_series() { + for &table in PARTITIONED_TABLES { + for outcome in ["ok", "degraded", "error"] { + metrics::counter!( + "buzz_partition_audit_runs_total", + "table" => table, + "outcome" => outcome + ) + .increment(0); + } + } +} + +/// Keep a table's last success timestamp exported through failed audits +/// without changing it, so the exporter's idle timeout cannot evict it. A +/// table that never succeeded exports 0, which reads as maximally stale. +fn refresh_last_success_recency(table: &'static str) { + metrics::gauge!( + "buzz_partition_audit_last_success_timestamp_seconds", + "table" => table + ) + .increment(0.0); +} + fn emit_audit_metrics(audit: &PartitionTableAudit, duration_seconds: f64, now: DateTime) { let outcome = if audit.degraded() { "degraded" } else { "ok" }; let uncovered = audit @@ -1788,6 +1871,335 @@ mod tests { assert_eq!(extra, Some(1.0)); } + fn month_bound(year: i32, month: u32) -> PartitionBound { + PartitionBound::Finite(Utc.with_ymd_and_hms(year, month, 1, 0, 0, 0).unwrap()) + } + + fn child_audit( + name: &str, + kind: PartitionChildKind, + lower: PartitionBound, + upper: PartitionBound, + ) -> PartitionChildAudit { + PartitionChildAudit { + schema: "public".to_string(), + name: name.to_string(), + relation_kind: "r".to_string(), + catch_all_nonempty: (upper == PartitionBound::MaxValue).then_some(false), + lower: Some(lower), + upper: Some(upper), + kind, + pending_detach: false, + default_nonempty: None, + missing_triggers: Vec::new(), + extra_triggers: Vec::new(), + } + } + + /// The repaired incident catalog, classified and covered by production code at `now`. + fn repaired_incident_audit(table: &'static str, now: DateTime) -> PartitionTableAudit { + let mut bounds = vec![ + ( + format!("{table}_p_past"), + PartitionBound::MinValue, + month_bound(2026, 7), + ), + ( + format!("{table}_p2026_07_08_legacy"), + month_bound(2026, 7), + month_bound(2026, 9), + ), + ]; + for month in 9..=12 { + let (end_year, end_month) = add_months(2026, month, 1).unwrap(); + bounds.push(( + format!("{table}_p2026_{month:02}"), + month_bound(2026, month), + month_bound(end_year, end_month), + )); + } + bounds.push(( + format!("{table}_p_future_next"), + month_bound(2027, 1), + PartitionBound::MaxValue, + )); + let children = bounds + .iter() + .map(|(name, lower, upper)| { + let kind = classify_child("r", table, name, Some(lower), Some(upper)); + child_audit(name, kind, lower.clone(), upper.clone()) + }) + .collect::>(); + let coverage_leaves = bounds + .into_iter() + .map(|(name, lower, upper)| PartitionLeafAudit { + schema: "public".to_string(), + root_child_schema: "public".to_string(), + root_child: name.clone(), + name, + catch_all_nonempty: (upper == PartitionBound::MaxValue).then_some(false), + lower, + upper, + nested: false, + is_default: false, + default_routed: false, + routing_exclusions: Vec::new(), + default_nonempty: None, + }) + .collect::>(); + let months = month_coverage(&coverage_leaves, 3, now).expect("month coverage"); + let serving_safe = coverage_leaves + .iter() + .any(|leaf| leaf_covers_timestamp(leaf, &now)); + PartitionTableAudit { + table, + partition_key: expected_partition_key(table).map(str::to_string), + expected_partition_key: expected_partition_key(table).unwrap(), + partition_key_valid: true, + children, + coverage_leaves, + months, + serving_safe, + } + } + + #[test] + fn repaired_incident_layout_is_historical_once_its_months_have_passed() { + for (now, degraded, outcome) in [ + ( + Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(), + true, + PartitionAuditOutcome::Degraded, + ), + ( + // The legacy upper bound equals the current month start. + Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(), + false, + PartitionAuditOutcome::Ok, + ), + ( + Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap(), + false, + PartitionAuditOutcome::Ok, + ), + ] { + let report = PartitionAuditReport { + audited_at: now, + tables: PARTITIONED_TABLES + .iter() + .map(|table| repaired_incident_audit(table, now)) + .collect(), + errors: Vec::new(), + }; + assert!(report.serving_safe(), "{now}"); + assert_eq!(report.outcome(), outcome, "{now}"); + for table in &report.tables { + assert_eq!(table.degraded(), degraded, "{} at {now}", table.table); + assert!(table + .months + .iter() + .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly)); + // Historical leaves stay visible to operators. + assert_eq!( + table + .children + .iter() + .filter(|child| child.kind == PartitionChildKind::LegacyLeaf) + .map(|child| child.name.as_str()) + .collect::>(), + [format!("{}_p2026_07_08_legacy", table.table)] + ); + } + let json = serde_json::to_value(&report).expect("serialize report"); + assert!(json["tables"][0]["children"] + .as_array() + .expect("children") + .iter() + .any(|child| child["name"] == "events_p2026_07_08_legacy" + && child["kind"] == "legacy_leaf")); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, || { + emit_audit_metrics(&report.tables[0], 0.01, now) + }); + let run_outcome = snapshotter + .snapshot() + .into_vec() + .into_iter() + .find_map(|(key, _, _, _)| { + (key.key().name() == "buzz_partition_audit_runs_total").then(|| { + key.key() + .labels() + .find(|label| label.key() == "outcome") + .map(|label| label.value().to_string()) + }) + }) + .flatten(); + let expected = if degraded { "degraded" } else { "ok" }; + assert_eq!(run_outcome.as_deref(), Some(expected), "{now}"); + } + } + + #[test] + fn report_outcome_ranks_errors_then_unsafe_then_degraded() { + let august = Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(); + let degraded_tables = || { + PARTITIONED_TABLES + .iter() + .map(|table| repaired_incident_audit(table, august)) + .collect::>() + }; + let mut incomplete = degraded_tables(); + incomplete.pop(); + let error = PartitionAuditError { + table: "delivery_log", + error: "statement timeout".to_string(), + }; + for (tables, errors, outcome) in [ + ( + degraded_tables(), + Vec::new(), + PartitionAuditOutcome::Degraded, + ), + ( + incomplete.clone(), + Vec::new(), + PartitionAuditOutcome::Unsafe, + ), + (incomplete, vec![error], PartitionAuditOutcome::Error), + ] { + let report = PartitionAuditReport { + audited_at: august, + tables, + errors, + }; + assert_eq!(report.outcome(), outcome); + } + } + + #[test] + fn legacy_leaf_degrades_until_its_upper_bound_reaches_the_current_month() { + let now = Utc.with_ymd_and_hms(2026, 9, 15, 12, 0, 0).unwrap(); + let september = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap(); + let historical = || { + child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 6), + month_bound(2026, 8), + ) + }; + let with = |child: PartitionChildAudit| vec![child]; + let mut missing_trigger = historical(); + missing_trigger.missing_triggers = vec!["partition_probe".to_string()]; + let mut extra_trigger = historical(); + extra_trigger.extra_triggers = vec!["child_only_probe".to_string()]; + let mut anomalous = historical(); + anomalous.kind = PartitionChildKind::Anomalous; + let mut default = historical(); + default.kind = PartitionChildKind::Default; + let cases = [ + ( + "ends before the current month", + with(historical()), + 3, + false, + ), + ( + "ends exactly at the current month start", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 7), + month_bound(2026, 9), + )), + 3, + false, + ), + ( + "ends one second into the current month", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 7), + PartitionBound::Finite(september + chrono::Duration::seconds(1)), + )), + 3, + true, + ), + ( + "covers lookahead months", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2026, 10), + month_bound(2026, 12), + )), + 3, + true, + ), + ( + "starts beyond the lookahead", + with(child_audit( + "events_legacy", + PartitionChildKind::LegacyLeaf, + month_bound(2027, 6), + month_bound(2027, 8), + )), + 3, + true, + ), + ( + "historical but missing a trigger", + with(missing_trigger), + 3, + true, + ), + ( + "historical but has an extra trigger", + with(extra_trigger), + 3, + true, + ), + ( + "historical bounds on an anomalous child", + with(anomalous), + 3, + true, + ), + ( + "historical bounds on a default child", + with(default), + 3, + true, + ), + ("no audited month to compare", with(historical()), -1, true), + ]; + for (case, children, months_ahead, degraded) in cases { + let months = (0..=months_ahead) + .map(|offset| { + let (year, month) = add_months(2026, 9, offset).unwrap(); + MonthCoverage { + start: month_start(year, month).unwrap(), + kind: MonthCoverageKind::CoveredByMonthly, + } + }) + .collect(); + let audit = PartitionTableAudit { + table: "events", + partition_key: Some("RANGE (created_at)".to_string()), + expected_partition_key: "RANGE (created_at)", + partition_key_valid: true, + children, + coverage_leaves: Vec::new(), + months, + serving_safe: true, + }; + assert_eq!(audit.degraded(), degraded, "{case} at {now}"); + } + } + pub(super) mod postgres_tests { use sqlx::postgres::PgPoolOptions; use uuid::Uuid; @@ -2480,7 +2892,20 @@ mod tests { .months .iter() .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly)); + assert!( + !table.degraded(), + "{} is historical in September", + table.table + ); } + + let september = audit_partition_catalog_report_at(&pool, 3, fixed_now()).await; + assert_eq!(september.outcome(), PartitionAuditOutcome::Ok); + let august = Utc.with_ymd_and_hms(2026, 8, 15, 12, 0, 0).unwrap(); + let august = audit_partition_catalog_report_at(&pool, 3, august).await; + assert!(august.serving_safe()); + assert!(august.tables.iter().all(PartitionTableAudit::degraded)); + assert_eq!(august.outcome(), PartitionAuditOutcome::Degraded); assert_eq!(catalog_snapshot(&pool).await, before); drop_schema(&admin, &schema).await; } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 44662bb9e1..d5c3cf60a2 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -2022,7 +2022,7 @@ mod tests { #[test] fn partition_manager_config_has_bounded_interval_and_create_kill_switch() { - let _guard = ENV_MUTEX.lock().unwrap(); + let _guards = env_guards(); let previous_interval = std::env::var_os("BUZZ_PARTITION_AUDIT_INTERVAL_SECS"); let previous_create = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED"); @@ -2054,7 +2054,7 @@ mod tests { #[test] fn partition_manager_create_kill_switch_parses_false_values_strictly() { - let _guard = ENV_MUTEX.lock().unwrap(); + let _guards = env_guards(); let previous = std::env::var_os("BUZZ_PARTITION_MANAGER_CREATE_ENABLED"); for value in ["FALSE", "off", " false "] { diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index e42c6a4975..4ed127dfff 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -12,7 +12,10 @@ use uuid::Uuid; use buzz_audit::AuditService; use buzz_auth::AuthService; use buzz_core::CommunityId; -use buzz_db::{partition::PARTITION_MANAGER_MONTHS_AHEAD, Db, DbConfig}; +use buzz_db::{ + partition::{PartitionAudit, PARTITION_MANAGER_MONTHS_AHEAD}, + Db, DbConfig, +}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; @@ -249,6 +252,49 @@ impl FleetUsageSchedule { const USAGE_METRICS_LOCK_KEY: i64 = 0x4255_5A5A_4D45_5452; +/// Run one explicit partition catalog audit attempt. +/// +/// `buzz_partition_audit_failures_total` counts failed explicit attempts: the +/// startup fallback audit and each periodic audit, or the periodic fallback +/// audit when advancement runs periodic maintenance. The audit inside +/// `maintain_partitions` is not an explicit attempt; its failure is counted +/// only through the fallback that follows it. +async fn counted_partition_audit( + attempt: impl std::future::Future>, +) -> buzz_db::Result { + let result = attempt.await; + if result.is_err() { + metrics::counter!("buzz_partition_audit_failures_total").increment(1); + } + result +} + +/// Maintain startup partitions, falling back to a read-only audit on failure. +/// +/// A maintenance failure alone never counts as an audit failure; a failed +/// fallback audit counts once. +async fn startup_partition_audit( + maintain: impl std::future::Future>, + fallback_audit: impl FnOnce() -> F, +) -> Option +where + F: std::future::Future>, +{ + match maintain.await { + Ok(audit) => Some(audit), + Err(error) => { + error!(%error, "Failed to ensure partitions"); + match counted_partition_audit(fallback_audit()).await { + Ok(audit) => Some(audit), + Err(error) => { + error!(%error, "Initial partition catalog audit failed"); + None + } + } + } + } +} + /// Retry missing diagnostics promptly, without tying audit recovery to readiness. /// Delays start after each attempt finishes, so slow audits cannot cause bursts. struct PartitionAuditSchedule { @@ -486,23 +532,15 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { advance_enabled: config.partition_manager_advance_enabled, }; let mut step = StepTimer::start(StartupStep::PartitionEnsure); - let startup_partition_audit = match db - .maintain_partitions(PARTITION_MANAGER_MONTHS_AHEAD, partition_policy) - .await - { - Ok(audit) => Some(audit), - Err(error) => { - step.degrade(); - error!(%error, "Failed to ensure partitions"); - match db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await { - Ok(audit) => Some(audit), - Err(error) => { - error!(%error, "Initial partition catalog audit failed"); - None - } - } - } - }; + let startup_partition_audit = startup_partition_audit( + async { + db.maintain_partitions(PARTITION_MANAGER_MONTHS_AHEAD, partition_policy) + .await + .inspect_err(|_| step.degrade()) + }, + || db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD), + ) + .await; step.finish(); let step = StepTimer::start(StartupStep::DeletionFenceVerify); @@ -837,19 +875,20 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { Ok(audit) => Ok(audit), Err(error) => { warn!(%error, "Periodic partition maintenance failed"); - db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await + counted_partition_audit( + db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD), + ) + .await } } } else { - db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD).await + counted_partition_audit(db.audit_partitions(PARTITION_MANAGER_MONTHS_AHEAD)) + .await }; schedule.record_attempt(result.is_ok()); match result { Ok(audit) => partition_state.record_partition_audit(audit), - Err(error) => { - metrics::counter!("buzz_partition_audit_failures_total").increment(1); - warn!(%error, "Periodic partition catalog audit failed") - } + Err(error) => warn!(%error, "Periodic partition catalog audit failed"), } } }); @@ -3030,12 +3069,12 @@ mod tests { use super::{ apply_activity_refresh, apply_stock_refresh, buzz_auto_migrate_enabled, connect_audit_pool, - dropped_in_memory_keys, emit_cached_fleet_usage_metrics, idle_timeout_secs, - jwks_next_retry_after_failed_refresh, nip_fi_jwks_refresh_loop, + counted_partition_audit, dropped_in_memory_keys, emit_cached_fleet_usage_metrics, + idle_timeout_secs, jwks_next_retry_after_failed_refresh, nip_fi_jwks_refresh_loop, record_legacy_usage_collection, refresh_due_fleet_usage, refresh_legacy_active_gauge_recency, relay_keypair_from_config, - run_jwks_refresh_supervisor, run_periodic_until_cancelled, EmissionScope, - FleetUsageSchedule, InMemoryMetricKey, PartitionAuditSchedule, + run_jwks_refresh_supervisor, run_periodic_until_cancelled, startup_partition_audit, + EmissionScope, FleetUsageSchedule, InMemoryMetricKey, PartitionAuditSchedule, FLEET_ACTIVITY_COLLECTION_DEADLINE, FLEET_STOCK_COLLECTION_DEADLINE, }; use buzz_db::DbConfig; @@ -3204,6 +3243,140 @@ mod tests { ); } + fn partition_audit_ok() -> buzz_db::Result { + Ok(buzz_db::partition::PartitionAudit { + audited_at: chrono::Utc::now(), + tables: Vec::new(), + }) + } + + fn partition_audit_err(detail: &str) -> buzz_db::Result { + Err(buzz_db::DbError::InvalidData(detail.to_string())) + } + + /// One recorded counter: name, labels, and value. + type CounterSample = (String, Vec<(String, String)>, u64); + + /// Drive `future` under a local recorder and return its counter values. + fn with_partition_counters( + future: F, + ) -> (F::Output, Vec) { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let output = metrics::with_local_recorder(&recorder, || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime") + .block_on(future) + }); + let counters = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter_map(|(key, _, _, value)| match value { + DebugValue::Counter(value) => Some(( + key.key().name().to_string(), + key.key() + .labels() + .map(|label| (label.key().to_string(), label.value().to_string())) + .collect(), + value, + )), + _ => None, + }) + .collect(); + (output, counters) + } + + fn aggregate_audit_failures(counters: &[CounterSample]) -> u64 { + counters + .iter() + .filter(|(name, _, _)| name == "buzz_partition_audit_failures_total") + .map(|(_, _, value)| value) + .sum() + } + + #[test] + fn startup_partition_audit_counts_only_a_failed_fallback_once() { + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_err("partition creation failed") }, + || async { partition_audit_err("catalog audit failed") }, + )); + assert!(audit.is_none()); + assert_eq!(aggregate_audit_failures(&counters), 1); + + // A DDL failure recovered by the fallback audit is not an audit failure. + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_err("partition creation failed") }, + || async { partition_audit_ok() }, + )); + assert!(audit.is_some()); + assert_eq!(aggregate_audit_failures(&counters), 0); + + let (audit, counters) = with_partition_counters(startup_partition_audit( + async { partition_audit_ok() }, + || async { panic!("successful ensure must not run the fallback audit") }, + )); + assert!(audit.is_some()); + assert_eq!(aggregate_audit_failures(&counters), 0); + } + + #[test] + fn periodic_partition_audit_counts_each_failed_attempt() { + let (result, counters) = + with_partition_counters(counted_partition_audit(async { partition_audit_ok() })); + assert!(result.is_ok()); + assert_eq!(aggregate_audit_failures(&counters), 0); + + let ((), counters) = with_partition_counters(async { + for _ in 0..2 { + assert!( + counted_partition_audit(async { partition_audit_err("timeout") }) + .await + .is_err() + ); + } + }); + assert_eq!(aggregate_audit_failures(&counters), 2); + } + + #[test] + fn unreachable_database_startup_counts_one_aggregate_audit_failure() { + let (audit, counters) = with_partition_counters(async { + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(200)) + .connect_lazy("postgres://127.0.0.1:1/buzz") + .expect("lazy test pool"); + let db = buzz_db::Db::from_pool(pool); + let months_ahead = buzz_db::partition::PARTITION_MANAGER_MONTHS_AHEAD; + let policy = buzz_db::partition::PartitionMaintenancePolicy { + create_enabled: true, + advance_enabled: false, + }; + startup_partition_audit(db.maintain_partitions(months_ahead, policy), || { + db.audit_partitions(months_ahead) + }) + .await + }); + assert!(audit.is_none()); + assert_eq!(aggregate_audit_failures(&counters), 1); + // Each per-table attempt, including the one inside maintenance, still has + // its own error outcome. + for table in ["events", "delivery_log"] { + let errors: u64 = counters + .iter() + .filter(|(name, labels, _)| { + name == "buzz_partition_audit_runs_total" + && labels.contains(&("table".to_string(), table.to_string())) + && labels.contains(&("outcome".to_string(), "error".to_string())) + }) + .map(|(_, _, value)| value) + .sum(); + assert_eq!(errors, 2, "{table}"); + } + } + #[test] fn buzz_auto_migrate_is_opt_in() { assert!(!buzz_auto_migrate_enabled(None)); diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 3566c0c019..67e4d3b35e 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -299,6 +299,7 @@ pub fn try_install(port: u16, gauge_idle_timeout_secs: u64) -> Result<(), Metric describe_auth_metrics(); crate::startup_steps::describe_metrics(); initialize_auth_metric_series(); + initialize_partition_audit_metric_series(); tokio::spawn(exporter); Ok(()) } @@ -522,6 +523,13 @@ pub(crate) fn initialize_auth_metric_series() { metrics::gauge!("buzz_ws_authenticated_connections_active").set(0.0); } +/// Export the partition audit counters at zero before the first audit, so +/// `increase()`-based alerts count the first failure and every new outcome. +pub(crate) fn initialize_partition_audit_metric_series() { + buzz_db::partition::initialize_audit_metric_series(); + metrics::counter!("buzz_partition_audit_failures_total").increment(0); +} + /// Record one issued NIP-42 challenge after it enters the connection writer. /// /// Before this lifecycle contract, `buzz_auth_attempts_total` incremented only @@ -1161,3 +1169,167 @@ mod tests { ); } } + +/// Fixtures for the partition audit alerts in +/// `docs/partition-catalog-monitoring.md`: the production exporter configuration +/// and audit metric emission export the series those rules read. +#[cfg(test)] +mod partition_alert_tests { + use std::collections::BTreeMap; + use std::time::Duration; + + const RUNS: &str = "buzz_partition_audit_runs_total"; + const LAST_SUCCESS: &str = "buzz_partition_audit_last_success_timestamp_seconds"; + const MANAGED_TABLES: [&str; 2] = ["delivery_log", "events"]; + + /// Sample value by `table` label for every sample of `metric` in a scrape. + /// Counters with several `outcome` labels keep the last sample per table. + fn samples(scrape: &str, metric: &str) -> BTreeMap { + scrape + .lines() + .filter_map(|line| line.strip_prefix(metric)?.strip_prefix('{')) + .filter_map(|labels| { + let (_, rest) = labels.split_once(r#"table=""#)?; + let (table, rest) = rest.split_once('"')?; + let (_, value) = rest.rsplit_once(' ')?; + Some((table.to_string(), value.parse().expect("numeric sample"))) + }) + .collect() + } + + fn tables(scrape: &str, metric: &str) -> Vec { + samples(scrape, metric).into_keys().collect() + } + + fn managed_tables() -> Vec { + MANAGED_TABLES + .iter() + .map(|table| table.to_string()) + .collect() + } + + fn unreachable_db() -> buzz_db::Db { + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(Duration::from_millis(200)) + .connect_lazy("postgres://127.0.0.1:1/buzz") + .expect("lazy test pool"); + buzz_db::Db::from_pool(pool) + } + + fn current_thread_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime") + } + + #[test] + fn never_successful_audits_export_a_zero_success_timestamp() { + let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1); + metrics::with_local_recorder(&recorder, || { + current_thread_runtime().block_on(async { + assert!(unreachable_db().audit_partitions(3).await.is_err()); + }); + }); + + let scrape = handle.render(); + assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}"); + let zero = MANAGED_TABLES + .iter() + .map(|table| (table.to_string(), 0.0)) + .collect::>(); + assert_eq!(samples(&scrape, LAST_SUCCESS), zero, "{scrape}"); + } + + #[test] + fn audit_counters_export_zero_baselines_before_the_first_audit() { + let (recorder, handle) = super::readiness_test_recorder_with_idle_timeout(1); + metrics::with_local_recorder(&recorder, super::initialize_partition_audit_metric_series); + + let scrape = handle.render(); + let zero_runs = scrape + .lines() + .filter(|line| line.starts_with(&format!("{RUNS}{{")) && line.ends_with(" 0")) + .count(); + assert_eq!(zero_runs, MANAGED_TABLES.len() * 3, "{scrape}"); + assert!( + scrape + .lines() + .any(|line| line == "buzz_partition_audit_failures_total 0"), + "{scrape}" + ); + + // A first failure then reads as an increase from the zero baseline. + metrics::with_local_recorder(&recorder, || { + current_thread_runtime().block_on(async { + assert!(unreachable_db().audit_partitions(3).await.is_err()); + }); + }); + let scrape = handle.render(); + for table in MANAGED_TABLES { + for (outcome, value) in [("ok", 0), ("degraded", 0), ("error", 1)] { + let line = scrape + .lines() + .find(|line| { + line.starts_with(&format!("{RUNS}{{")) + && line.contains(&format!(r#"table="{table}""#)) + && line.contains(&format!(r#"outcome="{outcome}""#)) + }) + .unwrap_or_else(|| panic!("{table}/{outcome} missing:\n{scrape}")); + assert!(line.ends_with(&format!(" {value}")), "{line}"); + } + } + } + + mod postgres_tests { + use super::*; + + #[test] + #[ignore = "requires Postgres"] + fn failed_audits_keep_the_last_success_past_the_idle_timeout() { + let idle_timeout = Duration::from_secs(1); + let (recorder, handle) = + crate::metrics::readiness_test_recorder_with_idle_timeout(idle_timeout.as_secs()); + let runtime = current_thread_runtime(); + let (working, failing) = metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect test database"); + let working = buzz_db::Db::from_pool(pool); + working.audit_partitions(3).await.expect("successful audit"); + (working, unreachable_db()) + }) + }); + + let scrape = handle.render(); + let first = samples(&scrape, LAST_SUCCESS); + assert_eq!(first.keys().cloned().collect::>(), managed_tables()); + + let audit = |db: &buzz_db::Db| { + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { db.audit_partitions(3).await.is_ok() }) + }) + }; + // A failed audit never makes the last success look fresh, and + // sustained failures keep it exported past the idle timeout, so its + // age keeps rising instead of the series disappearing. + assert!(!audit(&failing)); + assert_eq!(samples(&handle.render(), LAST_SUCCESS), first); + std::thread::sleep(idle_timeout + Duration::from_millis(250)); + assert!(!audit(&failing)); + assert_eq!(samples(&handle.render(), LAST_SUCCESS), first); + + // When audits stop running altogether the timestamp is evicted, + // but the run counters remain for the not-running rule. + std::thread::sleep(idle_timeout + Duration::from_millis(250)); + let scrape = handle.render(); + assert!(tables(&scrape, LAST_SUCCESS).is_empty(), "{scrape}"); + assert_eq!(tables(&scrape, RUNS), managed_tables(), "{scrape}"); + + // The next success exports a fresh timestamp again. + assert!(audit(&working)); + assert_eq!(tables(&handle.render(), LAST_SUCCESS), managed_tables()); + } + } +} diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index e762095ca3..7e015a5dc8 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1649,9 +1649,11 @@ impl AppState { /// Snapshot the last completed audit without accessing the database. /// - /// Periodic refresh failures retain the last-known-good audit by design; - /// operators should alert on staleness of - /// `buzz_partition_audit_last_success_timestamp_seconds`. + /// Periodic refresh failures retain the last-known-good audit by design. + /// Alert on the age of + /// `buzz_partition_audit_last_success_timestamp_seconds`, which failed + /// audits keep exported, and on audit runs stopping. See + /// `docs/partition-catalog-monitoring.md`. pub fn partition_audit_snapshot(&self) -> Option { let cached = match self.partition_audit.read() { Ok(cached) => cached, diff --git a/docs/partition-catalog-monitoring.md b/docs/partition-catalog-monitoring.md new file mode 100644 index 0000000000..8371403d93 --- /dev/null +++ b/docs/partition-catalog-monitoring.md @@ -0,0 +1,150 @@ +# Partition catalog monitoring + +The relay audits the `events` and `delivery_log` partition catalogs at startup +and every `BUZZ_PARTITION_AUDIT_INTERVAL_SECS` (default 900 seconds). Results are +diagnostic only. They appear in `/_status.partition_catalog` and in the metrics +below, and they never change `/_readiness` or `/_liveness`. Alert on them; do not +gate traffic on them. + +## Outcomes + +`buzz-admin partition-audit` prints one outcome for the whole catalog. + +| Outcome | Meaning | `buzz-admin` exit code | +|---|---|---| +| `ok` | Every managed table covers now and needs no operator attention. | 0 | +| `degraded` | Every table covers now, but one needs attention. | 0 | +| `unsafe` | A table cannot route a row timestamped now. | 2 | +| `error` | At least one table could not be audited. | 5 | + +The relay's `buzz_partition_audit_runs_total{outcome}` is per table and is only +`ok`, `degraded`, or `error`. There is no `unsafe` outcome there: a table that +cannot route a row timestamped now is `degraded` and has +`buzz_partition_serving_safe` 0. + +A table is `degraded` when it has a partition-key mismatch, a `DEFAULT` or +anomalous child, trigger drift, an occupied catch-all or `DEFAULT` leaf, or a +current or lookahead month not covered by a bounded leaf. A bounded legacy leaf +with a non-canonical name degrades the table while it covers the current month or +later. Once its upper bound is at or before the start of the current month, it is +historical. It remains in the per-child report as `legacy_leaf` but no longer +degrades the table. + +## Metrics + +Per-table gauges are set only by a successful audit of that table: +`buzz_partition_audit_last_success_timestamp_seconds`, +`buzz_partition_serving_safe`, `buzz_partition_uncovered_months`, +`buzz_partition_catch_all_covered_months`, `buzz_partition_default_covered_months`, +`buzz_partition_anomalous_children`, `buzz_partition_catch_all_nonempty`, +`buzz_partition_default_nonempty`, `buzz_partition_trigger_parity_missing`, and +`buzz_partition_trigger_parity_extra`. A failed audit leaves them unchanged, so a +failure never makes the last success look fresh. + +The exporter removes a gauge that has not been updated within its idle timeout. +The default is 2,700 seconds: three times the larger of the usage-metrics and +partition-audit intervals. A failed audit of a table refreshes that table's +`buzz_partition_audit_last_success_timestamp_seconds` without changing its +value, so sustained failures keep the last success exported and its age keeps +rising. A table that has never been audited successfully exports `0`. The +timestamp disappears only when audits stop running for longer than the idle +timeout; the other per-table gauges can be evicted during sustained failures. + +Counters are not idle-evicted while the process runs. The relay exports every +run outcome for both managed tables, and the failure counter, at 0 when its +metrics exporter starts, so `increase()` counts the first failure and the first +run with a new outcome: + +- `buzz_partition_audit_runs_total{table, outcome}` increments once per table + per audit. The outcome is `ok`, `degraded`, or `error`, including the opening + audit of each partition maintenance pass. +- `buzz_partition_audit_failures_total` counts failed explicit audit attempts: + the fallback audit that runs when a maintenance pass fails, and each periodic + audit when maintenance does not run. A maintenance failure is not counted by + itself. If the fallback audit then fails, that attempt counts once. +- `buzz_partition_create_attempts_total{table, outcome}` counts per-month + decisions in each maintenance pass: `created`, `error`, or `skipped_covered` + for a month that was already covered. Maintenance runs at startup, and on + every periodic audit when `BUZZ_PARTITION_MANAGER_ADVANCE_ENABLED` is on, so + `skipped_covered` then grows on every pass in steady state. + +## Alerts + +Use all three rules below. Aggregate with `without (...)`, not `by (...)`, so +cluster, account, environment, namespace, and pod labels stay in the alert. + +When a pod terminates, its scrape target disappears and its series go stale. +Instant selectors drop a stale series at once, but range selectors such as +`[30m]` keep reading its earlier samples until they leave the window. Each +range-based rule therefore also requires the counter to be exported now, which +it is for the whole life of a relay, so a terminated pod cannot keep or start an +alert. + +`BuzzPartitionAuditNotRunning` also requires the run counter to have existed one +window ago. After a successful startup audit, the first periodic audit runs a +full interval later, so a new pod's counter stays flat until then and would +otherwise read as a stopped loop. + +```yaml +# The last success is older than two default audit intervals. This also fires +# for a table that has never audited successfully, whose timestamp is 0, and +# keeps firing through sustained failures. Scale 1800 with +# BUZZ_PARTITION_AUDIT_INTERVAL_SECS. +- alert: BuzzPartitionAuditStale + expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800 + for: 10m + +# The relay is running but its audit loop has stopped. Use a window of about +# two audit intervals, and keep the window plus `for` below the gauge idle +# timeout (2,700 s by default), so this fires before the stopped loop's +# timestamp is evicted and the stale rule resolves. Use the same duration for +# the window and the offset. A loop that stops in a pod's first window is +# caught once the pod is a window old, at about 35 minutes. +- alert: BuzzPartitionAuditNotRunning + expr: | + sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 + and sum without (outcome) (buzz_partition_audit_runs_total) + and sum without (outcome) (buzz_partition_audit_runs_total offset 30m) + for: 5m + +# Explicit audit attempts keep failing, even if a later attempt succeeds. +- alert: BuzzPartitionAuditFailing + expr: | + increase(buzz_partition_audit_failures_total[1h]) >= 2 + and buzz_partition_audit_failures_total +``` + +The counters exist from relay start, and the success timestamp exists after the +first audit attempt, whether or not it succeeded. The startup audit runs within +seconds of the zero baselines, usually before the first scrape, so +`BuzzPartitionAuditFailing` can miss a failure in that one attempt; if it does +not recover, `BuzzPartitionAuditStale` fires. The run counters are exported +before the relay connects to the database, so a live relay that is stuck before +its first audit shows flat zero counters, which `BuzzPartitionAuditNotRunning` +catches. + +`degraded` asks for operator attention; it does not mean rows cannot be +written. Historical legacy leaves no longer count, so a repaired catalog +returns to `ok` once the current month passes the repaired range and every +current and lookahead month is a bounded monthly partition. Months still covered +by a catch-all stay `degraded` until advancement moves the catch-all. Coverage +problems show directly in `buzz_partition_serving_safe` and +`buzz_partition_uncovered_months`. + +The tests in `crates/buzz-relay/src/metrics.rs` (`partition_alert_tests`) check +that the production exporter and audit emit the series these rules read: zero +counter baselines before the first audit, a `0` timestamp before any success, +the last success kept through failures past the idle timeout, and run counters +that outlive an evicted timestamp. They do not +evaluate the rules, their `for` durations, or label matching. +`partition-catalog-monitoring.rules.test.yml` evaluates the rules in +`partition-catalog-monitoring.rules.yml`, including their `for` durations, +against a fresh pod, a stopped loop, a terminated pod, a table that never +succeeded, and repeated failures. CI does not run it. Keep the rules file +identical to the block above, and run it after changing either: + +```bash +promtool test rules docs/partition-catalog-monitoring.rules.test.yml +``` + +Then validate label matching against your own alerting configuration. diff --git a/docs/partition-catalog-monitoring.rules.test.yml b/docs/partition-catalog-monitoring.rules.test.yml new file mode 100644 index 0000000000..3a897c5b61 --- /dev/null +++ b/docs/partition-catalog-monitoring.rules.test.yml @@ -0,0 +1,122 @@ +# Run with: promtool test rules docs/partition-catalog-monitoring.rules.test.yml +# +# Series are sampled every minute and use the default 900 s audit interval. The +# startup audit finishes before the first scrape, so run counters start at 1. +rule_files: + - partition-catalog-monitoring.rules.yml + +evaluation_interval: 1m + +tests: + # A healthy new pod audits at startup and then every 15 minutes. Its counter + # is flat for the first interval, which must not read as a stopped loop. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="fresh",table="events",outcome="ok"}' + values: '1x14 2x14 3x14 4x14 5x14' + - series: 'buzz_partition_audit_runs_total{pod="fresh",table="events",outcome="degraded"}' + values: '0x74' + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="fresh",table="events"}' + values: '0x14 900x14 1800x14 2700x14 3600x14' + alert_rule_test: + - eval_time: 10m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 14m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 29m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 70m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 70m + alertname: BuzzPartitionAuditStale + exp_alerts: [] + + # The audit loop stops after the 15-minute audit while the pod keeps running. + # Both rules fire before the timestamp's 2,700 s idle eviction at 60 minutes. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="stopped",table="events",outcome="ok"}' + values: '1x14 2x60' + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="stopped",table="events"}' + values: '0x14 900x45' + alert_rule_test: + - eval_time: 40m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 52m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: + - exp_labels: {pod: stopped, table: events} + - eval_time: 57m + alertname: BuzzPartitionAuditStale + exp_alerts: + - exp_labels: {pod: stopped, table: events} + + # The audit loop stops right after the startup audit. It is caught once the + # pod is one window old. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="early",table="events",outcome="ok"}' + values: '1x80' + alert_rule_test: + - eval_time: 33m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 36m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: + - exp_labels: {pod: early, table: events} + + # A pod terminates at 24 minutes. Range selectors still read its earlier + # samples, long enough to complete `for: 5m` at 50 minutes, but it must not + # start an alert. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_runs_total{pod="gone",table="events",outcome="ok"}' + values: '1x14 2x9 stale' + - series: 'buzz_partition_audit_failures_total{pod="gone"}' + values: '0x10 1 2 3x7 stale' + alert_rule_test: + - eval_time: 45m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 52m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 60m + alertname: BuzzPartitionAuditNotRunning + exp_alerts: [] + - eval_time: 30m + alertname: BuzzPartitionAuditFailing + exp_alerts: [] + + # A table that has never audited successfully exports a 0 timestamp. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_last_success_timestamp_seconds{pod="never",table="events"}' + values: '0x60' + alert_rule_test: + - eval_time: 35m + alertname: BuzzPartitionAuditStale + exp_alerts: [] + - eval_time: 45m + alertname: BuzzPartitionAuditStale + exp_alerts: + - exp_labels: {pod: never, table: events} + + # One failure does not alert; a second within an hour does. + - interval: 1m + input_series: + - series: 'buzz_partition_audit_failures_total{pod="once"}' + values: '0x10 1x50' + - series: 'buzz_partition_audit_failures_total{pod="twice"}' + values: '0x10 1x10 2x40' + alert_rule_test: + - eval_time: 40m + alertname: BuzzPartitionAuditFailing + exp_alerts: + - exp_labels: {pod: twice} diff --git a/docs/partition-catalog-monitoring.rules.yml b/docs/partition-catalog-monitoring.rules.yml new file mode 100644 index 0000000000..cef7430109 --- /dev/null +++ b/docs/partition-catalog-monitoring.rules.yml @@ -0,0 +1,20 @@ +# Alert rules from docs/partition-catalog-monitoring.md. Keep them identical to +# the doc; partition-catalog-monitoring.rules.test.yml tests them. +groups: + - name: buzz-partition-catalog + rules: + - alert: BuzzPartitionAuditStale + expr: time() - buzz_partition_audit_last_success_timestamp_seconds > 1800 + for: 10m + + - alert: BuzzPartitionAuditNotRunning + expr: | + sum without (outcome) (increase(buzz_partition_audit_runs_total[30m])) == 0 + and sum without (outcome) (buzz_partition_audit_runs_total) + and sum without (outcome) (buzz_partition_audit_runs_total offset 30m) + for: 5m + + - alert: BuzzPartitionAuditFailing + expr: | + increase(buzz_partition_audit_failures_total[1h]) >= 2 + and buzz_partition_audit_failures_total diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b51e575973..9b8d613647 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -135,6 +135,9 @@ run_unit_tests() { run_test_step "buzz-admin community archive command tests" \ cargo test -p buzz-admin communities -- --nocapture + run_test_step "buzz-admin partition audit outcome contract test" \ + cargo test -p buzz-admin tests::partition_audit_outcomes_keep_their_json_names_and_exit_codes -- --exact --nocapture + # Multi-tenant conformance gate: independent replay checker + golden # fixtures (buzz-conformance). Pure in-process trace replay, no infra. run_test_step "buzz-conformance tests" \ @@ -287,6 +290,9 @@ run_unit_tests() { run_test_step "buzz-relay auth metrics contract tests" \ cargo test -p buzz-relay --lib metrics::contract_tests:: -- --nocapture + run_test_step "buzz-relay partition alert series tests" \ + cargo test -p buzz-relay --lib metrics::partition_alert_tests:: -- --nocapture + local nip_fi_exact_tests=( audio::room::tests::roster_revisions_are_ordered_and_snapshot_is_authoritative connection::tests::auth_lifecycle_reconciles_every_terminal_and_never_leaks_gauge From 43b88c3a76dada96fcbdc23e521569b95c9fd206 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 13 Oct 2026 17:24:29 -0400 Subject: [PATCH 012/146] docs(nips): allow NIP-FA session token on the adapter origin (#8198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 NIP-FA limited the adapter session token to the NIP's own endpoints, which stopped a client from reusing the same login for the adapter operator's other APIs on that host. This changes the rule to the adapter's origin (the scheme, host, and port of `{adapter_base}`). The token still never goes to a relay or any other origin, and the rest of the security bullet is unchanged. Signed-off-by: Will Pfleger Co-authored-by: Alia --- docs/nips/NIP-FA.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nips/NIP-FA.md b/docs/nips/NIP-FA.md index d7c06fe643..f2554f93de 100644 --- a/docs/nips/NIP-FA.md +++ b/docs/nips/NIP-FA.md @@ -171,7 +171,7 @@ WebSocket connections send only `Nostr-Federated-Identity` on the upgrade reques ## Security Considerations -- Clients MUST send `session_token` only to the adapter endpoints defined in this NIP, and MUST NOT send it to a relay or any other origin. A leaked session token lets its holder request assertions for a key they control until the adapter binds the user to a key. +- Clients MUST send `session_token` only to the adapter's origin (the scheme, host, and port of `{adapter_base}`), and MUST NOT send it to a relay or any other origin. A leaked session token lets its holder request assertions for a key they control until the adapter binds the user to a key. - Clients and adapters MUST NOT log credentials: the `Authorization` and `Nostr-Authorization` header values on every adapter request, including `/v1/session`, and the `handoff_secret` and `session_token` values in the code exchange request and response. ## Privacy From 8599efb6a3cfbd3d4bc4534f6de766b4481c7fca Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:30:20 +1100 Subject: [PATCH 013/146] feat(relay): allow HTTP operator listener delivery URLs (#8287) ## Summary Operator listener configuration currently rejects HTTP URLs, preventing delivery to sibling services over internal HTTP connections. Accept HTTP and HTTPS delivery URLs while retaining the existing pubkey, duplicate-route, and URL restrictions. Add configuration coverage for internal hostnames and custom ports, verify signed delivery through the real HTTP client, and include the listener suite in both unit-test runners. Shared NIP-98 signing and push gateway code are unchanged. ### Related issue Related: #7934 refactors operator listener delivery tests. No matching open issue found. ### Testing - `cargo test -p buzz-relay --lib operator_listener`: 13 passed, including signed HTTP delivery and redirect rejection. - `cargo fmt --all -- --check`, shell syntax, Justfile parsing, and diff whitespace checks passed. - `just test`: passed. - `just ci`: stopped at mobile analysis with an existing undefined `buzzDevPushAppProfile` in `mobile/test/features/settings/notification_toggle_test.dart:81`. Workspace and native desktop formatting/Clippy, desktop frontend checks, and web checks passed. The mobile file is unchanged by this PR. Locally verified with a spiked listener client Signed-off-by: jsibbison-square --- .env.example | 2 + Justfile | 2 +- crates/buzz-relay/src/config.rs | 39 ++++++++----- crates/buzz-relay/src/operator_listener.rs | 67 +++++++++++++++++++++- scripts/run-tests.sh | 3 + 5 files changed, 97 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 8e1fe15939..61f44af72d 100644 --- a/.env.example +++ b/.env.example @@ -150,6 +150,8 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns # Deployment-global operator-listener routes. Each configured Nostr identity # may register target pubkeys and receives mention notifications at its URL. +# URLs may use HTTP or HTTPS. Use HTTP only for operator-controlled internal +# connections: NIP-98 signs requests. # Format: pubkey:deliveryUrl;pubkey:deliveryUrl # BUZZ_OPERATOR_LISTENER_TIMEOUT_MS=5000 # BUZZ_OPERATOR_LISTENERS=<64-char hex pubkey>:https://listener.example/mentions diff --git a/Justfile b/Justfile index d73a9cc547..312ec115b9 100644 --- a/Justfile +++ b/Justfile @@ -531,7 +531,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) + 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::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + 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-relay/src/config.rs b/crates/buzz-relay/src/config.rs index d5c3cf60a2..45c8f5e35d 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -289,7 +289,7 @@ pub struct Config { /// skipped — a typo must not silently disable an operator. pub relay_operator_pubkeys: Vec, - /// Configured operator-listener identities and their HTTPS delivery URLs. + /// Configured operator-listener identities and their HTTP(S) delivery URLs. pub operator_listener_delivery_urls: HashMap, /// Allow NIP-OA owner attestation for relay membership. @@ -532,7 +532,7 @@ fn parse_operator_listener_delivery_urls( "BUZZ_OPERATOR_LISTENERS endpoint is not a valid URL: {e}" )) })?; - if url.scheme() != "https" + if !matches!(url.scheme(), "http" | "https") || url.host().is_none() || !url.username().is_empty() || url.password().is_some() @@ -540,7 +540,7 @@ fn parse_operator_listener_delivery_urls( || url.fragment().is_some() { return Err(ConfigError::InvalidValue( - "BUZZ_OPERATOR_LISTENERS endpoints must be HTTPS URLs without credentials, query, or fragment" + "BUZZ_OPERATOR_LISTENERS endpoints must be HTTP(S) URLs without credentials, query, or fragment" .to_string(), )); } @@ -2686,9 +2686,14 @@ mod tests { fn operator_listener_routes_reject_unsafe_or_duplicate_entries() { let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; for raw in [ - format!("{key}:http://listener.example/mentions"), + format!("{key}:ftp://listener.example/mentions"), + format!("{key}:http://user:pass@listener:8080/mentions"), + format!("{key}:http://listener:8080/mentions?token=secret"), + format!("{key}:http://listener:8080/mentions#fragment"), format!("{key}:https://user:pass@listener.example/mentions"), format!("{key}:https://listener.example/mentions?token=secret"), + format!("{key}:https://listener.example/mentions#fragment"), + format!("{key}:http://listener:8080/mentions;{key}:https://other.example"), format!("{key}:https://listener.example/mentions;{key}:https://other.example"), ] { assert!( @@ -2740,15 +2745,23 @@ mod tests { fn valid_and_absent_operator_listener_config_preserve_routes() { let _guards = env_guards(); let key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let raw = format!("{key}:https://listener.example/mentions"); - let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); - assert_eq!( - config - .operator_listener_delivery_urls - .get(key) - .map(url::Url::as_str), - Some("https://listener.example/mentions") - ); + for endpoint in [ + "https://listener.example/mentions", + "http://operator-listener/mentions", + "http://operator-listener:8080/mentions", + "http://127.0.0.1:8080/mentions", + "http://[::1]:8080/mentions", + ] { + let raw = format!("{key}:{endpoint}"); + let config = config_with_operator_listeners(Some(&raw)).expect("valid listener config"); + assert_eq!( + config + .operator_listener_delivery_urls + .get(key) + .map(url::Url::as_str), + Some(endpoint) + ); + } let config = config_with_operator_listeners(None).expect("absent listener config"); assert!(config.operator_listener_delivery_urls.is_empty()); diff --git a/crates/buzz-relay/src/operator_listener.rs b/crates/buzz-relay/src/operator_listener.rs index a21292d2ae..8545497e38 100644 --- a/crates/buzz-relay/src/operator_listener.rs +++ b/crates/buzz-relay/src/operator_listener.rs @@ -636,7 +636,7 @@ mod tests { ); } - async fn read_http_request(stream: &mut TcpStream) -> (String, Vec) { + async fn read_http_request(stream: &mut TcpStream) -> (String, Vec, String) { let mut reader = BufReader::new(stream); let mut request_line = String::new(); reader @@ -645,6 +645,7 @@ mod tests { .expect("read request line"); let mut content_length = 0; + let mut authorization = String::new(); loop { let mut line = String::new(); reader.read_line(&mut line).await.expect("read header"); @@ -654,6 +655,8 @@ mod tests { if let Some((name, value)) = line.split_once(':') { if name.eq_ignore_ascii_case("content-length") { content_length = value.trim().parse().expect("valid content length"); + } else if name.eq_ignore_ascii_case("authorization") { + authorization = value.trim().to_owned(); } } } @@ -663,7 +666,67 @@ mod tests { .read_exact(&mut body) .await .expect("read request body"); - (request_line, body) + (request_line, body, authorization) + } + + #[tokio::test] + async fn successful_http_delivery_is_signed_and_completes_claim() { + use nostr::nips::nip98::{verify_auth_header, HttpMethod}; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("HTTP listener"); + let endpoint = url::Url::parse(&format!( + "http://{}/mentions", + listener.local_addr().expect("listener address") + )) + .expect("HTTP endpoint"); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept delivery"); + let request = read_http_request(&mut stream).await; + stream + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .await + .expect("write success response"); + request + }); + let (_, relay_keypair, delivery) = delivery_fixture(1); + let routes = HashMap::from([(hex::encode(&delivery.listener_pubkey), endpoint.clone())]); + let store = MockStore::default(); + let http = build_delivery_http_client(Duration::from_secs(2)).expect("delivery client"); + let transport = ReqwestDeliveryTransport { http: &http }; + + deliver_one( + &routes, + &relay_keypair, + &store, + &transport, + delivery.clone(), + ) + .await; + + let (request_line, body, authorization) = + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("HTTP server completed") + .expect("HTTP server task"); + assert!(request_line.starts_with("POST /mentions HTTP/1.1")); + let signer = verify_auth_header( + &authorization, + &endpoint, + HttpMethod::POST, + nostr::Timestamp::now(), + Some(&body), + ) + .expect("valid signature binding the HTTP endpoint and body"); + assert_eq!(signer, relay_keypair.public_key()); + let payload: serde_json::Value = serde_json::from_slice(&body).expect("notification JSON"); + assert_eq!(payload["pubkey"], hex::encode(&delivery.target_pubkey)); + assert_eq!(payload["event_id"], hex::encode(&delivery.event_id)); + assert_eq!( + store.calls(), + [StoreCall::Complete(delivery.id, delivery.claim_id)] + ); } #[tokio::test] diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9b8d613647..efdd511dba 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -178,6 +178,9 @@ run_unit_tests() { run_test_step "buzz-relay push runtime tests" \ cargo test -p buzz-relay --lib push_runtime:: -- --nocapture + run_test_step "buzz-relay operator listener tests" \ + cargo test -p buzz-relay --lib operator_listener::tests:: -- --nocapture + run_test_step "buzz-relay channel authorization tests" \ cargo test -p buzz-relay --lib handlers::channel_authz:: -- --nocapture From 52d13efc08ec2b80878f24672913ac465b37e148 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 14 Oct 2026 08:27:26 -0400 Subject: [PATCH 014/146] fix(buzz-db): delete workflow and membership children explicitly (#8212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Workflow deletion and relay-member removal relied on `ON DELETE CASCADE` to remove child rows. Both paths now delete their own children inside the same transaction, so a later migration can drop those foreign-key actions without changing behavior. **Workflow deletion** (`store/workflow/deletion.rs`, the coordinate path used by NIP-09 deletes of workflow definitions): - Locks the target workflow row with `SELECT … FOR UPDATE`, under the same owner and coordinate predicate as before. A concurrent run or fire insert has to lock that row for its foreign key, so the child deletes see every committed child. - Deletes scheduled fires first. A new fire has to lock the workflow row, and a cron attach locks the fire before the run, so deleting fires before the run lock keeps the lock order consistent and avoids a deadlock. - Then locks the workflow's runs `FOR UPDATE` in `id` order. An approval that names another workflow but references one of these runs key-share locks only the run, so the workflow lock alone doesn't exclude it. - `delete_workflow_with_children` then deletes approvals (for the workflow or any of its runs), then runs, then the workflow, and returns its `channel_id`. **Relay-member removal** (`store/relay_members.rs`): `remove_relay_member` and `remove_relay_member_if_role` share a new `delete_member_with_acceptances` helper. It locks the member row under the existing predicate (any non-owner, or an exact role), deletes `join_policy_acceptances`, then deletes the member, in one transaction. The `RemoveResult` outcomes are unchanged. **Removed:** `delete_workflow` (no callers) and `delete_workflow_for_owner` (test-only), plus their `Db` wrappers. The community-confinement test now drives the production coordinate deletion. ## Tests - `test_support::retire_foreign_key_delete_actions` rewrites every `CASCADE`, `SET NULL` and `SET DEFAULT` foreign key in the test's own per-test database as `NO ACTION`, which is the target schema of the follow-up migration. It asserts that none remain. All triggers stay live, including foreign-key checks and the community write fence. It refuses to run unless `current_database()` has the per-test wrapper's exact shape (`buzz_nt_` plus 24 hex digits), which excludes the run's `_desired` template. It checks the name only. - `workflow_deletion_removes_children_without_cascades` and `member_removal_deletes_acceptances_without_cascades` run the production paths on that schema and require every child row to be gone. One approval references another workflow but this workflow's run, which pins the `run_id` branch. The owner, role-mismatch and not-found outcomes still hold. - `workflow_deletion_waits_for_and_removes_concurrent_run` and `member_removal_waits_for_and_removes_concurrent_acceptance` hold a child insert open in a second transaction, wait until the removal is blocked on a lock, and then commit. The removal must succeed with no children left. - `workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock` holds a fire row the way the cron attach does, then attaches a run to it while the deletion waits. Both must succeed. - `workflow_deletion_waits_for_and_removes_concurrent_cross_workflow_approval` does the same with an approval for another workflow that references this workflow's run. - The bridge test schema now includes `join_policy_acceptances`. ## What got simpler Two delete paths no longer depend on implicit constraint actions, and two unused workflow-delete functions are gone. This is the first step of retiring the `CASCADE` and `SET NULL` foreign keys. **Identified debt (later PRs):** `channel_members` and `personal_read_frontiers` (no hard-delete path found yet for their parents), the deletion `manifest_keys` cascade, the `SET NULL` actions on `executor_heartbeats` and `product_feedback`, and the `users` self-reference. That last one is a composite `(community_id, agent_owner_pubkey)` `ON DELETE SET NULL` that would try to null the `NOT NULL` `community_id`. Also, a scheduled fire for workflow B can link to a run of A (that FK is already `NO ACTION`), which would block A's run delete; the scheduler always attaches a run to its own workflow's fire, so this needs same-workflow enforcement in the cascade work. ## Verification Head `081be4c0d`, tree `ab649487`, on a Linux cloud workstation: fmt, buzz-db and workspace clippy, source policy and buzz-db lib pass; the six new tests pass; buzz-db PostgreSQL passes 490/490 and buzz-relay PostgreSQL 378/378. A sixth mutation, moving the fire delete back after the run lock, makes `workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock` fail with `40P01` (deadlock detected). Previous head: Head `787c7c42d`, tree `6b9cf5d5`, on a Linux cloud workstation: fmt, buzz-db and workspace clippy, source policy and buzz-db lib pass; the new tests pass 5/5; buzz-relay PostgreSQL passes 378/378; buzz-db PostgreSQL passes 488/489, the one failure being `parent_contention_times_out_without_blocking_the_other_table` in `store/partition/`, which this PR doesn't touch and which passes on its own at the same tree. A fifth mutation, removing the run lock, makes the new cross-workflow approval test fail with the reviewer's exact 23503 on `workflow_approvals_community_id_run_id_fkey`. Previous head `00cba2f4a`, tree `2f89fed4`, base `e9269cbdf`. Results from a Linux cloud workstation: - fmt, buzz-db and workspace clippy, the source policy and buzz-db lib all pass. - The buzz-db PostgreSQL suite passes 488/488. - The buzz-relay PostgreSQL suite passes 378/378. Each of four mutations makes exactly one of the new tests fail with the foreign-key violation you'd expect (23503): - swapping the fires and runs deletes - deleting approvals by `workflow_id` only - removing the workflow row lock - removing the member row lock GitHub CI is green at this head. buzz-review-completed --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- crates/buzz-db/src/store/community.rs | 38 +-- crates/buzz-db/src/store/relay_members.rs | 199 +++++++++++-- crates/buzz-db/src/store/workflow.rs | 82 +---- crates/buzz-db/src/store/workflow/deletion.rs | 74 ++++- .../src/store/workflow/deletion_tests.rs | 279 +++++++++++++++++- crates/buzz-db/src/test_support.rs | 90 ++++++ crates/buzz-relay/src/api/bridge.rs | 4 +- 7 files changed, 619 insertions(+), 147 deletions(-) diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 6cf43ee7c5..2407535958 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -1180,39 +1180,7 @@ mod postgres_tests { } async fn named_contender_db(application_name: &str) -> Db { - use std::str::FromStr as _; - let options = - sqlx::postgres::PgConnectOptions::from_str(&crate::test_support::database_url()) - .expect("parse test database URL") - .application_name(application_name); - let pool = sqlx::postgres::PgPoolOptions::new() - .max_connections(1) - .connect_with(options) - .await - .expect("connect contender DB"); - Db::from_pool(pool) - } - - async fn wait_for_lock_wait(db: &Db, application_name: &str) { - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let waiting: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \ - WHERE datname = current_database() AND application_name = $1 \ - AND wait_event_type = 'Lock')", - ) - .bind(application_name) - .fetch_one(&db.pool) - .await - .expect("inspect contender lock wait"); - if waiting { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("contender reached a lock wait"); + Db::from_pool(crate::test_support::named_pool(application_name).await) } /// An archive that queues behind an ownership transfer must re-check @@ -1256,7 +1224,7 @@ mod postgres_tests { .await } }); - wait_for_lock_wait(&db, &transfer_name).await; + crate::test_support::wait_for_lock_wait(&db.pool, &transfer_name).await; let archive_name = format!("archive-transfer-archive-{}", Uuid::new_v4().simple()); let archive_db = named_contender_db(&archive_name).await; @@ -1269,7 +1237,7 @@ mod postgres_tests { .await } }); - wait_for_lock_wait(&db, &archive_name).await; + crate::test_support::wait_for_lock_wait(&db.pool, &archive_name).await; owner_rows_gate .rollback() diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 8db2082014..742e9173f2 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -243,11 +243,51 @@ pub enum RemoveResult { RoleMismatch, } +/// Deletes a membership and its join-policy acceptances in one transaction. +/// +/// With `expected_role`, only a member holding exactly that role is removed; +/// without it, any non-owner is. The member row is locked under that predicate +/// first, so a concurrent acceptance insert (which must lock the member row for +/// its foreign key) either commits before the child delete sees it or fails. +/// Children go before the parent so no foreign-key action is needed. +async fn delete_member_with_acceptances( + connection: &mut sqlx::PgConnection, + community: CommunityId, + pubkey: &str, + expected_role: Option<&str>, +) -> Result { + let mut tx = sqlx::Connection::begin(connection).await?; + let locked = sqlx::query( + "SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2 \ + AND CASE WHEN $3::text IS NULL THEN role <> 'owner' ELSE role = $3 END \ + FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .bind(expected_role) + .fetch_optional(&mut *tx) + .await?; + if locked.is_none() { + return Ok(false); + } + sqlx::query("DELETE FROM join_policy_acceptances WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2") + .bind(community.as_uuid()) + .bind(pubkey) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(true) +} + /// Removes a relay member atomically, refusing to delete the owner. /// -/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the -/// owner-protection check and the deletion are one atomic operation — -/// no TOCTOU race between a separate read and delete. +/// The owner-protection check and the deletion run in one transaction under a +/// row lock, so there is no TOCTOU race between a separate read and delete. pub async fn remove_relay_member( pool: &PgPool, community: CommunityId, @@ -255,20 +295,11 @@ pub async fn remove_relay_member( ) -> Result { let mut connection = observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let result = sqlx::query( - "DELETE FROM relay_members \ - WHERE community_id = $1 AND pubkey = $2 AND role <> 'owner'", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .execute(&mut *connection) - .await?; - - if result.rows_affected() > 0 { + if delete_member_with_acceptances(&mut connection, community, pubkey, None).await? { return Ok(RemoveResult::Removed); } - // rows_affected == 0: either not found or is owner. One cheap read to + // Nothing was locked: either not found or is owner. One cheap read to // distinguish the two cases so callers can return the right error message. let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) @@ -285,9 +316,8 @@ pub async fn remove_relay_member( /// Removes a relay member only if their current role matches `expected_role`. /// -/// The delete and the role check are collapsed into a single -/// `DELETE … WHERE pubkey = $1 AND role = $2`, making the operation atomic — -/// no TOCTOU race between a prior read and this delete. +/// The role check and the delete run in one transaction under a row lock, so +/// there is no TOCTOU race between a prior read and this delete. /// /// Returns: /// - `Removed` — row was deleted. @@ -304,20 +334,13 @@ pub async fn remove_relay_member_if_role( ) -> Result { let mut connection = observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let result = sqlx::query( - "DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2 AND role = $3", - ) - .bind(community.as_uuid()) - .bind(pubkey) - .bind(expected_role) - .execute(&mut *connection) - .await?; - - if result.rows_affected() > 0 { + if delete_member_with_acceptances(&mut connection, community, pubkey, Some(expected_role)) + .await? + { return Ok(RemoveResult::Removed); } - // rows_affected == 0: either not found or role changed. One cheap read to + // Nothing was locked: either not found or role changed. One cheap read to // distinguish the cases so callers can return the right error message. let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) @@ -1392,6 +1415,126 @@ mod postgres_tests { ); } + async fn acceptances(pool: &PgPool, community: CommunityId, pubkey: &str) -> i64 { + sqlx::query_scalar( + "SELECT count(*) FROM join_policy_acceptances WHERE community_id = $1 AND pubkey = $2", + ) + .bind(community.as_uuid()) + .bind(pubkey) + .fetch_one(pool) + .await + .expect("count acceptances") + } + + /// Both removal paths delete acceptance evidence themselves: with every + /// foreign-key delete action retired, nothing cascades. Owners and a role + /// mismatch keep theirs. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn member_removal_deletes_acceptances_without_cascades() { + let pool = setup_pool().await; + crate::test_support::retire_foreign_key_delete_actions(&pool).await; + let (community, owner) = owned_community(&pool).await; + let version = "a".repeat(64); + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3)", + ) + .bind(community.as_uuid()) + .bind(&owner) + .bind(&version) + .execute(&pool) + .await + .expect("owner acceptance"); + let member = test_pubkey(); + let admin = test_pubkey(); + for (pubkey, role) in [(&member, "member"), (&admin, "admin")] { + claim_relay_membership(&pool, community, pubkey, role, Some(&version)) + .await + .expect("claim membership"); + assert_eq!(acceptances(&pool, community, pubkey).await, 1); + } + + assert_eq!( + remove_relay_member(&pool, community, &owner) + .await + .expect("owner removal"), + RemoveResult::IsOwner + ); + assert_eq!(acceptances(&pool, community, &owner).await, 1); + assert_eq!( + remove_relay_member_if_role(&pool, community, &admin, "member") + .await + .expect("mismatched removal"), + RemoveResult::RoleMismatch + ); + assert_eq!(acceptances(&pool, community, &admin).await, 1); + + assert_eq!( + remove_relay_member(&pool, community, &member) + .await + .expect("member removal"), + RemoveResult::Removed + ); + assert_eq!(acceptances(&pool, community, &member).await, 0); + assert_eq!( + remove_relay_member_if_role(&pool, community, &admin, "admin") + .await + .expect("role removal"), + RemoveResult::Removed + ); + assert_eq!(acceptances(&pool, community, &admin).await, 0); + } + + /// An acceptance inserted concurrently with the removal holds the member + /// row's key-share lock. Removal must wait for it at its row lock and then + /// delete it; with no cascade, a removal that took its child snapshot first + /// would fail the foreign-key check on the member. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn member_removal_waits_for_and_removes_concurrent_acceptance() { + let pool = setup_pool().await; + crate::test_support::retire_foreign_key_delete_actions(&pool).await; + let (community, _owner) = owned_community(&pool).await; + let member = test_pubkey(); + claim_relay_membership(&pool, community, &member, "member", Some(&"a".repeat(64))) + .await + .expect("claim membership"); + + let mut inserter = pool.begin().await.expect("begin acceptance insert"); + sqlx::query( + "INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \ + VALUES ($1, $2, $3)", + ) + .bind(community.as_uuid()) + .bind(&member) + .bind("b".repeat(64)) + .execute(&mut *inserter) + .await + .expect("insert concurrent acceptance"); + + let name = format!("member-remove-{}", Uuid::new_v4().simple()); + let remover = crate::test_support::named_pool(&name).await; + let removal = tokio::spawn({ + let member = member.clone(); + async move { remove_relay_member(&remover, community, &member).await } + }); + crate::test_support::wait_for_lock_wait(&pool, &name).await; + inserter + .commit() + .await + .expect("commit concurrent acceptance"); + + assert_eq!( + removal + .await + .expect("join removal") + .expect("removal after concurrent acceptance"), + RemoveResult::Removed + ); + assert_eq!(acceptances(&pool, community, &member).await, 0); + } + async fn owned_community(pool: &PgPool) -> (CommunityId, String) { let community = make_test_community(pool).await; let owner = test_pubkey(); diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index da02c7f75d..c89bf0cbf0 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -775,56 +775,6 @@ pub async fn disable_workflows_for_owner_in_channel_on_conn( Ok(affected) } -/// Delete a workflow and all its runs/approvals (CASCADE). -/// -/// NOTE: see the cache-invalidation note on [`update_workflow`]. The relay's -/// deletion path uses [`delete_workflow_for_owner`], which returns the -/// `channel_id` needed for invalidation. (No current callers.) -pub async fn delete_workflow(pool: &PgPool, community_id: CommunityId, id: Uuid) -> Result<()> { - let affected = sqlx::query("DELETE FROM workflows WHERE community_id = $1 AND id = $2") - .bind(community_id.as_uuid()) - .bind(id) - .execute(pool) - .await? - .rows_affected(); - - if affected == 0 { - return Err(DbError::NotFound(format!("workflow {id}"))); - } - Ok(()) -} - -/// Delete a workflow only when it belongs to `owner_pubkey`. -/// -/// Used by event-driven deletion paths where the workflow UUID is attacker -/// controlled. Keeping the owner predicate in the DELETE statement avoids a -/// check-then-delete race and ensures a caller cannot delete another user's -/// workflow just by learning its UUID. -/// -/// Returns the deleted workflow's `channel_id` so the caller can invalidate -/// the per-channel trigger cache without a separate lookup. -pub async fn delete_workflow_for_owner( - pool: &PgPool, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], -) -> Result> { - let row = sqlx::query( - "DELETE FROM workflows WHERE community_id = $1 AND id = $2 AND owner_pubkey = $3 \ - RETURNING channel_id", - ) - .bind(community_id.as_uuid()) - .bind(id) - .bind(owner_pubkey) - .fetch_optional(pool) - .await?; - - match row { - Some(row) => Ok(row.try_get("channel_id")?), - None => Err(DbError::NotFound(format!("workflow {id}"))), - } -} - // -- Workflow Run CRUD -------------------------------------------------------- /// Insert a new workflow run. Returns the new run's UUID. @@ -1684,24 +1634,6 @@ impl Db { .await } - /// Delete a workflow and all its runs/approvals. - #[datastore_span(name = "delete_workflow", system = "postgresql")] - pub async fn delete_workflow(&self, community_id: CommunityId, id: Uuid) -> Result<()> { - crate::workflow::delete_workflow(&self.pool, community_id, id).await - } - - /// Delete a workflow only when it belongs to the provided owner. - /// Returns the deleted workflow's `channel_id`. - #[datastore_span(name = "delete_workflow_for_owner", system = "postgresql")] - pub async fn delete_workflow_for_owner( - &self, - community_id: CommunityId, - id: Uuid, - owner_pubkey: &[u8], - ) -> Result> { - crate::workflow::delete_workflow_for_owner(&self.pool, community_id, id, owner_pubkey).await - } - /// Find a workflow by owner pubkey and name within a community. Used for /// NIP-09 a-tag deletion where the d-tag is the workflow name (not UUID). #[datastore_span(name = "find_workflow_by_owner_and_name", system = "postgresql")] @@ -2656,9 +2588,9 @@ mod postgres_tests { } /// Issue 4 (workflow lifecycle): deleting `A/id` must not delete `B/id` - /// when both communities hold the same workflow UUID. Pre-fix - /// `delete_workflow` predicated only on `id`, so a NIP-09 a-tag deletion in - /// one community would erase the colliding workflow in every community. + /// when both communities hold the same workflow UUID. Pre-fix the workflow + /// delete predicated only on `id`, so a NIP-09 a-tag deletion in one + /// community would erase the colliding workflow in every community. #[tokio::test] #[ignore = "requires Postgres"] async fn workflow_delete_is_confined_to_its_community() { @@ -2684,7 +2616,13 @@ mod postgres_tests { ) .await; - delete_workflow(&pool, community_a, shared_workflow_id) + crate::Db::from_pool(pool.clone()) + .delete_workflow_by_coordinate( + community_a, + &[0xb2; 32], + &shared_workflow_id.to_string(), + chrono::Utc::now().timestamp(), + ) .await .expect("delete A's workflow"); diff --git a/crates/buzz-db/src/store/workflow/deletion.rs b/crates/buzz-db/src/store/workflow/deletion.rs index 27fde32b47..84f4b82845 100644 --- a/crates/buzz-db/src/store/workflow/deletion.rs +++ b/crates/buzz-db/src/store/workflow/deletion.rs @@ -4,7 +4,6 @@ use buzz_core::{kind::KIND_WORKFLOW_DEF, CommunityId, StoredEvent}; use buzz_datastore_tracing::datastore_span; use chrono::{DateTime, Utc}; use nostr::Event; -use sqlx::Row; use uuid::Uuid; use crate::AdmittedTx; @@ -115,13 +114,16 @@ async fn delete_workflow_in_transaction( } // UUID coordinates are canonical; retain the legacy name-based path. - // The owner predicate remains in the mutation, not just a prior check. + // The owner predicate is in the locking SELECT; FOR UPDATE keeps it + // binding for the deletes below. let workflow_id = Uuid::parse_str(d_tag).ok(); - let row = sqlx::query( - "DELETE FROM workflows WHERE community_id = $1 AND owner_pubkey = $2 \ + // Lock the workflow first: a concurrent run or fire insert must lock it for + // its foreign key, so the child deletes below see every committed child. + let target: Option = sqlx::query_scalar( + "SELECT id FROM workflows WHERE community_id = $1 AND owner_pubkey = $2 \ AND id = COALESCE($3::uuid, (SELECT id FROM workflows \ WHERE community_id = $1 AND owner_pubkey = $2 AND name = $4 LIMIT 1)) \ - RETURNING channel_id", + FOR UPDATE", ) .bind(community_id.as_uuid()) .bind(owner_pubkey) @@ -129,6 +131,10 @@ async fn delete_workflow_in_transaction( .bind(d_tag) .fetch_optional(tx.conn()) .await?; + let deleted_channel = match target { + Some(id) => Some(delete_workflow_with_children(tx, id).await?), + None => None, + }; let definitions = sqlx::query( "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 \ AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL AND created_at <= $5", @@ -140,17 +146,65 @@ async fn delete_workflow_in_transaction( .bind(cutoff) .execute(tx.conn()) .await?; - let changed = row.is_some() || definitions.rows_affected() > 0; - let channel_id = row - .map(|row| row.try_get("channel_id")) - .transpose()? - .flatten(); + let changed = deleted_channel.is_some() || definitions.rows_affected() > 0; + let channel_id = deleted_channel.flatten(); Ok(WorkflowDeletionOutcome { changed, channel_id, }) } +/// Delete a locked workflow and the rows that hang off it, children first, so +/// no foreign-key action is needed: scheduled fires, approvals (of the workflow +/// or any of its runs), then runs. Returns the workflow's `channel_id`. +async fn delete_workflow_with_children(tx: &mut AdmittedTx, id: Uuid) -> Result> { + let community = *tx.community().as_uuid(); + // Fires go before the run lock. A new fire must key-share lock the workflow, + // which is already locked. Attaching a run to a fire locks the fire row and + // then the run, so taking the run lock first would invert that order and can + // deadlock; deleting the fire first waits for any in-flight attach instead. + sqlx::query( + "DELETE FROM scheduled_workflow_fires WHERE community_id = $1 AND workflow_id = $2", + ) + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + // The workflow lock stops new runs, but an approval naming another workflow + // can still reference one of these runs; it key-share locks only that run. + // Lock the runs (in a fixed order) so such an insert either commits before + // the approval delete below or waits until this transaction ends. + sqlx::query( + "SELECT id FROM workflow_runs WHERE community_id = $1 AND workflow_id = $2 \ + ORDER BY id FOR UPDATE", + ) + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + sqlx::query( + "DELETE FROM workflow_approvals WHERE community_id = $1 AND (workflow_id = $2 \ + OR run_id IN (SELECT id FROM workflow_runs \ + WHERE community_id = $1 AND workflow_id = $2))", + ) + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + sqlx::query("DELETE FROM workflow_runs WHERE community_id = $1 AND workflow_id = $2") + .bind(community) + .bind(id) + .execute(tx.conn()) + .await?; + Ok(sqlx::query_scalar( + "DELETE FROM workflows WHERE community_id = $1 AND id = $2 RETURNING channel_id", + ) + .bind(community) + .bind(id) + .fetch_one(tx.conn()) + .await?) +} + #[cfg(test)] #[path = "deletion_tests.rs"] mod postgres_tests; diff --git a/crates/buzz-db/src/store/workflow/deletion_tests.rs b/crates/buzz-db/src/store/workflow/deletion_tests.rs index 0dd4909179..7210d2efb7 100644 --- a/crates/buzz-db/src/store/workflow/deletion_tests.rs +++ b/crates/buzz-db/src/store/workflow/deletion_tests.rs @@ -174,7 +174,10 @@ async fn workflow_deletion_preserves_newer_definition_and_cleans_legacy_orphan() assert_present(&db, &query, id).await; // Old relay releases deleted the executable row, but not the definition. - db.delete_workflow_for_owner(community, id, &owner) + sqlx::query("DELETE FROM workflows WHERE community_id = $1 AND id = $2") + .bind(community.as_uuid()) + .bind(id) + .execute(&db.pool) .await .expect("legacy deletion"); assert_eq!( @@ -360,3 +363,277 @@ async fn quiescing_community_rejects_workflow_deletion_at_admission_before_repla .expect("count deletion request"); assert_eq!(stored, 0, "a rejected deletion request must not be stored"); } + +async fn count_where(db: &Db, table: &str, column: &str, community: CommunityId, id: Uuid) -> i64 { + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT count(*) FROM {table} WHERE community_id = $1 AND {column} = $2" + ))) + .bind(community.as_uuid()) + .bind(id) + .fetch_one(&db.pool) + .await + .expect("count children") +} + +/// With every foreign-key delete action retired, the coordinate delete must +/// remove approvals (of the workflow or of its runs), fires and runs itself, +/// in an order the remaining constraints accept. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_removes_children_without_cascades() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + let query = seed(&db, community, &keys, id, &d_tag, now).await; + let other = Uuid::new_v4(); + seed(&db, community, &keys, other, &other.to_string(), now).await; + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + sqlx::query( + "INSERT INTO scheduled_workflow_fires \ + (community_id, workflow_id, scheduled_for, workflow_run_id) VALUES ($1, $2, NOW(), $3)", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(run_id) + .execute(&db.pool) + .await + .expect("scheduled claim linked to run"); + // One approval hangs off the workflow; the other names a different + // workflow but this workflow's run, so only the run_id branch reaches it. + for (token, workflow_id) in [("workflow-approval", id), ("run-approval", other)] { + crate::workflow::create_approval( + &db.pool, + crate::workflow::CreateApprovalParams { + community_id: community, + token, + workflow_id, + run_id, + step_id: "gate", + step_index: 0, + approver_spec: "@anyone", + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + }, + ) + .await + .expect("approval"); + } + + let outcome = db + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + .expect("delete without cascades"); + assert!(outcome.changed); + assert_absent(&db, &query, id).await; + for (table, column, key) in [ + ("workflow_approvals", "workflow_id", id), + ("workflow_approvals", "run_id", run_id), + ("scheduled_workflow_fires", "workflow_id", id), + ("workflow_runs", "workflow_id", id), + ] { + assert_eq!( + count_where(&db, table, column, community, key).await, + 0, + "{table} rows by {column} must be deleted explicitly" + ); + } + assert!(get_workflow(&db.pool, community, other).await.is_ok()); +} + +/// A run inserted concurrently with the delete holds the workflow's key-share +/// lock. The delete must wait for it at its row lock and then remove the run; +/// with no cascade, a delete that took its child snapshot first would fail the +/// foreign-key check on the workflow. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_waits_for_and_removes_concurrent_run() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + seed(&db, community, &keys, id, &d_tag, now).await; + + let mut inserter = db.pool.begin().await.expect("begin run insert"); + sqlx::query( + "INSERT INTO workflow_runs (community_id, id, workflow_id, status, current_step, execution_trace) \ + VALUES ($1, $2, $3, 'pending', 0, '[]')", + ) + .bind(community.as_uuid()) + .bind(Uuid::new_v4()) + .bind(id) + .execute(&mut *inserter) + .await + .expect("insert concurrent run"); + + let name = format!("workflow-delete-{}", Uuid::new_v4().simple()); + let deleter = Db::from_pool(crate::test_support::named_pool(&name).await); + let deletion = tokio::spawn(async move { + deleter + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + }); + crate::test_support::wait_for_lock_wait(&db.pool, &name).await; + inserter.commit().await.expect("commit concurrent run"); + + let outcome = deletion + .await + .expect("join deletion") + .expect("delete after concurrent run"); + assert!(outcome.changed); + assert_eq!( + count_where(&db, "workflow_runs", "workflow_id", community, id).await, + 0 + ); +} + +/// An approval naming another workflow but one of this workflow's runs only +/// key-share locks that run, so the workflow lock does not exclude it. The +/// delete must lock the runs, wait for the insert, and then remove it; with no +/// cascade, committing it between the approval and run deletes would fail the +/// run delete's foreign-key check. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_waits_for_and_removes_concurrent_cross_workflow_approval() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + seed(&db, community, &keys, id, &d_tag, now).await; + let other = Uuid::new_v4(); + seed(&db, community, &keys, other, &other.to_string(), now).await; + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + + let mut inserter = db.pool.begin().await.expect("begin approval insert"); + sqlx::query( + "INSERT INTO workflow_approvals (community_id, token, workflow_id, run_id, step_id, \ + step_index, approver_spec, status, expires_at) \ + VALUES ($1, $2, $3, $4, 'gate', 0, '@anyone', 'pending', NOW() + INTERVAL '1 hour')", + ) + .bind(community.as_uuid()) + .bind(Uuid::new_v4().as_bytes().to_vec()) + .bind(other) + .bind(run_id) + .execute(&mut *inserter) + .await + .expect("insert concurrent cross-workflow approval"); + + let name = format!("workflow-delete-{}", Uuid::new_v4().simple()); + let deleter = Db::from_pool(crate::test_support::named_pool(&name).await); + let deletion = tokio::spawn(async move { + deleter + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + }); + crate::test_support::wait_for_lock_wait(&db.pool, &name).await; + inserter.commit().await.expect("commit concurrent approval"); + + let outcome = deletion + .await + .expect("join deletion") + .expect("delete after concurrent approval"); + assert!(outcome.changed); + for (table, column, key) in [ + ("workflow_approvals", "run_id", run_id), + ("workflow_runs", "workflow_id", id), + ] { + assert_eq!( + count_where(&db, table, column, community, key).await, + 0, + "{table}" + ); + } + assert!(get_workflow(&db.pool, community, other).await.is_ok()); +} + +/// Attaching a run to a scheduled fire locks the fire row, then key-share +/// locks the run for its foreign key. The delete must take those two in the +/// same order (fire before run), so an attach in flight when the delete +/// starts completes and is then removed, rather than deadlocking with it. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_deletion_waits_for_concurrent_fire_attach_without_deadlock() { + let (db, community) = setup().await; + crate::test_support::retire_foreign_key_delete_actions(&db.pool).await; + let keys = Keys::generate(); + let owner = keys.public_key().to_bytes(); + let id = Uuid::new_v4(); + let d_tag = id.to_string(); + let now = Timestamp::now().as_secs(); + seed(&db, community, &keys, id, &d_tag, now).await; + let run_id = create_workflow_run(&db.pool, community, id, None, None) + .await + .expect("run"); + let scheduled_for: chrono::DateTime = sqlx::query_scalar( + "INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) \ + VALUES ($1, $2, date_trunc('second', NOW())) RETURNING scheduled_for", + ) + .bind(community.as_uuid()) + .bind(id) + .fetch_one(&db.pool) + .await + .expect("unlinked scheduled claim"); + + // Hold the fire row as an attach does before its foreign-key check runs. + let mut attacher = db.pool.begin().await.expect("begin fire attach"); + sqlx::query( + "SELECT 1 FROM scheduled_workflow_fires \ + WHERE community_id = $1 AND workflow_id = $2 AND scheduled_for = $3 FOR UPDATE", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(scheduled_for) + .execute(&mut *attacher) + .await + .expect("lock fire row"); + + let name = format!("workflow-delete-{}", Uuid::new_v4().simple()); + let deleter = Db::from_pool(crate::test_support::named_pool(&name).await); + let deletion = tokio::spawn(async move { + deleter + .delete_workflow_by_coordinate(community, &owner, &d_tag, now as i64) + .await + }); + crate::test_support::wait_for_lock_wait(&db.pool, &name).await; + let attached = sqlx::query( + "UPDATE scheduled_workflow_fires SET workflow_run_id = $4 \ + WHERE community_id = $1 AND workflow_id = $2 AND scheduled_for = $3", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(scheduled_for) + .bind(run_id) + .execute(&mut *attacher) + .await + .expect("attach run to fire without deadlock"); + assert_eq!(attached.rows_affected(), 1); + attacher.commit().await.expect("commit fire attach"); + + let outcome = deletion + .await + .expect("join deletion") + .expect("delete after concurrent fire attach"); + assert!(outcome.changed); + for (table, column, key) in [ + ("scheduled_workflow_fires", "workflow_id", id), + ("workflow_runs", "workflow_id", id), + ] { + assert_eq!( + count_where(&db, table, column, community, key).await, + 0, + "{table}" + ); + } +} diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs index 73fa14c366..e94b8b1449 100644 --- a/crates/buzz-db/src/test_support.rs +++ b/crates/buzz-db/src/test_support.rs @@ -82,3 +82,93 @@ pub(crate) fn desired_state_schema_sql() -> String { pub(crate) fn desired_state_schema_dir() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../schema") } + +/// Rewrite every `ON DELETE CASCADE`, `SET NULL` and `SET DEFAULT` foreign key +/// in this test's database as `NO ACTION`: the schema the cascade retirement +/// targets. Every trigger stays live, including the foreign-key checks and the +/// community fence, so a delete path exercised afterwards must remove its +/// children itself, in an order the remaining constraints accept. +/// +/// Each PostgreSQL test process owns its database (`crates/buzz-db/TESTING.md`), +/// so the rewrite does not reach other tests. It refuses any database whose +/// name does not match the per-test wrapper's (`buzz_nt_` plus 24 hex digits), +/// which excludes the run's `_desired` template. The name is the only check: +/// a manually created database with that shape would pass. +pub(crate) async fn retire_foreign_key_delete_actions(pool: &sqlx::PgPool) { + let database: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(pool) + .await + .expect("read current database"); + assert!( + database + .strip_prefix("buzz_nt_") + .is_some_and(|hash| hash.len() == 24 + && hash + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))), + "refusing to rewrite foreign keys in shared database {database}; \ + run this test through scripts/postgres-test-run.sh" + ); + sqlx::raw_sql( + r#"DO $$ + DECLARE c record; + BEGIN + FOR c IN SELECT conrelid::regclass AS tbl, conname, pg_get_constraintdef(oid) AS def + FROM pg_constraint + WHERE contype = 'f' AND confdeltype IN ('c', 'n', 'd') AND conparentid = 0 + LOOP + EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I, ADD CONSTRAINT %I %s', + c.tbl, c.conname, c.conname, + regexp_replace(c.def, ' ON DELETE (CASCADE|SET NULL|SET DEFAULT)( \([^)]*\))?', '')); + END LOOP; + END $$"#, + ) + .execute(pool) + .await + .expect("retire foreign-key delete actions"); + let remaining: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint WHERE contype = 'f' AND confdeltype IN ('c', 'n', 'd')", + ) + .fetch_one(pool) + .await + .expect("count foreign-key delete actions"); + assert_eq!(remaining, 0, "every foreign-key delete action is retired"); +} + +/// A single-connection pool whose session carries `application_name`, so +/// [`wait_for_lock_wait`] can tell when its statement is blocked. +pub(crate) async fn named_pool(application_name: &str) -> sqlx::PgPool { + use std::str::FromStr as _; + let options = sqlx::postgres::PgConnectOptions::from_str(&database_url()) + .expect("parse test database URL") + .application_name(application_name); + sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("connect named test pool") +} + +/// Wait until the session named `application_name` in this test's database is +/// blocked on a lock. +pub(crate) async fn wait_for_lock_wait(pool: &sqlx::PgPool, application_name: &str) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let waiting: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM pg_stat_activity \ + WHERE datname = current_database() AND application_name = $1 \ + AND wait_event_type = 'Lock')", + ) + .bind(application_name) + .fetch_one(pool) + .await + .expect("inspect lock wait"); + if waiting { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("session reached a lock wait"); +} diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index bd39ada46e..b762af391d 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8403,7 +8403,9 @@ pub(crate) mod postgres_tests { "CREATE SCHEMA {schema}; \ CREATE TABLE {schema}.users (LIKE public.users INCLUDING ALL); \ CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ - CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL);" + CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL); \ + CREATE TABLE {schema}.join_policy_acceptances \ + (LIKE public.join_policy_acceptances INCLUDING ALL);" ))) .execute(state.db.pool()) .await From 8bb49a1b6a5ffb5b89eb2e860673ee7bff2fd29b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 09:22:42 -0400 Subject: [PATCH 015/146] feat(relay): publish channel type and creator on every channel-state event (#8174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Clients cannot tell who created a channel, and only the channel-metadata event says what type of channel it is. This PR makes every relay-signed channel-state event carry both facts, so a client can find "the channel that this key created" with one ordinary Nostr filter. - Channel metadata, admin list and member list events (kinds 39000, 39001 and 39002) now all carry the channel type as their first `t` tag, then the creator as a `P` tag. - The creator comes from the relay's own record of who signed the channel's create request. No client can set it, and it does not change when ownership moves. - Channel metadata (kind:39000) also carries `["created_at", ]`, the time the channel was created. The event's own `created_at` changes on every edit, so before this a client could not tell which of two channels is older. This is part 1 of a 3-part stack (channel labels and creator lookup). The system channels stack (#8180–#8182) builds on it. ### Related issue None found. ### Details - **Why `P`.** Several Nostr specs (NIP-22, NIP-34, NIP-72) use uppercase `P` for the author of the root object, and lowercase `p` for people involved. Lowercase `p` is already used on these events for members and DM participants, and a filter matches tag values, not their meaning, so `#p` would mix them. - **Trust.** A client that trusts a channel because of its creator must also check that the relay key signed the event. - **Order.** The type `t` tag comes first, so clients that read the first `t` tag still get the type after part 2 adds labels as more `t` tags. - **Creation time.** The relay reads it from `channels.created_at`, so an edit does not change it. #8176 uses it to pick one channel when two creates race. - `buzz-admin reconcile` writes the same tags when it rebuilds these events. `NOSTR.md` documents the tags. - **Spec.** New `docs/nips/NIP-CL.md` defines these tags, the trust rule and lookup by creator. Later PRs in both stacks add their parts to it. `NOSTR.md` links to it. ### Testing - Relay tests in `group_state_postgres_tests.rs`: each of the three kinds carries the type first, then the creator; the creator stays the same after an ownership transfer; kind:39000 carries the stored creation time, and still does after an edit. - Unit test for the shared tag builder that both the relay and `buzz-admin reconcile` use. ### Stack These are two stacks. Merge them in this order. Client support (creating, labeling and finding these channels) will be in block/buzz-app, not the buzz SDK and CLI. **Stack 1: channel labels and creator lookup (#8174–#8176)** 1. #8174 channel type and creator tags, and the channel creation time ← this PR 2. #8175 channel labels 3. #8176 database-side label and creator match, and the rule for choosing one channel **Stack 2: system channels (#8180–#8182)**, stacked on stack 1 because it uses labels and the creator tag 4. #8180 the system channel type 5. #8181 leave system channels out of reads that do not name them 6. #8182 channel-state filters opt in to system channels --------- Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- NOSTR.md | 25 +- crates/buzz-admin/src/main.rs | 15 +- crates/buzz-core/src/channel.rs | 52 +++- crates/buzz-db/src/store/channel.rs | 12 +- crates/buzz-db/src/store/channel_members.rs | 26 ++ .../handlers/group_state_postgres_tests.rs | 240 ++++++++++++++++++ crates/buzz-relay/src/handlers/mod.rs | 2 + .../buzz-relay/src/handlers/side_effects.rs | 54 +++- docs/nips/NIP-CL.md | 101 ++++++++ 9 files changed, 511 insertions(+), 16 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/group_state_postgres_tests.rs create mode 100644 docs/nips/NIP-CL.md diff --git a/NOSTR.md b/NOSTR.md index cb1f20e84f..a3a021e490 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -57,9 +57,9 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | -| **Group admins (kind:39001)** | ✅ | Relay-signed; `d` tag + `p` tags with roles (`owner`, `admin`) | -| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `closed`, `["t", ]`, `["P", ]` and `["created_at", ]` tags; `about` only if description non-empty; `private` if applicable; `hidden` for DM channels | +| **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | +| **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `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. | @@ -103,13 +103,26 @@ relay to specific external Nostr identities without granting full access. ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. +[NIP-CL](docs/nips/NIP-CL.md) defines the channel identity tags on these events and how clients +use them to find channels. All discovery events include a `d` tag set to the channel UUID (NIP-29 addressable event convention): | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `closed` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. | -| **39001** | `d=`, `p` tags with role label (`owner`, `admin`) | Admin list | -| **39002** | `d=`, `p` tags for all members | Member list | +| **39000** | `d=`, `name`, `closed`, `t=`, `P=`, `created_at=` (always); `about` (if description non-empty); `private` (if applicable); `hidden` (DM channels only) | Group metadata. **Note:** `closed` is always emitted per NIP-29 convention (Buzz channels require explicit membership), but open channels are still readable/writable by non-members at runtime. The tag reflects the membership model, not access enforcement. | +| **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | +| **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | + +On all three kinds the channel type `t` tag comes first, then `P`. `P` is +the public key that signed the channel's kind:9007. The relay writes it +from the database, and it does not change when ownership moves. Uppercase +`P` follows NIP-22, NIP-34 and NIP-72, where it names the author of the root +object; lowercase `p` already lists members and DM participants on these +kinds. + +kind:39000 also carries `["created_at", ]`: the time the +channel was created. The event's own `created_at` is the time of the last +edit, so use the tag to find the older of two channels. Events are stored **channel-scoped** so access control applies — private channel member lists are only visible to members. Discover groups via historical REQ: diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 33fc74b6d1..9c4177b701 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -739,6 +739,7 @@ async fn reconcile_channels( channel_arg: Option, relay_key_arg: Option, ) -> Result<()> { + use buzz_core::channel::{channel_created_at_tag, group_state_identity_tags}; use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; @@ -839,7 +840,11 @@ async fn reconcile_channels( tags.push(Tag::parse(["hidden"])?); } tags.push(Tag::parse(["closed"])?); - tags.push(Tag::parse(["t", &channel.channel_type])?); + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); + tags.push(channel_created_at_tag(channel.created_at.timestamp())?); let event = EventBuilder::new(Kind::Custom(39000), "") .tags(tags) @@ -852,6 +857,10 @@ async fn reconcile_channels( // kind:39001 — admins { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); for m in members .iter() .filter(|m| m.role == "owner" || m.role == "admin") @@ -871,6 +880,10 @@ async fn reconcile_channels( // kind:39002 — members { let mut tags: Vec = vec![Tag::parse(["d", &channel_id_str])?]; + tags.extend(group_state_identity_tags( + &channel.channel_type, + &channel.created_by, + )?); for m in &members { let pk = hex::encode(&m.pubkey); tags.push(Tag::parse(["p", &pk, "", &m.role])?); diff --git a/crates/buzz-core/src/channel.rs b/crates/buzz-core/src/channel.rs index 5c20afefd4..41ba48670c 100644 --- a/crates/buzz-core/src/channel.rs +++ b/crates/buzz-core/src/channel.rs @@ -17,6 +17,39 @@ pub fn canonical_channel_name(name: &str) -> &str { .trim_end() } +/// Tags that identify a channel on every NIP-29 group-state event the relay +/// signs (kinds 39000–39002), in this order: +/// +/// 1. `["t", ]`. It comes first, so a client that reads the +/// first `t` tag gets the channel type. +/// 2. `["P", ]`: the key that signed the channel's kind:9007. +/// It comes from the database and does not change when ownership moves. +/// Uppercase `P` names the author of the root object, as in NIP-22, +/// NIP-34 and NIP-72; lowercase `p` already means members and DM +/// participants on these kinds. +/// +/// Filters such as `{kinds:[39000], #P:[]}` select by these tags. +pub fn group_state_identity_tags( + channel_type: &str, + created_by: &[u8], +) -> Result, nostr::event::tag::Error> { + Ok(vec![ + nostr::Tag::parse(["t", channel_type])?, + nostr::Tag::parse(["P", &hex::encode(created_by)])?, + ]) +} + +/// Tag on kind:39000 that holds the channel's creation time in Unix seconds. +/// +/// The event's own `created_at` is the time of the last edit, so it cannot +/// rank channels by age. +pub const CHANNEL_CREATED_AT_TAG: &str = "created_at"; + +/// The [`CHANNEL_CREATED_AT_TAG`] tag for a channel created at `unix_seconds`. +pub fn channel_created_at_tag(unix_seconds: i64) -> Result { + nostr::Tag::parse([CHANNEL_CREATED_AT_TAG, &unix_seconds.to_string()]) +} + /// Whether a channel is publicly visible or invite-only. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChannelVisibility { @@ -180,7 +213,24 @@ impl FromStr for MemberRole { #[cfg(test)] mod tests { - use super::canonical_channel_name; + use super::{canonical_channel_name, group_state_identity_tags}; + + #[test] + fn group_state_identity_tags_put_the_type_first_then_the_creator() { + let creator = [0xab; 32]; + let tags: Vec> = group_state_identity_tags("forum", &creator) + .expect("valid tags") + .into_iter() + .map(|tag| tag.to_vec()) + .collect(); + assert_eq!( + tags, + vec![ + vec!["t".to_string(), "forum".to_string()], + vec!["P".to_string(), "ab".repeat(32)], + ] + ); + } #[test] fn channel_names_trim_whitespace_and_drop_all_leading_hashes() { diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 8d43473d50..46375595cf 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -26,7 +26,7 @@ pub use crate::channel_members::{ get_users_bulk, is_member, list_large_channel_rosters_needing_reconciliation, lock_member_snapshot, membership_pairs, remove_member, verify_channel_roster_fence_behavior, verify_channel_roster_fence_catalog, AccessibleChannel, BotChannelEntry, BotMemberRecord, - LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, + ChannelIdentity, LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; async fn begin_event_write_transaction( @@ -99,6 +99,16 @@ pub struct ChannelRecord { pub ttl_deadline: Option>, } +impl ChannelRecord { + /// The facts every group-state event publishes about this channel. + pub fn identity(&self) -> ChannelIdentity { + ChannelIdentity { + channel_type: self.channel_type.clone(), + created_by: self.created_by.clone(), + } + } +} + /// Creates a new channel, bootstraps the creator as owner, and returns the record. #[allow(clippy::too_many_arguments)] pub async fn create_channel( diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 5cf3ed40dc..7ff5146775 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -301,11 +301,25 @@ pub async fn insert_auto_membership_in_transaction( // ── End transaction-level helpers ───────────────────────────────────────────── +/// The immutable facts that every relay-signed group-state event +/// (kinds 39000–39002) publishes about its channel. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelIdentity { + /// Channel type string (e.g. `"stream"`, `"forum"`, `"dm"`). + pub channel_type: String, + /// Public key bytes of the channel creator (the kind:9007 signer). + pub created_by: Vec, +} + /// An active member roster captured while holding the channel's membership /// serialization lock on one writer connection. pub struct LockedMemberSnapshot { /// Canonical active members captured behind the lock. pub members: Vec, + /// The channel's type and creator, read on the same connection, so the + /// roster carries the same identity tags as the channel's other + /// group-state events. `None` when no channel row exists. + pub identity: Option, channel_id: Uuid, relay_pubkey: Vec, tx: AdmittedTx, @@ -477,8 +491,20 @@ pub async fn lock_member_snapshot( .into_iter() .map(row_to_member_record) .collect::>>()?; + let identity = sqlx::query_as::<_, (String, Vec)>( + "SELECT channel_type::text, created_by FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_optional(tx.conn()) + .await? + .map(|(channel_type, created_by)| ChannelIdentity { + channel_type, + created_by, + }); Ok(LockedMemberSnapshot { members, + identity, channel_id, relay_pubkey: relay_pubkey.to_vec(), tx, diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs new file mode 100644 index 0000000000..2df126d097 --- /dev/null +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -0,0 +1,240 @@ +//! Postgres-backed tests for the relay-signed NIP-29 group-state events +//! (kinds 39000–39002) that channel creation and edits publish. + +use std::sync::Arc; + +use buzz_core::TenantContext; +use buzz_db::event::EventQuery; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use crate::handlers::ingest::{ingest_event, HttpAuthMethod, IngestAuth}; +use crate::state::AppState; +use buzz_auth::Scope; + +pub(crate) async fn state() -> Arc { + crate::state::tests::test_state_with_database_url(&crate::test_support::database_url()).await +} + +pub(crate) async fn community(state: &AppState, label: &str) -> TenantContext { + let host = format!("{label}-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + TenantContext::resolved(community, &host) +} + +pub(crate) fn http_auth(keys: &Keys) -> IngestAuth { + IngestAuth::Http { + pubkey: keys.public_key(), + scopes: vec![ + Scope::ChannelsWrite, + Scope::MessagesWrite, + Scope::AdminChannels, + Scope::ChannelsRead, + ], + auth_method: HttpAuthMethod::Nip98, + } +} + +/// Ingest a kind:9007 that `keys` signs, with `extra` tags after the ID and +/// name, and return the new channel's ID. +pub(crate) async fn create_channel( + state: &Arc, + tenant: &TenantContext, + keys: &Keys, + extra: &[&[&str]], +) -> Uuid { + let channel_id = Uuid::new_v4(); + let mut tags = vec![ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", &format!("group-state-{}", channel_id.simple())]).unwrap(), + ]; + for tag in extra { + tags.push(Tag::parse(tag.iter().copied()).unwrap()); + } + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 9007"); + ingest_event(state, tenant, event, http_auth(keys)) + .await + .unwrap_or_else(|error| panic!("create channel: {error:?}")); + channel_id +} + +/// The current relay-signed group-state event of `kind` for `channel_id`. +pub(crate) async fn group_state_tags( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, + kind: i32, +) -> Vec> { + let events = state + .db + .query_events(&EventQuery { + kinds: Some(vec![kind]), + d_tag: Some(channel_id.to_string()), + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await + .expect("query group state"); + let event = events + .first() + .unwrap_or_else(|| panic!("no kind:{kind} for {channel_id}")); + event + .event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect() +} + +/// kind:39000 carries the channel's creation time, and an edit keeps it. +/// Mutation: use the edit time instead of `channels.created_at` → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn metadata_carries_the_channel_creation_time() { + let state = state().await; + let tenant = community(&state, "group-state-created-at").await; + let creator = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + + let created_at = |tags: &[Vec]| -> Vec { + tags.iter() + .filter(|tag| tag[0] == "created_at") + .map(|tag| tag[1].clone()) + .collect() + }; + let stored = state + .db + .get_channel_for_event_write(tenant.community(), channel_id) + .await + .expect("channel") + .created_at + .timestamp(); + let tags = group_state_tags(&state, &tenant, channel_id, 39000).await; + assert_eq!(created_at(&tags), vec![stored.to_string()], "{tags:?}"); + + // Move the creation time far into the past, then edit. The new 39000 + // must still report the creation time, not the time of the edit. + sqlx::query("UPDATE channels SET created_at = to_timestamp(1577836800) WHERE id = $1") + .bind(channel_id) + .execute(state.db.pool()) + .await + .expect("backdate channel"); + let edit = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", &channel_id.to_string()]).unwrap(), + Tag::parse(["name", &format!("renamed-{}", channel_id.simple())]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, edit, http_auth(&creator)) + .await + .expect("edit channel"); + let tags = group_state_tags(&state, &tenant, channel_id, 39000).await; + assert!( + tags.iter() + .any(|tag| tag[0] == "name" && tag[1].starts_with("renamed-")), + "edit published: {tags:?}" + ); + assert_eq!( + created_at(&tags), + vec!["1577836800".to_string()], + "{tags:?}" + ); +} + +/// Every group-state kind carries the channel type and the creator, so +/// `#t` and `#P` filters match 39001 and 39002 as well as 39000. +/// Mutation: drop the identity tags from 39001 or 39002 → RED. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn group_state_events_carry_type_then_creator() { + let state = state().await; + let tenant = community(&state, "group-state-identity").await; + let creator = Keys::generate(); + let channel_id = create_channel( + &state, + &tenant, + &creator, + &[&["channel_type", "forum"], &["visibility", "private"]], + ) + .await; + + let creator_hex = creator.public_key().to_hex(); + for kind in [39000, 39001, 39002] { + let tags = group_state_tags(&state, &tenant, channel_id, kind).await; + let t_tags: Vec<&Vec> = tags.iter().filter(|tag| tag[0] == "t").collect(); + assert_eq!( + t_tags.first().map(|tag| tag[1].as_str()), + Some("forum"), + "kind:{kind} must lead with the channel type: {tags:?}" + ); + let creators: Vec<&str> = tags + .iter() + .filter(|tag| tag[0] == "P") + .map(|tag| tag[1].as_str()) + .collect(); + assert_eq!( + creators, + vec![creator_hex.as_str()], + "kind:{kind}: {tags:?}" + ); + } +} + +/// The creator tag names the kind:9007 signer, not the current owner. +/// Mutation: derive `P` from the owner role → RED after the transfer. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn creator_tag_survives_ownership_transfer() { + let state = state().await; + let tenant = community(&state, "group-state-creator").await; + let creator = Keys::generate(); + let successor = Keys::generate(); + let channel_id = create_channel(&state, &tenant, &creator, &[]).await; + + // Add the successor as owner, then the creator leaves. + let channel_hex = channel_id.to_string(); + let add = EventBuilder::new(Kind::Custom(9000), "") + .tags([ + Tag::parse(["h", &channel_hex]).unwrap(), + Tag::parse(["p", &successor.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "owner"]).unwrap(), + ]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, add, http_auth(&creator)) + .await + .expect("add successor"); + let leave = EventBuilder::new(Kind::Custom(9022), "") + .tags([Tag::parse(["h", &channel_hex]).unwrap()]) + .sign_with_keys(&creator) + .unwrap(); + ingest_event(&state, &tenant, leave, http_auth(&creator)) + .await + .expect("creator leaves"); + + let roster = group_state_tags(&state, &tenant, channel_id, 39002).await; + assert!( + !roster + .iter() + .any(|tag| tag[0] == "p" && tag[1] == creator.public_key().to_hex()), + "creator left the roster: {roster:?}" + ); + let creator_hex = creator.public_key().to_hex(); + for kind in [39001, 39002] { + let tags = group_state_tags(&state, &tenant, channel_id, kind).await; + assert!( + tags.iter() + .any(|tag| tag[0] == "P" && tag[1] == creator_hex), + "kind:{kind} keeps the creator: {tags:?}" + ); + } +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index e4eea46906..8d5175aa45 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -14,6 +14,8 @@ pub mod community_provisioning; pub mod count; /// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; +#[cfg(test)] +pub(crate) mod group_state_postgres_tests; /// NIP-IA identity archive request handler (kinds 9035–9036). pub mod identity_archive; /// imeta tag validation helpers. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 8cc7d3abc6..d821a46516 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1165,9 +1165,27 @@ async fn emit_addressable_discovery_event( Ok(()) } -fn group_members_tags(group_id: &str, members: &[MemberRecord]) -> anyhow::Result> { - let mut tags: Vec = Vec::with_capacity(members.len() + 1); +/// Tags that identify the channel on every relay-signed group-state event: +/// the channel type `t` first, then the creator `P`. +fn group_state_identity_tags( + identity: &buzz_db::channel::ChannelIdentity, +) -> anyhow::Result> { + Ok(buzz_core::channel::group_state_identity_tags( + &identity.channel_type, + &identity.created_by, + )?) +} + +fn group_members_tags( + group_id: &str, + identity: Option<&buzz_db::channel::ChannelIdentity>, + members: &[MemberRecord], +) -> anyhow::Result> { + let mut tags: Vec = Vec::with_capacity(members.len() + 3); tags.push(Tag::parse(["d", group_id])?); + if let Some(identity) = identity { + tags.extend(group_state_identity_tags(identity)?); + } for member in members { let pubkey_hex = hex::encode(&member.pubkey); // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url @@ -1182,7 +1200,11 @@ async fn store_group_members_event( member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, ) -> anyhow::Result> { let group_id = member_snapshot.channel_id().to_string(); - let tags = group_members_tags(&group_id, &member_snapshot.members)?; + let tags = group_members_tags( + &group_id, + member_snapshot.identity.as_ref(), + &member_snapshot.members, + )?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1282,8 +1304,14 @@ pub async fn emit_group_discovery_events( } // Buzz channels always require explicit membership tags.push(Tag::parse(["closed"])?); - // Channel type tag so clients can distinguish stream/forum/dm without inference - tags.push(Tag::parse(["t", &channel.channel_type])?); + // Channel type tag so clients can distinguish stream/forum/dm without + // inference, then the creator. The same tags lead 39001 and 39002. + tags.extend(group_state_identity_tags(&channel.identity())?); + // Creation time, so readers can pick the oldest of two channels. The + // event's own created_at changes on every edit. + tags.push(buzz_core::channel::channel_created_at_tag( + channel.created_at.timestamp(), + )?); // Optional topic / purpose for richer client UX if let Some(ref topic) = channel.topic { if !topic.is_empty() { @@ -1319,6 +1347,7 @@ pub async fn emit_group_discovery_events( { let mut tags: Vec = vec![Tag::parse(["d", &group_id])?]; + tags.extend(group_state_identity_tags(&channel.identity())?); for m in members .iter() .filter(|m| m.role == "owner" || m.role == "admin") @@ -3991,8 +4020,19 @@ mod tests { }) .collect(); - let tags = group_members_tags(&channel_id.to_string(), &members).expect("build tags"); - assert_eq!(tags.len(), 1_502, "d tag plus every member p tag"); + let identity = buzz_db::channel::ChannelIdentity { + channel_type: "forum".to_string(), + created_by: vec![0xab; 32], + }; + let tags = group_members_tags(&channel_id.to_string(), Some(&identity), &members) + .expect("build tags"); + assert_eq!( + tags.len(), + 1_504, + "d, type and creator tags plus every member p tag" + ); + assert_eq!(tags[1].as_slice(), ["t", "forum"]); + assert_eq!(tags[2].as_slice(), ["P".to_string(), "ab".repeat(32)]); let late_pubkey = hex::encode(&members[1_500].pubkey); assert!(tags.iter().any(|tag| { diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md new file mode 100644 index 0000000000..f533d0b4e3 --- /dev/null +++ b/docs/nips/NIP-CL.md @@ -0,0 +1,101 @@ +NIP-CL +====== + +Channel Identity and Discovery +------------------------------ + +`draft` `optional` `relay` + +This NIP adds identity facts to the relay-signed NIP-29 channel-state events +(kinds 39000–39002). With these facts, a client can find channels with +ordinary NIP-01 filters. It needs no new filter key and no relay-specific API. + +## Motivation + +Plugins and agents often create a channel for their own use and must find it +again later, for example "the config channel that this agent created". With +plain NIP-29, a client cannot: + +- tell who created a channel; +- read the channel type from any event except kind:39000; or +- tell which of two channels is older. The `created_at` of a kind:39000 event + is the time of the last edit, not the time of creation. + +## Channel identity tags + +On every channel-state event that the relay signs, the relay MUST put these +tags in this order, before any other `t` or `P` tag: + +1. `["t", ]`, for example `stream`, `forum`, `dm`, `workflow`. +2. `["P", ]`. + +kind:39000 also carries `["created_at", ]`. + +Example kind:39000 (signing fields and unrelated tags omitted): + +```json +{ + "kind": 39000, + "pubkey": "", + "created_at": 1760025600, + "tags": [ + ["d", "9b353519-f4fe-4757-aef4-bec6cc0ae54c"], + ["name", "agent-config"], + ["t", "stream"], + ["P", ""], + ["created_at", "1759939200"] + ] +} +``` + +| Tag | Kinds | Meaning | +| --- | --- | --- | +| first `t` | 39000–39002 | Channel type. | +| `P` | 39000–39002 | Public key that signed the kind:9007 that created the channel. | +| `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. | + +**Channel type.** Clients MUST read the channel type from the first `t` tag. +A relay can add other `t` tags after it. + +**Creator.** The relay writes `P` from its own record of the kind:9007 that +created the channel. No client can set it. It does not change when ownership +moves to another member or when the channel is edited. + +Uppercase `P` follows NIP-22, NIP-34 and NIP-72, where `P` names the author of +the root object. On these kinds, lowercase `p` already lists members, admins +and DM participants. A filter matches tag values, not their roles, so `#p` +would mix creators with members. + +**Creation time.** The `created_at` tag does not change when the channel is +edited. Use it, not the event's `created_at`, to compare the age of channels. + +**Older events.** A relay adds these tags only when it publishes a +channel-state event. An event that a relay version without this NIP published +can lack them, including one published during an upgrade. Such an event stays +until the channel next changes, for example on an edit or a membership +change. A client MUST treat a missing tag as unknown. A missing `P` does not +mean that a key did not create the channel. + +## Finding channels + +Use ordinary NIP-01 filters on the channel-state kinds. + +| To find | Filter | +| --- | --- | +| Channels that a key created | `{"kinds": [39000], "#P": [""]}` | +| Channels of one type | `{"kinds": [39000], "#t": [""]}` | + +A `#P` filter does not return a channel whose events lack `P` (see +**Older events**). So it can miss channels that a key created before the relay +supported this NIP. + +## Trust + +A client that relies on these tags MUST check that the relay's key signed the +event, as for all NIP-29 group-state events. + +## Relation to other NIPs + +- NIP-29: the group-state events that this NIP adds tags to. +- NIP-01: the filters that find channels. +- NIP-22, NIP-34 and NIP-72: the convention for uppercase `P`. From d25384a4f3c3a940b5b999f26105091c58ab76d6 Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 14 Oct 2026 10:58:12 -0400 Subject: [PATCH 016/146] buzz-db: admit every fenced-table writer in app code (#8219) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This makes every serving writer of a community-write-fenced table take community admission in application code. That's the "Dual enforcement" step of the Supported Writer Contract in `ARCHITECTURE.md`. Admission means the shared community deletion lock plus the active/not-deleted check from `deletion::guard_community_write`. It's taken before the transaction's first tenant write or row lock and held until commit. The 32 `community_write_fence_*` triggers, the six fence SQL functions and `validate_serving_catalog` are unchanged. They stay authoritative. Removing them is a separate migration that will be checked table by table. ## Changes - **New admission entry point.** `begin_community_write_transaction` (`runtime/mod.rs`) builds an `AdmittedTx` for non-event writers. It sits next to the existing event-write entry points and shares their implementation. - **Writers moved onto it:** - `api_tokens`, `archived_identities`, `channels`, `channel_members` (including DM and admin paths), `community_bans`, `git_repo_names` - `moderation_actions` and `moderation_reports`, `personal_read_accounts` and `personal_read_frontiers`, `pubkey_allowlist` - `push_leases`, `push_match_queue`, `push_wake_outbox` - `relay_invites`, `relay_members` (including the new-community owner bootstrap and the startup `pubkey_allowlist` backfill), `join_policy_acceptances` - `thread_metadata`, `users`, `workflows`, `workflow_runs`, `workflow_approvals`, `scheduled_workflow_fires` - Personal-read helpers now require `&mut AdmittedTx`. - **`buzz-audit`.** An append now passes admission first, then takes a transaction-scoped audit-chain lock (`pg_advisory_xact_lock`). This replaces the session lock and the `catch_unwind` unlock cleanup. The relay audit worker retries a `lock_timeout` on either lock and keeps the entry; an admission refusal is terminal, as the trigger's refusal was. - **Fleet-wide statements.** These keep `community_write_allowed(...)` in the same SQL statement, because there's no single community to admit up front: - scheduled-fire prune - invite expiry - push matcher claim and exhausted-job cleanup - startup `d_tag` backfill - channel TTL cleanup - **Relay-member removal.** `remove_relay_member` and `remove_relay_member_if_role` run `delete_member_with_acceptances` inside their admitted transaction. The helper no longer opens its own transaction, so the member lock, the `join_policy_acceptances` delete and the member delete all hold admission until commit. `delete_workflow` and `delete_workflow_for_owner` are gone on `main`, and the coordinate deletion path they used to back was already admitted. - **Unchanged exceptions.** The deletion-executor generation exception and the serving-lease path work as before. - **Tables with no production writer.** `delivery_log` and `subscriptions` are only written by a historical cutover script and a test fixture. Their triggers stay. - **No cross-community moves.** No production `community_id` move exists on a fenced table. The only `SET community_id = NULL` is the deletion-executor purge. So no dual-community lock ordering was needed. ## What got simpler - Per-store ad-hoc writer helpers are gone: `begin_event_write_transaction` and `acquire_event_write_connection` in `channel.rs`, and `begin_operation_transaction` in `push.rs`. - Audit append drops the session advisory unlock, the `catch_unwind`/`resume_unwind` panic handling, `log_inner` and the `futures-util` dependency. ## Notes for reviewers - **New crate dependency.** `buzz-audit` now depends on `buzz-db` only to call the free function `buzz_db::deletion::guard_community_write`. `ARCHITECTURE.md` names this as the one sanctioned subsystem edge, and its audit section is updated to match. - **Hot paths.** `ensure_user` first checks whether the user exists and returns without admission when it does (no write happens). Only a real insert takes admission. `touch_api_token` has no production caller today. - **No-op writers stay read-only.** The startup allowlist backfill returns early on plain reads when the legacy table is gone, it has no rows for the community, or the community already has members, so a quiescing deployment community still starts. It re-checks under admission before inserting. - **Owner mutations.** When admission refuses a transfer or a provisioning and a deletion request exists, both report `DeletionPending`, as before. For a transfer, the operator API keeps its coded `deletion_lifecycle_conflict` 409. For provisioning, it keeps the existing uncoded 409, `owner_conflict: community deletion is pending`. - **Per-community worker loops.** `claim_due_push_wakes` and similar now return `AccessDenied` for a community that goes quiescing between the active-host scan and the claim. The worker logs it at `debug` and the next scan skips the community. - **Kick side effects.** `membership_removal_fence` admits before the membership lock to keep the lock order of `add_member`. A community that leaves `active` between the kick and the fence skips live subscription eviction; the membership cache is already invalidated and fan-out re-checks membership. This is documented on the function. ## Testing - **New refusal tests.** 19 tests: the 18 in `store::admission_postgres_tests`, plus `audit_append_requires_application_admission_with_triggers_bypassed`. Each runs with ordinary triggers disabled (`session_replication_role = replica`), checks that a raw fenced insert would succeed, and asserts that the writer is refused for both `quiescing` and `fenced` communities. Active writes still succeed. - **The tests catch a missing check.** Measured in review at `6fc073c3f`: with the guard in `AdmittedTx::admit` bypassed, 25 tests fail. That includes 16 of the 18 database admission tests. The audit test stays green there, because audit calls `guard_community_write` directly. - **Affected PG suites, with triggers on:** 183/183 passed across 16 store suites, push 18/18 run isolated, audit 7/7. The existing artifact, reaction and replaceable admission tests pass. - **Review fixes, each checked to fail without its fix:** - `allowlist_backfill_requires_application_admission` and `owner_mutations_report_pending_deletion_when_admission_refuses` fail with the old `relay_members.rs`. - `empty_allowlist_backfill_skips_admission` fails (`AccessDenied … write-fenced (quiescing)`) without the allowlist-rows early return. - `audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once` holds the community deletion lock exclusive and fails without the admission retry arm. - The existing audit-chain retry test now looks for the transaction-scoped lock and passes. - The two owner-revoke mini-schema tests (`bridge.rs`, `ingest.rs`) now include `communities` and `community_deletion_lock_key`, which admission reads; both failed without that. - **Static checks:** `cargo fmt --check` and workspace `cargo clippy --all-targets -D warnings` pass. - **Not run locally:** the `mobile-check` step of `just ci` (the build machine couldn't reach pub.dev, and clean base fails the same way) and the full `just test` lane. CI on this PR covers both. `mesh_demo::demo_join_forwarded_arm_round_trips_echo` fails on the build machine at base `394d4a434` as well; it uses no database. Refs BUZZ-314. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- ARCHITECTURE.md | 4 +- Cargo.lock | 2 +- crates/buzz-audit/Cargo.toml | 2 +- crates/buzz-audit/src/error.rs | 7 + crates/buzz-audit/src/service.rs | 103 ++- crates/buzz-db/src/lib.rs | 5 +- crates/buzz-db/src/runtime/admitted_tx.rs | 8 +- crates/buzz-db/src/runtime/mod.rs | 24 +- .../src/store/admission_postgres_tests.rs | 796 ++++++++++++++++++ crates/buzz-db/src/store/allowlist.rs | 12 +- crates/buzz-db/src/store/api_token.rs | 48 +- .../buzz-db/src/store/archived_identities.rs | 14 +- crates/buzz-db/src/store/channel.rs | 131 +-- crates/buzz-db/src/store/channel_members.rs | 58 +- crates/buzz-db/src/store/community.rs | 6 + crates/buzz-db/src/store/deletion.rs | 49 +- crates/buzz-db/src/store/dm.rs | 33 +- crates/buzz-db/src/store/git_repo.rs | 16 +- crates/buzz-db/src/store/mod.rs | 3 + crates/buzz-db/src/store/moderation.rs | 85 +- .../src/store/personal_read/postgres_tests.rs | 8 +- .../buzz-db/src/store/personal_read/writes.rs | 34 +- crates/buzz-db/src/store/push.rs | 156 ++-- .../buzz-db/src/store/relay_admin_actions.rs | 138 ++- crates/buzz-db/src/store/relay_invite.rs | 25 +- crates/buzz-db/src/store/relay_members.rs | 199 +++-- crates/buzz-db/src/store/thread.rs | 34 +- crates/buzz-db/src/store/user.rs | 45 +- crates/buzz-db/src/store/workflow.rs | 119 ++- crates/buzz-db/tests/observability_source.rs | 21 +- crates/buzz-relay/src/api/bridge.rs | 20 +- crates/buzz-relay/src/handlers/ingest.rs | 15 +- crates/buzz-relay/src/push_runtime.rs | 7 +- crates/buzz-relay/src/state.rs | 86 +- 34 files changed, 1865 insertions(+), 448 deletions(-) create mode 100644 crates/buzz-db/src/store/admission_postgres_tests.rs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bab06526ed..e0875618dd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -145,7 +145,7 @@ buzz-admin (operator CLI: relay membership + key generation) buzz-test-client (integration test harness + manual CLI) ``` -**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. +**Key architectural principle:** The relay is the single source of truth. `buzz-relay` orchestrates all subsystems by calling them directly — it imports `buzz-db`, `buzz-auth`, `buzz-pubsub`, `buzz-search`, `buzz-audit`, and `buzz-workflow`. However, those subsystems are isolated from each other: `buzz-workflow` never calls `buzz-pubsub`, `buzz-search` never calls `buzz-db`, etc. The one sanctioned exception is `buzz-audit`, which calls `buzz_db::deletion::guard_community_write` to admit each audit append into its community before taking the chain lock. Cross-subsystem coordination happens only through the relay. In multi-community mode, the relay also owns propagation of `TenantContext`; service crates should receive community-scoped inputs rather than independently deriving tenancy from client-controlled event tags. --- @@ -596,7 +596,7 @@ Tamper-evident append-only log with SHA-256 hash chaining. **Hash covers:** seq (big-endian bytes), timestamp (RFC3339), event_id, event_kind (big-endian), actor_pubkey, action string, channel_id (16 bytes or 16 zero bytes if None), canonical metadata JSON (BTreeMap for deterministic key ordering), prev_hash. -**Single-writer guarantee:** `pg_advisory_lock` before each transaction. Lock released in all branches including panic (`catch_unwind`). +**Single-writer guarantee:** each append runs in one transaction that first passes community write admission (the shared community deletion lock plus the active/undeleted check, via `buzz-db`'s `DeletionStore::guard_transaction`), then takes a per-community `pg_advisory_xact_lock`. Both locks release on commit or rollback, including a dropped future. **10 audit actions:** `EventCreated`, `EventDeleted`, `ChannelCreated`, `ChannelUpdated`, `ChannelDeleted`, `MemberAdded`, `MemberRemoved`, `AuthSuccess`, `AuthFailure`, `RateLimitExceeded`. diff --git a/Cargo.lock b/Cargo.lock index 0a924f4e36..27a35a4ae5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,8 +1058,8 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-datastore-tracing", + "buzz-db", "chrono", - "futures-util", "hex", "metrics", "serde", diff --git a/crates/buzz-audit/Cargo.toml b/crates/buzz-audit/Cargo.toml index 766ade6505..a1af6aac0c 100644 --- a/crates/buzz-audit/Cargo.toml +++ b/crates/buzz-audit/Cargo.toml @@ -9,6 +9,7 @@ description = "Hash-chain audit log for Buzz" [dependencies] buzz-core = { workspace = true } +buzz-db = { workspace = true } buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } tokio = { workspace = true } @@ -21,4 +22,3 @@ metrics = { workspace = true } thiserror = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } -futures-util = { workspace = true } diff --git a/crates/buzz-audit/src/error.rs b/crates/buzz-audit/src/error.rs index 6f28302b4b..9d63e15725 100644 --- a/crates/buzz-audit/src/error.rs +++ b/crates/buzz-audit/src/error.rs @@ -14,6 +14,10 @@ pub enum AuditError { #[error("database error: {0}")] Database(#[from] sqlx::Error), + /// Community write admission failed before the audit chain was locked. + #[error("audit write admission failed")] + Admission(#[from] buzz_db::DbError), + /// The `prev_hash` of an entry does not match the hash of the preceding /// entry in the same community's chain. #[error( @@ -77,6 +81,9 @@ mod tests { AuditError::HashMismatch { seq: 42 }, AuditError::UnknownAction, AuditError::UnsupportedHashVersion { version: 99 }, + AuditError::Admission(buzz_db::DbError::AccessDenied(format!( + "community {community} is write-fenced" + ))), ]; for err in &domain_errors { diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 518ae33cec..3d2cb8d364 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -1,6 +1,5 @@ use chrono::{DateTime, Utc}; -use futures_util::FutureExt as _; -use sqlx::{Acquire, PgPool, Row}; +use sqlx::{PgPool, Row}; use tracing::{debug, warn}; use uuid::Uuid; @@ -17,16 +16,14 @@ use crate::{ /// The `created_at` stamped on a new entry. /// /// Reduced to the precision Postgres round-trips before it is hashed — see -/// [`to_storage_precision`]. Split out from [`AuditService::log_inner`] so the +/// [`to_storage_precision`]. Split out from [`AuditService::log`] so the /// invariant is testable without a database. fn log_timestamp() -> DateTime { to_storage_precision(Utc::now()) } -/// Per-community advisory lock key. Derived in Postgres from the community UUID -/// so two communities never serialize each other's audit writes (which would be -/// both a throughput bottleneck and a cross-tenant timing oracle). The lock is -/// taken with `pg_advisory_lock(hashtextextended(...))` — see [`AuditService::log`]. +/// Per-community audit-chain advisory lock key. Admission takes the community +/// deletion lock first; this key then serializes appends within that community. const AUDIT_LOCK_NAMESPACE: &str = "buzz_audit:"; /// Append-only, per-community hash-chain audit log backed by Postgres. @@ -47,50 +44,22 @@ impl AuditService { /// Append a new entry to the calling community's chain. /// - /// Serialized per-community via `pg_advisory_lock`. Postgres advisory locks - /// are session-scoped, so we acquire before the transaction and release - /// after commit (or on any error path). + /// Community admission precedes the transaction-scoped audit-chain lock. + /// Both locks are released on commit or rollback, including a dropped future. #[datastore_span( name = "audit_log", system = "postgresql", fields(action = %entry.action) )] pub async fn log(&self, entry: NewAuditEntry) -> Result { - let mut conn = self.pool.acquire().await?; - - // Per-community advisory lock: hash the namespaced community id to an - // i64 lock key inside Postgres. Communities lock independently. + let mut tx = self.pool.begin().await?; + buzz_db::deletion::guard_community_write(&mut tx, entry.community_id).await?; let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{}", entry.community_id); - sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(&lock_key) - .execute(&mut *conn) + .execute(&mut *tx) .await?; - // Run the chain append and release the lock regardless of outcome. - // catch_unwind so a panic still releases the lock before the connection - // returns to the pool. - let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry)) - .catch_unwind() - .await; - - let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") - .bind(&lock_key) - .execute(&mut *conn) - .await; - - match result { - Ok(inner_result) => inner_result, - Err(panic_payload) => std::panic::resume_unwind(panic_payload), - } - } - - async fn log_inner( - &self, - conn: &mut sqlx::pool::PoolConnection, - entry: NewAuditEntry, - ) -> Result { - let mut tx = conn.begin().await?; - // The stored row keys on the raw UUID; the typed `CommunityId` on the // input is the provenance fence, dereferenced here at the DB boundary. let community_id = *entry.community_id.as_uuid(); @@ -321,6 +290,58 @@ mod postgres_tests { id } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_append_requires_application_admission_with_triggers_bypassed() { + let _g = db_lock().lock().await; + let url = std::env::var("DATABASE_URL").expect("Postgres lane must provide DATABASE_URL"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("connect test database"); + let active = make_community(&pool).await; + for state in ["quiescing", "fenced"] { + let id = make_community(&pool).await; + let mut tx = pool.begin().await.expect("begin lifecycle transition"); + sqlx::query( + "SELECT set_config('buzz.deletion_executor_community', $1, true), \ + set_config('buzz.deletion_fence_generation', \ + (SELECT deletion_fence_generation::text FROM communities WHERE id = $2), true)", + ) + .bind(id.to_string()) + .bind(id) + .execute(&mut *tx) + .await + .expect("authorize lifecycle transition"); + sqlx::query("UPDATE communities SET deletion_state = $2 WHERE id = $1") + .bind(id) + .bind(state) + .execute(&mut *tx) + .await + .expect("set lifecycle state"); + tx.commit().await.expect("commit lifecycle state"); + + sqlx::query("SET session_replication_role = replica") + .execute(&pool) + .await + .expect("disable ordinary triggers"); + let error = AuditService::new(pool.clone()) + .log(new_entry(id, AuditAction::EventCreated)) + .await + .expect_err("fenced audit append must reject before chain lock"); + assert!( + matches!(error, AuditError::Admission(buzz_db::DbError::AccessDenied(message)) if message.contains("write-fenced")), + "expected application admission failure" + ); + } + let entry = AuditService::new(pool.clone()) + .log(new_entry(active, AuditAction::EventCreated)) + .await + .expect("active audit append succeeds"); + assert_eq!(entry.seq, 1); + } + fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry { NewAuditEntry { community_id: CommunityId::from_uuid(community_id), diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index f8adfefab7..2f306c35d0 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -60,8 +60,9 @@ pub const DB_CONNECTION_TERMINALS: [(DbPoolRole, DbConnectionStep, DbConnectionO pub const DB_CONNECTION_RAW_SERIES_PER_POD: usize = runtime::CONNECTION_RAW_SERIES_PER_POD; pub(crate) use runtime::{ begin_community_event_write_transaction, - begin_community_event_write_transaction_with_legacy_metrics, insert_mentions_in_transaction, - observability, route_proof, ReadSessionInner, RouteDecision, RoutePredicate, + begin_community_event_write_transaction_with_legacy_metrics, begin_community_write_transaction, + insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, + RoutePredicate, }; pub use store::{ admin_moderation, allowlist, api_token, archived_identities, artifact, channel, diff --git a/crates/buzz-db/src/runtime/admitted_tx.rs b/crates/buzz-db/src/runtime/admitted_tx.rs index fb0bb4f41f..eb4607be25 100644 --- a/crates/buzz-db/src/runtime/admitted_tx.rs +++ b/crates/buzz-db/src/runtime/admitted_tx.rs @@ -1,4 +1,4 @@ -//! Community-admitted event-write transactions. +//! Community-admitted write transactions. use std::collections::BTreeSet; @@ -9,12 +9,12 @@ use uuid::Uuid; use crate::deletion::{DeletionStore, ServingWriteLease}; use crate::Result; -/// An event-write transaction that has passed community admission. +/// A transaction that has passed community write admission. /// /// Its only constructors take the shared community admission lock (or /// validate a serving write lease) on the transaction they wrap, for the /// community they record, so construction and admission are one step. -/// Event-write helpers take `&mut AdmittedTx` and read the community from it, +/// Write helpers take `&mut AdmittedTx` and read the community from it, /// so the compiler rejects a raw [`sqlx::Transaction`] or a transaction /// admitted for a different community. /// @@ -154,7 +154,7 @@ impl AdmittedTx { } /// Commit the transaction. This is the only commit path for admitted - /// event writes. + /// writes. /// /// Refreshes the TTL of every channel that received an event first, as /// the last statement before COMMIT. diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 7be9402da4..266003f5a0 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -132,9 +132,9 @@ pub(crate) async fn insert_mentions_in_transaction( Ok(()) } -/// Start a tenant-local event-write transaction and take the shared community +/// Start a tenant-local write transaction and take the shared community /// deletion lock before any serving mutation. -async fn begin_community_event_write_transaction_with_metric_population( +async fn begin_community_write_transaction_with_metric_population( pool: &PgPool, community: CommunityId, operation: observability::WriterOperation, @@ -163,7 +163,7 @@ pub(crate) async fn begin_community_event_write_transaction( community: CommunityId, operation: observability::WriterOperation, ) -> Result { - begin_community_event_write_transaction_with_metric_population( + begin_community_write_transaction_with_metric_population( pool, community, operation, @@ -177,7 +177,7 @@ pub(crate) async fn begin_community_event_write_transaction_with_legacy_metrics( community: CommunityId, operation: observability::WriterOperation, ) -> Result { - begin_community_event_write_transaction_with_metric_population( + begin_community_write_transaction_with_metric_population( pool, community, operation, @@ -186,6 +186,22 @@ pub(crate) async fn begin_community_event_write_transaction_with_legacy_metrics( .await } +/// Begin a community write before any tenant mutation or row lock. The +/// admission lock is held by the returned transaction until commit or rollback. +pub(crate) async fn begin_community_write_transaction( + pool: &PgPool, + community: CommunityId, + operation: observability::WriterOperation, +) -> Result { + begin_community_write_transaction_with_metric_population( + pool, + community, + operation, + CommunityEventWriteMetricPopulation::TypedOnly, + ) + .await +} + /// Database handle. Clone is cheap (Arc-backed pool). #[derive(Clone, Debug)] pub struct Db { diff --git a/crates/buzz-db/src/store/admission_postgres_tests.rs b/crates/buzz-db/src/store/admission_postgres_tests.rs new file mode 100644 index 0000000000..6f1d8cab68 --- /dev/null +++ b/crates/buzz-db/src/store/admission_postgres_tests.rs @@ -0,0 +1,796 @@ +//! Live writer-admission regressions. A one-connection pool runs with ordinary +//! PostgreSQL triggers disabled, so a missing application check is observable. + +use crate::{Db, DbConfig, DbError}; +use buzz_core::CommunityId; +use uuid::Uuid; + +async fn fixture() -> (Db, [CommunityId; 3]) { + let config = DbConfig { + database_url: crate::test_support::database_url(), + max_connections: 1, + min_connections: 1, + ..DbConfig::default() + }; + let db = Db::new(&config).await.expect("connect test database"); + let pool = &db.pool; + let mut communities = [CommunityId::from_uuid(Uuid::nil()); 3]; + for community in &mut communities { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("admission-{id}.example")) + .execute(pool) + .await + .expect("create community"); + *community = CommunityId::from_uuid(id); + } + crate::test_support::set_deletion_state(pool, *communities[1].as_uuid(), "quiescing").await; + crate::test_support::set_deletion_state(pool, *communities[2].as_uuid(), "fenced").await; + + // One physical session serves every test API call. PostgreSQL's replica + // role skips the 32 community-write triggers as well as other ordinary + // triggers; the application precheck remains fully active. + sqlx::query("SET session_replication_role = replica") + .execute(pool) + .await + .expect("disable ordinary triggers on the test session"); + let role: String = sqlx::query_scalar("SHOW session_replication_role") + .fetch_one(pool) + .await + .expect("read replication role"); + assert_eq!(role, "replica"); + // Witness that the database backstop is truly bypassed: this raw fenced + // insert would be rejected with the community-write trigger enabled. + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2)") + .bind(communities[2].as_uuid()) + .bind(vec![0xfe_u8; 32]) + .execute(pool) + .await + .expect("raw fenced insert succeeds with triggers disabled"); + (db, communities) +} + +fn assert_fenced(error: DbError) { + assert!( + matches!(&error, DbError::AccessDenied(message) if message.contains("write-fenced")), + "expected application admission rejection, got {error:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn api_token_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::api_token::create_api_token( + &db.pool, + *community.as_uuid(), + &[1; 32], + &[2; 32], + "test", + &[], + None, + None, + ) + .await + .expect_err("token create must reject"), + ); + assert_fenced( + super::api_token::revoke_all_tokens(&db.pool, *community.as_uuid(), &[2; 32], &[2; 32]) + .await + .expect_err("token revoke must reject"), + ); + } + super::api_token::create_api_token( + &db.pool, + *communities[0].as_uuid(), + &[3; 32], + &[2; 32], + "active", + &[], + None, + None, + ) + .await + .expect("active token create succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn archived_identity_writers_require_application_admission() { + let (db, communities) = fixture().await; + let pubkey = "a".repeat(64); + let actor = "b".repeat(64); + let event = "c".repeat(64); + for community in &communities[1..] { + assert_fenced( + super::archived_identities::archive( + &db.pool, *community, &pubkey, "self", &actor, None, None, &event, + ) + .await + .expect_err("archive must reject"), + ); + assert_fenced( + super::archived_identities::unarchive(&db.pool, *community, &pubkey) + .await + .expect_err("unarchive must reject"), + ); + } + assert!(super::archived_identities::archive( + &db.pool, + communities[0], + &pubkey, + "self", + &actor, + None, + None, + &event, + ) + .await + .expect("active archive succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn allowlist_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + db.add_to_allowlist(*community, &[1; 32], &[2; 32], None) + .await + .expect_err("allowlist add must reject"), + ); + assert_fenced( + db.remove_from_allowlist(*community, &[1; 32]) + .await + .expect_err("allowlist remove must reject"), + ); + } + assert!(db + .add_to_allowlist(communities[0], &[1; 32], &[2; 32], None) + .await + .expect("active allowlist add succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn git_name_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::git_repo::reserve_repo_name(&db.pool, *community, "repo", "owner") + .await + .expect_err("repo reserve must reject"), + ); + assert_fenced( + super::git_repo::release_repo_name(&db.pool, *community, "repo", "owner") + .await + .expect_err("repo release must reject"), + ); + } + assert_eq!( + super::git_repo::reserve_repo_name(&db.pool, communities[0], "repo", "owner") + .await + .expect("active repo reserve succeeds"), + super::git_repo::ReserveOutcome::Reserved + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn relay_member_writers_require_application_admission() { + let (db, communities) = fixture().await; + let pubkey = "d".repeat(64); + for community in &communities[1..] { + assert_fenced( + super::relay_members::add_relay_member(&db.pool, *community, &pubkey, "member", None) + .await + .expect_err("member add must reject"), + ); + assert_fenced( + super::relay_members::remove_relay_member(&db.pool, *community, &pubkey) + .await + .expect_err("member remove must reject"), + ); + } + assert!(super::relay_members::add_relay_member( + &db.pool, + communities[0], + &pubkey, + "member", + None, + ) + .await + .expect("active member add succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn relay_invite_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::relay_invite::mint_relay_invite( + &db.pool, + *community, + &"e".repeat(64), + 3600, + Some(1), + ) + .await + .expect_err("invite mint must reject"), + ); + assert_fenced( + super::relay_invite::claim_relay_invite( + &db.pool, + *community, + &[3; 32], + &"f".repeat(64), + None, + ) + .await + .expect_err("invite claim must reject before row lock"), + ); + } + super::relay_invite::mint_relay_invite( + &db.pool, + communities[0], + &"e".repeat(64), + 3600, + Some(1), + ) + .await + .expect("active invite mint succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn user_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::user::ensure_user(&db.pool, *community, &[1; 32]) + .await + .expect_err("user ensure must reject"), + ); + assert_fenced( + super::user::update_user_profile( + &db.pool, + *community, + &[1; 32], + Some("name"), + None, + None, + None, + ) + .await + .expect_err("profile update must reject"), + ); + } + assert!(super::user::ensure_user(&db.pool, communities[0], &[1; 32]) + .await + .expect("active user ensure succeeds")); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn moderation_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::moderation::insert_report( + &db.pool, + *community, + super::moderation::NewReport { + report_event_id: &[3; 32], + reporter_pubkey: &[1; 32], + target: super::moderation::ReportTarget::Pubkey(vec![2; 32]), + channel_id: None, + report_type: "spam", + note: None, + }, + ) + .await + .expect_err("moderation report must reject"), + ); + assert_fenced( + super::moderation::insert_action( + &db.pool, + *community, + super::moderation::NewAction { + actor_pubkey: &[1; 32], + action: "ban", + target_pubkey: Some(&[2; 32]), + target_event_id: None, + channel_id: None, + reason_code: None, + public_reason: None, + private_reason: None, + matched_principal: None, + actor_authority: None, + }, + ) + .await + .expect_err("moderation action must reject"), + ); + assert_fenced( + super::moderation::ban_member(&db.pool, *community, &[1; 32], &[2; 32], None, None) + .await + .expect_err("ban must reject"), + ); + assert_fenced( + super::moderation::unban_member(&db.pool, *community, &[1; 32], &[2; 32]) + .await + .expect_err("unban must reject"), + ); + } + super::moderation::ban_member(&db.pool, communities[0], &[1; 32], &[2; 32], None, None) + .await + .expect("active ban succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn workflow_writers_require_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::workflow::create_workflow( + &db.pool, *community, None, &[1; 32], "workflow", "{}", &[2; 32], + ) + .await + .expect_err("workflow create must reject"), + ); + assert_fenced( + super::workflow::create_workflow_run(&db.pool, *community, Uuid::new_v4(), None, None) + .await + .expect_err("workflow run create must reject"), + ); + assert_fenced( + super::workflow::claim_scheduled_workflow_fire( + &db.pool, + *community, + Uuid::new_v4(), + chrono::Utc::now(), + ) + .await + .expect_err("schedule claim must reject"), + ); + assert_fenced( + super::workflow::attach_scheduled_workflow_run( + &db.pool, + *community, + Uuid::new_v4(), + chrono::Utc::now(), + Uuid::new_v4(), + ) + .await + .expect_err("schedule attach must reject"), + ); + assert_fenced( + super::workflow::create_approval( + &db.pool, + super::workflow::CreateApprovalParams { + community_id: *community, + token: "fenced-approval", + workflow_id: Uuid::new_v4(), + run_id: Uuid::new_v4(), + step_id: "step", + step_index: 0, + approver_spec: "owner", + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + }, + ) + .await + .expect_err("approval create must reject"), + ); + assert_fenced( + super::workflow::update_approval_by_stored_hash( + &db.pool, + *community, + &[4; 32], + super::workflow::ApprovalStatus::Granted, + Some(&[1; 32]), + None, + ) + .await + .expect_err("approval update must reject"), + ); + } + super::workflow::create_workflow( + &db.pool, + communities[0], + None, + &[1; 32], + "active", + "{}", + &[2; 32], + ) + .await + .expect("active workflow create succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn push_writers_require_application_admission() { + let (db, communities) = fixture().await; + let version = super::push::LeaseVersion { + source_event_id: &[3; 32], + source_created_at: chrono::Utc::now().timestamp(), + generation: 1, + expires_at: chrono::Utc::now().timestamp() + 3600, + }; + for community in &communities[1..] { + assert_fenced( + super::push::revoke_lease(&db.pool, *community, &[1; 32], "install", version) + .await + .expect_err("lease revoke must reject"), + ); + assert_fenced( + super::push::complete_match_batch(&db.pool, *community, Uuid::new_v4(), &[vec![2; 32]]) + .await + .expect_err("matcher complete must reject"), + ); + assert_fenced( + super::push::complete_wake(&db.pool, *community, Uuid::new_v4(), Uuid::new_v4()) + .await + .expect_err("wake complete must reject"), + ); + } + super::push::revoke_lease(&db.pool, communities[0], &[1; 32], "install", version) + .await + .expect("active lease revoke succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn channel_and_membership_writers_require_application_admission() { + let (db, communities) = fixture().await; + let creator = [1_u8; 32]; + for community in &communities[1..] { + assert_fenced( + super::channel::create_channel( + &db.pool, + *community, + "fenced", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &creator, + None, + ) + .await + .expect_err("channel create must reject"), + ); + assert_fenced( + super::channel::set_canvas(&db.pool, *community, Uuid::new_v4(), Some("text")) + .await + .expect_err("canvas update must reject"), + ); + assert_fenced( + super::channel_members::add_member( + &db.pool, + *community, + Uuid::new_v4(), + &[2; 32], + super::channel::MemberRole::Member, + None, + ) + .await + .expect_err("membership add must reject"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "active", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &creator, + None, + ) + .await + .expect("active channel create succeeds"); + super::channel_members::add_member( + &db.pool, + communities[0], + channel.id, + &[2; 32], + super::channel::MemberRole::Member, + None, + ) + .await + .expect("active membership add succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn dm_writers_require_application_admission() { + let (db, communities) = fixture().await; + let participants: [&[u8]; 2] = [&[1; 32], &[2; 32]]; + for community in &communities[1..] { + assert_fenced( + super::dm::create_dm(&db.pool, *community, &participants, participants[0]) + .await + .expect_err("DM create must reject"), + ); + assert_fenced( + super::dm::hide_dm(&db.pool, *community, Uuid::new_v4(), participants[0]) + .await + .expect_err("DM hide must reject"), + ); + } + super::dm::create_dm(&db.pool, communities[0], &participants, participants[0]) + .await + .expect("active DM create succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn thread_metadata_writer_requires_application_admission() { + let (db, communities) = fixture().await; + let timestamp = chrono::Utc::now(); + for community in &communities[1..] { + assert_fenced( + super::thread::insert_thread_metadata( + &db.pool, + *community, + &[1; 32], + timestamp, + Uuid::new_v4(), + None, + None, + None, + None, + 0, + false, + ) + .await + .expect_err("thread metadata must reject"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "thread", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &[1; 32], + None, + ) + .await + .expect("active channel"); + super::thread::insert_thread_metadata( + &db.pool, + communities[0], + &[1; 32], + timestamp, + channel.id, + None, + None, + None, + None, + 0, + false, + ) + .await + .expect("active thread metadata insert succeeds"); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn relay_admin_domain_writer_requires_application_admission() { + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_fenced( + super::relay_admin_actions::deploy_kick_member( + &db.pool, + *community, + Uuid::new_v4(), + &[1; 32], + &[2; 32], + ) + .await + .expect_err("deployment kick must reject"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "kick", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &[1; 32], + None, + ) + .await + .expect("active channel"); + assert!(matches!( + super::relay_admin_actions::deploy_kick_member( + &db.pool, + communities[0], + channel.id, + &[1; 32], + &[2; 32], + ) + .await + .expect("active kick succeeds"), + super::relay_admin_actions::KickResult::Removed + )); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn personal_read_writer_requires_application_admission() { + use nostr::{EventBuilder, Keys, Kind}; + let (db, communities) = fixture().await; + let actor = Keys::generate(); + let target = super::personal_read::ReadTarget { + channel_id: Uuid::new_v4(), + root_id: None, + }; + let intent = super::personal_read::ReadIntent::MarkThrough { + target, + message_id: "a".repeat(64), + }; + for community in &communities[1..] { + assert_fenced( + db.apply_personal_read_intent(*community, &actor.public_key(), &intent) + .await + .expect_err("personal read must reject before account lock"), + ); + } + let channel = super::channel::create_channel( + &db.pool, + communities[0], + "reads", + super::channel::ChannelType::Stream, + super::channel::ChannelVisibility::Open, + None, + &actor.public_key().to_bytes(), + None, + ) + .await + .expect("active channel"); + let event = EventBuilder::new(Kind::Custom(9), "read") + .sign_with_keys(&Keys::generate()) + .expect("sign event"); + db.insert_event(communities[0], &event, Some(channel.id)) + .await + .expect("insert active event"); + let intent = super::personal_read::ReadIntent::MarkThrough { + target: super::personal_read::ReadTarget { + channel_id: channel.id, + root_id: None, + }, + message_id: event.id.to_hex(), + }; + assert_eq!( + db.apply_personal_read_intent(communities[0], &actor.public_key(), &intent) + .await + .expect("active personal read succeeds"), + super::personal_read::IntentOutcome::Applied + ); +} + +async fn count_relay_members(db: &Db, community: CommunityId) -> i64 { + sqlx::query_scalar("SELECT count(*) FROM relay_members WHERE community_id = $1") + .bind(community.as_uuid()) + .fetch_one(&db.pool) + .await + .expect("count relay members") +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn allowlist_backfill_requires_application_admission() { + let (db, communities) = fixture().await; + for community in &communities { + sqlx::query("INSERT INTO pubkey_allowlist (community_id, pubkey) VALUES ($1, $2)") + .bind(community.as_uuid()) + .bind(vec![0x5a_u8; 32]) + .execute(&db.pool) + .await + .expect("seed legacy allowlist row"); + } + for community in &communities[1..] { + assert_fenced( + super::relay_members::backfill_from_allowlist(&db.pool, *community) + .await + .expect_err("backfill into a write-fenced community must reject"), + ); + assert_eq!(count_relay_members(&db, *community).await, 0); + } + assert_eq!( + super::relay_members::backfill_from_allowlist(&db.pool, communities[0]) + .await + .expect("active backfill succeeds"), + 1 + ); + assert_eq!(count_relay_members(&db, communities[0]).await, 1); + + // A community that already has members needs no write, so startup does + // not fail on admission even while the community is quiescing. + sqlx::query("INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'member')") + .bind(communities[1].as_uuid()) + .bind("e".repeat(64)) + .execute(&db.pool) + .await + .expect("seed existing member"); + assert_eq!( + super::relay_members::backfill_from_allowlist(&db.pool, communities[1]) + .await + .expect("no-op backfill does not need admission"), + 0 + ); + assert_eq!(count_relay_members(&db, communities[1]).await, 1); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn empty_allowlist_backfill_skips_admission() { + // No members and no legacy allowlist rows: there is nothing to write, so + // startup must not fail on admission while the community is fenced. + let (db, communities) = fixture().await; + for community in &communities[1..] { + assert_eq!( + super::relay_members::backfill_from_allowlist(&db.pool, *community) + .await + .expect("empty-allowlist backfill does not need admission"), + 0 + ); + assert_eq!(count_relay_members(&db, *community).await, 0); + } +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn owner_mutations_report_pending_deletion_when_admission_refuses() { + let (db, communities) = fixture().await; + let quiescing = communities[1]; + sqlx::query( + "INSERT INTO community_deletion_requests (community_id, community_host, requested_by) \ + SELECT id, host, 'admission-test' FROM communities WHERE id = $1", + ) + .bind(quiescing.as_uuid()) + .execute(&db.pool) + .await + .expect("seed deletion request"); + let owner = "f".repeat(64); + let next_owner = "1".repeat(64); + + assert_eq!( + super::relay_members::transfer_ownership(&db.pool, quiescing, &next_owner, &owner) + .await + .expect("transfer returns a result"), + super::relay_members::TransferResult::DeletionPending + ); + assert_eq!( + db.provision_owner(quiescing, &owner) + .await + .expect("provision returns a result"), + super::relay_members::ProvisionOwnerResult::DeletionPending + ); + + // Without a deletion request the refusal stays a lifecycle conflict. + let fenced = communities[2]; + assert_eq!( + super::relay_members::transfer_ownership(&db.pool, fenced, &next_owner, &owner) + .await + .expect("transfer returns a result"), + super::relay_members::TransferResult::LifecycleConflict + ); + assert_eq!( + db.provision_owner(fenced, &owner) + .await + .expect("provision returns a result"), + super::relay_members::ProvisionOwnerResult::LifecycleConflict + ); + assert_eq!(count_relay_members(&db, quiescing).await, 0); + assert_eq!(count_relay_members(&db, fenced).await, 0); +} diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs index 2b72780a62..c493cab22f 100644 --- a/crates/buzz-db/src/store/allowlist.rs +++ b/crates/buzz-db/src/store/allowlist.rs @@ -70,8 +70,9 @@ impl Db { added_by: &[u8], note: Option<&str>, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( &self.pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -83,8 +84,9 @@ impl Db { .bind(pubkey) .bind(added_by) .bind(note) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -95,8 +97,9 @@ impl Db { community: CommunityId, pubkey: &[u8], ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( &self.pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -104,8 +107,9 @@ impl Db { sqlx::query("DELETE FROM pubkey_allowlist WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/api_token.rs b/crates/buzz-db/src/store/api_token.rs index 41d4dcbad2..6c0eab0bf2 100644 --- a/crates/buzz-db/src/store/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -38,6 +38,12 @@ pub async fn create_api_token( }) .transpose()?; + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; sqlx::query( r#" INSERT INTO api_tokens @@ -53,9 +59,9 @@ pub async fn create_api_token( .bind(&scopes_json) .bind(&channel_ids_json) .bind(expires_at) - .execute(pool) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(id) } @@ -94,6 +100,12 @@ pub async fn create_api_token_if_under_limit( // Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10 // **for this (community, owner) pair**. The subquery and insert execute atomically -- // no separate count + insert race. + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; let result = sqlx::query( r#" INSERT INTO api_tokens @@ -118,8 +130,9 @@ pub async fn create_api_token_if_under_limit( .bind(&channel_ids_json) .bind(expires_at) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; if result.rows_affected() == 0 { // Limit exceeded -- the WHERE clause prevented the INSERT. @@ -279,6 +292,12 @@ pub async fn revoke_token( owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; let result = sqlx::query( r#" UPDATE api_tokens @@ -293,9 +312,9 @@ pub async fn revoke_token( .bind(community_id) .bind(id) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -309,6 +328,12 @@ pub async fn revoke_all_tokens( owner_pubkey: &[u8], revoked_by: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + CommunityId::from_uuid(community_id), + crate::observability::WriterOperation::Authentication, + ) + .await?; let result = sqlx::query( r#" UPDATE api_tokens @@ -321,9 +346,9 @@ pub async fn revoke_all_tokens( .bind(revoked_by) .bind(community_id) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected()) } @@ -504,13 +529,20 @@ impl Db { /// Record a token usage (update `last_used_at`), scoped to community. #[datastore_span(name = "touch_api_token", system = "postgresql")] pub async fn touch_api_token(&self, community_id: CommunityId, hash: &[u8]) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + &self.pool, + community_id, + crate::observability::WriterOperation::Authentication, + ) + .await?; sqlx::query( "UPDATE api_tokens SET last_used_at = NOW() WHERE community_id = $1 AND token_hash = $2", ) .bind(community_id.as_uuid()) .bind(hash) - .execute(&self.pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index b1636de31a..986985bc46 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -64,8 +64,9 @@ pub async fn archive( replaced_by: Option<&str>, request_event_id: &str, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; @@ -82,9 +83,9 @@ pub async fn archive( .bind(reason) .bind(replaced_by) .bind(request_event_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -93,8 +94,9 @@ pub async fn archive( /// Returns `true` if a row was deleted, `false` if the identity was not archived /// in that community. pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; @@ -102,9 +104,9 @@ pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) - sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") .bind(community_id.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 46375595cf..13a93263e9 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -29,27 +29,6 @@ pub use crate::channel_members::{ ChannelIdentity, LargeChannelRoster, LockedMemberSnapshot, MemberRecord, UserRecord, }; -async fn begin_event_write_transaction( - pool: &PgPool, -) -> Result> { - let connection = crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?; - Ok(sqlx::Transaction::begin(connection, None).await?) -} - -async fn acquire_event_write_connection( - pool: &PgPool, -) -> Result> { - Ok(crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?) -} - /// A channel row as returned from the database. #[derive(Debug, Clone)] pub struct ChannelRecord { @@ -135,7 +114,12 @@ pub async fn create_channel( let id = Uuid::new_v4(); - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" @@ -152,7 +136,7 @@ pub async fn create_channel( .bind(description) .bind(created_by) .bind(ttl_seconds) - .execute(&mut *tx) + .execute(tx.conn()) .await?; sqlx::query( @@ -169,7 +153,7 @@ pub async fn create_channel( .bind(id) .bind(created_by) .bind(created_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let row = sqlx::query( @@ -186,7 +170,7 @@ pub async fn create_channel( ) .bind(community_id.as_uuid()) .bind(id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_channel_record(row)?; @@ -228,7 +212,12 @@ pub async fn create_channel_with_id( return Err(DbError::InvalidData("channel name is required".into())); } - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let rows_affected = sqlx::query( r#" @@ -246,7 +235,7 @@ pub async fn create_channel_with_id( .bind(description) .bind(created_by) .bind(ttl_seconds) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected(); @@ -268,7 +257,7 @@ pub async fn create_channel_with_id( .bind(channel_id) .bind(created_by) .bind(created_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -286,7 +275,7 @@ pub async fn create_channel_with_id( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_channel_record(row)?; @@ -361,17 +350,24 @@ pub async fn set_canvas( channel_id: Uuid, canvas: Option<&str>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let rows = sqlx::query( "UPDATE channels SET canvas = $1 WHERE community_id = $2 AND id = $3 AND deleted_at IS NULL", ) .bind(canvas) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(pool) + .execute(tx.conn()) .await?; if rows.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; Ok(()) } @@ -593,22 +589,33 @@ pub async fn update_channel( // this transition — whose own deadline reset is then the latest word. // Non-TTL updates don't touch the fast path and skip the lock. if updates.ttl_seconds.is_some() { - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))") .bind(channel_ttl_lock_key(community_id, channel_id)) - .execute(&mut *tx) + .execute(tx.conn()) .await?; - let result = q.execute(&mut *tx).await?; + let result = q.execute(tx.conn()).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } tx.commit().await?; } else { - let mut connection = acquire_event_write_connection(pool).await?; - let result = q.execute(&mut *connection).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let result = q.execute(tx.conn()).await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; } get_channel_with_operation( @@ -628,7 +635,12 @@ pub async fn set_topic( topic: &str, set_by: &[u8], ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -637,11 +649,12 @@ pub async fn set_topic( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; Ok(()) } @@ -653,7 +666,12 @@ pub async fn set_purpose( purpose: &str, set_by: &[u8], ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \ WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL", @@ -662,11 +680,12 @@ pub async fn set_purpose( .bind(set_by) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { return Err(DbError::ChannelNotFound(channel_id)); } + tx.commit().await?; Ok(()) } @@ -679,14 +698,19 @@ pub async fn archive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; match row { @@ -707,9 +731,10 @@ pub async fn archive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -722,14 +747,19 @@ pub async fn unarchive_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result<()> { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; // First check: does the channel exist and what is its state? let row = sqlx::query( "SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; match row { @@ -752,9 +782,10 @@ pub async fn unarchive_channel( ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -767,15 +798,21 @@ pub async fn soft_delete_channel( community_id: CommunityId, channel_id: Uuid, ) -> Result { - let mut connection = acquire_event_write_connection(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( "UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", ) .bind(community_id.as_uuid()) .bind(channel_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 7ff5146775..1994de14ed 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -4,7 +4,7 @@ //! roster snapshots hold that same lock through replacement publication. use chrono::{DateTime, Utc}; -use sqlx::{PgConnection, PgPool, Postgres, Row, Transaction}; +use sqlx::{PgConnection, PgPool, Row}; use uuid::Uuid; use crate::channel::{row_to_channel_record, ChannelRecord}; @@ -539,18 +539,18 @@ pub async fn add_member( ))); } - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the whole role-check / owner-count / upsert // sequence against concurrent membership writes on this channel. - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; - let channel = get_channel_tx(&mut tx, community_id, channel_id).await?; + let channel = get_channel_tx(tx.conn(), community_id, channel_id).await?; let effective_role = if channel.visibility == "private" { let inviter = invited_by.ok_or_else(|| { @@ -561,7 +561,7 @@ pub async fn add_member( let is_creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice(); if !is_creator_bootstrap { - let inviter_role_str = get_active_role_tx(&mut tx, community_id, channel_id, inviter) + let inviter_role_str = get_active_role_tx(tx.conn(), community_id, channel_id, inviter) .await? .ok_or_else(|| { DbError::AccessDenied("inviter is not an active member".to_string()) @@ -587,7 +587,7 @@ pub async fn add_member( // elevated roles. Self-join always gets Member. if role.is_elevated() { let granter_role = match invited_by { - Some(inv) => get_active_role_tx(&mut tx, community_id, channel_id, inv).await?, + Some(inv) => get_active_role_tx(tx.conn(), community_id, channel_id, inv).await?, None => None, }; match granter_role.as_deref() { @@ -619,10 +619,12 @@ pub async fn add_member( // current authority from a removed row would make soft-deleted ownership a // resurrection token: an owner removed by another owner could self-rejoin // via kind:9021 (`Member, None`) and silently regain ownership. - let current_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let current_role = get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await?; if let Some(current_role) = current_role.filter(|r| r != effective_role.as_str()) { let actor_role = match invited_by { - Some(inviter) => get_active_role_tx(&mut tx, community_id, channel_id, inviter).await?, + Some(inviter) => { + get_active_role_tx(tx.conn(), community_id, channel_id, inviter).await? + } None => None, }; let actor_role: Option = actor_role.and_then(|r| r.parse().ok()); @@ -642,7 +644,7 @@ pub async fn add_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -668,7 +670,7 @@ pub async fn add_member( .bind(pubkey) .bind(effective_role.as_str()) .bind(invited_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let row = sqlx::query( @@ -680,7 +682,7 @@ pub async fn add_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_member_record(row)?; @@ -725,20 +727,20 @@ pub async fn remove_member( crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? }; - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::EventWrite, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; // First statement: serialize the actor-role check, the last-owner count and // the UPDATE against concurrent membership writes on this channel (same key // as `add_member`). - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; if !is_self_remove { - let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey) + let actor_role_str = get_active_role_tx(tx.conn(), community_id, channel_id, actor_pubkey) .await? .ok_or_else(|| DbError::AccessDenied("actor is not an active member".to_string()))?; let actor_role: MemberRole = actor_role_str.parse().map_err(|_| { @@ -754,7 +756,7 @@ pub async fn remove_member( // Defense-in-depth: prevent removing the last owner regardless of caller. // Callers (REST handlers, NIP-29 handlers) also check this, but the DB // layer enforces it as the final safety net. - let target_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?; + let target_role = get_active_role_tx(tx.conn(), community_id, channel_id, pubkey).await?; if target_role.as_deref() == Some("owner") { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members \ @@ -762,7 +764,7 @@ pub async fn remove_member( ) .bind(community_id.as_uuid()) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let owner_count: i64 = row.try_get("cnt")?; if owner_count <= 1 { @@ -783,7 +785,7 @@ pub async fn remove_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -900,7 +902,7 @@ pub struct MembershipRemovalFence { pub still_removed: bool, // Holds the advisory transaction lock. The caller commits via // `commit_disabling_workflows`; on drop without commit the tx rolls back. - tx: Transaction<'static, Postgres>, + tx: AdmittedTx, } impl MembershipRemovalFence { @@ -950,19 +952,27 @@ impl MembershipRemovalFence { /// lock (the transaction is implicitly rolled back if not committed). To /// durably write the workflow-disable and release the lock in one step, call /// [`Self::commit_disabling_workflows`]. +/// +/// Write admission is taken up front, before the membership lock, so the +/// lock order matches [`add_member`]. Taking it lazily in +/// [`Self::commit_disabling_workflows`] would invert that order. As a result, +/// a community that leaves `active` between the kick commit and this call +/// refuses the fence, and the caller skips its live side effects. That is +/// intended: the membership cache is already invalidated and fan-out +/// re-checks membership before delivery. pub async fn membership_removal_fence( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], ) -> Result { - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community_id, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?; + acquire_channel_membership_lock(tx.conn(), community_id, channel_id).await?; let row = sqlx::query( "SELECT removed_at FROM channel_members \ @@ -971,7 +981,7 @@ pub async fn membership_removal_fence( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; // Row absent → never joined or hard-deleted; treat as removed (safe to diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 2407535958..e084d8ed17 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -563,6 +563,12 @@ impl Db { return Ok(CreateCommunityWithOwnerResult::LimitReached); } + // The community row was just created in this transaction. Admit + // its first tenant row before inserting the initial owner. + self.deletion_store() + .guard_transaction(&mut tx, CommunityId::from_uuid(id)) + .await?; + sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1, $2, 'owner', NULL)", ) diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index eef0ac8b0b..d673c5d8d7 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -2955,27 +2955,14 @@ impl DeletionStore { } /// Take the shared community deletion lock inside an existing transaction. + /// + /// See [`guard_community_write`]. pub async fn guard_transaction( &self, tx: &mut Transaction<'_, Postgres>, community: CommunityId, ) -> Result<()> { - lock_community_deletion_shared(tx, community).await?; - let state: Option = sqlx::query_scalar( - "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", - ) - .bind(community.as_uuid()) - .fetch_optional(&mut **tx) - .await?; - match state.as_deref() { - Some("active") => Ok(()), - Some(other) => Err(DbError::AccessDenied(format!( - "community {community} is write-fenced ({other})" - ))), - None => Err(DbError::AccessDenied(format!( - "community {community} is missing or tombstoned" - ))), - } + guard_community_write(tx, community).await } /// Take the shared community deletion lock inside an existing transaction @@ -3342,6 +3329,36 @@ async fn lock_community_deletion( Ok(()) } +/// Admit a community write inside an existing transaction. +/// +/// Takes the shared community deletion lock, then requires the community to +/// be `active` and not tombstoned. Refusal is [`DbError::AccessDenied`]; +/// a lock timeout surfaces as the underlying `55P03` database error. The +/// lock is transaction-scoped, so commit, rollback, or a dropped transaction +/// releases it. Crates outside `buzz-db` that own their own transactions +/// (the audit chain) admit through this function. +pub async fn guard_community_write( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, +) -> Result<()> { + lock_community_deletion_shared(tx, community).await?; + let state: Option = sqlx::query_scalar( + "SELECT deletion_state FROM communities WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .fetch_optional(&mut **tx) + .await?; + match state.as_deref() { + Some("active") => Ok(()), + Some(other) => Err(DbError::AccessDenied(format!( + "community {community} is write-fenced ({other})" + ))), + None => Err(DbError::AccessDenied(format!( + "community {community} is missing or tombstoned" + ))), + } +} + pub(crate) async fn lock_community_deletion_shared( tx: &mut Transaction<'_, Postgres>, community: CommunityId, diff --git a/crates/buzz-db/src/store/dm.rs b/crates/buzz-db/src/store/dm.rs index 89e4a0e522..8d51866e85 100644 --- a/crates/buzz-db/src/store/dm.rs +++ b/crates/buzz-db/src/store/dm.rs @@ -127,7 +127,12 @@ pub async fn create_dm( let hash = compute_participant_hash(participants); - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; // Idempotency check inside the transaction. let existing = sqlx::query( @@ -148,7 +153,7 @@ pub async fn create_dm( ) .bind(community_id.as_uuid()) .bind(hash.as_slice()) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if let Some(row) = existing { @@ -177,7 +182,7 @@ pub async fn create_dm( .bind(&name) .bind(created_by) .bind(hash.as_slice()) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Add all participants as members with role='member'. @@ -196,7 +201,7 @@ pub async fn create_dm( .bind(id) .bind(*pk) .bind(created_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -213,7 +218,7 @@ pub async fn create_dm( ) .bind(community_id.as_uuid()) .bind(id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let record = row_to_channel_record(row)?; @@ -402,6 +407,12 @@ pub async fn hide_dm( channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE channel_members @@ -412,7 +423,7 @@ pub async fn hide_dm( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(pool) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -421,6 +432,7 @@ pub async fn hide_dm( ))); } + tx.commit().await?; Ok(()) } @@ -434,6 +446,12 @@ pub async fn unhide_dm( channel_id: Uuid, pubkey: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" UPDATE channel_members @@ -444,9 +462,10 @@ pub async fn unhide_dm( .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } diff --git a/crates/buzz-db/src/store/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs index fac10c3f61..164b055a28 100644 --- a/crates/buzz-db/src/store/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -90,8 +90,9 @@ pub async fn reserve_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -108,10 +109,11 @@ pub async fn reserve_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; if inserted.is_some() { + tx.commit().await?; return Ok(ReserveOutcome::Reserved); } @@ -123,9 +125,9 @@ pub async fn reserve_repo_name( ) .bind(community.as_uuid()) .bind(repo_id) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; - + tx.commit().await?; match existing { Some(row) => { let holder: String = row @@ -183,8 +185,9 @@ pub async fn release_repo_name( repo_id: &str, owner_pubkey: &str, ) -> Result { - let mut connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; @@ -195,8 +198,9 @@ pub async fn release_repo_name( .bind(community.as_uuid()) .bind(repo_id) .bind(owner_pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected()) } diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index 91c4a6e282..efa46f1748 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -78,3 +78,6 @@ mod artifact_query; #[cfg(test)] mod artifact_postgres_tests; + +#[cfg(test)] +mod admission_postgres_tests; diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index cdcc339fe5..00bcb3af87 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -188,6 +188,12 @@ pub async fn insert_report( community: CommunityId, report: NewReport<'_>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let (target_kind, target_event_id, target_pubkey, target_blob_sha256) = match &report.target { ReportTarget::Event(id) => ("event", Some(id.as_slice()), None, None), ReportTarget::Pubkey(pubkey) => ("pubkey", None, Some(pubkey.as_slice()), None), @@ -223,8 +229,9 @@ pub async fn insert_report( .bind(report.report_type) .bind(report.note) .bind(initial_status) - .fetch_one(pool) + .fetch_one(tx.conn()) .await?; + tx.commit().await?; Ok(row.try_get("id")?) } @@ -313,6 +320,12 @@ pub async fn resolve_report( resolved_by: &[u8], action_id: Option, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" UPDATE moderation_reports @@ -325,8 +338,9 @@ pub async fn resolve_report( .bind(status) .bind(resolved_by) .bind(action_id) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -340,6 +354,12 @@ pub async fn ban_member( reason: Option<&str>, expires_at: Option>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; sqlx::query( r#" INSERT INTO community_bans ( @@ -358,8 +378,9 @@ pub async fn ban_member( .bind(expires_at) .bind(reason) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -371,6 +392,12 @@ pub async fn unban_member( pubkey: &[u8], actor: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" UPDATE community_bans @@ -383,8 +410,9 @@ pub async fn unban_member( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -404,7 +432,12 @@ pub async fn unban_member_with_audit( actor: &[u8], actor_authority: &str, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" @@ -418,7 +451,7 @@ pub async fn unban_member_with_audit( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -438,7 +471,7 @@ pub async fn unban_member_with_audit( .bind(actor) .bind(pubkey) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -456,7 +489,12 @@ pub async fn untimeout_member_with_audit( actor: &[u8], actor_authority: &str, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" @@ -469,7 +507,7 @@ pub async fn untimeout_member_with_audit( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -488,7 +526,7 @@ pub async fn untimeout_member_with_audit( .bind(actor) .bind(pubkey) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -504,6 +542,12 @@ pub async fn timeout_member( muted_until: DateTime, reason: Option<&str>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; sqlx::query( r#" INSERT INTO community_bans ( @@ -521,8 +565,9 @@ pub async fn timeout_member( .bind(muted_until) .bind(reason) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -534,6 +579,12 @@ pub async fn untimeout_member( pubkey: &[u8], actor: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#" UPDATE community_bans @@ -545,8 +596,9 @@ pub async fn untimeout_member( .bind(community.as_uuid()) .bind(pubkey) .bind(actor) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -704,6 +756,12 @@ pub async fn insert_action( community: CommunityId, action: NewAction<'_>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Authorization, + ) + .await?; let row = sqlx::query( r#" INSERT INTO moderation_actions ( @@ -725,8 +783,9 @@ pub async fn insert_action( .bind(action.private_reason) .bind(action.matched_principal) .bind(action.actor_authority.unwrap_or("community")) - .fetch_one(pool) + .fetch_one(tx.conn()) .await?; + tx.commit().await?; Ok(row.try_get("id")?) } diff --git a/crates/buzz-db/src/store/personal_read/postgres_tests.rs b/crates/buzz-db/src/store/personal_read/postgres_tests.rs index 136eb89148..cba0d992a4 100644 --- a/crates/buzz-db/src/store/personal_read/postgres_tests.rs +++ b/crates/buzz-db/src/store/personal_read/postgres_tests.rs @@ -258,7 +258,13 @@ async fn personal_read_intent_does_not_lock_shared_conversation_rows() { .execute(&pool) .await .unwrap(); - let mut held = pool.begin().await.unwrap(); + let mut held = crate::begin_community_write_transaction( + &pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await + .unwrap(); super::writes::lock_account(&mut held, community, &actor.public_key().to_bytes()) .await .unwrap(); diff --git a/crates/buzz-db/src/store/personal_read/writes.rs b/crates/buzz-db/src/store/personal_read/writes.rs index 9d4c4a6586..77df951f59 100644 --- a/crates/buzz-db/src/store/personal_read/writes.rs +++ b/crates/buzz-db/src/store/personal_read/writes.rs @@ -1,10 +1,10 @@ use super::model::*; use buzz_core::CommunityId; use chrono::{DateTime, Utc}; -use sqlx::{Acquire, PgConnection, Row}; +use sqlx::{PgConnection, Row}; use uuid::Uuid; -use crate::{observability, Db, Result}; +use crate::{observability, AdmittedTx, Db, Result}; pub(super) fn event_id(value: &str) -> Option> { if value.len() != 64 || value.bytes().any(|b| !b.is_ascii_hexdigit()) { @@ -26,7 +26,7 @@ pub(super) async fn deadlines(conn: &mut PgConnection) -> Result<()> { /// Start the account if this is the actor's first read intent, then serialize /// private frontier writes, never shared conversation rows. pub(super) async fn lock_account( - conn: &mut PgConnection, + tx: &mut AdmittedTx, community: CommunityId, actor: &[u8], ) -> Result<()> { @@ -37,14 +37,14 @@ pub(super) async fn lock_account( ) .bind(community.as_uuid()) .bind(actor) - .execute(&mut *conn) + .execute(tx.conn()) .await?; sqlx::query( "SELECT actor FROM personal_read_accounts WHERE community_id=$1 AND actor=$2 FOR UPDATE", ) .bind(community.as_uuid()) .bind(actor) - .fetch_one(conn) + .fetch_one(tx.conn()) .await?; Ok(()) } @@ -161,7 +161,7 @@ pub(super) async fn valid_target( /// Advance a monotone frontier. The anchor ID follows the greatest arrival; /// an equal arrival keeps the existing anchor. async fn frontier( - conn: &mut PgConnection, + tx: &mut AdmittedTx, community: CommunityId, actor: &[u8], target: &ReadTarget, @@ -179,12 +179,12 @@ async fn frontier( THEN EXCLUDED.through_message_id ELSE personal_read_frontiers.through_message_id END", ).bind(community.as_uuid()).bind(actor).bind(target.channel_id).bind(root) .bind(msg.received_at).bind(&msg.id) - .execute(&mut *conn).await?; + .execute(tx.conn()).await?; Ok(()) } pub(super) async fn apply( - conn: &mut PgConnection, + tx: &mut AdmittedTx, community: CommunityId, actor: &[u8], intent: &ReadIntent, @@ -194,11 +194,11 @@ pub(super) async fn apply( let Some(id) = event_id(message_id) else { return Ok(IntentOutcome::Invalid); }; - let Some(root) = valid_target(conn, community, actor, target).await? else { + let Some(root) = valid_target(tx.conn(), community, actor, target).await? else { return Ok(IntentOutcome::Blocked); }; let Some(msg) = message( - conn, + tx.conn(), community, target.channel_id, &id, @@ -214,7 +214,7 @@ pub(super) async fn apply( { return Ok(IntentOutcome::Blocked); } - frontier(conn, community, actor, target, &root, &msg).await?; + frontier(tx, community, actor, target, &root, &msg).await?; } } Ok(IntentOutcome::Applied) @@ -230,11 +230,13 @@ impl Db { actor: &nostr::PublicKey, intent: &ReadIntent, ) -> Result { - let mut conn = - observability::acquire_writer(&self.pool, observability::WriterOperation::EventWrite) - .await?; - let mut tx = conn.begin().await?; - deadlines(&mut tx).await?; + let mut tx = crate::begin_community_write_transaction( + &self.pool, + community, + observability::WriterOperation::EventWrite, + ) + .await?; + deadlines(tx.conn()).await?; let actor = actor.to_bytes(); lock_account(&mut tx, community, &actor).await?; let outcome = apply(&mut tx, community, &actor, intent).await?; diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 1a95aecf70..4269c3f7e6 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -21,14 +21,6 @@ async fn acquire_operation_connection( Ok(crate::observability::acquire_writer(pool, operation).await?) } -async fn begin_operation_transaction( - pool: &PgPool, - operation: crate::observability::WriterOperation, -) -> Result> { - let connection = acquire_operation_connection(pool, operation).await?; - Ok(sqlx::Transaction::begin(connection, None).await?) -} - /// Namespace for the per-community push-gate advisory lock. The post-commit /// enqueue worker takes it SHARED, while lease transitions take it EXCLUSIVE. /// The worker checks receipt-time eligibility after obtaining the lock. Legacy @@ -485,11 +477,14 @@ async fn replace_lease( // lease" to "eligible"; serialize it against the event producer's shared // gate lock (gate → lease row, matching accept_lease_event's global order). // Revocations (is_active = false) never make eligibility true and skip it. - let mut tx = - begin_operation_transaction(pool, crate::observability::WriterOperation::EventWrite) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::EventWrite, + ) + .await?; if is_active { - acquire_push_gate_lock(&mut tx, community).await?; + acquire_push_gate_lock(tx.conn(), community).await?; } // The conflict predicate is the acceptance state machine. Keeping both @@ -537,7 +532,7 @@ async fn replace_lease( .bind(max_class) .bind(subscriptions) .bind(version.expires_at) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if accepted.is_some() { @@ -553,7 +548,7 @@ async fn replace_lease( .bind(community.as_uuid()) .bind(author) .bind(installation_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; tx.commit().await?; let current_created_at: i64 = current.try_get("source_created_at")?; @@ -635,9 +630,12 @@ pub async fn enqueue_wakes( if requests.is_empty() { return Ok(Vec::new()); } - let mut tx = - begin_operation_transaction(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; // 1. Lock and read the current lease row for every distinct requested // (author, installation), in deterministic order. @@ -666,7 +664,7 @@ pub async fn enqueue_wakes( .bind(community.as_uuid()) .bind(&lock_authors) .bind(&lock_installs) - .fetch_all(&mut *tx) + .fetch_all(tx.conn()) .await?; let mut leases: std::collections::HashMap<(Vec, String), (i64, Vec)> = std::collections::HashMap::with_capacity(lease_rows.len()); @@ -739,7 +737,7 @@ pub async fn enqueue_wakes( .bind(&ins_events) .bind(&ins_classes) .bind(&ins_expires) - .fetch_all(&mut *tx) + .fetch_all(tx.conn()) .await?; for row in inserted { let key: (Vec, Vec) = (row.try_get("endpoint_hash")?, row.try_get("event_id")?); @@ -762,7 +760,7 @@ pub async fn enqueue_wakes( .bind(community.as_uuid()) .bind(&ins_endpoints) .bind(&ins_events) - .fetch_all(&mut *tx) + .fetch_all(tx.conn()) .await?; for row in dup_rows { let key: (Vec, Vec) = @@ -932,9 +930,12 @@ where // recoverable after their claim lease expires. let gone: Vec> = attempts.into_keys().collect(); if !gone.is_empty() { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -942,8 +943,9 @@ where .bind(community.as_uuid()) .bind(claim_id) .bind(&gone) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; } if jobs.is_empty() { return Ok(None); @@ -1014,19 +1016,24 @@ pub async fn complete_match_batch( if event_ids.is_empty() { return Ok(0); } - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; - Ok(sqlx::query( + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; + let affected = sqlx::query( "DELETE FROM push_match_queue \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", ) .bind(community.as_uuid()) .bind(claim_id) .bind(event_ids) - .execute(&mut *connection) + .execute(tx.conn()) .await? - .rows_affected()) + .rows_affected(); + tx.commit().await?; + Ok(affected) } /// Release fenced matcher claims from one batch for retry at the supplied @@ -1041,10 +1048,13 @@ pub async fn retry_match_batch( if event_ids.is_empty() { return Ok(0); } - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; - Ok(sqlx::query( + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; + let affected = sqlx::query( "UPDATE push_match_queue \ SET state='pending', claim_id=NULL, lease_until=NULL, next_attempt_at=$4 \ WHERE community_id=$1 AND claim_id=$2 AND state='matching' AND event_id = ANY($3)", @@ -1053,9 +1063,11 @@ pub async fn retry_match_batch( .bind(claim_id) .bind(event_ids) .bind(next) - .execute(&mut *connection) + .execute(tx.conn()) .await? - .rows_affected()) + .rows_affected(); + tx.commit().await?; + Ok(affected) } /// Claim due jobs for one community, recovering expired worker leases. @@ -1069,9 +1081,12 @@ pub async fn claim_due_wakes( lease_until: DateTime, ) -> Result> { let claim_id = Uuid::new_v4(); - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let rows = sqlx::query( r#" WITH candidates AS ( @@ -1119,8 +1134,9 @@ pub async fn claim_due_wakes( .bind(limit) .bind(claim_id) .bind(lease_until) - .fetch_all(&mut *connection) + .fetch_all(tx.conn()) .await?; + tx.commit().await?; rows.into_iter().map(row_to_claimed_wake).collect() } @@ -1187,9 +1203,12 @@ pub async fn complete_wake( id: Uuid, claim_id: Uuid, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'delivered', claim_id = NULL, lease_until = NULL \ @@ -1198,8 +1217,9 @@ pub async fn complete_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1211,9 +1231,12 @@ pub async fn retry_wake( claim_id: Uuid, next_attempt_at: DateTime, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'pending', next_attempt_at = $4, claim_id = NULL, lease_until = NULL \ @@ -1223,8 +1246,9 @@ pub async fn retry_wake( .bind(id) .bind(claim_id) .bind(next_attempt_at) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1235,9 +1259,12 @@ pub async fn fail_wake( id: Uuid, claim_id: Uuid, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_wake_outbox \ SET state = 'failed', claim_id = NULL, lease_until = NULL \ @@ -1246,8 +1273,9 @@ pub async fn fail_wake( .bind(community.as_uuid()) .bind(id) .bind(claim_id) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1262,9 +1290,12 @@ pub async fn disable_endpoint_generation( installation_id: &str, generation: i64, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "UPDATE push_leases SET endpoint_enabled = false, updated_at = now() \ WHERE community_id = $1 AND author = $2 AND installation_id = $3 \ @@ -1274,8 +1305,9 @@ pub async fn disable_endpoint_generation( .bind(author) .bind(installation_id) .bind(generation) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -1289,9 +1321,12 @@ pub async fn prune_wake_outbox( community: CommunityId, before: DateTime, ) -> Result { - let mut connection = - acquire_operation_connection(pool, crate::observability::WriterOperation::Maintenance) - .await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + crate::observability::WriterOperation::Maintenance, + ) + .await?; let result = sqlx::query( "DELETE FROM push_wake_outbox o \ WHERE o.community_id = $1 AND o.created_at < $2 \ @@ -1304,8 +1339,9 @@ pub async fn prune_wake_outbox( ) .bind(community.as_uuid()) .bind(before) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected()) } diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 0a876beb2a..e1cf4ca4c5 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -143,7 +143,12 @@ pub async fn resolve_report_decision_atomic( channel_id: Option, reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // CAS: open → terminal. The update count tells us whether the report was open. let updated = sqlx::query( @@ -157,7 +162,7 @@ pub async fn resolve_report_decision_atomic( .bind(report_id) .bind(terminal_status) .bind(actor_pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated.rows_affected() == 0 { @@ -182,7 +187,7 @@ pub async fn resolve_report_decision_atomic( .bind(channel_id) .bind(reason) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -221,7 +226,12 @@ pub async fn claim_report( target_event_id: Option<&[u8]>, channel_id: Option, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Lock the report row to serialize concurrent claims on the same report. let report_row = sqlx::query( @@ -234,7 +244,7 @@ pub async fn claim_report( ) .bind(community_id.as_uuid()) .bind(report_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let Some(report_row) = report_row else { @@ -258,7 +268,7 @@ pub async fn claim_report( .bind(community_id.as_uuid()) .bind(report_id) .bind(request_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if let Some(row) = existing { @@ -296,7 +306,7 @@ pub async fn claim_report( .bind(timeout_until) .bind(target_pubkey) .bind(channel_id) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; let action_id: Uuid = action_row.try_get("id")?; @@ -318,7 +328,7 @@ pub async fn claim_report( .bind(channel_id) .bind(reason) .bind(actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // CAS: set report to processing with active_action_id. @@ -332,7 +342,7 @@ pub async fn claim_report( .bind(community_id.as_uuid()) .bind(report_id) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated.rows_affected() == 0 { @@ -457,7 +467,12 @@ pub async fn execute_ban_with_marker( actor_pubkey: &[u8], reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Verify lease ownership first — abort without touching domain rows if the // lease is already gone. This prevents the commit entirely on a stale worker. @@ -474,7 +489,7 @@ pub async fn execute_ban_with_marker( ) .bind(action_id) .bind(lease_token) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; if !owned { @@ -488,7 +503,7 @@ pub async fn execute_ban_with_marker( // under the lock, not before it. sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") .bind(action_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; sqlx::query( @@ -505,7 +520,7 @@ pub async fn execute_ban_with_marker( .bind(target_pubkey) .bind(actor_pubkey) .bind(reason) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let marker = sqlx::query( @@ -521,7 +536,7 @@ pub async fn execute_ban_with_marker( ) .bind(action_id) .bind(lease_token) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if marker.rows_affected() == 0 { @@ -548,7 +563,12 @@ pub async fn execute_timeout_with_marker( until: DateTime, reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let owned: bool = sqlx::query_scalar( r#" @@ -563,7 +583,7 @@ pub async fn execute_timeout_with_marker( ) .bind(action_id) .bind(lease_token) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; if !owned { @@ -574,7 +594,7 @@ pub async fn execute_timeout_with_marker( // Acquire the action-row lock before evaluating the wall-clock expiry check. sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") .bind(action_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; sqlx::query( @@ -593,7 +613,7 @@ pub async fn execute_timeout_with_marker( .bind(until) .bind(actor_pubkey) .bind(reason) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let marker = sqlx::query( @@ -609,7 +629,7 @@ pub async fn execute_timeout_with_marker( ) .bind(action_id) .bind(lease_token) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if marker.rows_affected() == 0 { @@ -648,7 +668,12 @@ pub async fn execute_kick_with_marker( target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let owned: bool = sqlx::query_scalar( r#" @@ -663,7 +688,7 @@ pub async fn execute_kick_with_marker( ) .bind(action_id) .bind(lease_token) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; if !owned { @@ -676,7 +701,7 @@ pub async fn execute_kick_with_marker( // Acquire the action-row lock before evaluating the wall-clock expiry check. sqlx::query("SELECT id FROM relay_admin_actions WHERE id = $1 FOR UPDATE") .bind(action_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let kick = sqlx::query( @@ -690,7 +715,7 @@ pub async fn execute_kick_with_marker( .bind(community_id.as_uuid()) .bind(channel_id) .bind(target_pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if kick.rows_affected() == 0 { @@ -711,7 +736,7 @@ pub async fn execute_kick_with_marker( ) .bind(action_id) .bind(lease_token) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if marker.rows_affected() == 0 { @@ -855,7 +880,12 @@ pub async fn finalize_success( reason: Option<&str>, timeout_until: Option>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Require step_marker = 'mutation_committed' to prevent premature finalization. let updated_action = sqlx::query( @@ -866,7 +896,7 @@ pub async fn finalize_success( "#, ) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated_action.rows_affected() == 0 { @@ -893,7 +923,7 @@ pub async fn finalize_success( .bind(terminal_status) .bind(actor_pubkey) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated_report.rows_affected() == 0 { @@ -930,7 +960,7 @@ pub async fn finalize_success( .bind(action_id) .bind(payload) .bind(format!("tombstone:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } @@ -953,7 +983,7 @@ pub async fn finalize_success( .bind(action_id) .bind(payload) .bind(format!("system_message:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } @@ -975,7 +1005,7 @@ pub async fn finalize_success( .bind(action_id) .bind(notice_payload) .bind(format!("reporter_notice:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -1024,7 +1054,7 @@ pub async fn finalize_success( .bind(action_id) .bind(affected_payload) .bind(format!("affected_user_notice:{action_str}")) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -1124,7 +1154,12 @@ pub async fn cancel_action( report_id: Uuid, cancelled_by: &[u8], ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Cancel only a pre-mutation `failed` action that BELONGS to the path // report and community. Fencing on report_id + report_community_id is what @@ -1146,7 +1181,7 @@ pub async fn cancel_action( .bind(report_id) .bind(community_id.as_uuid()) .bind(cancelled_by) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if updated.rows_affected() != 1 { @@ -1171,7 +1206,7 @@ pub async fn cancel_action( .bind(community_id.as_uuid()) .bind(report_id) .bind(action_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; if reopened.rows_affected() != 1 { @@ -1219,7 +1254,12 @@ pub async fn reopen_report( actor_role: &str, reason: Option<&str>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Lock the report row to serialize concurrent reopen/resolve on it. let report_row = sqlx::query( @@ -1232,7 +1272,7 @@ pub async fn reopen_report( ) .bind(community_id.as_uuid()) .bind(report_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let Some(report_row) = report_row else { @@ -1252,7 +1292,7 @@ pub async fn reopen_report( .bind(community_id.as_uuid()) .bind(report_id) .bind(request_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if existing.is_some() { @@ -1280,7 +1320,7 @@ pub async fn reopen_report( ) .bind(community_id.as_uuid()) .bind(report_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Durable audit row. Inserted as 'succeeded' so the recovery worker never @@ -1299,7 +1339,7 @@ pub async fn reopen_report( .bind(actor_pubkey) .bind(actor_role) .bind(reason) - .execute(&mut *tx) + .execute(tx.conn()) .await?; tx.commit().await?; @@ -1643,6 +1683,12 @@ pub async fn deploy_kick_member( target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; // Use a direct UPDATE to avoid the tenant ownership check in channel::remove_member. // This is the deployment-authority primitive: no actor role check. let result = sqlx::query( @@ -1656,8 +1702,9 @@ pub async fn deploy_kick_member( .bind(community_id.as_uuid()) .bind(channel_id) .bind(target_pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; if result.rows_affected() > 0 { Ok(KickResult::Removed) @@ -1765,7 +1812,12 @@ pub async fn claim_direct_action( pool: &PgPool, input: &DirectActionInput<'_>, ) -> Result { - let mut tx = pool.begin().await?; + let mut tx = crate::begin_community_write_transaction( + pool, + input.community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let inserted = sqlx::query( r#" INSERT INTO relay_admin_actions ( @@ -1788,7 +1840,7 @@ pub async fn claim_direct_action( .bind(input.target_pubkey) .bind(input.target_event_id) .bind(input.channel_id) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; let Some(row) = inserted else { @@ -1818,7 +1870,7 @@ pub async fn claim_direct_action( .bind(input.channel_id) .bind(input.reason) .bind(input.actor_authority) - .execute(&mut *tx) + .execute(tx.conn()) .await?; let rec = row_to_action(row)?; tx.commit().await?; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 4bb48e121c..6487696151 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -116,15 +116,12 @@ pub async fn mint_relay_invite( // community-scoped database write. The trigger remains the final backstop, // but this typed guard keeps a quiescing community from surfacing as an // opaque SQLSTATE/HTTP 500 at the API boundary. - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; - crate::deletion::DeletionStore::new(pool.clone()) - .guard_transaction(&mut tx, community) - .await?; let row = sqlx::query( "INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \ VALUES ($1, $2, $3, $4, $5) \ @@ -135,7 +132,7 @@ pub async fn mint_relay_invite( .bind(max_uses) .bind(expires_at) .bind(created_by) - .fetch_one(&mut *tx) + .fetch_one(tx.conn()) .await?; tx.commit().await?; @@ -226,12 +223,12 @@ pub async fn claim_relay_invite( claimer_pubkey: &str, policy_version: Option<&str>, ) -> Result { - let connection = crate::observability::acquire_writer( + let mut tx = crate::begin_community_write_transaction( pool, + community, crate::observability::WriterOperation::Authorization, ) .await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; // 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn. let row = sqlx::query( @@ -242,7 +239,7 @@ pub async fn claim_relay_invite( ) .bind(community.as_uuid()) .bind(token_hash) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; // 3. No matching invite. @@ -279,7 +276,7 @@ pub async fn claim_relay_invite( sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(claimer_pubkey) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if existing.is_some() { @@ -292,7 +289,7 @@ pub async fn claim_relay_invite( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } tx.commit().await?; @@ -334,7 +331,7 @@ pub async fn claim_relay_invite( ) .bind(community.as_uuid()) .bind(claimer_pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected() > 0; @@ -349,7 +346,7 @@ pub async fn claim_relay_invite( .bind(community.as_uuid()) .bind(claimer_pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -374,7 +371,7 @@ pub async fn claim_relay_invite( .bind(new_use_count) .bind(community.as_uuid()) .bind(invite_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // 11. Commit. diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 742e9173f2..6b21c7ce01 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -150,8 +150,12 @@ pub async fn add_relay_member( role: &str, added_by: Option<&str>, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, $4) ON CONFLICT (community_id, pubkey) DO NOTHING", @@ -160,8 +164,9 @@ pub async fn add_relay_member( .bind(pubkey) .bind(role) .bind(added_by) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -177,9 +182,12 @@ pub async fn claim_relay_membership( role: &str, policy_version: Option<&str>, ) -> Result { - let connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - let mut tx = sqlx::Transaction::begin(connection, None).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; let inserted = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ VALUES ($1, $2, $3, 'invite') \ @@ -188,7 +196,7 @@ pub async fn claim_relay_membership( .bind(community.as_uuid()) .bind(pubkey) .bind(role) - .execute(&mut *tx) + .execute(tx.conn()) .await? .rows_affected() > 0; @@ -201,7 +209,7 @@ pub async fn claim_relay_membership( .bind(community.as_uuid()) .bind(pubkey) .bind(version) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } @@ -243,20 +251,21 @@ pub enum RemoveResult { RoleMismatch, } -/// Deletes a membership and its join-policy acceptances in one transaction. +/// Deletes a membership and its join-policy acceptances inside the caller's +/// admitted transaction. /// /// With `expected_role`, only a member holding exactly that role is removed; /// without it, any non-owner is. The member row is locked under that predicate /// first, so a concurrent acceptance insert (which must lock the member row for /// its foreign key) either commits before the child delete sees it or fails. -/// Children go before the parent so no foreign-key action is needed. +/// Children go before the parent so no foreign-key action is needed. The +/// caller commits. async fn delete_member_with_acceptances( - connection: &mut sqlx::PgConnection, - community: CommunityId, + tx: &mut crate::AdmittedTx, pubkey: &str, expected_role: Option<&str>, ) -> Result { - let mut tx = sqlx::Connection::begin(connection).await?; + let community = tx.community(); let locked = sqlx::query( "SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2 \ AND CASE WHEN $3::text IS NULL THEN role <> 'owner' ELSE role = $3 END \ @@ -265,7 +274,7 @@ async fn delete_member_with_acceptances( .bind(community.as_uuid()) .bind(pubkey) .bind(expected_role) - .fetch_optional(&mut *tx) + .fetch_optional(tx.conn()) .await?; if locked.is_none() { return Ok(false); @@ -273,14 +282,13 @@ async fn delete_member_with_acceptances( sqlx::query("DELETE FROM join_policy_acceptances WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; sqlx::query("DELETE FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *tx) + .execute(tx.conn()) .await?; - tx.commit().await?; Ok(true) } @@ -293,9 +301,14 @@ pub async fn remove_relay_member( community: CommunityId, pubkey: &str, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - if delete_member_with_acceptances(&mut connection, community, pubkey, None).await? { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; + if delete_member_with_acceptances(&mut tx, pubkey, None).await? { + tx.commit().await?; return Ok(RemoveResult::Removed); } @@ -304,9 +317,10 @@ pub async fn remove_relay_member( let exists = sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; if exists.is_some() { Ok(RemoveResult::IsOwner) } else { @@ -332,11 +346,14 @@ pub async fn remove_relay_member_if_role( pubkey: &str, expected_role: &str, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; - if delete_member_with_acceptances(&mut connection, community, pubkey, Some(expected_role)) - .await? - { + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; + if delete_member_with_acceptances(&mut tx, pubkey, Some(expected_role)).await? { + tx.commit().await?; return Ok(RemoveResult::Removed); } @@ -345,9 +362,10 @@ pub async fn remove_relay_member_if_role( let row = sqlx::query("SELECT role FROM relay_members WHERE community_id = $1 AND pubkey = $2") .bind(community.as_uuid()) .bind(pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; match row { None => Ok(RemoveResult::NotFound), Some(r) => { @@ -372,8 +390,12 @@ pub async fn update_relay_member_role( pubkey: &str, new_role: &str, ) -> Result { - let mut connection = - observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( "UPDATE relay_members SET role = $1, updated_at = now() \ WHERE community_id = $2 AND pubkey = $3 AND role <> 'owner'", @@ -381,8 +403,9 @@ pub async fn update_relay_member_role( .bind(new_role) .bind(community.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() > 0) } @@ -476,14 +499,7 @@ async fn lock_owner_mutation_admission( return Ok(OwnerMutationAdmission::Allowed(existing_owners)); } - let deletion_pending: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ - WHERE community_id = $1 AND stage <> 'aborted')", - ) - .bind(community.as_uuid()) - .fetch_one(&mut **tx) - .await?; - if deletion_pending { + if deletion_pending(tx, community).await? { return Ok(OwnerMutationAdmission::DeletionPending); } @@ -497,6 +513,21 @@ async fn lock_owner_mutation_admission( Ok(OwnerMutationAdmission::Allowed(existing_owners)) } +/// Whether `community` has a non-aborted deletion request. +/// +/// A quiescing or fenced community always has one, so owner mutations that +/// admission refuses report `DeletionPending` rather than a generic lifecycle +/// conflict. +async fn deletion_pending(conn: &mut sqlx::PgConnection, community: CommunityId) -> Result { + Ok(sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM community_deletion_requests \ + WHERE community_id = $1 AND stage <> 'aborted')", + ) + .bind(community.as_uuid()) + .fetch_one(conn) + .await?) +} + async fn bootstrap_owner_with_operation( pool: &PgPool, community: CommunityId, @@ -507,6 +538,16 @@ async fn bootstrap_owner_with_operation( let connection = observability::acquire_writer(pool, operation).await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + if let Err(error) = crate::deletion::guard_community_write(&mut tx, community).await { + return match error { + DbError::AccessDenied(_) if deletion_pending(&mut tx, community).await? => { + Ok(ProvisionOwnerResult::DeletionPending) + } + DbError::AccessDenied(_) => Ok(ProvisionOwnerResult::LifecycleConflict), + other => Err(other), + }; + } + match lock_owner_mutation_admission(&mut tx, community, &pubkey, OwnerMutationMode::Converge) .await? { @@ -730,6 +771,27 @@ pub async fn transfer_ownership( observability::acquire_writer(pool, observability::WriterOperation::Authorization).await?; let mut tx = sqlx::Transaction::begin(connection, None).await?; + if let Err(error) = crate::deletion::guard_community_write(&mut tx, community).await { + return match error { + DbError::AccessDenied(_) if deletion_pending(&mut tx, community).await? => { + Ok(TransferResult::DeletionPending) + } + DbError::AccessDenied(_) => { + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM communities WHERE id = $1)") + .bind(community.as_uuid()) + .fetch_one(&mut *tx) + .await?; + if exists { + Ok(TransferResult::LifecycleConflict) + } else { + Ok(TransferResult::NoOwner) + } + } + other => Err(other), + }; + } + // 1. Serialize on the transferee so concurrent transfers to the same // recipient cannot both pass the ownership count check. crate::observability::observe_advisory_lock( @@ -825,40 +887,52 @@ pub async fn transfer_ownership( /// /// Converts BYTEA pubkeys to lowercase hex text and inserts them as members of /// `community`. Returns the number of rows inserted, or 0 if: -/// - the `pubkey_allowlist` table doesn't exist, or +/// - the `pubkey_allowlist` table doesn't exist, +/// - `pubkey_allowlist` has no rows for this community, or /// - `relay_members` already has rows for this community (migration ran in a /// prior startup). /// /// The empty-table guard prevents re-adding members that were intentionally /// removed by an admin after the initial backfill. pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> Result { + // The common startup case writes nothing: the legacy table is gone, it + // has no rows for this community, or the community already has members. + // Answer those with plain reads so a startup with nothing to backfill + // never needs write admission, and a quiescing deployment community keeps + // starting as it did before. let mut connection = observability::acquire_writer(pool, observability::WriterOperation::Bootstrap).await?; - // Check if pubkey_allowlist table exists. let exists: bool = sqlx::query_scalar( "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')", ) .fetch_one(&mut *connection) .await?; - - if !exists { + if !exists || community_has_members(&mut connection, community).await? { return Ok(0); } - - // Only backfill if this community's relay_members is empty — once it has - // rows (from a previous backfill or manual admin commands), we must not - // re-add members that were intentionally removed. - let has_members: bool = - sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") - .bind(community.as_uuid()) - .fetch_one(&mut *connection) - .await?; - - if has_members { + let has_allowlist_rows: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pubkey_allowlist WHERE community_id = $1)", + ) + .bind(community.as_uuid()) + .fetch_one(&mut *connection) + .await?; + if !has_allowlist_rows { return Ok(0); } + drop(connection); + let mut tx = crate::begin_community_write_transaction( + pool, + community, + observability::WriterOperation::Bootstrap, + ) + .await?; + // Re-check under admission: another pod may have backfilled meanwhile. + if community_has_members(tx.conn(), community).await? { + tx.commit().await?; + return Ok(0); + } let result = sqlx::query( "INSERT INTO relay_members (community_id, pubkey, role, added_by, created_at) \ SELECT $1, encode(pubkey, 'hex'), 'member', NULL, added_at \ @@ -867,12 +941,27 @@ pub async fn backfill_from_allowlist(pool: &PgPool, community: CommunityId) -> R ON CONFLICT (community_id, pubkey) DO NOTHING", ) .bind(community.as_uuid()) - .execute(&mut *connection) + .execute(tx.conn()) .await?; - + tx.commit().await?; Ok(result.rows_affected()) } +/// Whether `community` has any `relay_members` row. Once it does (from a +/// previous backfill or admin commands), the backfill must not re-add members +/// that were intentionally removed. +async fn community_has_members( + conn: &mut sqlx::PgConnection, + community: CommunityId, +) -> Result { + Ok( + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members WHERE community_id = $1)") + .bind(community.as_uuid()) + .fetch_one(conn) + .await?, + ) +} + impl Db { /// Returns `true` if `pubkey` (64-char hex) is a member of `community`. /// diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index 5b30f5df83..719861241c 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -11,23 +11,6 @@ use uuid::Uuid; use buzz_datastore_tracing::datastore_span; -async fn acquire_event_write_connection( - pool: &PgPool, -) -> Result> { - Ok(crate::observability::acquire_writer( - pool, - crate::observability::WriterOperation::EventWrite, - ) - .await?) -} - -async fn begin_event_write_transaction( - pool: &PgPool, -) -> Result> { - let connection = acquire_event_write_connection(pool).await?; - Ok(sqlx::Transaction::begin(connection, None).await?) -} - use buzz_core::CommunityId; use crate::{ @@ -148,7 +131,12 @@ pub async fn insert_thread_metadata( depth: i32, broadcast: bool, ) -> Result<()> { - let mut tx = begin_event_write_transaction(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" @@ -171,7 +159,7 @@ pub async fn insert_thread_metadata( .bind(root_event_created_at) .bind(depth) .bind(broadcast) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Only bump reply counts if the row was actually inserted (not a duplicate). @@ -197,7 +185,7 @@ pub async fn insert_thread_metadata( .bind(parent_ts) .bind(pid) .bind(channel_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Ensure the root also has a row (may differ from parent for nested replies). @@ -219,7 +207,7 @@ pub async fn insert_thread_metadata( .bind(root_ts) .bind(root_id) .bind(channel_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } @@ -235,7 +223,7 @@ pub async fn insert_thread_metadata( ) .bind(community_id.as_uuid()) .bind(pid) - .execute(&mut *tx) + .execute(tx.conn()) .await?; // Increment root's total descendant count. @@ -249,7 +237,7 @@ pub async fn insert_thread_metadata( ) .bind(community_id.as_uuid()) .bind(root_id) - .execute(&mut *tx) + .execute(tx.conn()) .await?; } } diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index ec4b897a61..d767b56c75 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -57,7 +57,23 @@ async fn ensure_user_with_operation( pubkey: &[u8], operation: crate::observability::WriterOperation, ) -> Result { + // Almost every call finds the user already registered. Answer that with + // one read, so the hot path skips the admission round trips; no write + // happens, so the fence contract is unaffected. let mut connection = crate::observability::acquire_writer(pool, operation).await?; + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2)", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_one(&mut *connection) + .await?; + if exists { + return Ok(false); + } + drop(connection); + + let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; let result = sqlx::query( r#" INSERT INTO users (community_id, pubkey) @@ -67,8 +83,9 @@ async fn ensure_user_with_operation( ) .bind(community_id.as_uuid()) .bind(pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -178,7 +195,14 @@ pub async fn update_user_profile( } query = query.bind(community_id.as_uuid()); query = query.bind(pubkey); - query.execute(pool).await?; + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + query.execute(tx.conn()).await?; + tx.commit().await?; Ok(()) } @@ -329,7 +353,7 @@ async fn set_agent_owner_with_operation( owner_pubkey: &[u8], operation: crate::observability::WriterOperation, ) -> Result { - let mut connection = crate::observability::acquire_writer(pool, operation).await?; + let mut tx = crate::begin_community_write_transaction(pool, community_id, operation).await?; // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( @@ -338,7 +362,7 @@ async fn set_agent_owner_with_operation( .bind(owner_pubkey) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .execute(&mut *connection) + .execute(tx.conn()) .await?; if result.rows_affected() == 0 { @@ -347,7 +371,7 @@ async fn set_agent_owner_with_operation( let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#) .bind(community_id.as_uuid()) .bind(agent_pubkey) - .fetch_optional(&mut *connection) + .fetch_optional(tx.conn()) .await?; if exists.is_none() { return Err(crate::error::DbError::NotFound( @@ -355,8 +379,10 @@ async fn set_agent_owner_with_operation( )); } // Row exists but owner already set — return false (not an error). + tx.commit().await?; return Ok(false); } + tx.commit().await?; Ok(true) } @@ -461,14 +487,21 @@ pub async fn set_channel_add_policy( "invalid channel_add_policy: {policy}" ))); } + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::Authorization, + ) + .await?; let result = sqlx::query( r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE community_id = $2 AND pubkey = $3"#, ) .bind(policy) .bind(community_id.as_uuid()) .bind(pubkey) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; if result.rows_affected() == 0 { return Err(crate::error::DbError::NotFound( "pubkey not found in users table".into(), diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index c89bf0cbf0..8f63d85555 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -15,7 +15,7 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use sha2::{Digest, Sha256}; -use sqlx::{PgConnection, PgPool, Row}; +use sqlx::{PgPool, Row}; use uuid::Uuid; use buzz_core::CommunityId; @@ -290,6 +290,12 @@ pub async fn create_workflow( definition_json: &str, definition_hash: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let id = Uuid::new_v4(); sqlx::query( @@ -306,8 +312,9 @@ pub async fn create_workflow( .bind(channel_id) .bind(definition_json) .bind(definition_hash) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(id) } @@ -328,6 +335,12 @@ pub async fn upsert_workflow( definition_json: &str, definition_hash: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" INSERT INTO workflows @@ -350,8 +363,9 @@ pub async fn upsert_workflow( .bind(channel_id) .bind(definition_json) .bind(definition_hash) - .fetch_optional(pool) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; if row.is_none() { return Err(DbError::AccessDenied(format!( @@ -507,6 +521,12 @@ pub async fn claim_scheduled_workflow_fire( workflow_id: Uuid, scheduled_for: DateTime, ) -> Result> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let row = sqlx::query( r#" INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) @@ -520,8 +540,9 @@ pub async fn claim_scheduled_workflow_fire( .bind(community_id.as_uuid()) .bind(workflow_id) .bind(scheduled_for) - .fetch_optional(pool) + .fetch_optional(tx.conn()) .await?; + tx.commit().await?; row.map(|row| { let community_id: Uuid = row.try_get("community_id")?; @@ -575,6 +596,12 @@ pub async fn attach_scheduled_workflow_run( scheduled_for: DateTime, workflow_run_id: Uuid, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let result = sqlx::query( r#" UPDATE scheduled_workflow_fires @@ -589,8 +616,9 @@ pub async fn attach_scheduled_workflow_run( .bind(workflow_id) .bind(scheduled_for) .bind(workflow_run_id) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(result.rows_affected() == 1) } @@ -634,6 +662,12 @@ pub async fn update_workflow( definition_json: &str, definition_hash: &[u8], ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -646,9 +680,10 @@ pub async fn update_workflow( .bind(definition_hash) .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow {id}"))); @@ -666,6 +701,12 @@ pub async fn update_workflow_status( id: Uuid, status: WorkflowStatus, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -676,9 +717,10 @@ pub async fn update_workflow_status( .bind(status.to_string()) .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow {id}"))); @@ -696,6 +738,12 @@ pub async fn set_workflow_enabled( id: Uuid, enabled: bool, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -706,9 +754,10 @@ pub async fn set_workflow_enabled( .bind(enabled) .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow {id}"))); @@ -729,6 +778,12 @@ pub async fn disable_workflows_for_owner_in_channel( channel_id: Uuid, owner_pubkey: &[u8], ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let affected = sqlx::query( r#" UPDATE workflows @@ -739,9 +794,10 @@ pub async fn disable_workflows_for_owner_in_channel( .bind(community_id.as_uuid()) .bind(channel_id) .bind(owner_pubkey) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; Ok(affected) } @@ -753,11 +809,16 @@ pub async fn disable_workflows_for_owner_in_channel( /// so the disable executes on the fence's own connection, avoiding a second /// pool acquisition while the fence holds one connection. pub async fn disable_workflows_for_owner_in_channel_on_conn( - conn: &mut PgConnection, + tx: &mut crate::AdmittedTx, community_id: CommunityId, channel_id: Uuid, owner_pubkey: &[u8], ) -> Result { + if tx.community() != community_id { + return Err(DbError::AccessDenied( + "workflow write community mismatch".into(), + )); + } let affected = sqlx::query( r#" UPDATE workflows @@ -768,7 +829,7 @@ pub async fn disable_workflows_for_owner_in_channel_on_conn( .bind(community_id.as_uuid()) .bind(channel_id) .bind(owner_pubkey) - .execute(&mut *conn) + .execute(tx.conn()) .await? .rows_affected(); @@ -789,6 +850,12 @@ pub async fn create_workflow_run( trigger_event_id: Option<&[u8]>, trigger_context: Option<&serde_json::Value>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let id = Uuid::new_v4(); sqlx::query( @@ -803,8 +870,9 @@ pub async fn create_workflow_run( .bind(workflow_id) .bind(trigger_event_id) .bind(trigger_context) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(id) } @@ -907,6 +975,12 @@ pub async fn update_workflow_run( trace: &serde_json::Value, failure: Option>, ) -> Result<()> { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let status_str = status.to_string(); let (error_code, error) = failure .map(|failure| (Some(failure.code), Some(failure.message))) @@ -935,9 +1009,10 @@ pub async fn update_workflow_run( .bind(&status_str) // for completed_at CASE .bind(community_id.as_uuid()) .bind(id) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; if affected == 0 { return Err(DbError::NotFound(format!("workflow_run {id}"))); @@ -983,6 +1058,12 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) -> expires_at, } = params; let token_hash = hash_approval_token(token); + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; sqlx::query( r#" @@ -999,8 +1080,9 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) -> .bind(step_index) .bind(approver_spec) .bind(expires_at) - .execute(pool) + .execute(tx.conn()) .await?; + tx.commit().await?; Ok(()) } @@ -1120,6 +1202,12 @@ pub async fn update_approval_by_stored_hash( approver_pubkey: Option<&[u8]>, note: Option<&str>, ) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; let status_str = status.to_string(); let affected = sqlx::query( r#" @@ -1139,9 +1227,10 @@ pub async fn update_approval_by_stored_hash( .bind(&status_str) // for denied_at CASE .bind(community_id.as_uuid()) .bind(token_hash) - .execute(pool) + .execute(tx.conn()) .await? .rows_affected(); + tx.commit().await?; Ok(affected > 0) } diff --git a/crates/buzz-db/tests/observability_source.rs b/crates/buzz-db/tests/observability_source.rs index 1608779952..4d4edcf8e9 100644 --- a/crates/buzz-db/tests/observability_source.rs +++ b/crates/buzz-db/tests/observability_source.rs @@ -207,48 +207,47 @@ fn p0_pool_acquisitions_use_typed_operation_pairs_without_other() { assert!(!thread_metadata.contains("fetch_optional(pool)")); let channel = include_str!("../src/store/channel.rs"); - assert!(channel.contains("async fn begin_event_write_transaction(")); - assert!(channel.contains("async fn acquire_event_write_connection(")); + assert!(channel.contains("begin_community_write_transaction(")); for (start, end, expected) in [ ( "pub async fn create_channel(\n", "/// Creates a channel with a client-supplied UUID", - "begin_event_write_transaction(pool)", + "begin_community_write_transaction(", ), ( "pub async fn create_channel_with_id(\n", "/// Fetches a channel record by `(community_id, id)`", - "begin_event_write_transaction(pool)", + "begin_community_write_transaction(", ), ( "pub async fn update_channel(\n", "/// Sets the topic for a channel", - "begin_event_write_transaction(pool)", + "begin_community_write_transaction(", ), ( "pub async fn set_topic(\n", "/// Sets the purpose for a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn set_purpose(\n", "/// Archives a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn archive_channel(\n", "/// Unarchives a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn unarchive_channel(\n", "/// Soft-delete a channel", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ( "pub async fn soft_delete_channel(\n", "/// Archive ephemeral channels", - "acquire_event_write_connection(pool)", + "begin_community_write_transaction(", ), ] { let function = channel @@ -903,11 +902,13 @@ const GUARDED_TABLE_WRITE_MARKERS: [&str; 9] = [ ]; const COMMUNITY_CHOKEPOINT_MARKER: &str = "begin_community_event_write_transaction("; +const GENERAL_COMMUNITY_CHOKEPOINT_MARKER: &str = "begin_community_write_transaction("; const COMMUNITY_CHOKEPOINT_LEGACY_MARKER: &str = "begin_community_event_write_transaction_with_legacy_metrics("; fn has_any_tenant_local_chokepoint(source: &str) -> bool { source.contains(COMMUNITY_CHOKEPOINT_MARKER) + || source.contains(GENERAL_COMMUNITY_CHOKEPOINT_MARKER) || source.contains(COMMUNITY_CHOKEPOINT_LEGACY_MARKER) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index b762af391d..be6743599b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8396,7 +8396,9 @@ pub(crate) mod postgres_tests { .await .expect("local Postgres and Redis"); // A schema whose `users` table can be taken away after the link, - // so only the owner-to-agent lookup fails at revoke time. + // so only the owner-to-agent lookup fails at revoke time. It + // carries `communities` and the deletion lock key so writes pass + // community write admission. let db_url = crate::test_support::database_url(); let schema = format!("late_owner_{}", uuid::Uuid::new_v4().simple()); sqlx::raw_sql(sqlx::AssertSqlSafe(format!( @@ -8405,7 +8407,10 @@ pub(crate) mod postgres_tests { CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ CREATE TABLE {schema}.relay_members (LIKE public.relay_members INCLUDING ALL); \ CREATE TABLE {schema}.join_policy_acceptances \ - (LIKE public.join_policy_acceptances INCLUDING ALL);" + (LIKE public.join_policy_acceptances INCLUDING ALL); \ + CREATE TABLE {schema}.communities (LIKE public.communities INCLUDING ALL); \ + CREATE FUNCTION {schema}.community_deletion_lock_key(target UUID) RETURNS BIGINT \ + LANGUAGE SQL IMMUTABLE STRICT AS 'SELECT public.community_deletion_lock_key(target)';" ))) .execute(state.db.pool()) .await @@ -8423,6 +8428,17 @@ pub(crate) mod postgres_tests { let tenant = fresh_tenant("late-owner.test"); let other = fresh_tenant("late-owner-other.test"); + // Both tenants pass community write admission in the schema. + for community in [&tenant, &other] { + sqlx::query(sqlx::AssertSqlSafe(format!( + "INSERT INTO {schema}.communities (id, host) VALUES ($1, $2)" + ))) + .bind(community.community().as_uuid()) + .bind(community.host()) + .execute(&admin) + .await + .expect("seed admitted community"); + } let (owner, agent) = (Keys::generate(), Keys::generate()); let agent_bytes = agent.public_key().to_bytes(); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5aad9689aa..0324d6f2cc 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -7150,7 +7150,8 @@ mod postgres_tests { use tokio_util::sync::CancellationToken; // A schema with bans but no `users` table: the ban commits, and only - // the owned-agent lookup fails. + // the owned-agent lookup fails. It carries `communities` and the + // deletion lock key so the ban passes community write admission. let db_url = crate::test_support::database_url(); let admin = sqlx::PgPool::connect(&db_url) .await @@ -7158,7 +7159,10 @@ mod postgres_tests { let schema = format!("revoke_owner_{}", Uuid::new_v4().simple()); sqlx::raw_sql(sqlx::AssertSqlSafe(format!( "CREATE SCHEMA {schema}; \ - CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL);" + CREATE TABLE {schema}.community_bans (LIKE public.community_bans INCLUDING ALL); \ + CREATE TABLE {schema}.communities (LIKE public.communities INCLUDING ALL); \ + CREATE FUNCTION {schema}.community_deletion_lock_key(target UUID) RETURNS BIGINT \ + LANGUAGE SQL IMMUTABLE STRICT AS 'SELECT public.community_deletion_lock_key(target)';" ))) .execute(&admin) .await @@ -7173,6 +7177,13 @@ mod postgres_tests { let state = build_canvas_ingest_state(&db_url, &pool).await; let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); let tenant = TenantContext::resolved(community, "revoke-owner.test".to_string()); + sqlx::query(sqlx::AssertSqlSafe(format!( + "INSERT INTO {schema}.communities (id, host) VALUES ($1, 'revoke-owner.test')" + ))) + .bind(community.as_uuid()) + .execute(&admin) + .await + .expect("seed admitted community"); let (owner, agent, bystander) = ( nostr::Keys::generate(), nostr::Keys::generate(), diff --git a/crates/buzz-relay/src/push_runtime.rs b/crates/buzz-relay/src/push_runtime.rs index 44183a8f1c..42930f6c1f 100644 --- a/crates/buzz-relay/src/push_runtime.rs +++ b/crates/buzz-relay/src/push_runtime.rs @@ -9,7 +9,7 @@ use buzz_core::filter::{filters_match, reader_authorized_for_event}; use chrono::{TimeDelta, Utc}; use nostr::Filter; use serde::{Deserialize, Serialize}; -use tracing::{error, warn}; +use tracing::{debug, error, warn}; use crate::{handlers::push_lease::Subscription, nip98::nip98_header, state::AppState}; @@ -398,6 +398,11 @@ async fn delivery_worker_loop(state: &AppState) { deliver_one(state, &http, wake).await; } } + // The community left `active` after the host scan; the + // next scan skips it, so this is not worth a warning. + Err(e @ buzz_db::DbError::AccessDenied(_)) => { + debug!(%community, "push wake claim refused: {e}") + } Err(e) => warn!(%community, "push wake claim failed: {e}"), } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 7e015a5dc8..1e88eb8652 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -2401,9 +2401,7 @@ async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::Ne metrics::histogram!("buzz_audit_log_seconds").record(t.elapsed().as_secs_f64()); return; } - Err(buzz_audit::AuditError::Database(sqlx::Error::Database(database_error))) - if database_error.code().as_deref() == Some("55P03") => - { + Err(error) if is_lock_timeout(&error) => { retries += 1; metrics::counter!("buzz_audit_log_lock_retries_total").increment(1); tracing::warn!( @@ -2423,6 +2421,20 @@ async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::Ne } } +/// Whether an audit append failed on `lock_timeout` (`55P03`), either on the +/// audit-chain lock or on the community admission lock taken before it. Both +/// are transient; the entry is kept and retried. Admission refusal +/// (`AccessDenied`) is terminal: a quiescing or fenced community refuses for +/// good. +fn is_lock_timeout(error: &buzz_audit::AuditError) -> bool { + let database_error = match error { + buzz_audit::AuditError::Database(sqlx::Error::Database(e)) + | buzz_audit::AuditError::Admission(buzz_db::DbError::Sqlx(sqlx::Error::Database(e))) => e, + _ => return false, + }; + database_error.code().as_deref() == Some("55P03") +} + impl std::fmt::Debug for AppState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AppState") @@ -2929,7 +2941,25 @@ pub(crate) mod tests { Arc::new(state) } + /// The lock an audit-retry test holds while the worker times out on it. + #[derive(Clone, Copy)] + enum HeldAuditLock { + /// The per-community audit-chain lock (`buzz_audit:`). + AuditChain, + /// The community deletion lock, held EXCLUSIVE as `begin_quiescing`, + /// `fence` and `abort` do; audit admission takes it SHARED first. + CommunityDeletion, + } + async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { + assert_audit_worker_retries_lock_timeout(HeldAuditLock::AuditChain).await; + } + + async fn audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once() { + assert_audit_worker_retries_lock_timeout(HeldAuditLock::CommunityDeletion).await; + } + + async fn assert_audit_worker_retries_lock_timeout(held: HeldAuditLock) { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL"); let observer = sqlx::PgPool::connect(&database_url) .await @@ -2974,11 +3004,21 @@ pub(crate) mod tests { // Mirrors buzz_audit::service::AUDIT_LOCK_NAMESPACE. let lock_key = format!("buzz_audit:{community_id}"); let mut holder = observer.acquire().await.expect("acquire lock holder"); - sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") - .bind(&lock_key) - .execute(&mut *holder) - .await - .expect("hold community audit lock"); + match held { + HeldAuditLock::AuditChain => { + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + } + HeldAuditLock::CommunityDeletion => { + sqlx::query("SELECT pg_advisory_lock(community_deletion_lock_key($1))") + .bind(community_id) + .execute(&mut *holder) + .await + } + } + .expect("hold lock"); let audit = Arc::new(AuditService::new(audit_pool)); let worker = tokio::spawn({ @@ -2997,7 +3037,7 @@ pub(crate) mod tests { "SELECT EXISTS (\ SELECT 1 FROM pg_stat_activity \ WHERE application_name = $1 \ - AND query LIKE 'SELECT pg_advisory_lock%' \ + AND query LIKE 'SELECT pg_advisory_xact_lock%' \ AND wait_event = 'advisory'\ )", ) @@ -3019,11 +3059,21 @@ pub(crate) mod tests { .await .expect("audit worker never retried after lock_timeout"); - sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") - .bind(&lock_key) - .execute(&mut *holder) - .await - .expect("release community audit lock"); + match held { + HeldAuditLock::AuditChain => { + sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_key) + .execute(&mut *holder) + .await + } + HeldAuditLock::CommunityDeletion => { + sqlx::query("SELECT pg_advisory_unlock(community_deletion_lock_key($1))") + .bind(community_id) + .execute(&mut *holder) + .await + } + } + .expect("release lock"); tokio::time::timeout(std::time::Duration::from_secs(3), worker) .await .expect("audit worker did not finish after lock release") @@ -3058,6 +3108,14 @@ pub(crate) mod tests { async fn audit_worker_retries_lock_timeout_until_original_entry_is_appended_once() { super::audit_worker_retries_lock_timeout_until_original_entry_is_appended_once().await; } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once() + { + super::audit_worker_retries_admission_lock_timeout_until_original_entry_is_appended_once() + .await; + } } #[test] From 67c4fe5e17c5e7b25bf6f5ddbe0b4aeeb0531c2f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 11:16:47 -0400 Subject: [PATCH 017/146] fix(deps): bump rustls to 0.23.46 for RUSTSEC-2026-0346 and -0347 (#8319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary The required Security check now fails on every branch, including main. The dependency audit (`cargo-deny`) reports two new advisories for rustls 0.23.45, the TLS library behind the relay, CLI and desktop connections: - **RUSTSEC-2026-0346:** printing a TLS client session value with `Debug` (for example, in a log line) shows its TLS secret. - **RUSTSEC-2026-0347:** when a server rejects Encrypted Client Hello (ECH, which hides the server name a client asks for), data the client buffered can go to the outer server. rustls 0.23.46 fixes both. This PR moves the root and desktop lockfiles to it, so the Security check passes again. ### Related issue None found. #8277 fixed an earlier advisory in the same way. ### Testing - `cargo-deny check` passes: "advisories ok, bans ok, licenses ok, sources ok". - `cargo tree --locked` resolves both lockfiles without more changes. Only the rustls version and checksum change. A plain `cargo update -p rustls` also moved unrelated `syn` and `getrandom` edges, so this PR leaves those out. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- Cargo.lock | 4 ++-- desktop/src-tauri/Cargo.lock | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27a35a4ae5..798825deaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8814,9 +8814,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7ba160f8c8..2b622d7c6c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -9071,9 +9071,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", From e68977f2708bf5666ae2e4946b6a860225bde49e Mon Sep 17 00:00:00 2001 From: Diem Nguyen Date: Wed, 14 Oct 2026 10:40:32 -0700 Subject: [PATCH 018/146] docs: link the Buzz feedback community and @buzzdotxyz on X (#8324) ## Summary Adds a **Community** section to the README so people know where to connect: the [Buzz feedback community](https://feedback.communities.buzz.xyz/invite/v2.yC8ZZIz5vwWCZYo2RRheuY1a0IKZRTXvxTmK7vIFIIY), our main public discussion forum, and [@buzzdotxyz on X](https://x.com/buzzdotxyz). There's no official Buzz Discord yet, so this PR leaves Discord out on purpose. ### Related issue N/A. Companion PR: block/buzz-app#1091. ### Testing Docs-only change. Both URLs return HTTP 200, and the diff is the README alone. --------- Signed-off-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 56439f00bc..bdf218e57d 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,13 @@ just reset # ⚠️ Wipe data + recreate --- +## Community + +- **[Buzz feedback community](https://feedback.communities.buzz.xyz/invite/v2.yC8ZZIz5vwWCZYo2RRheuY1a0IKZRTXvxTmK7vIFIIY)** — ask questions, share feedback, and connect with other Buzz users +- **[@buzzdotxyz on X](https://x.com/buzzdotxyz)** — news and updates + +--- + ## What it is not - Not blockchain. Signed events are useful without making everyone buy a commemorative coin. From 99c5fc8bba2c4037ac1d72a82f7afea18905d66d Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Wed, 14 Oct 2026 14:36:00 -0400 Subject: [PATCH 019/146] ci(docker): publish qualified, sortable push gateway main tags (#8305) ## Summary This PR makes two changes to the push gateway image job (`push-gateway-merge` in `docker.yml`). Together they let staging auto-promote the gateway the same way it already auto-promotes the relay: 1. **Publish only after same-SHA CI qualification.** `push-gateway-merge` now needs `qualify` as well as `push-gateway-build`. This is the same gate the relay's `merge` job already uses: a successful `ci.yml` push run on the exact source SHA. Before this change, the gateway image was published even when main CI was red. That was safe while staging pinned the gateway by hand. It isn't safe once the newest main image deploys automatically. 2. **Publish the sortable `:main--` tag on main pushes.** It's the same `type=raw` line the relay matrix gained in #8240. It uses the same `enable=` guard, so it applies only to `push` on `refs/heads/main`. A Lexical selector on `^main-\d{14}-[0-9a-f]{7}$` can then pick the newest gateway build from the tag list alone. `:sha-<7>` has no ordering. `:main`, `:sha-<7>`, and the release semver tags don't change. The gateway has no debug variant, so no `debug-main-*` tag is published. **Tradeoff:** a gateway image now appears only after `qualify` finishes, which takes as long as main CI. If main CI fails, no gateway image is published for that commit, and that includes `:main` and `:sha-<7>`. The relay already behaves this way, and it's the behavior we want for anything that auto-deploys. **What got simpler:** the relay and gateway images now share the same publication gate and the same tag set. Downstream tooling no longer needs a special case for the gateway's unqualified, unordered tags. **What stays different:** the relay's `merge` job also attests the `deployment-eligibility/v1` predicate (`docs/deployment-identity.md`); the gateway publishes no such attestation. That's intentional for now: the staging promotion guard selects the gateway by package and `main-*` tag and does not verify attestations, so the gateway warehouse must not be wired to require one. If promotion ever starts verifying eligibility, the gateway needs the same attestation first. ### Related issue N/A (staging rollout tooling). The downstream promotion-guard and warehouse changes land separately. The warehouse switches on only after this tag has been published. ### Testing - `scripts/test-relay-image-eligibility-workflow.sh` now pins both properties of the `push-gateway-merge` job (it runs in CI under "Relay image eligibility contract"). Both checks match whole active lines, so a commented-out copy can't satisfy them. - At `9b8c49600` it passes. - With the live `needs` line replaced by `needs: [push-gateway-build]` and a commented-out `# needs: [push-gateway-build, qualify]` left behind, it fails. - With the gateway `main-*` line commented out, it fails. - With `needs` reverted to `push-gateway-build`, it fails with `push gateway image must publish only after same-SHA CI qualification`. - With the `main-*` tag line removed, it fails with `push gateway image must publish the sortable main--<7> tag on main pushes`. - `scripts/test-release-ref-contract.sh` now counts the sortable `main-*` tag line on all three image legs (relay, relay-debug, gateway) instead of two. It passes at `9b8c49600`. - `bin/actionlint .github/workflows/docker.yml` is clean at `f6c03b6b5`. - PR runs don't push images. The first real `main-*` gateway tag appears on the first main push after merge, and I'll check the registry for it 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 | 9 ++++++++- scripts/test-relay-image-eligibility-workflow.sh | 14 ++++++++++++++ scripts/test-release-ref-contract.sh | 9 +++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 596c247eee..233943268c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -34,6 +34,10 @@ 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. # +# ghcr.io/block/buzz-push-gateway carries the same :main, :sha-<7> and +# :main--<7> tags on main pushes, and publishes only after the +# relay's `qualify` job accepts the same source SHA. +# # :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 @@ -614,7 +618,9 @@ jobs: name: Publish public push gateway image if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 - needs: push-gateway-build + # Staging promotes every published main gateway image automatically, so the + # gateway publishes only from a source the relay's same-SHA CI qualified. + needs: [push-gateway-build, qualify] timeout-minutes: 15 permissions: contents: read @@ -646,6 +652,7 @@ jobs: type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},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: Merge and publish manifest id: manifest working-directory: /tmp/gateway-digests diff --git a/scripts/test-relay-image-eligibility-workflow.sh b/scripts/test-relay-image-eligibility-workflow.sh index 40745a8e88..f6adc4c6f3 100755 --- a/scripts/test-relay-image-eligibility-workflow.sh +++ b/scripts/test-relay-image-eligibility-workflow.sh @@ -44,6 +44,20 @@ grep -Fq "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" <<<"$merge_ exit 1 } +# Staging auto-promotes the newest main--<7> gateway image, so the gateway +# must publish that tag and only from a source the relay CI qualified. Match +# whole lines (-x) so a commented-out or otherwise inactive copy cannot satisfy +# the contract after the live line regresses. +gateway_merge_job=$(awk '/^ push-gateway-merge:/{f=1} f&&/^ [a-z][a-z_-]*:$/&&!/^ push-gateway-merge:/{exit} f' "$workflow") +grep -Fxq " needs: [push-gateway-build, qualify]" <<<"$gateway_merge_job" || { + echo "push gateway image must publish only after same-SHA CI qualification" >&2 + exit 1 +} +grep -Fxq " type=raw,value=main-{{commit_date 'YYYYMMDDHHmmss'}}-{{sha}},enable=\${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}" <<<"$gateway_merge_job" || { + echo "push gateway image must publish the sortable main--<7> tag on main pushes" >&2 + exit 1 +} + select_run() { jq -r --arg source_sha aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -f "$selector" | jq -r '.id // empty' } diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 5b11a71087..e5903cb46c 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -52,12 +52,13 @@ 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. +# Kargo's buzz-image and buzz-push-gateway-image warehouses select Lexical over +# ^main-[0-9]{14}-[0-9a-f]{7}$; a drifted format stops Freight discovery +# silently, so pin all three image legs (relay, relay-debug, push gateway). 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 +if [ "$sortable_count" != 3 ]; then + echo "docker.yml must publish the sortable main-- tag on all three image legs (found $sortable_count)" >&2 exit 1 fi grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml" From 2270b66e147c8955edbfc445aed4b9633f9dff6e Mon Sep 17 00:00:00 2001 From: Ian Oberst Date: Wed, 14 Oct 2026 11:36:26 -0700 Subject: [PATCH 020/146] test(buzz-db): drop partition scratch schemas on panic (#8293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The partition Postgres tests dropped their scratch schema with a `drop_schema` call at the end of each test body. When a test panicked, that call never ran, so the schema leaked into the shared database, along with any `{schema}_*` sibling schemas or reader role the test created. A reviewer on #7993 hit this locally: the leaked schema plus a database-wide catalog query made later runs fail. CI hides it because the test wrapper gives each process its own database. `scratch_pool` / `scratch_pool_with_max_connections` now return `(pool, ScratchSchema)`. The guard owns the admin pool and schema name and cleans up on `Drop`: - **Fresh thread and connection.** `Drop` runs on the test's runtime thread, which can't drive the test's pools while it blocks, so cleanup runs on its own thread with its own runtime and connection. - **Terminates the test's backends first.** Both pools record each connection's backend pid. A test that panics mid-transaction keeps its locks, because the rollback would only run on the blocked runtime. Without this step `DROP SCHEMA` waits on those locks. - **Drops `{name}` plus every schema and role named `{name}_*`.** This replaces the per-test sibling-schema and `DROP ROLE` cleanup. The reader role is now named `{schema}_reader`. - **Bounded.** `lock_timeout = '10s'`. Failures print a warning and never panic inside `Drop`. The 52 call sites are migrated mechanically. Test-only change. ### Related issue Follow-up from review on #7993. No existing issue found. ### Testing - New `scratch_schema_is_dropped_when_a_test_panics_holding_a_lock`: the test body creates a sibling schema, takes `ACCESS EXCLUSIVE` on a scratch table in an open transaction, and panics. The test then asserts no `{name}` / `{name}_*` schema remains. - Proved it can fail: with the backend-termination step disabled, cleanup hits the lock timeout and the test fails, reporting both leaked schemas. - Partition Postgres lane: `scripts/postgres-test-run.sh -p buzz-db --lib -E 'test(partition)'`, 53/53 passed. - The same filter run directly against the shared `buzz` database: 53/53 passed, then 0 `partition_audit_test_%` schemas and 0 `partition_audit_%` roles remained. - `cargo clippy -p buzz-db --tests` clean. Pre-push hooks passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Ian Oberst Co-authored-by: Ian Oberst Co-authored-by: Claude Opus 5.5 --- crates/buzz-db/src/store/partition.rs | 418 +++++++++++++----- .../src/store/partition/maintenance_tests.rs | 83 ++-- 2 files changed, 341 insertions(+), 160 deletions(-) diff --git a/crates/buzz-db/src/store/partition.rs b/crates/buzz-db/src/store/partition.rs index 8322313de3..cd5a5cd6b5 100644 --- a/crates/buzz-db/src/store/partition.rs +++ b/crates/buzz-db/src/store/partition.rs @@ -2201,49 +2201,207 @@ mod tests { } pub(super) mod postgres_tests { + use std::sync::{Arc, Mutex}; + use sqlx::postgres::PgPoolOptions; + use sqlx::Connection; use uuid::Uuid; use super::*; - pub(in crate::store::partition) async fn scratch_pool() -> (PgPool, PgPool, String) { + /// A uniquely named scratch schema that is dropped when the guard goes + /// out of scope, including when the test panics. + /// + /// Cleanup also drops every schema and role named `{name}_*`, so a test + /// that needs extra schemas or roles derives their names from `name`. + /// + /// Bind the guard to a named variable such as `_scratch` for the whole + /// test: `let (pool, _) = scratch_pool().await` drops it, and the + /// schema with it, immediately. + pub(in crate::store::partition) struct ScratchSchema { + pub(in crate::store::partition) admin: PgPool, + pub(in crate::store::partition) name: String, + url: String, + backends: Arc>>, + } + + impl Drop for ScratchSchema { + fn drop(&mut self) { + // Drop runs on the test's runtime thread, which cannot drive the + // test's pools while it blocks here, so clean up over a fresh + // connection on a thread with its own runtime. + let url = std::mem::take(&mut self.url); + let name = self.name.clone(); + let backends = std::mem::take( + &mut *self + .backends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + let cleanup = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())? + .block_on(drop_scratch_objects(&url, &name, &backends)) + }) + .join(); + let error = match cleanup { + Ok(Ok(())) => return, + Ok(Err(error)) => error, + Err(_) => "cleanup thread panicked".to_owned(), + }; + eprintln!( + "warning: failed to drop scratch schema {}: {error}", + self.name + ); + } + } + + async fn drop_scratch_objects( + url: &str, + name: &str, + backends: &[i32], + ) -> std::result::Result<(), String> { + let mut connection = PgConnection::connect(url) + .await + .map_err(|error| error.to_string())?; + // Every step is idempotent, so keep going after a failure and + // report all of them rather than skipping the remaining objects. + let mut errors = Vec::new(); + let mut record = |step: &str, result: sqlx::Result<()>| { + if let Err(error) = result { + errors.push(format!("{step}: {error}")); + } + }; + let result = sqlx::query("SET lock_timeout = '10s'") + .execute(&mut connection) + .await + .map(drop); + record("set lock_timeout", result); + // A panicking test can leave a transaction holding locks in the + // schema; its rollback would only run on the blocked test runtime. + let result = sqlx::query( + "SELECT pg_terminate_backend(pid, 5000) FROM pg_stat_activity \ + WHERE pid = ANY($1) AND datname = current_database()", + ) + .bind(backends) + .execute(&mut connection) + .await + .map(drop); + record("terminate test backends", result); + let schemas: Vec = sqlx::query_scalar( + "SELECT nspname::text FROM pg_namespace \ + WHERE nspname = $1 OR starts_with(nspname, $1 || '_')", + ) + .bind(name) + .fetch_all(&mut connection) + .await + .unwrap_or_else(|error| { + record("list schemas", Err(error)); + Vec::new() + }); + for schema in schemas { + let result = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP SCHEMA IF EXISTS {} CASCADE", + quote_identifier(&schema) + ))) + .execute(&mut connection) + .await + .map(drop); + record(&format!("drop schema {schema}"), result); + } + let roles: Vec = sqlx::query_scalar( + "SELECT rolname::text FROM pg_roles WHERE starts_with(rolname, $1 || '_')", + ) + .bind(name) + .fetch_all(&mut connection) + .await + .unwrap_or_else(|error| { + record("list roles", Err(error)); + Vec::new() + }); + for role in roles { + let result = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP ROLE IF EXISTS {}", + quote_identifier(&role) + ))) + .execute(&mut connection) + .await + .map(drop); + record(&format!("drop role {role}"), result); + } + record("close connection", connection.close().await); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("; ")) + } + } + + /// Pool options that record each connection's backend pid in `backends` + /// and point its `search_path` at `search_path`, when given. + fn tracked_pool_options( + search_path: Option, + backends: Arc>>, + ) -> PgPoolOptions { + PgPoolOptions::new().after_connect(move |connection, _| { + let search_path = search_path.clone(); + let backends = Arc::clone(&backends); + Box::pin(async move { + if let Some(schema) = search_path { + sqlx::query(sqlx::AssertSqlSafe(format!("SET search_path TO {schema}"))) + .execute(&mut *connection) + .await?; + } + let pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *connection) + .await?; + backends + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(pid); + Ok(()) + }) + }) + } + + /// A pool whose `search_path` is a fresh scratch schema, plus the guard + /// that drops it. Bind the guard to a named variable, never `_`. + pub(in crate::store::partition) async fn scratch_pool() -> (PgPool, ScratchSchema) { scratch_pool_with_max_connections(4).await } pub(in crate::store::partition) async fn scratch_pool_with_max_connections( max_connections: u32, - ) -> (PgPool, PgPool, String) { + ) -> (PgPool, ScratchSchema) { let url = crate::test_support::database_url(); - let schema = format!("partition_audit_test_{}", Uuid::new_v4().simple()); - let admin = PgPool::connect(&url).await.expect("connect admin pool"); - sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {schema}"))) - .execute(&admin) - .await - .expect("create scratch schema"); - let search_path_schema = schema.clone(); - let pool = PgPoolOptions::new() - .max_connections(max_connections) - .after_connect(move |connection, _| { - let schema = search_path_schema.clone(); - Box::pin(async move { - sqlx::query(sqlx::AssertSqlSafe(format!("SET search_path TO {schema}"))) - .execute(connection) - .await?; - Ok(()) - }) - }) + let name = format!("partition_audit_test_{}", Uuid::new_v4().simple()); + let backends = Arc::new(Mutex::new(Vec::new())); + let admin = tracked_pool_options(None, Arc::clone(&backends)) .connect(&url) .await - .expect("connect scratch pool"); - (pool, admin, schema) - } - - pub(in crate::store::partition) async fn drop_schema(admin: &PgPool, schema: &str) { - let _ = sqlx::query(sqlx::AssertSqlSafe(format!( - "DROP SCHEMA IF EXISTS {schema} CASCADE" + .expect("connect admin pool"); + let scratch = ScratchSchema { + admin, + name, + url, + backends, + }; + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {}", + scratch.name ))) - .execute(admin) - .await; + .execute(&scratch.admin) + .await + .expect("create scratch schema"); + let pool = + tracked_pool_options(Some(scratch.name.clone()), Arc::clone(&scratch.backends)) + .max_connections(max_connections) + .connect(&scratch.url) + .await + .expect("connect scratch pool"); + (pool, scratch) } pub(in crate::store::partition) async fn seed_parents(pool: &PgPool) { @@ -2412,10 +2570,80 @@ mod tests { } } + #[test] + #[ignore = "requires Postgres"] + fn scratch_schema_is_dropped_when_a_test_panics_holding_a_lock() { + let runtime = || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("build test runtime") + }; + let (name_sender, name_receiver) = std::sync::mpsc::channel(); + let outcome = std::thread::spawn(move || { + runtime().block_on(async move { + let (pool, scratch) = scratch_pool().await; + seed_parents(&pool).await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "CREATE SCHEMA {}_children", + scratch.name + ))) + .execute(&scratch.admin) + .await + .expect("create sibling schema"); + for statement in [ + format!("CREATE ROLE {}_reader NOLOGIN", scratch.name), + format!("GRANT USAGE ON SCHEMA {0} TO {0}_reader", scratch.name), + ] { + sqlx::query(sqlx::AssertSqlSafe(statement)) + .execute(&scratch.admin) + .await + .expect("create scratch role"); + } + let mut holder = pool.begin().await.expect("begin lock holder"); + sqlx::query("LOCK TABLE events IN ACCESS EXCLUSIVE MODE") + .execute(&mut *holder) + .await + .expect("lock scratch table"); + name_sender + .send(scratch.name.clone()) + .expect("send scratch schema name"); + panic!("deliberate panic while holding a scratch schema lock"); + }) + }) + .join(); + assert!(outcome.is_err(), "the scratch test body must panic"); + let name = name_receiver.recv().expect("scratch schema name"); + + let (schemas, roles): (Vec, Vec) = runtime().block_on(async { + let mut connection = PgConnection::connect(&crate::test_support::database_url()) + .await + .expect("connect leftover check"); + let schemas = sqlx::query_scalar( + "SELECT nspname::text FROM pg_namespace \ + WHERE nspname = $1 OR starts_with(nspname, $1 || '_')", + ) + .bind(&name) + .fetch_all(&mut connection) + .await + .expect("list leftover schemas"); + let roles = sqlx::query_scalar( + "SELECT rolname::text FROM pg_roles WHERE starts_with(rolname, $1 || '_')", + ) + .bind(&name) + .fetch_all(&mut connection) + .await + .expect("list leftover roles"); + (schemas, roles) + }); + assert!(schemas.is_empty(), "leaked scratch schemas: {schemas:?}"); + assert!(roles.is_empty(), "leaked scratch roles: {roles:?}"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn fresh_layout_is_serving_safe_and_audit_is_read_only() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; let before = catalog_snapshot(&pool).await; let audit = audit_partition_catalog_at(&pool, 3, fixed_now()) @@ -2431,13 +2659,12 @@ mod tests { .all(|month| month.kind == MonthCoverageKind::CoveredByCatchAll) })); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn wrong_root_partition_key_never_proves_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents_with_keys(&pool, "alternate_at", "delivered_at").await; for table in PARTITIONED_TABLES { create_child( @@ -2473,13 +2700,13 @@ mod tests { .expect("delivery_log audit"); assert!(delivery_log.partition_key_valid); assert!(delivery_log.serving_safe); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn occupied_catch_all_is_reported_as_degraded() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_fresh_layout(&pool).await; sqlx::query( "INSERT INTO events (created_at, alternate_at) \ @@ -2519,17 +2746,17 @@ mod tests { assert!(delivery_log.children.iter().any(|child| { child.name == "delivery_log_p_future" && child.catch_all_nonempty == Some(false) })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn occupied_cross_schema_catch_all_is_reported_as_degraded() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_fresh_layout(&pool).await; let child_schema = format!("{schema}_children"); sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {child_schema}"))) - .execute(&admin) + .execute(&scratch.admin) .await .expect("create child schema"); sqlx::query(sqlx::AssertSqlSafe(format!( @@ -2567,20 +2794,19 @@ mod tests { && leaf.name == "events_p_future" && leaf.root_child_schema == child_schema })); - drop_schema(&admin, &schema).await; - drop_schema(&admin, &child_schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn same_named_cross_schema_children_keep_distinct_trigger_metadata() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; let first_schema = format!("{schema}_first"); let second_schema = format!("{schema}_second"); for child_schema in [&first_schema, &second_schema] { sqlx::query(sqlx::AssertSqlSafe(format!("CREATE SCHEMA {child_schema}"))) - .execute(&admin) + .execute(&scratch.admin) .await .expect("create child schema"); } @@ -2676,19 +2902,17 @@ mod tests { .expect("serialized coverage leaves") .iter() .any(|leaf| leaf["schema"] == second_schema)); - drop_schema(&admin, &schema).await; - drop_schema(&admin, &first_schema).await; - drop_schema(&admin, &second_schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn report_retains_successful_table_when_another_table_errors() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_fresh_layout(&pool).await; - let role = format!("partition_audit_reader_{}", Uuid::new_v4().simple()); + let role = format!("{schema}_reader"); sqlx::query(sqlx::AssertSqlSafe(format!("CREATE ROLE {role} NOLOGIN"))) - .execute(&admin) + .execute(&scratch.admin) .await .expect("create restricted audit role"); for statement in [ @@ -2696,12 +2920,12 @@ mod tests { format!("GRANT SELECT ON {schema}.events_p_future TO {role}"), ] { sqlx::query(sqlx::AssertSqlSafe(statement)) - .execute(&admin) + .execute(&scratch.admin) .await .expect("grant restricted audit access"); } let url = crate::test_support::database_url(); - let role_schema = schema.clone(); + let role_schema = schema.to_owned(); let role_name = role.clone(); let restricted = PgPoolOptions::new() .max_connections(1) @@ -2731,17 +2955,12 @@ mod tests { assert!(!report.serving_safe()); restricted.close().await; - drop_schema(&admin, &schema).await; - sqlx::query(sqlx::AssertSqlSafe(format!("DROP ROLE {role}"))) - .execute(&admin) - .await - .expect("drop restricted audit role"); } #[tokio::test] #[ignore = "requires Postgres"] async fn pending_detach_never_proves_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; let mut blocker = pool.acquire().await.expect("acquire blocker"); @@ -2824,13 +3043,12 @@ mod tests { && child.pending_detach && child.kind == PartitionChildKind::Anomalous })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn repaired_layout_recognizes_bounds_not_catch_all_name() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -2907,13 +3125,12 @@ mod tests { assert!(august.tables.iter().all(PartitionTableAudit::degraded)); assert_eq!(august.outcome(), PartitionAuditOutcome::Degraded); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn uncovered_months_are_created_with_trigger_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -2937,13 +3154,12 @@ mod tests { .iter() .all(|month| month.kind == MonthCoverageKind::CoveredByMonthly) && table.missing_trigger_count() == 0)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn default_partitions_report_occupancy_without_mutation() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_uncovered_layout(&pool).await; for table in PARTITIONED_TABLES { sqlx::query(sqlx::AssertSqlSafe(format!( @@ -3037,13 +3253,12 @@ mod tests { .await .expect("check canonical relation")); } - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn parent_default_does_not_mask_gap_below_explicit_sibling() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { create_child( @@ -3116,13 +3331,13 @@ mod tests { .execute(&pool) .await .is_err()); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn total_audit_timeout_bounds_blocked_occupancy_probes() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_uncovered_layout(&pool).await; for table in PARTITIONED_TABLES { sqlx::query(sqlx::AssertSqlSafe(format!( @@ -3132,7 +3347,11 @@ mod tests { .await .expect("create default partition"); } - let mut blocker = admin.begin().await.expect("begin blocking transaction"); + let mut blocker = scratch + .admin + .begin() + .await + .expect("begin blocking transaction"); sqlx::query(sqlx::AssertSqlSafe(format!( "LOCK TABLE {schema}.events_default IN ACCESS EXCLUSIVE MODE" ))) @@ -3157,13 +3376,12 @@ mod tests { .all(|error| error.error.contains("exceeded total timeout of 100ms"))); blocker.rollback().await.expect("release blocking lock"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn create_pins_non_iso_date_style() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; seed_uncovered_layout(&pool).await; sqlx::query("SET DateStyle TO 'SQL, DMY'") .execute(&pool) @@ -3186,13 +3404,12 @@ mod tests { .await .expect("show DateStyle"); assert_eq!(date_style, "SQL, DMY"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn create_pins_non_utc_time_zone() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; seed_uncovered_layout(&pool).await; sqlx::query("SET TimeZone TO 'America/Los_Angeles'") .execute(&pool) @@ -3215,13 +3432,12 @@ mod tests { .await .expect("show TimeZone"); assert_eq!(time_zone, "America/Los_Angeles"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn disabled_child_trigger_degrades_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; sqlx::query("ALTER TABLE events_p_future DISABLE TRIGGER partition_probe") .execute(&pool) @@ -3240,13 +3456,12 @@ mod tests { assert!(events.children.iter().any(|child| { child.name == "events_p_future" && child.missing_triggers == ["partition_probe"] })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn always_enabled_parent_trigger_preserves_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; sqlx::query("ALTER TABLE events ENABLE ALWAYS TRIGGER partition_probe") .execute(&pool) @@ -3266,13 +3481,13 @@ mod tests { .children .iter() .all(|child| child.missing_triggers.is_empty())); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn disabled_nested_leaf_trigger_degrades_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { let nested = format!("{table}_nested"); @@ -3339,18 +3554,18 @@ mod tests { assert_eq!(events.missing_trigger_count(), 1); let expected = format!( "{}:partition_probe", - qualified_relation_name(&schema, "events_nested_first") + qualified_relation_name(schema, "events_nested_first") ); assert!(events.children.iter().any(|child| { child.name == "events_nested" && child.missing_triggers == [expected.as_str()] })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn child_only_nested_leaf_trigger_degrades_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; create_nested_child( &pool, @@ -3396,31 +3611,29 @@ mod tests { assert_eq!(events.extra_trigger_count(), 1); let expected = format!( "{}:child_only_probe", - qualified_relation_name(&schema, "events_nested_first") + qualified_relation_name(schema, "events_nested_first") ); assert!(events.children.iter().any(|child| { child.name == "events_nested" && child.extra_triggers == [expected.as_str()] })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_skips_create_without_catalog_mutation() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_fresh_layout(&pool).await; let before = catalog_snapshot(&pool).await; ensure_future_partitions_at(&pool, 3, true, fixed_now()) .await .expect("covered no-op"); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn kill_switch_audits_but_does_not_create() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3445,13 +3658,12 @@ mod tests { .months .iter() .all(|month| month.kind == MonthCoverageKind::Uncovered))); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn nested_children_without_leaves_do_not_prove_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { create_nested_child( @@ -3477,13 +3689,12 @@ mod tests { && table.months[0].kind == MonthCoverageKind::Uncovered })); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn nested_descendant_leaves_prove_coverage_but_remain_degraded() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { let nested = format!("{table}_nested"); @@ -3519,13 +3730,12 @@ mod tests { && table.months[0].kind == MonthCoverageKind::CoveredByMonthly })); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn occupied_nested_catch_all_is_reported_on_leaf_and_root_child() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for (table, column) in [("events", "created_at"), ("delivery_log", "delivered_at")] { let nested = format!("{table}_nested"); @@ -3584,13 +3794,12 @@ mod tests { assert!(delivery_log.coverage_leaves.iter().any(|leaf| { leaf.name == "delivery_log_nested_future" && leaf.catch_all_nonempty == Some(false) })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn nested_different_partition_key_does_not_prove_coverage() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { let nested = format!("{table}_nested"); @@ -3623,13 +3832,12 @@ mod tests { && table.coverage_leaves.is_empty() && table.months[0].kind == MonthCoverageKind::Uncovered })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn audit_pins_non_iso_session_rendering() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; seed_fresh_layout(&pool).await; sqlx::query("SET DateStyle TO 'SQL, DMY'") .execute(&pool) @@ -3658,13 +3866,12 @@ mod tests { .expect("show TimeZone"); assert_eq!(date_style, "SQL, DMY"); assert_eq!(time_zone, "America/Los_Angeles"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn audit_transaction_applies_and_restores_statement_timeout() { - let (pool, admin, schema) = scratch_pool_with_max_connections(1).await; + let (pool, _scratch) = scratch_pool_with_max_connections(1).await; sqlx::query("SET statement_timeout TO '37s'") .execute(&pool) .await @@ -3692,13 +3899,12 @@ mod tests { .await .expect("show restored statement timeout"); assert_eq!(restored, "37s"); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn statement_triggers_are_not_part_of_leaf_parity() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { sqlx::query(sqlx::AssertSqlSafe(format!( @@ -3734,13 +3940,12 @@ mod tests { && table.missing_trigger_count() == 0 && table.extra_trigger_count() == 0 })); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn canonical_name_with_wrong_bounds_is_a_real_creation_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3776,13 +3981,13 @@ mod tests { .find(|table| table.table == "events") .expect("events audit"); assert_eq!(events.months[0].kind, MonthCoverageKind::Uncovered); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn canonical_name_attached_to_wrong_parent_is_a_real_creation_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; + let schema = scratch.name.as_str(); seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3833,18 +4038,17 @@ mod tests { ) "#, ) - .bind(&schema) + .bind(schema) .fetch_one(&pool) .await .expect("check attachment"); assert!(!attached_to_events); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn unrelated_canonical_relation_is_a_real_creation_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3869,13 +4073,12 @@ mod tests { assert!(relation_name_exists(&pool, "events_p2026_09") .await .expect("query unrelated relation")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn anomalous_child_and_trigger_mismatch_degrade_without_aborting() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -3923,7 +4126,6 @@ mod tests { .iter() .any(|child| child.extra_triggers == ["child_only_probe"]) })); - drop_schema(&admin, &schema).await; } } } diff --git a/crates/buzz-db/src/store/partition/maintenance_tests.rs b/crates/buzz-db/src/store/partition/maintenance_tests.rs index 99a1684814..35091f0d1e 100644 --- a/crates/buzz-db/src/store/partition/maintenance_tests.rs +++ b/crates/buzz-db/src/store/partition/maintenance_tests.rs @@ -328,8 +328,8 @@ mod postgres_tests { use super::*; use crate::store::partition::tests::postgres_tests::{ - catalog_snapshot, create_child, drop_schema, fixed_now, scratch_pool, - scratch_pool_with_max_connections, seed_parents, + catalog_snapshot, create_child, fixed_now, scratch_pool, scratch_pool_with_max_connections, + seed_parents, }; /// The repaired incident layout for both managed parents, optionally with @@ -441,7 +441,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn empty_catch_all_advances_to_canonical_layout_and_is_idempotent() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let audit = maintain(&pool).await.expect("advance"); @@ -474,13 +474,12 @@ mod postgres_tests { let before = catalog_snapshot(&pool).await; maintain(&pool).await.expect("idempotent rerun"); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn canonical_catch_all_name_is_replaced_in_one_transaction() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; for table in PARTITIONED_TABLES { create_child( @@ -506,13 +505,12 @@ mod postgres_tests { .children .iter() .any(|child| child.name == format!("{}_p2026_09", table.table)))); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn populated_catch_all_is_refused_before_any_lock() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query("INSERT INTO events (created_at, alternate_at) VALUES ('2027-02-01', now())") .execute(&pool) @@ -544,13 +542,12 @@ mod postgres_tests { .collect::>() }; assert_eq!(events_only(&after), events_only(&before)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn counterpart_contention_fails_fast_and_rolls_back() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let before = catalog_snapshot(&pool).await; let holder = hold_lock( @@ -575,13 +572,12 @@ mod postgres_tests { assert_eq!(catalog_snapshot(&pool).await, before); assert_advanced(&maintain(&pool).await.expect("advance after release")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn counterpart_reader_does_not_block_advancement() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let holder = hold_lock(&pool, "SELECT count(*) FROM communities").await; @@ -589,13 +585,12 @@ mod postgres_tests { drop(holder); assert_advanced(&result.expect("advance beside a counterpart reader")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn writer_checking_a_foreign_key_behind_maintenance_does_not_deadlock() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; // An in-flight insert: the parent first, then the foreign-key check. let mut writer = hold_lock(&pool, "LOCK TABLE ONLY events IN ROW EXCLUSIVE MODE").await; @@ -615,7 +610,7 @@ mod postgres_tests { WHERE $1 = ANY(pg_blocking_pids(pid)))", ) .bind(writer_pid) - .fetch_one(&admin) + .fetch_one(&scratch.admin) .await .expect("check queued maintenance"); if queued { @@ -650,7 +645,6 @@ mod postgres_tests { .expect("maintenance task") .expect("advance after the writer commits"), ); - drop_schema(&admin, &schema).await; } async fn wait_until_blocked_by(admin: &PgPool, pid: i32, waiters: i64) { @@ -673,7 +667,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn foreign_key_committed_while_waiting_on_the_parent_is_locked_nowait() { - let (pool, admin, schema) = scratch_pool_with_max_connections(6).await; + let (pool, scratch) = scratch_pool_with_max_connections(6).await; seed_repaired_layout(&pool, true).await; sqlx::query("CREATE TABLE reviewers (id UUID PRIMARY KEY)") .execute(&pool) @@ -708,12 +702,12 @@ mod postgres_tests { .expect("roll back counterpart writer"); } }); - wait_until_blocked_by(&admin, migration_pid, 1).await; + wait_until_blocked_by(&scratch.admin, migration_pid, 1).await; let maintenance = tokio::spawn({ let pool = pool.clone(); async move { maintain(&pool).await } }); - wait_until_blocked_by(&admin, migration_pid, 2).await; + wait_until_blocked_by(&scratch.admin, migration_pid, 2).await; migration.commit().await.expect("commit migration"); let result = maintenance.await.expect("maintenance task"); @@ -733,7 +727,6 @@ mod postgres_tests { release_writer.send(()).expect("release counterpart writer"); writer.await.expect("counterpart writer task"); assert_advanced(&maintain(&pool).await.expect("advance after release")); - drop_schema(&admin, &schema).await; } async fn backend_pid(connection: &mut PgConnection) -> i32 { @@ -753,7 +746,7 @@ mod postgres_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn foreign_key_referencing_the_parent_committed_while_waiting_is_refused() { - let (pool, admin, schema) = scratch_pool_with_max_connections(6).await; + let (pool, scratch) = scratch_pool_with_max_connections(6).await; seed_repaired_layout(&pool, true).await; sqlx::query( "CREATE TABLE event_refs \ @@ -775,7 +768,7 @@ mod postgres_tests { let pool = pool.clone(); async move { maintain(&pool).await } }); - wait_until_blocked_by(&admin, migration_pid, 1).await; + wait_until_blocked_by(&scratch.admin, migration_pid, 1).await; migration.commit().await.expect("commit migration"); let result = maintenance.await.expect("maintenance task"); @@ -783,13 +776,12 @@ mod postgres_tests { refused_events_only(&result, "events is referenced by a foreign key"), "{result:?}" ); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_foreign_keys_outside_the_parent_are_refused() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; sqlx::query("CREATE TABLE reviewers (id UUID PRIMARY KEY)") .execute(&pool) @@ -842,13 +834,12 @@ mod postgres_tests { } drop(holder); assert_advanced(&maintain(&pool).await.expect("advance without the keys")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_foreign_key_committed_while_waiting_on_the_parent_is_refused() { - let (pool, admin, schema) = scratch_pool_with_max_connections(6).await; + let (pool, scratch) = scratch_pool_with_max_connections(6).await; seed_repaired_layout(&pool, true).await; sqlx::query("CREATE TABLE reviewers (id UUID PRIMARY KEY)") .execute(&pool) @@ -868,7 +859,7 @@ mod postgres_tests { let pool = pool.clone(); async move { maintain(&pool).await } }); - wait_until_blocked_by(&admin, reader_pid, 1).await; + wait_until_blocked_by(&scratch.admin, reader_pid, 1).await; migration.commit().await.expect("commit migration"); reader.commit().await.expect("release parent"); @@ -885,13 +876,12 @@ mod postgres_tests { .await .expect("count catch-all key"); assert_eq!(kept, 1); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn deadlock_victim_is_classified_as_a_lock_timeout() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_parents(&pool).await; let mut first = hold_lock(&pool, "LOCK TABLE ONLY events IN ACCESS EXCLUSIVE MODE").await; let mut second = hold_lock( @@ -925,13 +915,12 @@ mod postgres_tests { PartitionMaintenanceOutcome::LockTimeout ); drop((first, second)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn parent_contention_times_out_without_blocking_the_other_table() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let holder = hold_lock(&pool, "LOCK TABLE ONLY events IN ACCESS SHARE MODE").await; @@ -961,13 +950,12 @@ mod postgres_tests { audited_at: audit.audited_at, tables: delivery_log, }); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn held_maintenance_lock_skips_without_ddl() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; let before = catalog_snapshot(&pool).await; let holder = hold_lock( @@ -982,13 +970,12 @@ mod postgres_tests { .expect("skipped_locked is not a failure"); assert_eq!(catalog_snapshot(&pool).await, before); drop(holder); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn concurrent_runners_converge_on_one_layout() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, true).await; let (first, second) = tokio::join!(maintain(&pool), maintain(&pool)); @@ -1012,13 +999,12 @@ mod postgres_tests { } // Whatever a fail-fast loser left undone, the next pass converges. assert_advanced(&maintain(&pool).await.expect("follow-up pass")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn client_deadline_rolls_back_and_discards_the_session() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; let before = catalog_snapshot(&pool).await; // The parent lock wait (2s) outlasts the 300ms client deadline. @@ -1050,7 +1036,7 @@ mod postgres_tests { "SELECT pid FROM pg_stat_activity WHERE $1 = ANY(pg_blocking_pids(pid))", ) .bind(holder_pid) - .fetch_all(&admin) + .fetch_all(&scratch.admin) .await .expect("sessions blocked by the holder"); let mut gone = blocked.is_empty(); @@ -1063,7 +1049,7 @@ mod postgres_tests { "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE pid = ANY($1))", ) .bind(&blocked) - .fetch_one(&admin) + .fetch_one(&scratch.admin) .await .expect("check abandoned backend"); gone = !alive; @@ -1086,13 +1072,12 @@ mod postgres_tests { .collect::>() }; assert_eq!(events_only(&after), events_only(&before)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn catch_all_with_child_only_trigger_is_refused() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query( "CREATE TRIGGER catch_all_only BEFORE INSERT ON events_p_future_next \ @@ -1120,13 +1105,12 @@ mod postgres_tests { .collect::>() }; assert_eq!(events_only(&after), events_only(&before)); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn stale_audit_after_a_concurrent_advance_is_a_noop() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; // A runner audits, then loses the race to a runner that commits first. let stale = audit_partition_catalog_at(&pool, PARTITION_MANAGER_MONTHS_AHEAD, fixed_now()) @@ -1167,13 +1151,12 @@ mod postgres_tests { .await .expect("losing runner"); assert_advanced(&reported); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn replan_that_finds_only_disabled_work_is_skipped_disabled() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query("DROP TABLE events_p_future_next") .execute(&pool) @@ -1210,13 +1193,12 @@ mod postgres_tests { (PartitionMaintenanceOutcome::SkippedDisabled, None) ); assert_eq!(catalog_snapshot(&pool).await, before); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn audit_retries_when_a_listed_partition_is_dropped() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; let mut dropper = pool.begin().await.expect("begin dropper"); let dropper_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") @@ -1242,7 +1224,7 @@ mod postgres_tests { WHERE $1 = ANY(pg_blocking_pids(pid)))", ) .bind(dropper_pid) - .fetch_one(&admin) + .fetch_one(&scratch.admin) .await .expect("check audit queue"); if queued { @@ -1263,13 +1245,12 @@ mod postgres_tests { .children .iter() .all(|child| child.name != "events_p_future_next")); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn unrelated_catch_all_name_is_an_operator_error() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query("CREATE TABLE events_p_future (unrelated BOOLEAN)") .execute(&pool) @@ -1297,13 +1278,12 @@ mod postgres_tests { .filter(|(name, ..)| name.starts_with("events_")) .count() ); - drop_schema(&admin, &schema).await; } #[tokio::test] #[ignore = "requires Postgres"] async fn incoming_foreign_key_is_refused_under_lock() { - let (pool, admin, schema) = scratch_pool().await; + let (pool, _scratch) = scratch_pool().await; seed_repaired_layout(&pool, false).await; sqlx::query( "CREATE TABLE event_refs (created_at TIMESTAMPTZ, alternate_at TIMESTAMPTZ, id BIGINT, \ @@ -1325,6 +1305,5 @@ mod postgres_tests { .iter() .any(|(name, ..)| name == "events_p_future_next")); assert_ne!(after, before, "delivery_log still advances"); - drop_schema(&admin, &schema).await; } } From ea24a3ee3d74679c8c489b83d351409e1b8d503e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 15:31:05 -0400 Subject: [PATCH 021/146] fix(ci): remove Detect Changed Paths timeout and deletion test race (#8300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary This PR removes two CI flakes that failed relay-stack runs on 2026-10-13. Both passed when rerun. - **Detect Changed Paths timeout.** This first CI job had a 2-minute limit. Its steps take about 75 seconds, but GitHub sometimes finishes the job minutes after the last step. In three runs, every step had finished, but GitHub still cancelled the job at the limit, and all later jobs failed. The limit is now 10 minutes. - **PostgreSQL deletion test.** `approval_boundary_blocks_claim_until_exact_inventory_is_approved` sometimes could not claim an approved deletion request. This was a race in the test, not a product bug. How the test race happens: - The test sends an UPDATE that the database must reject. Then it claims the same row. PostgreSQL sends the error to the client before it rolls back the statement and releases its row lock. sqlx returns as soon as it gets the error. - The claim runs on another pooled connection with `FOR UPDATE ... SKIP LOCKED`. It can skip the row while the lock is still held, and return no claim. - The test now runs the rejected UPDATE on its own connection. Then it sends `SELECT 1` on that connection before it claims. That round trip completes only after PostgreSQL has finished the rollback. Production code does not change. A claim that skips a row locked by another transaction is correct behavior. I checked the other tests in this file. Only this test has a rejected write on the request row immediately before a `SKIP LOCKED` claim. In the other tests, a blocking `approve` call runs before the claim. ### Related issue N/A. No existing PR found. Failing runs: [timeout #8174](https://github.com/block/buzz/actions/runs/38468285648/job/115540525751), [timeout #8176](https://github.com/block/buzz/actions/runs/38468321774/job/115541550557), [timeout #8182](https://github.com/block/buzz/actions/runs/38468329504/job/115541197994), [deletion test #8182](https://github.com/block/buzz/actions/runs/38468329504/job/115550003726). ### Testing - I confirmed the order in the PostgreSQL 17 source: `EmitErrorReport()` (which flushes to the client) runs before `AbortCurrentTransaction()` in `tcop/postgres.c`. A BEFORE UPDATE trigger locks the row first (`GetTupleForTrigger` in `commands/trigger.c`). - I could not reproduce the failure locally: 0 failures in 410 runs, including runs on a CPU-limited PostgreSQL container. The fixed test passes against PostgreSQL 17. Signed-off-by: Logan Johnson Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> --- .github/workflows/ci.yml | 4 +++- crates/buzz-db/src/store/deletion.rs | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1ff1c47fe..0b7b2a7cd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,9 @@ jobs: changes: name: Detect Changed Paths runs-on: ubuntu-latest - timeout-minutes: 2 + # The steps take about 75 seconds, but GitHub can finish the job minutes + # after the last step. A 2-minute limit cancelled finished runs. + timeout-minutes: 10 permissions: contents: read pull-requests: read diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index d673c5d8d7..2196bb2a83 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -6304,17 +6304,27 @@ mod postgres_tests { mismatched_approval.is_err(), "approval digest must remain database-bound to the frozen request digest" ); + // PostgreSQL sends this rejection before it rolls the statement back + // and releases the request row lock, and sqlx returns on the error. + // The claim below uses SKIP LOCKED on another pooled connection, so + // finish a round trip on this connection before claiming. + let mut tamper_conn = db.pool.acquire().await.expect("acquire tamper connection"); let mismatched_request = sqlx::query( "UPDATE community_deletion_requests SET inventory_digest = $2 WHERE id = $1", ) .bind(request.id) .bind(vec![1_u8; 32]) - .execute(&db.pool) + .execute(&mut *tamper_conn) .await; assert!( mismatched_request.is_err(), "the frozen request digest must remain bound to its approval" ); + sqlx::query("SELECT 1") + .execute(&mut *tamper_conn) + .await + .expect("rejected update has rolled back"); + drop(tamper_conn); assert!(store .claim_specific(request.id, "executor-a", DEFAULT_LEASE_DURATION) .await From 7b9551aba830019a875b6fc24b5a25d74eceeb65 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 14 Oct 2026 12:35:58 -0700 Subject: [PATCH 022/146] fix(ios): open push replies in their containing thread (#8294) Tapping a push notification for a thread reply currently opens the channel at its parent. Preserve the verified event's direct-parent thread ID through the iOS notification payload and Flutter bridge so the tap opens the containing thread and highlights that exact reply, even when newer replies have arrived. This matches Inbox's destination selection for nested replies; broadcast replies still open in the channel. Closest related work: https://github.com/block/buzz/pull/6159 (original push-tap routing). ### Validation - Follow-up [5b70e1a](https://github.com/block/buzz/pull/8294/changes/5b70e1a5cb58104b5e17b48493bbde2bb6a6f207) selects the last valid 64-character ASCII-hex reply marker, matching relay ingest. Regression tests failed before the fix; all 80 XCTest and 47 Swift Testing tests passed afterward with Xcode 26.5. [Current-head CI](https://github.com/block/buzz/actions/runs/38607128406) passed, including Flutter tests, Android build, native package tests, complete iOS simulator and unsigned release builds, and native accessibility tests. The [current-head security review](https://github.com/block/buzz/pull/8294#issuecomment-6174205374) reports no findings. Physical evidence below predates this validation-only follow-up. - [Baseline CI on f465f91](https://github.com/block/buzz/actions/runs/38436936153) passed, including Flutter and native package tests, Android build, complete iOS simulator and unsigned release builds, and native accessibility tests. - Full local validation passed on [f465f91](https://github.com/block/buzz/commit/f465f9196fdb6054e302c7ad31ea9de91381c32b), including 3,215 Flutter tests (four skipped) and three unconfigured-push tests. The 49 focused navigation tests also passed after the baseline merge. - Added coverage for cold/warm bridge delivery, exact reply highlighting with newer arrivals, direct-parent and broadcast destinations, and malformed optional thread IDs. The Codex finding for an empty reply marker is fixed and resolved. - Physical acceptance passed on the managed iPhone 12 mini using signed Buzz Dev 10140, built from f465f91 with SDK 26.5. Real push taps and Inbox opened the same containing thread and highlighted the exact reply. An older alert retained its target after a newer reply arrived; a retained alert also worked after terminating the app. Top-level push navigation reached the channel. - Installed in place with the existing identity, app data and pairing retained. No key reset or rotation was performed. Notifications were restored to off and device control released. Xcode 27 hits an unchanged dependency compiler error; the matching Xcode 26.5 toolchain built and signed this tested candidate successfully. ### Physical acceptance | Warm push: older reply A | Inbox: reply B | Cold push: reply B | | --- | --- | --- | | ![Older reply A highlighted with newer B visible](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--warm-older-reply.png) | ![Inbox highlights reply B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--inbox-exact-reply.png) | ![Cold push highlights reply B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--cold-push-exact-reply.png) | [Physical test details](https://github.com/block/buzz/pull/8294#issuecomment-6151991840). ### Before and after Synthetic Flutter widget captures with a newer reply already present. | Before: channel at outer parent | After: exact reply in containing thread | | --- | --- | | ![Before](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--before.png) | ![After](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--after.png) | After merge, repeat warm/cold push versus Inbox in the integrated mobile candidate, including older alerts, top-level and broadcast notifications. --------- Signed-off-by: Tom Brow --- .../BuzzPushNavigationTarget.swift | 37 +++++++- .../BuzzPushNotificationResolver.swift | 3 +- .../BuzzPushNavigationTargetTests.swift | 62 +++++++++++++ .../BuzzPushNotificationResolverTests.swift | 31 +++++++ mobile/ios/Runner/AppDelegate.swift | 4 +- mobile/lib/shared/push/push_bridge.dart | 6 +- .../channels/channel_detail_page_test.dart | 86 +++++++++++++++++++ mobile/test/shared/push/push_bridge_test.dart | 53 ++++++++++++ 8 files changed, 275 insertions(+), 7 deletions(-) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift index 9218655021..c6cb813648 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNavigationTarget.swift @@ -9,18 +9,43 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { public let communityID: String public let channelID: String - public init(eventID: String, communityID: String, channelID: String) { + /// Direct parent whose thread contains the target reply, matching Inbox. + public let threadRootID: String? + + public init( + eventID: String, communityID: String, channelID: String, threadRootID: String? = nil + ) { self.eventID = eventID self.communityID = communityID self.channelID = channelID + self.threadRootID = threadRootID } public var userInfoValue: [String: String] { - [ + var value = [ "event_id": eventID, "community_id": communityID, "channel_id": channelID, ] + value["thread_root_id"] = threadRootID + return value + } + + static func threadRootID(in tags: [[String]]) -> String? { + // Inbox opens broadcast replies in the channel and other replies in their + // direct parent's thread, even when the NIP-10 root names an outer thread. + if tags.contains(where: { $0.count >= 2 && $0[0] == "broadcast" && $0[1] == "1" }) { + return nil + } + // Match relay ingest: the last valid 64-character ASCII-hex reply ID wins. + // A later malformed marker must not hide an earlier valid parent. + return tags.last(where: { tag in + guard tag.count >= 4, tag[0] == "e", tag[3] == "reply" else { return false } + let bytes = tag[1].utf8 + return bytes.count == 64 && bytes.allSatisfy { + (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) + } + })?[1] } /// Decodes a target without trusting other fields from the APNs payload. @@ -28,7 +53,7 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { from userInfo: [AnyHashable: Any] ) -> BuzzPushNavigationTarget? { guard let raw = userInfo[userInfoKey] as? [String: Any], - raw.count == 3, + Set(raw.keys).isSubset(of: ["event_id", "community_id", "channel_id", "thread_root_id"]), let eventID = raw["event_id"] as? String, let communityID = raw["community_id"] as? String, let channelID = raw["channel_id"] as? String, @@ -38,10 +63,14 @@ public struct BuzzPushNavigationTarget: Codable, Equatable, Sendable { else { return nil } + if let rawThread = raw["thread_root_id"] { + guard let thread = rawThread as? String, !thread.isEmpty else { return nil } + } return BuzzPushNavigationTarget( eventID: eventID, communityID: communityID, - channelID: channelID + channelID: channelID, + threadRootID: raw["thread_root_id"] as? String ) } diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index 7836385a77..d195748021 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -462,7 +462,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { BuzzPushNavigationTarget( eventID: event.id, communityID: community.id, - channelID: $0 + channelID: $0, + threadRootID: BuzzPushNavigationTarget.threadRootID(in: event.tags) ) }, senderPubkey: event.pubkey.lowercased(), diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift index 414e9446a2..c2bba98b7d 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNavigationTargetTests.swift @@ -75,3 +75,65 @@ import Testing #expect(buffer.take() == first) #expect(buffer.take() == nil) } + +@Test func `Reply target preserves containing thread through user info and cold-start buffer`() { + let target = BuzzPushNavigationTarget( + eventID: "reply", communityID: "community", channelID: "channel", threadRootID: "parent" + ) + let decoded = BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) + #expect(decoded == target) + let buffer = BuzzPushNavigationBuffer() + buffer.record(target) + #expect(buffer.take() == target) +} + +@Test func `Reject malformed optional thread and unknown navigation fields`() { + let cases: [[String: Any]] = [ + ["thread_root_id": ""], ["thread_root_id": 42], ["unknown": "value"], + ] + for extra in cases { + var raw: [String: Any] = [ + "event_id": "reply", "community_id": "community", "channel_id": "channel", + ] + raw.merge(extra) { _, new in new } + #expect(BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: raw] + ) == nil) + } +} + +@Test func `Empty reply marker preserves channel navigation`() { + let thread = BuzzPushNavigationTarget.threadRootID(in: [["e", "", "", "reply"]]) + #expect(thread == nil) + let target = BuzzPushNavigationTarget( + eventID: "message", communityID: "community", channelID: "channel", threadRootID: thread + ) + #expect(BuzzPushNavigationTarget.decodeIfPresent( + from: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue] + ) == target) +} + +@Test func `Thread destination keeps direct parent and broadcast semantics`() { + let parent = String(repeating: "a", count: 64) + let tags = [["e", "outer-root", "", "root"], ["e", parent, "", "reply"]] + #expect(BuzzPushNavigationTarget.threadRootID(in: tags) == parent) + #expect(BuzzPushNavigationTarget.threadRootID(in: tags + [["broadcast", "1"]]) == nil) + #expect(BuzzPushNavigationTarget.threadRootID(in: [["e", "outer-root", "", "root"]]) == nil) +} + +@Test func `Last valid reply marker wins despite trailing malformed markers`() { + let first = String(repeating: "a", count: 64) + let last = String(repeating: "B", count: 64) + let invalidIDs = ["", "bad", String(repeating: "a", count: 63), + String(repeating: "a", count: 65), String(repeating: "g", count: 64), + String(repeating: "é", count: 64)] + for invalid in invalidIDs { + let invalidTag = ["e", invalid, "", "reply"] + #expect(BuzzPushNavigationTarget.threadRootID(in: [invalidTag]) == nil) + #expect(BuzzPushNavigationTarget.threadRootID(in: [ + ["e", first, "", "reply"], ["e", last, "", "reply"], invalidTag, + ]) == last) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift index 6620f19369..8d8de632be 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushNotificationResolverTests.swift @@ -162,6 +162,37 @@ final class BuzzPushNotificationResolverTests: XCTestCase { } } + func testReplyNavigationUsesDirectParentAndKeepsExactEvent() throws { + let parent = String(repeating: "a", count: 64) + let reply = event(id: "reply-id", content: "Reply preview", tags: [ + ["h", Self.channelID], + ["e", "outer-root", "", "root"], + ["e", parent, "", "reply"], + ["e", "bad", "", "reply"], + ["e", "", "", "reply"], + ]) + let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution( + events: [reply], community: community() + )?.0) + let target = try XCTUnwrap(resolution.navigationTarget) + XCTAssertEqual(target.eventID, "reply-id") + XCTAssertEqual(target.userInfoValue["thread_root_id"], parent) + } + + func testNavigationKeepsTopLevelAndBroadcastMessagesInChannel() throws { + for tags in [ + [["h", Self.channelID]], + [["h", Self.channelID], ["e", "mention", "", "mention"]], + [["h", Self.channelID], ["e", "root", "", "root"]], + [["h", Self.channelID], ["e", "parent", "", "reply"], ["broadcast", "1"]], + ] { + let resolution = try XCTUnwrap(BuzzPushNotificationResolver.decodeResolution( + events: [event(content: "Preview", tags: tags)], community: community() + )?.0) + XCTAssertNil(resolution.navigationTarget?.threadRootID) + } + } + func testResolveSucceedsAndMutatesGatewayContent() throws { let event = try JSONDecoder().decode( VerifiedNostrEvent.self, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 4d1b95c386..ce7dc6e543 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1074,10 +1074,12 @@ import os.log extension BuzzPushNavigationTarget { fileprivate var flutterArguments: [String: String] { - [ + var arguments = [ "eventId": eventID, "communityId": communityID, "channelId": channelID, ] + arguments["threadRootId"] = threadRootID + return arguments } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 49d2c96cde..dc11f0e5f7 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -106,18 +106,22 @@ MessageDeepLink? _pushNotificationLink(Object? arguments) { final eventId = arguments['eventId']; final communityId = arguments['communityId']; final channelId = arguments['channelId']; + final threadRootId = arguments['threadRootId']; if (eventId is! String || eventId.isEmpty || communityId is! String || communityId.isEmpty || channelId is! String || - channelId.isEmpty) { + channelId.isEmpty || + (threadRootId != null && + (threadRootId is! String || threadRootId.isEmpty))) { return null; } return MessageDeepLink( communityId: communityId, channelId: channelId, messageId: eventId, + threadRootId: threadRootId as String?, ); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f768564391..cf746acde5 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/features/channels/channel_actions_sheet.dart'; import '../profile/presence_snapshot_test.dart' @@ -11415,6 +11416,91 @@ void main() { }); }); + testWidgets('push tap highlights its exact reply after newer arrivals', ( + tester, + ) async { + pendingPushNotificationLink.value = null; + installBuzzPushMethodHandler(); + addTearDown(() { + pendingPushNotificationLink.value = null; + }); + const channel = MethodChannel('buzz/push'); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'eventId': 'target', + 'communityId': 'community', + 'channelId': _channelId, + 'threadRootId': 'parent', + }), + ), + (_) {}, + ); + final link = pendingPushNotificationLink.value!; + final root = _textMsg( + id: 'root', + pubkey: 'alice', + content: 'Outer root', + createdAt: 1000, + ); + final parent = _textMsg( + id: 'parent', + pubkey: 'alice', + content: 'Containing thread', + createdAt: 1100, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + final target = _textMsg( + id: 'target', + pubkey: 'alice', + content: 'Notification reply', + createdAt: 1200, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ], + ); + final newer = _textMsg( + id: 'newer', + pubkey: 'alice', + content: 'Newer reply', + createdAt: 1300, + extraTags: const [ + ['e', 'root', '', 'root'], + ['e', 'parent', '', 'reply'], + ], + ); + await tester.pumpWidget( + _buildTestable( + messages: [root, parent, target, newer], + initialMessageId: link.messageId, + initialThreadRootId: link.threadRootId, + threadReplies: { + 'root': [parent, target, newer], + }, + ), + ); + await tester.pumpAndSettle(); + final page = tester.widget( + find.byType(ThreadDetailPage), + ); + expect(page.threadHead.id, 'parent'); + expect(page.initialMessageId, 'target'); + final decoration = + tester + .widget( + find.byKey(const ValueKey('thread-message-target')), + ) + .decoration + as BoxDecoration; + expect(decoration.color!.a, closeTo(0.12, 0.001)); + expect(find.text('Newer reply'), findsOneWidget); + }); + testWidgets('opens a nested reply in its direct-parent thread', ( tester, ) async { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index 1c76b9a69b..926cb48b5d 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -340,6 +340,57 @@ void main() { ); }); + test( + 'preserves the exact reply and containing thread on a warm tap', + () async { + final response = Completer(); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + _channel.name, + _channel.codec.encodeMethodCall( + const MethodCall('notificationOpened', { + 'eventId': 'older-reply', + 'communityId': 'community-id', + 'channelId': 'channel-id', + 'threadRootId': 'direct-parent', + }), + ), + response.complete, + ); + expect( + _channel.codec.decodeEnvelope((await response.future)!), + 'handled', + ); + expect( + pendingPushNotificationLink.value, + const MessageDeepLink( + communityId: 'community-id', + channelId: 'channel-id', + messageId: 'older-reply', + threadRootId: 'direct-parent', + ), + ); + }, + ); + + for (final invalidThread in ['', 42]) { + test('rejects malformed thread ID $invalidThread', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + _channel, + (_) async => { + 'eventId': 'reply', + 'communityId': 'community', + 'channelId': 'channel', + 'threadRootId': invalidThread, + }, + ); + await syncPendingBuzzPushNotificationResponse(); + expect(pendingPushNotificationLink.value, isNull); + }); + } + test('rejects a notification response with an empty channel ID', () async { final response = Completer(); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -412,6 +463,7 @@ void main() { expect(call.method, 'takePendingNotificationResponse'); return { 'eventId': 'b' * 64, + 'threadRootId': 'a' * 64, 'communityId': 'community-id', 'channelId': '123e4567-e89b-42d3-a456-426614174000', }; @@ -425,6 +477,7 @@ void main() { communityId: 'community-id', channelId: '123e4567-e89b-42d3-a456-426614174000', messageId: 'b' * 64, + threadRootId: 'a' * 64, ), ); }); From 231a469de5652ce425782e98fcd011881fb24fe8 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 14 Oct 2026 16:35:26 -0400 Subject: [PATCH 023/146] Refresh mobile message bubbles, reactions, and activity cards (#8281) ## Summary Refresh mobile conversations with left-aligned grouped bubbles without tails, tighter consecutive-message spacing, distinct sender colors, theme-aware incoming backgrounds, and compact overlapping reaction trays. Attach reply counts beside reactions with up to three participant avatars; keep outgoing mentions and links readable in both themes. Coordinate a 500 ms composer-to-message transition with spring contraction and an anchored avatar. Message ownership follows the signing identity so profile reloads cannot recolor outgoing bubbles. Center channel activities without timestamps or icons, keep names tappable, and show two affected members plus a **See all** sheet. Hide legacy purpose activities, match media corners to bubble groups, and add iOS status-bar scroll-to-top. This is an independent mobile PR; no desktop changes or color picker. ## Validation - Full `just mobile-check` passed; `just mobile-test` passed 3,275 tests with 4 skips, plus 3 unconfigured-gateway checks. - Fixed message-widget lint warnings and a stale notification fixture that blocked analysis. Made the video-controller disposal test wait for lifecycle events instead of fixed delays. Mobile pre-push checks are enabled. - Physical iPhone previews were installed throughout development. The latest bubble, reply-avatar, contrast, and spacing updates were installed and launched on the physical iPhone. Final motion and status-bar behavior still need device visual verification; Android has automated coverage but no physical-device verification. --------- Signed-off-by: kenny lopez --- mobile/ios/Runner/IosNavigationBar.swift | 2 + ...NativeMessagePresentationCoordinator.swift | 16 +- mobile/ios/Runner/NativeReactionDetails.swift | 41 + .../channels/channel_detail_page.dart | 13 +- .../activity_members_sheet.dart | 81 ++ .../channel_detail_page/message_bubble.dart | 403 +++++----- .../channel_detail_page/message_list.dart | 84 +- .../oldest_navigation.dart | 88 +++ .../channel_detail_page/system_rows.dart | 582 ++++++-------- mobile/lib/features/channels/compose_bar.dart | 1 + .../compose_bar/compose_bar_widget.dart | 15 +- .../features/channels/compose_bar/layout.dart | 33 +- .../channels/composer_send_origin.dart | 23 + ...local_message_send_animation_provider.dart | 20 +- .../local_message_send_transition.dart | 130 +++- .../channels/local_send_bubble_surface.dart | 148 ++++ .../lib/features/channels/local_send_row.dart | 80 ++ .../features/channels/message_actions.dart | 5 + .../message_actions/quick_reaction_row.dart | 22 +- .../features/channels/message_content.dart | 155 +++- .../message_content/bubble_segments.dart | 58 ++ .../message_content/bubble_shape.dart | 49 ++ .../message_content/media_carousel.dart | 8 +- .../channels/message_footer_layout.dart | 128 +++ .../lib/features/channels/reaction_row.dart | 122 ++- .../features/channels/thread_detail_page.dart | 9 +- .../thread_detail_page/message_list.dart | 40 + .../thread_detail_page/thread_message.dart | 375 +++++---- .../features/channels/timeline_message.dart | 38 +- .../channels/voice_note_attachment.dart | 5 +- .../lib/shared/emoji/native_emoji_glyph.dart | 39 +- .../lib/shared/widgets/frosted_scaffold.dart | 49 +- .../lib/shared/widgets/ios_scroll_to_top.dart | 136 ++++ .../widgets/message_attachment_corners.dart | 21 + .../shared/widgets/message_author_meta.dart | 26 +- .../channels/channel_detail_page_test.dart | 734 +++++++++++------- .../action_row_tests.dart | 71 +- .../bubble_tests.dart | 733 +++++++++++++++++ .../features/channels/compose_bar_test.dart | 74 ++ .../channels/composer_send_origin_test.dart | 73 ++ .../local_message_send_transition_test.dart | 199 +++-- .../local_send_bubble_surface_test.dart | 230 ++++++ .../media_viewer_page/video_viewer_test.dart | 19 +- .../message_content_bubble_cases.dart | 273 +++++++ .../channels/message_content_test.dart | 7 +- .../channels/message_footer_layout_test.dart | 120 +++ .../features/channels/reaction_row_test.dart | 156 ++-- .../reaction_row_test/combined_tests.dart | 297 +++++++ .../channels/timeline_message_test.dart | 2 +- .../widgets/ios_scroll_to_top_test.dart | 166 ++++ 50 files changed, 4898 insertions(+), 1301 deletions(-) create mode 100644 mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart create mode 100644 mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart create mode 100644 mobile/lib/features/channels/composer_send_origin.dart create mode 100644 mobile/lib/features/channels/local_send_bubble_surface.dart create mode 100644 mobile/lib/features/channels/local_send_row.dart create mode 100644 mobile/lib/features/channels/message_content/bubble_segments.dart create mode 100644 mobile/lib/features/channels/message_content/bubble_shape.dart create mode 100644 mobile/lib/features/channels/message_footer_layout.dart create mode 100644 mobile/lib/shared/widgets/ios_scroll_to_top.dart create mode 100644 mobile/lib/shared/widgets/message_attachment_corners.dart create mode 100644 mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart create mode 100644 mobile/test/features/channels/composer_send_origin_test.dart create mode 100644 mobile/test/features/channels/local_send_bubble_surface_test.dart create mode 100644 mobile/test/features/channels/message_content_bubble_cases.dart create mode 100644 mobile/test/features/channels/message_footer_layout_test.dart create mode 100644 mobile/test/features/channels/reaction_row_test/combined_tests.dart create mode 100644 mobile/test/shared/widgets/ios_scroll_to_top_test.dart diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index 9942923409..614796e830 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -212,6 +212,7 @@ private final class NavigationContentController: UIViewController { view = scrollView scrollView.contentSize = CGSize(width: 1, height: 10000) scrollView.isUserInteractionEnabled = false + scrollView.scrollsToTop = false scrollView.backgroundColor = .clear if #available(iOS 26.0, *) { // Configure the native effect before UIKit lays out the bar; the @@ -269,6 +270,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView { if #available(iOS 27.0, *) { edgeScrollView.accessibilityIdentifier = "navigation-status-edge" edgeScrollView.isUserInteractionEnabled = false + edgeScrollView.scrollsToTop = false edgeScrollView.accessibilityElementsHidden = true edgeScrollView.backgroundColor = .clear edgeScrollView.contentInsetAdjustmentBehavior = .never diff --git a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift index 4b7821dcd3..1f0d8dd1b8 100644 --- a/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift +++ b/mobile/ios/Runner/NativeMessagePresentationCoordinator.swift @@ -8,6 +8,7 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati private weak var presented: UIViewController? private var pendingResult: FlutterResult? private var requestID: String? + private var terminalValue: [String: String]? init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) { channel = FlutterMethodChannel(name: "buzz/native_message_presentation", binaryMessenger: messenger) @@ -74,6 +75,9 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } else { let sheet = NativeReactionDetailsViewController(data: data) sheet.onClose = { [weak self] in self?.dismiss([:]) } + sheet.onAddReaction = { [weak self] in + self?.dismiss(["action": "addReaction"]) + } sheet.onToggle = { [weak self] emoji in self?.channel.invokeMethod("toggleReaction", arguments: [ "requestId": data["requestId"] as? String ?? "", "emoji": emoji, @@ -98,8 +102,13 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } private func dismiss(_ value: [String: String]) { + guard pendingResult != nil, terminalValue == nil else { return } + terminalValue = value guard let presented else { complete(value); return } - let completion: () -> Void = { [weak self] in self?.complete(value) } + let completion: () -> Void = { [weak self] in + guard let self, self.presented === presented else { return } + self.complete(value) + } if let menu = presented as? NativeMessageMenuViewController { menu.animateOut { menu.dismiss(animated: false, completion: completion) @@ -110,14 +119,17 @@ final class NativeMessagePresentationCoordinator: NSObject, UIAdaptivePresentati } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + guard presented === presentationController.presentedViewController else { return } complete([:]) } private func complete(_ value: [String: String]) { let result = pendingResult + let terminal = terminalValue ?? value + terminalValue = nil pendingResult = nil presented = nil requestID = nil - result?(value) + result?(terminal) } } diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index 8d6361e92c..2b445ad930 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -1,9 +1,13 @@ import UIKit import ImageIO +import CoreText /// Native reaction toggles above the complete list of reactors. final class NativeReactionDetailsViewController: UIViewController, UITableViewDataSource { var onClose: (() -> Void)? + var onAddReaction: (() -> Void)? + private let canAddReaction: Bool + private let addReactionIconCodePoint: Int var onToggle: ((String) -> Void)? private let sheetColor: UIColor private let foregroundColor: UIColor @@ -32,6 +36,8 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa } sheetColor = color("sheetColor", fallback: .systemGroupedBackground) foregroundColor = color("foregroundColor", fallback: .label) + canAddReaction = data["canAddReaction"] as? Bool == true + addReactionIconCodePoint = data["addReactionIconCodePoint"] as? Int ?? 0 outlineColor = color("outlineColor", fallback: .separator) reactions = data["reactions"] as? [[String: Any]] ?? [] profiles = data["profiles"] as? [String: [String: Any]] ?? [:] @@ -130,8 +136,43 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa } } + // Use the exact bundled Tabler font that backs BuzzIcons in Flutter. + private static let tablerFont: UIFont? = { + guard let frameworks = Bundle.main.privateFrameworksURL, + let provider = CGDataProvider(url: frameworks.appendingPathComponent( + "App.framework/flutter_assets/assets/fonts/TablerIcons.ttf") as CFURL), + let font = CGFont(provider), let name = font.postScriptName else { return nil } + CTFontManagerRegisterGraphicsFont(font, nil) + return UIFont(name: name as String, size: 24) + }() + + private var addReactionImage: UIImage? { + guard let font = Self.tablerFont, + let scalar = UnicodeScalar(addReactionIconCodePoint) else { return nil } + let glyph = NSAttributedString(string: String(scalar), attributes: [.font: font]) + let size = glyph.size() + return UIGraphicsImageRenderer(size: CGSize(width: 28, height: 28)).image { _ in + glyph.draw(at: CGPoint(x: (28 - size.width) / 2, y: (28 - size.height) / 2)) + }.withRenderingMode(.alwaysTemplate) + } + private func rebuildFilters() { for child in filters.arrangedSubviews { child.removeFromSuperview() } + if canAddReaction { + let add = UIButton(type: .system) + var config = UIButton.Configuration.gray() + config.image = addReactionImage + if config.image == nil { config.title = "Add reaction" } + config.cornerStyle = .capsule + config.baseForegroundColor = foregroundColor + add.configuration = config + add.accessibilityLabel = "Add reaction" + add.accessibilityIdentifier = "reaction-sheet-add" + add.widthAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + add.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true + add.addAction(UIAction { [weak self] _ in self?.onAddReaction?() }, for: .touchUpInside) + filters.addArrangedSubview(add) + } for reaction in reactions { guard let emoji = reaction["emoji"] as? String else { continue } addFilter(title: "\(reaction["count"] as? Int ?? 0)", emoji: emoji, reaction: reaction) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index b25edaa883..f8506a5775 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,6 @@ +import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/ios_scroll_to_top.dart'; +import 'message_footer_layout.dart'; import 'dart:async'; import 'dart:math' show cos, max, min, pi; import 'dart:ui' show ImageFilter, lerpDouble; @@ -30,7 +33,6 @@ import '../../shared/widgets/load_error_view.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/skeleton.dart'; import '../profile/presence_cache_provider.dart'; -import '../profile/profile_provider.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; @@ -65,7 +67,6 @@ import 'message_actions.dart'; import 'message_action_backdrop_state.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; -import 'message_mention_pill.dart'; import 'message_skeleton_body.dart'; import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/read_state_provider.dart'; @@ -81,7 +82,9 @@ import 'thread_replies_provider.dart'; import 'timeline_message.dart'; part 'channel_detail_page/message_list.dart'; +part 'channel_detail_page/oldest_navigation.dart'; part 'channel_detail_page/system_rows.dart'; +part 'channel_detail_page/activity_members_sheet.dart'; part 'channel_detail_page/huddle_sheet.dart'; part 'channel_detail_page/huddle_call_avatar.dart'; part 'channel_detail_page/huddle_participant_cluster.dart'; @@ -323,10 +326,8 @@ class ChannelDetailPage extends HookConsumerWidget { initialOldestOrdinaryUnreadMessageIdRef.value; final initialForcedUnreadMessageIds = initialForcedUnreadMessageIdsRef.value; - final currentPubkey = ref - .watch(profileProvider) - .whenData((value) => value?.pubkey) - .value; + // Ownership comes from the signing identity, not reloadable profile metadata. + final currentPubkey = ref.watch(currentPubkeyProvider); // Only show channel-level typing (exclude thread-scoped entries and self). final typingEntries = ref .watch(channelTypingProvider(channel.id)) diff --git a/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart b/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart new file mode 100644 index 0000000000..69a38f128b --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/activity_members_sheet.dart @@ -0,0 +1,81 @@ +part of '../channel_detail_page.dart'; + +class _ActivityMembersSheet extends ConsumerWidget { + const _ActivityMembersSheet({required this.pubkeys, required this.channelId}); + + final List pubkeys; + final String channelId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + Grid.md + MediaQuery.viewPaddingOf(context).bottom, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: Material( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.container), + clipBehavior: Clip.antiAlias, + child: ListTileTheme.merge( + contentPadding: const EdgeInsets.symmetric(horizontal: Grid.xs), + minLeadingWidth: 22, + horizontalTitleGap: Grid.xs, + child: ListView.separated( + key: const ValueKey('activity-members-list'), + shrinkWrap: true, + cacheExtent: 0, + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + itemCount: pubkeys.length, + separatorBuilder: (context, index) => Divider( + height: 1, + thickness: 1, + indent: Grid.xs * 2 + 22, + endIndent: Grid.xs, + color: context.colors.onSurface.withValues(alpha: 0.07), + ), + itemBuilder: (context, index) => _ActivityMemberRow( + key: ValueKey('activity-member-${pubkeys[index]}'), + pubkey: pubkeys[index], + channelId: channelId, + ), + ), + ), + ), + ), + ); + } +} + +class _ActivityMemberRow extends HookConsumerWidget { + const _ActivityMemberRow({ + super.key, + required this.pubkey, + required this.channelId, + }); + final String pubkey; + final String channelId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cache = ref.watch(userCacheProvider); + final names = ref.watch(channelIdentityNamesProvider(channelId)); + useEffect(() { + ref.read(userCacheProvider.notifier).preload([pubkey]); + return null; + }, [pubkey]); + return ListTile( + leading: SmallAvatar(pubkey: pubkey, userCache: cache, size: 40), + title: Text(names.labelFor(pubkey)), + onTap: () => showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider(channelId), + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 41af87571e..bda5701e62 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -3,7 +3,9 @@ part of '../channel_detail_page.dart'; class _MessageBubble extends HookConsumerWidget { final TimelineMessage message; final bool showAuthor; + final bool showAvatar; final bool hasReplies; + final Widget? replyFooter; final Map channelNames; final String currentChannelId; final String? currentPubkey; @@ -16,7 +18,9 @@ class _MessageBubble extends HookConsumerWidget { const _MessageBubble({ required this.message, required this.showAuthor, + this.showAvatar = true, required this.hasReplies, + this.replyFooter, required this.channelNames, required this.currentChannelId, required this.currentPubkey, @@ -153,19 +157,223 @@ class _MessageBubble extends HookConsumerWidget { ), child: Padding( padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RepaintBoundary( - key: messageSnapshotKey, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( + child: MessageFooterLayout( + animateSize: true, + overlap: message.reactions.isNotEmpty || replyFooter != null + ? Grid.xs + : 0, + footer: message.reactions.isNotEmpty || replyFooter != null + ? Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: Wrap( + spacing: Grid.xxs, + runSpacing: Grid.half, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (message.reactions.isNotEmpty) + ReactionRow( + combined: true, + messageId: message.id, + channelId: currentChannelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + if (replyFooter != null) + Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: replyFooter, + ), + ], + ), + ) + : const SizedBox.shrink(), + child: RepaintBoundary( + key: messageSnapshotKey, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + left: Grid.xs, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + showTimestamp: true, + nameStyle: messageUsernameTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + metadataStyle: + messageMetadataTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + timestampStyle: + messageTimestampTextStyle + .copyWith( + fontSize: 11, + height: 17 / 11, + ), + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + names: + channelIdentityNamesProvider( + currentChannelId, + ), + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + bubble: true, + hasReactions: + message.reactions.isNotEmpty || + replyFooter != null, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + ), + ], + ), + ), + ), + ], + ), + if (showAvatar) + Positioned( + left: 0, + bottom: 0, + child: GestureDetector( onTap: () => showUserProfileSheet( context, message.pubkey, @@ -178,160 +386,11 @@ class _MessageBubble extends HookConsumerWidget { pubkey: message.pubkey, isAgent: isAgent, ), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel( - profile, - ), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => - showUserProfileSheet( - context, - message.pubkey, - names: - channelIdentityNamesProvider( - currentChannelId, - ), - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - currentChannelId, - ), - ), - ), - ], - ), ), ), - ], - ), + ], ), - if (message.reactions.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - ), - child: ReactionRow( - messageId: message.id, - channelId: currentChannelId, - reactions: message.reactions, - onToggle: (emoji) => toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, - ), - ), - ), - ], + ), ), ), ), @@ -340,36 +399,20 @@ class _MessageBubble extends HookConsumerWidget { } } -Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: Grid.xxl), - child: Text( - key: key, - formatMessageTime(createdAt), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: messageTimestampTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ); -} - class _UserAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; final bool isAgent; - final double size; const _UserAvatar({ required this.profile, required this.pubkey, required this.isAgent, - this.size = messageAvatarSize, }); @override Widget build(BuildContext context) { + const size = messageAvatarSize; final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); final avatarUrl = profile?.avatarUrl; 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 2c18dcf786..2bafc7330d 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -53,6 +53,9 @@ class _MessageList extends HookConsumerWidget { final stickyDayTimestamp = useValueNotifier(null); final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height); final isLoadingOlder = useState(false); + final navigatingOldest = useRef(false); + final olderFetchPaused = useRef(false); + final oldestNavigationGeneration = useRef(0); final isAtLatest = useState(true); final settledImeBottomInset = useState( usesFixedAndroidImeViewport @@ -346,6 +349,14 @@ class _MessageList extends HookConsumerWidget { try { final targetIndex = reversedIndexOf(latestNavigationTargetId.value) ?? 0; + if (latestNavigationTargetId.value != null) { + itemScrollController.jumpTo( + index: targetIndex, + // Index zero already includes the composer padding. + alignment: targetIndex == 0 ? 0 : latestAlignment(), + ); + return; + } await itemScrollController.scrollTo( index: targetIndex, alignment: latestAlignment(), @@ -506,7 +517,9 @@ class _MessageList extends HookConsumerWidget { .reduce((a, b) => a > b ? a : b); if (!hasUserScrolled.value || oldestVisible < displayEntries.length - 3 || - isLoadingOlder.value) { + isLoadingOlder.value || + navigatingOldest.value || + olderFetchPaused.value) { return; } final notifier = ref.read( @@ -514,9 +527,14 @@ class _MessageList extends HookConsumerWidget { ); if (notifier.reachedOldest) return; isLoadingOlder.value = true; - notifier.fetchOlder().whenComplete( - () => isLoadingOlder.value = false, - ); + notifier + .fetchOlder() + .then((loaded) { + olderFetchPaused.value = !loaded && !notifier.reachedOldest; + }) + .whenComplete(() { + if (context.mounted) isLoadingOlder.value = false; + }); } var disposed = false; @@ -675,6 +693,23 @@ class _MessageList extends HookConsumerWidget { return null; }, [latestEntryId, localSendAnimations]); + _useChannelScrollToOldest( + context, + notifier: notifier, + itemScrollController: itemScrollController, + displayCount: displayEntries.length, + isLoadingOlder: isLoadingOlder, + navigatingOldest: navigatingOldest, + olderFetchPaused: olderFetchPaused, + oldestNavigationGeneration: oldestNavigationGeneration, + onStart: () { + followsLatest.value = false; + hasUserScrolled.value = true; + isAtLatest.value = false; + if (showUnreadNavigation) detachedWhileUnreadShown.value = true; + }, + ); + if (entries.isEmpty) { return Center( child: Column( @@ -743,6 +778,9 @@ class _MessageList extends HookConsumerWidget { ); if (notification is UserScrollNotification && notification.direction != ScrollDirection.idle) { + navigatingOldest.value = false; + oldestNavigationGeneration.value++; + olderFetchPaused.value = false; hasUserScrolled.value = true; followsLatest.value = false; if (showUnreadNavigation) { @@ -807,6 +845,7 @@ class _MessageList extends HookConsumerWidget { final showAuthor = !message.isSystem && (message.hasAttachments || + prevMessage?.hasAttachments == true || prevMessage == null || prevMessage.isSystem || showDayDivider || @@ -814,7 +853,22 @@ class _MessageList extends HookConsumerWidget { message.pubkey.toLowerCase() || (message.createdAt - prevMessage.createdAt) > 300); + final nextMessage = chronIdx + 1 < displayEntries.length + ? displayEntries[chronIdx + 1].first.message + : null; + final showAvatar = + message.hasAttachments || + nextMessage == null || + nextMessage.isSystem || + nextMessage.hasAttachments || + !isSameDay(message.createdAt, nextMessage.createdAt) || + nextMessage.pubkey.toLowerCase() != + message.pubkey.toLowerCase() || + nextMessage.createdAt - message.createdAt > 300; + return LocalMessageSendTransition( + messageId: message.id, + channelId: channelId, key: ValueKey('channel-message-send-${message.id}'), animate: isRecentLocalMessageSendAnimation( localSendAnimations, @@ -853,7 +907,19 @@ class _MessageList extends HookConsumerWidget { _MessageBubble( message: message, showAuthor: showAuthor, + showAvatar: showAvatar, hasReplies: entry.summary != null, + replyFooter: entry.summary == null + ? null + : _ThreadSummaryRow( + summary: entry.summary!, + message: message, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), channelNames: channelNamesMap, currentChannelId: channelId, currentPubkey: currentPubkey, @@ -863,16 +929,6 @@ class _MessageList extends HookConsumerWidget { composerFocusNode: composerFocusNode, restoreComposerFocus: restoreComposerFocus, ), - if (entry.summary != null) - _ThreadSummaryRow( - summary: entry.summary!, - message: message, - allMessages: allMessages, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), ], ], ), diff --git a/mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart b/mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart new file mode 100644 index 0000000000..abf8d07dde --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page/oldest_navigation.dart @@ -0,0 +1,88 @@ +part of '../channel_detail_page.dart'; + +// Each provider page is bounded; a gesture must also bound its page count. +const _oldestNavigationPageLimit = 3; + +// Owns pagination for one status-bar gesture without changing list layout. +void _useChannelScrollToOldest( + BuildContext context, { + required ChannelMessagesNotifier notifier, + required ItemScrollController itemScrollController, + required int displayCount, + required ValueNotifier isLoadingOlder, + required ObjectRef navigatingOldest, + required ObjectRef olderFetchPaused, + required ObjectRef oldestNavigationGeneration, + required VoidCallback onStart, +}) { + final currentDisplayCount = useRef(displayCount); + currentDisplayCount.value = displayCount; + Future navigateToOldest() async { + if (navigatingOldest.value || + !itemScrollController.isAttached || + displayCount == 0) { + return; + } + navigatingOldest.value = true; + olderFetchPaused.value = false; + final generation = ++oldestNavigationGeneration.value; + bool isCurrent() => + navigatingOldest.value && + oldestNavigationGeneration.value == generation; + var ownsFetch = false; + var pagesFetched = 0; + onStart(); + try { + // Wait for an already-running edge fetch before owning pagination. + while (isLoadingOlder.value && context.mounted && isCurrent()) { + await WidgetsBinding.instance.endOfFrame; + } + while (context.mounted && + isCurrent() && + !notifier.reachedOldest && + pagesFetched < _oldestNavigationPageLimit) { + isLoadingOlder.value = true; + ownsFetch = true; + pagesFetched++; + final loaded = await notifier.fetchOlder(); + if (!context.mounted) return; + isLoadingOlder.value = false; + ownsFetch = false; + if (!loaded) { + olderFetchPaused.value = !notifier.reachedOldest; + break; + } + await WidgetsBinding.instance.endOfFrame; + } + if (!context.mounted || + !isCurrent() || + !itemScrollController.isAttached) { + return; + } + // Landing at the loaded edge must not trigger automatic extra pages. + // The next status-bar tap or manual scroll resumes pagination. + if (pagesFetched == _oldestNavigationPageLimit && + !notifier.reachedOldest) { + olderFetchPaused.value = true; + } + final index = currentDisplayCount.value - 1; + if (index < 0) return; + if (MediaQuery.disableAnimationsOf(context)) { + itemScrollController.jumpTo(index: index); + } else { + await itemScrollController.scrollTo( + index: index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } finally { + if (oldestNavigationGeneration.value == generation) { + navigatingOldest.value = false; + } + if (context.mounted && ownsFetch) isLoadingOlder.value = false; + } + } + + useIosScrollToTop(context, () => unawaited(navigateToOldest())); +} diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 9d8eb03312..df581c6b47 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -39,38 +39,13 @@ class _SystemMessageRow extends HookConsumerWidget { } final target = systemEvent.targetPubkey?.trim().toLowerCase(); - final targetIsAgent = - ref.watch(agentMentionPubkeysProvider(channelId)).contains(target) || - userCache[target]?.ownerPubkey != null; final messageStyleAction = switch (systemEvent.type) { SystemEventType.memberRemoved => [ const TextSpan(text: 'removed '), if (target == null || target.isEmpty) TextSpan(text: resolveLabel(target)) else - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Semantics( - container: true, - label: resolveLabel(target), - button: true, - child: GestureDetector( - onTap: () => showUserProfileSheet( - context, - target, - names: channelIdentityNamesProvider(channelId), - ), - child: ExcludeSemantics( - child: MessageMentionPill( - label: resolveLabel(target), - isAgent: targetIsAgent, - textStyle: _systemActionTextStyle(context), - ), - ), - ), - ), - ), + _systemNameSpan(context, target, channelId, resolveLabel), const TextSpan(text: ' from the channel'), ], SystemEventType.topicChanged => [ @@ -83,6 +58,13 @@ class _SystemMessageRow extends HookConsumerWidget { text: describeChannelTextFieldChange('purpose', systemEvent.purpose), ), ], + SystemEventType.memberLeft => [const TextSpan(text: 'left the channel')], + SystemEventType.channelArchived => [ + const TextSpan(text: 'archived this channel'), + ], + SystemEventType.channelUnarchived => [ + const TextSpan(text: 'unarchived this channel'), + ], SystemEventType.channelCreated => [ const TextSpan(text: 'created this channel'), ], @@ -95,10 +77,6 @@ class _SystemMessageRow extends HookConsumerWidget { final messageStyleActor = messageStyleAction == null ? null : systemEvent.actorPubkey?.trim(); - final usesMessageStyleLayout = - groupedMembership != null || - (messageStyleActor != null && messageStyleActor.isNotEmpty); - final reactions = groupedMessages == null ? message.reactions : _aggregateSystemMessageReactions(sourceMessages); @@ -133,6 +111,8 @@ class _SystemMessageRow extends HookConsumerWidget { context: context, ref: ref, message: message, + reactions: reactions, + onToggleReaction: toggleGroupedReaction, channelId: channelId, canManageMessage: false, allMessages: null, @@ -161,60 +141,58 @@ class _SystemMessageRow extends HookConsumerWidget { borderRadius: BorderRadius.circular(Radii.md), highlightColor: context.colors.primary.withValues(alpha: 0.1), child: Padding( - padding: EdgeInsets.only( - top: usesMessageStyleLayout ? Grid.xs : Grid.xxs, - bottom: usesMessageStyleLayout ? 0 : Grid.xxs, - ), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ RepaintBoundary( key: spotlightKey, - child: groupedMembership != null - ? _MembershipSystemMessageContent( - event: groupedMembership, - channelId: channelId, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - ) - : messageStyleActor != null && - messageStyleActor.isNotEmpty && - messageStyleAction != null - ? _MessageStyleSystemMessageContent( - displayPubkey: messageStyleActor, - channelId: channelId, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - actionSpans: messageStyleAction, - ) - : Row( - children: [ - _systemEventAvatar( - context, - systemEvent, - userCache, - channelId, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Text( - systemEvent.describe(resolveLabel), - style: systemMessageBodyTextStyle.copyWith( - color: context.colors.onSurfaceVariant, + child: Align( + alignment: Alignment.center, + child: Container( + key: ValueKey('activity-card-${message.id}'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(Radii.container), + ), + child: IntrinsicWidth( + child: groupedMembership != null + ? _MembershipSystemMessageContent( + event: groupedMembership, + channelId: channelId, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + ) + : messageStyleActor != null && + messageStyleActor.isNotEmpty && + messageStyleAction != null + ? _MessageStyleSystemMessageContent( + displayPubkey: messageStyleActor, + channelId: channelId, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: messageStyleAction, + ) + : Text.rich( + TextSpan( + children: [ + TextSpan( + text: systemEvent.describe(resolveLabel), + ), + ], ), + textAlign: TextAlign.center, + style: _systemActionTextStyle(context), ), - ), - _messageTimestamp( - context, - message.createdAt, - key: ValueKey( - 'system-message-timestamp-${message.id}', - ), - ), - ], - ), + ), + ), + ), ), if (systemEvent.type == SystemEventType.huddleStarted && systemEvent.ephemeralChannelId != null) @@ -226,14 +204,7 @@ class _SystemMessageRow extends HookConsumerWidget { isArchived: isArchived, ), if (reactions.isNotEmpty) - Padding( - padding: EdgeInsets.only( - left: - (usesMessageStyleLayout ? messageAvatarSize : 36) + - (usesMessageStyleLayout - ? messageAvatarContentGap - : Grid.xxs), - ), + IntrinsicWidth( child: ReactionRow( messageId: message.id, channelId: channelId, @@ -251,17 +222,17 @@ class _SystemMessageRow extends HookConsumerWidget { } } -const _maxVisibleAdditionalMemberNames = 3; - class _MembershipDisplayEvent { final String? actorPubkey; final List targetPubkeys; final bool isSelfJoin; + final bool isRemoval; const _MembershipDisplayEvent({ required this.actorPubkey, required this.targetPubkeys, required this.isSelfJoin, + required this.isRemoval, }); } @@ -273,7 +244,8 @@ _MembershipDisplayEvent? _membershipDisplayEvent( final first = messages.first.systemEvent; final firstActor = first?.actorPubkey?.trim().toLowerCase(); final firstTarget = first?.targetPubkey?.trim().toLowerCase(); - if (first?.type != SystemEventType.memberJoined || + if ((first?.type != SystemEventType.memberJoined && + first?.type != SystemEventType.memberRemoved) || firstActor == null || firstActor.isEmpty || firstTarget == null || @@ -281,20 +253,21 @@ _MembershipDisplayEvent? _membershipDisplayEvent( return null; } - final isSelfJoin = firstActor == firstTarget; + final isRemoval = first!.type == SystemEventType.memberRemoved; + final isSelfJoin = !isRemoval && firstActor == firstTarget; final targets = []; for (final message in messages) { final event = message.systemEvent; final actor = event?.actorPubkey?.trim().toLowerCase(); final target = event?.targetPubkey?.trim().toLowerCase(); - if (event?.type != SystemEventType.memberJoined || + if (event?.type != first.type || actor == null || actor.isEmpty || target == null || target.isEmpty || (isSelfJoin ? actor != target - : actor != firstActor || actor == target)) { + : actor != firstActor || (!isRemoval && actor == target))) { return null; } targets.add(target); @@ -302,8 +275,9 @@ _MembershipDisplayEvent? _membershipDisplayEvent( return _MembershipDisplayEvent( actorPubkey: isSelfJoin ? null : firstActor, - targetPubkeys: targets, + targetPubkeys: targets.toSet().toList(), isSelfJoin: isSelfJoin, + isRemoval: isRemoval, ); } @@ -365,38 +339,72 @@ class _MembershipSystemMessageContent extends StatelessWidget { @override Widget build(BuildContext context) { final firstTarget = event.targetPubkeys.first; - final additionalTargets = event.targetPubkeys.skip(1).toList(); - final visibleTargets = additionalTargets - .take(_maxVisibleAdditionalMemberNames) - .toList(); - final hiddenTargets = additionalTargets - .skip(_maxVisibleAdditionalMemberNames) - .toList(); - final actionStyle = _systemActionTextStyle(context); + final additionalTargets = event.targetPubkeys.skip(1).take(1).toList(); + final hiddenCount = event.targetPubkeys.length - 2; final actionSpans = [ TextSpan( text: event.isSelfJoin ? 'joined the channel' - // No "was": the name renders on the line above via - // MessageAuthorMeta, so this reads as a status line rather than a - // sentence continuing across the metadata row. Matches desktop's - // SystemMessageRow. `SystemEvent.describe` keeps "was added by" - // because it builds subject and predicate into one string. - : 'added by ${resolveLabel(event.actorPubkey)}', + : event.isRemoval + ? 'removed ' + : 'added ', ), + if (!event.isSelfJoin) + _systemNameSpan(context, firstTarget, channelId, resolveLabel), if (additionalTargets.isNotEmpty) - TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '), + TextSpan( + text: event.isSelfJoin + ? ' along with ' + : additionalTargets.length == 1 && hiddenCount <= 0 + ? ' and ' + : ', ', + ), ..._memberNameSpans( context, - visibleTargets: visibleTargets, - hiddenTargets: hiddenTargets, + targets: additionalTargets, + channelId: channelId, resolveLabel: resolveLabel, - style: actionStyle, ), + if (hiddenCount > 0) + TextSpan( + text: ' and $hiddenCount ${hiddenCount == 1 ? 'other' : 'others'}', + ), + if (!event.isSelfJoin) + TextSpan( + text: event.isRemoval ? ' from the channel' : ' to the channel', + ), + if (hiddenCount > 0) ...[ + const TextSpan(text: '. '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics( + button: true, + child: GestureDetector( + onTap: () => showBuzzModalBottomSheet( + context: context, + title: event.isRemoval ? 'Removed members' : 'Added members', + isScrollControlled: true, + showDragHandle: true, + builder: (_) => _ActivityMembersSheet( + pubkeys: event.targetPubkeys, + channelId: channelId, + ), + ), + child: Text( + 'See all', + style: _systemActionTextStyle( + context, + )?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ), + ), + ], ]; return _MessageStyleSystemMessageContent( - displayPubkey: firstTarget, + displayPubkey: event.isSelfJoin ? firstTarget : event.actorPubkey!, channelId: channelId, createdAt: createdAt, resolveLabel: resolveLabel, @@ -407,7 +415,7 @@ class _MembershipSystemMessageContent extends StatelessWidget { } TextStyle? _systemActionTextStyle(BuildContext context) { - return systemMessageBodyTextStyle.copyWith( + return context.textTheme.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ); } @@ -431,189 +439,75 @@ class _MessageStyleSystemMessageContent extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - displayPubkey, - names: channelIdentityNamesProvider(channelId), - ), - child: _UserAvatar( - profile: userCache[displayPubkey.toLowerCase()], - pubkey: displayPubkey, - isAgent: - userCache[displayPubkey.toLowerCase()]?.ownerPubkey != null, - size: messageAvatarSize, - ), - ), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: Grid.half), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: MessageAuthorMeta( - displayName: resolveLabel(displayPubkey), - username: messageUsernameLabel( - userCache[displayPubkey.toLowerCase()], - ), - timestamp: formatMessageTime(createdAt), - nameColor: context.colors.onSurface, - metadataColor: context.colors.onSurfaceVariant, - nameStyle: systemMessageHeadingTextStyle, - displayNameKey: ValueKey( - 'system-message-author-$displayPubkey', - ), - usernameKey: ValueKey( - 'system-message-username-$displayPubkey', - ), - timestampKey: ValueKey( - 'system-message-timestamp-$displayPubkey', - ), - ), - ), - Text.rich( - TextSpan( - style: _systemActionTextStyle(context), - children: actionSpans, - ), - ), - ], - ), - ), - ), - ], + return Text.rich( + TextSpan( + children: [ + _systemNameSpan(context, displayPubkey, channelId, resolveLabel), + const TextSpan(text: ' '), + ...actionSpans, + ], + ), + textAlign: TextAlign.center, + style: _systemActionTextStyle(context), ); } } +WidgetSpan _systemNameSpan( + BuildContext context, + String pubkey, + String channelId, + String Function(String?) resolveLabel, +) { + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider(channelId), + ), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: max( + 0, + MediaQuery.sizeOf(context).width - Grid.gutter * 2 - Grid.lg, + ), + ), + child: Text( + resolveLabel(pubkey), + key: ValueKey('system-message-author-$pubkey'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _systemActionTextStyle(context), + ), + ), + ), + ), + ); +} + List _memberNameSpans( BuildContext context, { - required List visibleTargets, - required List hiddenTargets, + required List targets, + required String channelId, required String Function(String? pubkey) resolveLabel, - required TextStyle? style, }) { - final spans = []; - for (var index = 0; index < visibleTargets.length; index++) { - final isLast = index == visibleTargets.length - 1; - final separator = index == 0 - ? '' - : isLast && hiddenTargets.isEmpty - ? (visibleTargets.length == 2 ? ' and ' : ', and ') - : ', '; - spans.add( - TextSpan(text: '$separator${resolveLabel(visibleTargets[index])}'), - ); - } - - if (hiddenTargets.isNotEmpty) { - final hiddenLabels = hiddenTargets.map(resolveLabel).toList(); - spans - ..add(const TextSpan(text: ', and ')) - ..add( - WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Tooltip( - message: hiddenLabels.join('\n'), - triggerMode: TooltipTriggerMode.tap, - child: Text( - '${hiddenTargets.length} others', - key: const Key('membership-overflow'), - style: style?.copyWith( - decoration: TextDecoration.underline, - decorationStyle: TextDecorationStyle.dotted, - ), - ), - ), + return [ + for (var index = 0; index < targets.length; index++) ...[ + if (index > 0) + TextSpan( + text: index == targets.length - 1 + ? (targets.length == 2 ? ' and ' : ', and ') + : ', ', ), - ); - } - - return spans; -} - -Widget _systemEventAvatar( - BuildContext context, - SystemEvent event, - Map userCache, - String channelId, -) { - final hasTarget = - event.targetPubkey != null && event.targetPubkey != event.actorPubkey; - - if (event.actorPubkey != null && hasTarget) { - // Two-avatar stack: actor + target (e.g. "Alice added Bob"). - return SizedBox( - width: 32, - height: 20, - child: Stack( - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - event.actorPubkey!, - names: channelIdentityNamesProvider(channelId), - ), - child: SmallAvatar( - pubkey: event.actorPubkey!, - userCache: userCache, - ), - ), - Positioned( - left: 12, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - event.targetPubkey!, - names: channelIdentityNamesProvider(channelId), - ), - child: SmallAvatar( - pubkey: event.targetPubkey!, - userCache: userCache, - ), - ), - ), - ], - ), - ); - } - - if (event.actorPubkey != null) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => showUserProfileSheet( - context, - event.actorPubkey!, - names: channelIdentityNamesProvider(channelId), - ), - child: SmallAvatar(pubkey: event.actorPubkey!, userCache: userCache), - ); - } - - // Fallback: generic icon when no actor is available. - return Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - shape: BoxShape.circle, - ), - child: Icon( - BuzzIcons.arrowLeftRight, - size: 12, - color: context.colors.onSurfaceVariant, - ), - ); + _systemNameSpan(context, targets[index], channelId, resolveLabel), + ], + ]; } class _ThreadSummaryRow extends ConsumerWidget { @@ -637,8 +531,8 @@ class _ThreadSummaryRow extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final participants = summary.participantPubkeys.take(3).toList(); final userCache = ref.watch(userCacheProvider); - return GestureDetector( onTap: () { Navigator.of(context).push( @@ -654,71 +548,59 @@ class _ThreadSummaryRow extends ConsumerWidget { ), ); }, - child: Padding( - key: ValueKey('thread-summary-${message.id}'), - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Stacked participant avatars. - SizedBox( - width: 32.0 + (summary.participantPubkeys.length - 1) * 20.0, - height: 32, - child: Stack( - children: [ - for (var i = 0; i < summary.participantPubkeys.length; i++) - Positioned( - left: i * 20.0, - child: SmallAvatar( - pubkey: summary.participantPubkeys[i], - userCache: userCache, - size: 32, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: summary.isCountPending - ? 'Replies' - : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.primary, - ), - ), - if (summary.lastReplyAt case final lastReplyAt?) ...[ - TextSpan( - text: ' · ', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.5, + child: Semantics( + button: true, + child: Container( + key: ValueKey('thread-summary-${message.id}'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs + Grid.quarter, + vertical: Grid.half, + ), + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: BorderRadius.circular(Radii.container), + border: Border.all(color: context.colors.surface, width: 2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (participants.isNotEmpty) ...[ + ExcludeSemantics( + child: SizedBox( + width: + Grid.gutter + (participants.length - 1) * Grid.twelve, + height: Grid.gutter, + child: Stack( + children: [ + for (var i = 0; i < participants.length; i++) + Positioned( + left: i * Grid.twelve, + child: SmallAvatar( + pubkey: participants[i], + userCache: userCache, + size: Grid.gutter, + ), ), - ), - ), - TextSpan( - text: - 'last reply ${formatThreadSummaryLastReplyTime(lastReplyAt)}', - style: replyPreviewTextStyle.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ], + ], + ), + ), + ), + const SizedBox(width: Grid.half), + ], + Flexible( + child: Text( + summary.isCountPending + ? 'Replies' + : '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - ), - ], + ], + ), ), ), ); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index c43d4057fe..e528970a04 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,3 +1,4 @@ +import 'composer_send_origin.dart'; import 'dart:async'; import 'dart:collection'; import 'dart:io'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 0a09eb5c3c..591bcdd2e1 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -32,6 +32,7 @@ class ComposeBar extends HookConsumerWidget { }); @override Widget build(BuildContext context, WidgetRef ref) { + final sendSurfaceKey = useMemoized(GlobalKey.new); final controller = useMemoized(_MarkdownEditingController.new); final composerText = useListenableSelector( controller, @@ -510,6 +511,15 @@ class ComposeBar extends HookConsumerWidget { uploadingCount.value > 0) { return; } + final submittedOrigin = ComposerSendOrigin.capture(sendSurfaceKey); + Future sendWithOrigin( + String content, + List mentionPubkeys, { + List> mediaTags = const [], + }) => ComposerSendOrigin.run( + submittedOrigin, + () => onSend(content, mentionPubkeys, mediaTags: mediaTags), + ); final submittedDraftRevision = draftRevision.value; // Resolved before any await: see // `_reportSendCancelledByCommunitySwitch`. @@ -632,7 +642,7 @@ class ComposeBar extends HookConsumerWidget { customEmoji: customEmoji, ), outgoing: outgoing, - onSend: onSend, + onSend: sendWithOrigin, messenger: messenger, ); return; @@ -652,7 +662,7 @@ class ComposeBar extends HookConsumerWidget { final cancellation = UploadCancellationToken(); final uploadService = ref.read(mediaUploadServiceProvider); activeUploadCancellation.value = cancellation; - final delivery = onSend; + final delivery = sendWithOrigin; unawaited(() async { var retainedForRetry = false; try { @@ -1055,6 +1065,7 @@ class ComposeBar extends HookConsumerWidget { attachmentSurface.value = _AttachmentSurface.closed; }, child: _ComposeBarLayout( + surfaceKey: sendSurfaceKey, voiceNoteRecorder: voiceNote.recorder, attachments: attachments.value, onRemoveAttachment: removeAttachment, diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 354a0d3ce2..b51dad01b9 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -1,6 +1,7 @@ part of '../compose_bar.dart'; class _ComposeBarLayout extends HookWidget { + final GlobalKey surfaceKey; final Widget? voiceNoteRecorder; final List<_PendingAttachment> attachments; final ValueChanged onRemoveAttachment; @@ -30,6 +31,7 @@ class _ComposeBarLayout extends HookWidget { final bool isSending; const _ComposeBarLayout({ + required this.surfaceKey, required this.voiceNoteRecorder, required this.attachments, required this.onRemoveAttachment, @@ -289,22 +291,25 @@ class _ComposeBarLayout extends HookWidget { final voiceNoteInsetProgress = hasVoiceNoteAttachment ? 1.0 : recordingTransition.value; - final composer = Container( - key: const ValueKey('composer-surface'), - decoration: BoxDecoration( - color: usesIosConcentricSurface - ? Colors.transparent - : context.colors.surfaceContainerHighest, - borderRadius: radius, - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, + final composer = KeyedSubtree( + key: surfaceKey, + child: Container( + key: const ValueKey('composer-surface'), + decoration: BoxDecoration( + color: usesIosConcentricSurface + ? Colors.transparent + : context.colors.surfaceContainerHighest, + borderRadius: radius, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), ), + padding: EdgeInsets.all( + Grid.xxs + Grid.half * voiceNoteInsetProgress, + ), + child: child, ), - padding: EdgeInsets.all( - Grid.xxs + Grid.half * voiceNoteInsetProgress, - ), - child: child, ); if (!usesIosConcentricSurface) return composer; return ConcentricSheetSurface( diff --git a/mobile/lib/features/channels/composer_send_origin.dart b/mobile/lib/features/channels/composer_send_origin.dart new file mode 100644 index 0000000000..270c8111f5 --- /dev/null +++ b/mobile/lib/features/channels/composer_send_origin.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +/// Carries submitted composer geometry through asynchronous send preparation. +class ComposerSendOrigin { + static final _zoneKey = Object(); + + /// Captures only geometry; no draft or identity data is retained. + static Rect? capture(GlobalKey key) { + final box = key.currentContext?.findRenderObject(); + return box is RenderBox && box.hasSize && box.attached + ? box.localToGlobal(Offset.zero) & box.size + : null; + } + + /// Geometry for this send, isolated from other concurrent submissions. + static Rect? get current => Zone.current[_zoneKey] as Rect?; + + /// Retains the submitted surface until this send creates its local claim. + static Future run(Rect? origin, Future Function() send) => + runZoned(send, zoneValues: {_zoneKey: origin}); +} diff --git a/mobile/lib/features/channels/local_message_send_animation_provider.dart b/mobile/lib/features/channels/local_message_send_animation_provider.dart index 1b3fa0f036..43f2d2dd07 100644 --- a/mobile/lib/features/channels/local_message_send_animation_provider.dart +++ b/mobile/lib/features/channels/local_message_send_animation_provider.dart @@ -1,3 +1,6 @@ +import 'package:flutter/widgets.dart'; + +import 'composer_send_origin.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; const _localMessageSendAnimationClaimWindow = Duration(seconds: 1); @@ -14,19 +17,34 @@ class LocalMessageSendAnimationNotifier /// Creates animation state scoped to [channelId]. LocalMessageSendAnimationNotifier(this.channelId); + final _origins = {}; + + /// Submitted composer geometry attached to this event's local send claim. + Rect? originFor(String eventId) => + isRecentLocalMessageSendAnimation(state, eventId) + ? _origins[eventId] + : null; + @override Map build() => const {}; /// Marks [eventId] as a newly signed local message eligible for animation. void mark(String eventId) { final now = DateTime.now(); - state = { + final claims = { for (final entry in state.entries) if (now.difference(entry.value) <= _localMessageSendAnimationClaimWindow) entry.key: entry.value, eventId: now, }; + _origins.removeWhere((id, _) => !claims.containsKey(id)); + if (ComposerSendOrigin.current case final origin?) { + _origins[eventId] = origin; + } else { + _origins.remove(eventId); + } + state = claims; } } diff --git a/mobile/lib/features/channels/local_message_send_transition.dart b/mobile/lib/features/channels/local_message_send_transition.dart index dcf8df4f9a..23024701e7 100644 --- a/mobile/lib/features/channels/local_message_send_transition.dart +++ b/mobile/lib/features/channels/local_message_send_transition.dart @@ -1,48 +1,86 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'local_message_send_animation_provider.dart'; +import 'local_send_row.dart'; /// Duration of a local message's entrance transition. -const localMessageSendTransitionDuration = Duration(milliseconds: 300); +const localMessageSendTransitionDuration = Duration(milliseconds: 500); -/// Default vertical offset, in child heights, for a message entrance. +// A damped spring contracts quickly, gently passes the target width, then +// settles. Normalize the endpoint so there is no snap when the clock completes. +final _contractionSpring = _SendContractionSpring(); + +class _SendContractionSpring extends Curve { + final _simulation = SpringSimulation( + SpringDescription.withDampingRatio(mass: 1, stiffness: 256, ratio: 0.82), + 0, + 1, + 4, + ); + + double get _seconds => + localMessageSendTransitionDuration.inMicroseconds / + Duration.microsecondsPerSecond; + + @override + double transformInternal(double t) => + _simulation.x(t * _seconds) / _simulation.x(_seconds); +} + +/// Default travel multiplier for a message entrance. const localMessageSendTransitionStartOffset = 1.5; -/// Vertical offset, in child heights, used for an avatar entrance. +/// Travel multiplier used when the message includes a sender header. const localMessageSendTransitionAvatarStartOffset = 2.5; -/// Slides a newly sent local message upward from beneath the composer. -class LocalMessageSendTransition extends HookWidget { +/// Coordinates upward row expansion and the bubble flight from the composer. +class LocalMessageSendTransition extends HookConsumerWidget { /// Creates a local send transition around [child]. const LocalMessageSendTransition({ super.key, required this.animate, + this.channelId, + this.messageId, + this.threadHeadId, this.startOffsetFactor = localMessageSendTransitionStartOffset, required this.child, }); + /// Composer scope used to retrieve the captured sending surface. + final String? channelId; + + /// Event whose local claim owns the submitted composer geometry. + final String? messageId; + final String? threadHeadId; + /// Whether to play the entrance transition. final bool animate; - /// Initial vertical offset expressed as a multiple of the child's height. + /// Initial vertical offset expressed as a multiple of 24 logical pixels. final double startOffsetFactor; - /// Message content translated by the transition. + /// Message row whose bottom stays anchored during expansion. final Widget child; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final origin = useMemoized( + () => !animate || channelId == null || messageId == null + ? null + : ref + .read(localMessageSendAnimationProvider(channelId!).notifier) + .originFor(messageId!), + const [], + ); final reduceMotion = MediaQuery.disableAnimationsOf(context); final controller = useAnimationController( duration: localMessageSendTransitionDuration, initialValue: animate && !reduceMotion ? 0 : 1, ); - final easedAnimation = useMemoized( - () => CurvedAnimation(parent: controller, curve: Curves.easeOutCubic), - [controller], - ); - useEffect(() => easedAnimation.dispose, [easedAnimation]); useEffect(() { if (animate && !reduceMotion) { @@ -60,13 +98,69 @@ class LocalMessageSendTransition extends HookWidget { }, [animate, controller, reduceMotion]); return AnimatedBuilder( - animation: easedAnimation, + animation: controller, child: child, - builder: (context, child) => FractionalTranslation( - translation: Offset(0, startOffsetFactor * (1 - easedAnimation.value)), - transformHitTests: false, - child: child, + builder: (context, child) => LocalSendProgress( + progress: const Interval( + 0.15, + 1, + curve: Curves.easeInOutCubic, + ).transform(controller.value), + widthProgress: _contractionSpring.transform(controller.value), + origin: origin, + startOffset: 24 * startOffsetFactor, + // History follows the lift, while width contracts ahead of it. + child: LocalSendRow( + progress: const Interval( + 0.15, + 1, + curve: Curves.easeInOutCubic, + ).transform(controller.value), + child: child!, + ), ), ); } } + +/// Shares the local entrance progress with bubble backgrounds beneath the row. +class LocalSendProgress extends InheritedWidget { + /// Creates a progress scope for the message row. + const LocalSendProgress({ + super.key, + required this.progress, + this.widthProgress, + this.origin, + this.startOffset = 36, + required super.child, + }); + + /// Horizontal contraction, independent of vertical travel. + /// Defaults to [progress] for surfaces without a staged entrance. + final double? widthProgress; + + /// Captured composer rectangle in global coordinates. + final Rect? origin; + + /// Fallback travel when no composer geometry is available. + final double startOffset; + + /// Entrance progress from zero (full width) to one (settled). + final double progress; + + /// Geometry and progress inherited by the bubble's paint surface. + static LocalSendProgress? scopeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + /// Returns settled progress for content outside a local-send transition. + static double of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.progress ?? + 1; + + @override + bool updateShouldNotify(LocalSendProgress oldWidget) => + progress != oldWidget.progress || + widthProgress != oldWidget.widthProgress; +} diff --git a/mobile/lib/features/channels/local_send_bubble_surface.dart b/mobile/lib/features/channels/local_send_bubble_surface.dart new file mode 100644 index 0000000000..946538405f --- /dev/null +++ b/mobile/lib/features/channels/local_send_bubble_surface.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'local_message_send_transition.dart'; + +/// Paints a local-send bubble from the captured composer rectangle. +/// The text travels with the bubble while keeping its final size and wrapping. +class LocalSendBubbleSurface extends SingleChildRenderObjectWidget { + /// Creates an animated background without changing the child layout. + const LocalSendBubbleSurface({ + super.key, + required this.color, + required this.shape, + required super.child, + }); + + /// Bubble fill in the active theme. + final Color color; + + /// Final conversation geometry, including grouped corners and thought dots. + final ShapeBorder shape; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderSendBubble(color, shape, LocalSendProgress.scopeOf(context)); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderProxyBox renderObject, + ) { + (renderObject as _RenderSendBubble).update( + color, + shape, + LocalSendProgress.scopeOf(context), + ); + } +} + +class _RenderSendBubble extends RenderProxyBox { + _RenderSendBubble(this.color, this.shape, this.motion); + + Color color; + ShapeBorder shape; + LocalSendProgress? motion; + double get progress => motion?.progress ?? 1; + + void update( + Color nextColor, + ShapeBorder nextShape, + LocalSendProgress? nextMotion, + ) { + if (color == nextColor && shape == nextShape && motion == nextMotion) { + return; + } + color = nextColor; + shape = nextShape; + motion = nextMotion; + markNeedsPaint(); + markNeedsSemanticsUpdate(); + } + + @override + Rect get paintBounds { + final width = constraints.hasBoundedWidth + ? constraints.maxWidth + : size.width; + final bounds = shape + .getOuterPath(Offset.zero & Size(width, size.height)) + .getBounds() + .expandToInclude(super.paintBounds); + return bounds + .expandToInclude(animatedRect) + .expandToInclude(child?.paintBounds.shift(childOffset) ?? Rect.zero); + } + + Rect get animatedRect { + final end = Offset.zero & size; + final origin = motion?.origin; + final start = origin == null + ? Rect.fromLTWH( + 0, + motion?.startOffset ?? 0, + constraints.hasBoundedWidth ? constraints.maxWidth : size.width, + size.height, + ) + : origin.shift(-localToGlobal(Offset.zero)); + // Contract horizontally first, then finish lifting into the timeline. + // Both tracks share a clock, but their overlapping curves differ. + final horizontal = Rect.lerp( + start, + end, + motion?.widthProgress ?? progress, + )!; + final vertical = Rect.lerp(start, end, progress)!; + return Rect.fromLTWH( + horizontal.left, + vertical.top, + horizontal.width, + vertical.height, + ); + } + + Offset get childOffset => progress < 1 ? animatedRect.topLeft : Offset.zero; + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.translateByDouble(childOffset.dx, childOffset.dy, 0, 1); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + final rect = animatedRect; + if (!shape.getOuterPath(rect).contains(position)) return false; + if (!hitTestChildren(result, position: position)) return false; + result.add(BoxHitTestEntry(this, position)); + return true; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + result.addWithPaintOffset( + offset: childOffset, + position: position, + hitTest: (result, position) => + child?.hitTest(result, position: position) ?? false, + ); + + @override + void paint(PaintingContext context, Offset offset) { + final rect = animatedRect; + final path = shape.getOuterPath(rect.shift(offset)); + context.canvas.drawPath(path, Paint()..color = color); + if (child case final child?) { + if (progress < 1) { + context.pushClipPath( + needsCompositing, + Offset.zero, + path.getBounds(), + path, + (context, _) => context.paintChild(child, offset + rect.topLeft), + ); + } else { + context.paintChild(child, offset); + } + } + } +} diff --git a/mobile/lib/features/channels/local_send_row.dart b/mobile/lib/features/channels/local_send_row.dart new file mode 100644 index 0000000000..77beb12944 --- /dev/null +++ b/mobile/lib/features/channels/local_send_row.dart @@ -0,0 +1,80 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Grows a message row upward while keeping the child's bottom anchored. +class LocalSendRow extends SingleChildRenderObjectWidget { + /// Creates a row driven by the same progress as its bubble entrance. + const LocalSendRow({super.key, required this.progress, required super.child}); + + /// Fraction of the final row height allocated in the conversation. + final double progress; + @override + RenderObject createRenderObject(BuildContext context) => + _RenderSendRow(progress); + @override + void updateRenderObject( + BuildContext context, + covariant RenderProxyBox renderObject, + ) { + (renderObject as _RenderSendRow).setProgress(progress); + } +} + +class _RenderSendRow extends RenderProxyBox { + _RenderSendRow(this.progress); + double progress; + void setProgress(double value) { + if (progress == value) return; + progress = value; + markNeedsLayout(); + markNeedsSemanticsUpdate(); + } + + Offset get childOffset => Offset(0, size.height - (child?.size.height ?? 0)); + @override + void performLayout() { + child?.layout(constraints, parentUsesSize: true); + // A zero-extent item can be culled by the indexed list before the lift + // begins, briefly removing its avatar. Keep it laid out from the first frame. + size = constraints.constrain( + Size( + child?.size.width ?? 0, + (child?.size.height ?? 0) * progress.clamp(0.0001, 1.0), + ), + ); + } + + @override + Rect get paintBounds => super.paintBounds.expandToInclude( + child?.paintBounds.shift(childOffset) ?? Rect.zero, + ); + + @override + void paint(PaintingContext context, Offset offset) { + if (child case final child?) { + context.paintChild(child, offset + childOffset); + } + } + + @override + void applyPaintTransform(RenderBox child, Matrix4 transform) { + transform.translateByDouble(childOffset.dx, childOffset.dy, 0, 1); + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!paintBounds.contains(position)) return false; + if (!hitTestChildren(result, position: position)) return false; + result.add(BoxHitTestEntry(this, position)); + return true; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + result.addWithPaintOffset( + offset: childOffset, + position: position, + hitTest: (result, position) => + child?.hitTest(result, position: position) ?? false, + ); +} diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 6aac44bfa6..a0af9e27fe 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -60,6 +60,7 @@ bool _selectMessageAction(BuildContext context) { } /// Presents message reactions and grouped actions in the shared bottom sheet. +/// Grouped rows can supply aggregate reactions and a toggle for all source events. /// Snapshot and preview callbacks are not used by the sheet presentation. Future showMessageActions({ required BuildContext context, @@ -68,6 +69,8 @@ Future showMessageActions({ required String channelId, required bool canManageMessage, List? allMessages, + List? reactions, + ValueChanged? onToggleReaction, String? currentPubkey, bool isMember = false, Rect? anchorRect, @@ -114,6 +117,8 @@ Future showMessageActions({ children: [ _QuickReactionRow( message: message, + reactions: reactions ?? message.reactions, + onToggleReaction: onToggleReaction, sheetContext: sheetContext, pageContext: context, pageRef: ref, 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 1673086e73..7d9e5898b5 100644 --- a/mobile/lib/features/channels/message_actions/quick_reaction_row.dart +++ b/mobile/lib/features/channels/message_actions/quick_reaction_row.dart @@ -2,6 +2,8 @@ part of '../message_actions.dart'; class _QuickReactionRow extends ConsumerWidget { final TimelineMessage message; + final List reactions; + final ValueChanged? onToggleReaction; /// The sheet's context, popped before the reaction fires. final BuildContext sheetContext; @@ -16,6 +18,8 @@ class _QuickReactionRow extends ConsumerWidget { const _QuickReactionRow({ required this.message, + required this.reactions, + this.onToggleReaction, required this.sheetContext, required this.pageContext, required this.pageRef, @@ -36,10 +40,12 @@ class _QuickReactionRow extends ConsumerWidget { }; void react(String value) { - if (message.reactions.any( - (r) => r.emoji == value && r.reactedByCurrentUser, - )) { - toggleReaction(pageRef, message, value); + if (reactions.any((r) => r.emoji == value && r.reactedByCurrentUser)) { + if (onToggleReaction != null) { + onToggleReaction!(value); + } else { + toggleReaction(pageRef, message, value); + } return; } // The generic picker is also used for composing and statuses. Record @@ -49,7 +55,11 @@ class _QuickReactionRow extends ConsumerWidget { // The sheet is on its way out, so the burst can't come from this tile — // hand it to the pill that's about to appear in the timeline. armReactionBurst(pageRef, message, value); - pageRef.read(channelActionsProvider).addReaction(message.id, value); + if (onToggleReaction != null) { + onToggleReaction!(value); + } else { + pageRef.read(channelActionsProvider).addReaction(message.id, value); + } } return LayoutBuilder( @@ -73,7 +83,7 @@ class _QuickReactionRow extends ConsumerWidget { child: _QuickReactionCircle( size: circleSize, label: dataset.displayName(emoji[index]), - selected: message.reactions.any( + selected: reactions.any( (r) => r.emoji == emoji[index] && r.reactedByCurrentUser, ), onTap: () { diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index a3ac34d859..cfbe413fa3 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:io'; import 'dart:math' as math; @@ -29,9 +30,11 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_only.dart'; import 'channels_provider.dart'; +import 'local_send_bubble_surface.dart'; import 'media_viewer_page.dart'; import 'message_content/link_normalizer.dart'; import 'message_media.dart'; +import 'message_content/bubble_segments.dart'; import 'message_mention_pill.dart'; import 'message_gallery.dart'; import 'message_gallery_frame.dart'; @@ -39,6 +42,7 @@ import 'message_media_geometry.dart'; import 'voice_note_attachment.dart'; part 'message_content/media_carousel.dart'; +part 'message_content/bubble_shape.dart'; part 'message_content/inline_components.dart'; part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; @@ -89,6 +93,21 @@ String _safeDownloadedFilename(String filename) { class MessageContent extends HookConsumerWidget { final String content; + /// Paint conversation text bubbles while keeping attachments on the page. + final bool bubble; + + /// Distinguishes messages authored by the signed-in identity. + final bool ownMessage; + + /// Marks the final message in a sender run for rounded corners and dots. + final bool bubbleEnd; + + /// Marks the first message in a sender run for rounded top corners. + final bool bubbleStart; + + /// Reserves room above overlapping reactions. + final bool hasReactions; + /// Display names for mentioned pubkeys, extracted from event p-tags. /// Keys are lowercase pubkeys, values are display names. final Map mentionNames; @@ -148,6 +167,11 @@ class MessageContent extends HookConsumerWidget { const MessageContent({ super.key, required this.content, + this.bubble = false, + this.ownMessage = false, + this.bubbleEnd = true, + this.bubbleStart = true, + this.hasReactions = false, this.mentionNames = const {}, this.mentionLabels = const {}, this.agentMentionPubkeys = const {}, @@ -167,9 +191,16 @@ class MessageContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final markdownTheme = GptMarkdownTheme.of(context); + final ownTextColor = context.colors.brightness == Brightness.dark + ? Colors.black + : Colors.white; final baseTextStyle = - baseStyle ?? - context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); + (baseStyle ?? + context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + )) + ?.copyWith(color: bubble && ownMessage ? ownTextColor : null); final resolvedMentionNames = mentionNames; final signedMentionPubkeys = mentionedPubkeysFromTags(tags); final mentionBindings = renderedMentionBindings( @@ -316,12 +347,12 @@ class MessageContent extends HookConsumerWidget { onChannelTap: resolvedChannelTap, ); - final markdown = KeyedSubtree( + Widget renderMarkdown(String text) => KeyedSubtree( key: ValueKey( - '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + '$text\u0000$mentionPresentationKey\u0000$channelPresentationKey', ), child: GptMarkdown( - finalContent, + text, style: style, followLinkColor: false, // normalizeBareLinks() already turns bare URLs into Markdown links; @@ -352,6 +383,90 @@ class MessageContent extends HookConsumerWidget { inlineComponents: inlineComponents, ), ); + final segments = bubble + ? messageBubbleSegments(finalContent, imetaByUrl) + : const <({String text, bool attachment})>[]; + final markdown = bubble + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, segment) in segments.indexed) + if (segment.attachment) + MessageAttachmentCorners( + borderRadius: _attachmentCorners( + start: bubbleStart && index == 0, + end: + bubbleEnd && + trailingGallery == null && + index == segments.length - 1, + ), + child: renderMarkdown(segment.text), + ) + else + Padding( + key: ValueKey('message-text-bubble-$index'), + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: LocalSendBubbleSurface( + color: ownMessage + ? (context.colors.brightness == Brightness.dark + ? const Color(0xFFF2F2F2) + : Colors.black) + : context.colors.secondaryContainer, + shape: _MessageBubbleShape( + start: bubbleStart && index == 0, + end: + bubbleEnd && + trailingGallery == null && + index == segments.length - 1, + ), + child: Padding( + padding: EdgeInsets.fromLTRB( + Grid.xs, + Grid.xxs, + Grid.xs, + hasReactions && index == segments.length - 1 + ? Grid.xs + : Grid.xxs, + ), + child: ownMessage + ? Theme( + data: Theme.of(context).copyWith( + colorScheme: context.colors.copyWith( + primary: ownTextColor, + ), + ), + child: GptMarkdownTheme( + gptThemeData: markdownTheme.copyWith( + h1: markdownTheme.h1?.copyWith( + color: ownTextColor, + ), + h2: markdownTheme.h2?.copyWith( + color: ownTextColor, + ), + h3: markdownTheme.h3?.copyWith( + color: ownTextColor, + ), + h4: markdownTheme.h4?.copyWith( + color: ownTextColor, + ), + h5: markdownTheme.h5?.copyWith( + color: ownTextColor, + ), + h6: markdownTheme.h6?.copyWith( + color: ownTextColor, + ), + ), + child: renderMarkdown(segment.text), + ), + ) + : renderMarkdown(segment.text), + ), + ), + ), + ], + ) + : renderMarkdown(finalContent); if (trailingGallery == null) return markdown; return Column( @@ -359,15 +474,21 @@ class MessageContent extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (trailingGallery.content.trim().isNotEmpty) markdown, - _MessageImageCarousel( - key: ValueKey( - trailingGallery.items.map((item) => item.url).join('\u0000'), + MessageAttachmentCorners( + borderRadius: _attachmentCorners( + start: bubbleStart && trailingGallery.content.trim().isEmpty, + end: bubbleEnd, + ), + child: _MessageImageCarousel( + key: ValueKey( + trailingGallery.items.map((item) => item.url).join('\u0000'), + ), + items: trailingGallery.items, + leadingOverflow: mediaCarouselLeadingOverflow, + trailingOverflow: mediaCarouselTrailingOverflow, + onReply: onMediaReply, + onMore: onMediaMore, ), - items: trailingGallery.items, - leadingOverflow: mediaCarouselLeadingOverflow, - trailingOverflow: mediaCarouselTrailingOverflow, - onReply: onMediaReply, - onMore: onMediaMore, ), ], ); @@ -612,7 +733,9 @@ class _MessageImagePreview extends HookConsumerWidget { child: MediaViewerHero( tag: heroTag, child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), child: MediaImage( url: url, decodeWidth: previewDecodeWidth, @@ -661,7 +784,9 @@ class _MessageMediaPreviewFrame extends StatelessWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: backgroundColor, - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), border: Border.all(color: context.colors.outlineVariant), ), child: child, diff --git a/mobile/lib/features/channels/message_content/bubble_segments.dart b/mobile/lib/features/channels/message_content/bubble_segments.dart new file mode 100644 index 0000000000..6ad52cdb8b --- /dev/null +++ b/mobile/lib/features/channels/message_content/bubble_segments.dart @@ -0,0 +1,58 @@ +import '../message_media.dart'; + +/// Splits top-level media from text without breaking Markdown blocks. +/// Fenced code, lists and quotes retain their nested content. +List<({String text, bool attachment})> messageBubbleSegments( + String content, + Map imeta, +) { + final result = <({String text, bool attachment})>[]; + final lines = []; + bool? attachment; + String? fence; + var nestedBlock = false; + final embed = RegExp(r'^!?\[[^\]]*\]\((https?://[^)\s]+)\)$'); + void flush() { + final text = lines.join('\n').trimRight(); + if (text.trim().isNotEmpty) { + result.add((text: text, attachment: attachment ?? false)); + } + lines.clear(); + } + + for (final line in content.split('\n')) { + final trimmed = line.trimLeft(); + final marker = RegExp(r'^(`{3,}|~{3,})').firstMatch(trimmed)?.group(1); + final inFence = fence != null || marker != null || line.startsWith(' '); + if (marker != null) { + if (fence == null) { + fence = marker; + } else if (marker[0] == fence[0] && marker.length >= fence.length) { + fence = null; + } + } + if (trimmed.isEmpty) { + nestedBlock = false; + } else if (RegExp(r'^(?:>|[-+*]\s|\d+[.)]\s)').hasMatch(trimmed)) { + nestedBlock = true; + } + // Indented embeds belong to a list, quote or code block. Lazy block + // continuations also stay together until a blank line ends the block. + final topLevel = line == trimmed && !nestedBlock; + final match = inFence || !topLevel + ? null + : embed.firstMatch(line.trimRight()); + final isAttachment = + match != null && + (line.trim().startsWith('!') || imeta.containsKey(match.group(1))); + if (attachment != null && + isAttachment != attachment && + line.trim().isNotEmpty) { + flush(); + } + if (line.trim().isNotEmpty) attachment = isAttachment; + lines.add(line); + } + flush(); + return result; +} diff --git a/mobile/lib/features/channels/message_content/bubble_shape.dart b/mobile/lib/features/channels/message_content/bubble_shape.dart new file mode 100644 index 0000000000..6c3f5676b0 --- /dev/null +++ b/mobile/lib/features/channels/message_content/bubble_shape.dart @@ -0,0 +1,49 @@ +part of '../message_content.dart'; + +/// Grouped message corners without a tail. +class _MessageBubbleShape extends ShapeBorder { + const _MessageBubbleShape({required this.start, required this.end}); + + final bool start; + final bool end; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + ShapeBorder scale(double t) => this; + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + final round = Radius.circular( + math.min(Radii.container, rect.shortestSide / 2), + ); + const joined = Radius.circular(Radii.sm); + final body = Path() + ..addRRect( + RRect.fromRectAndCorners( + rect, + topLeft: start ? round : joined, + topRight: round, + bottomLeft: end ? round : joined, + bottomRight: round, + ), + ); + return body; + } + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) => + getOuterPath(rect, textDirection: textDirection); + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {} +} + +BorderRadius _attachmentCorners({required bool start, required bool end}) => + BorderRadius.only( + topLeft: Radius.circular(start ? Radii.container : Radii.sm), + topRight: const Radius.circular(Radii.container), + bottomLeft: Radius.circular(end ? Radii.container : Radii.sm), + bottomRight: const Radius.circular(Radii.container), + ); diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 3436fd4ab7..d847db1670 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -146,7 +146,9 @@ class _MessageImageCarousel extends HookConsumerWidget { clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), border: Border.all( color: context.colors.outlineVariant, ), @@ -154,7 +156,9 @@ class _MessageImageCarousel extends HookConsumerWidget { child: MediaViewerHero( tag: heroTags[index], child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(Radii.md), child: MediaImage( url: item.url, decodeWidth: previewDecodeWidths[index], diff --git a/mobile/lib/features/channels/message_footer_layout.dart b/mobile/lib/features/channels/message_footer_layout.dart new file mode 100644 index 0000000000..a35409a9e2 --- /dev/null +++ b/mobile/lib/features/channels/message_footer_layout.dart @@ -0,0 +1,128 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Places reactions at the bottom edge of a message. +/// Both children remain inside the hit-test bounds, including overlapping badges. +class MessageFooterLayout extends StatelessWidget { + /// Creates a message with overlapping reaction controls. + const MessageFooterLayout({ + super.key, + required this.child, + required this.footer, + this.overlap = 0, + this.animateSize = false, + }); + + /// Main message content. + final Widget child; + + /// Controls attached to the bottom of the message. + final Widget footer; + + /// Vertical distance shared by the message and its controls. + final double overlap; + + /// Smoothly adjusts the space occupied by added or removed reactions. + final bool animateSize; + + @override + Widget build(BuildContext context) { + final layout = _FooterLayout(overlap: overlap, children: [child, footer]); + if (!animateSize || MediaQuery.disableAnimationsOf(context)) return layout; + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOutCubic, + alignment: Alignment.topLeft, + clipBehavior: Clip.none, + child: layout, + ); + } +} + +class _FooterLayout extends MultiChildRenderObjectWidget { + const _FooterLayout({required this.overlap, required super.children}); + final double overlap; + + @override + RenderObject createRenderObject(BuildContext context) => + _RenderMessageFooter(overlap); + + @override + void updateRenderObject( + BuildContext context, + covariant RenderBox renderObject, + ) { + (renderObject as _RenderMessageFooter) + ..overlap = overlap + ..markNeedsLayout(); + } +} + +class _RenderMessageFooter extends RenderBox + with + ContainerRenderObjectMixin< + RenderBox, + ContainerBoxParentData + >, + RenderBoxContainerDefaultsMixin< + RenderBox, + ContainerBoxParentData + > { + _RenderMessageFooter(this.overlap); + + double overlap; + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! ContainerBoxParentData) { + child.parentData = _FooterParentData(); + } + } + + Size _layout(BoxConstraints constraints, {required bool dry}) { + final content = firstChild!; + final footer = lastChild!; + final loose = constraints.loosen(); + Size measure(RenderBox child) { + if (dry) return child.getDryLayout(loose); + child.layout(loose, parentUsesSize: true); + return child.size; + } + + final contentSize = measure(content); + final footerSize = measure(footer); + final width = math.max(contentSize.width, footerSize.width); + final footerOffset = Offset(0, math.max(0, contentSize.height - overlap)); + if (!dry) { + (content.parentData! as ContainerBoxParentData).offset = + Offset.zero; + (footer.parentData! as ContainerBoxParentData).offset = + footerOffset; + } + return constraints.constrain( + Size( + width, + math.max(contentSize.height, footerOffset.dy + footerSize.height), + ), + ); + } + + @override + Size computeDryLayout(BoxConstraints constraints) => + _layout(constraints, dry: true); + + @override + void performLayout() => size = _layout(constraints, dry: false); + + @override + void paint(PaintingContext context, Offset offset) => + defaultPaint(context, offset); + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) => + defaultHitTestChildren(result, position: position); +} + +class _FooterParentData extends ContainerBoxParentData {} diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 367a92e455..cca734f184 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -104,6 +104,9 @@ class ReactionRow extends HookWidget { /// Channel whose members define the reactor names' comparison context. final String channelId; + /// Groups reaction controls in one capsule attached to a bubble. + final bool combined; + final List reactions; final void Function(String emoji) onToggle; @@ -122,6 +125,7 @@ class ReactionRow extends HookWidget { required this.reactions, required this.onToggle, this.showAddButton = false, + this.combined = false, this.onAddReaction, }); @@ -148,6 +152,78 @@ class ReactionRow extends HookWidget { // the point. Without it, an empty row would be dead space. if (reactions.isEmpty && !showAddButton) return const SizedBox.shrink(); + if (combined && reactions.isNotEmpty) { + final count = reactions.fold( + 0, + (sum, reaction) => sum + reaction.count, + ); + final ownReaction = reactions.any( + (reaction) => reaction.reactedByCurrentUser, + ); + void openDetails() => showReactionDetailSheet( + context: context, + channelId: channelId, + reactions: liveReactions, + onToggle: (emoji) { + if (context.mounted) latestToggle.value(emoji); + }, + onAddReaction: showAddButton ? onAddReaction : null, + ); + return Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: Align( + widthFactor: 1, + alignment: Alignment.centerLeft, + child: Semantics( + button: true, + label: + 'View $count ${count == 1 ? 'reaction' : 'reactions'}' + '${ownReaction ? ', including yours' : ''}', + child: GestureDetector( + key: const ValueKey('combined-reaction-tray'), + behavior: HitTestBehavior.opaque, + onTap: openDetails, + onLongPress: openDetails, + child: ExcludeSemantics( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.secondaryContainer, + borderRadius: BorderRadius.circular(Radii.container), + border: Border.all(color: context.colors.surface, width: 2), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs + Grid.quarter, + vertical: Grid.half, + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Grid.quarter, + runSpacing: Grid.quarter, + children: [ + for (final reaction in reactions) + Padding( + padding: const EdgeInsets.all(Grid.quarter), + child: _ReactionEmoji(reaction: reaction, size: 20), + ), + Padding( + padding: const EdgeInsets.only(left: Grid.half), + child: Text( + '${reactions.fold(0, (sum, reaction) => sum + reaction.count)}', + style: context.textTheme.labelMedium, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } + return Padding( padding: const EdgeInsets.only(top: Grid.half), child: Wrap( @@ -354,10 +430,25 @@ class _ReactionEmoji extends StatelessWidget { Widget build(BuildContext context) { final emojiUrl = reaction.emojiUrl; if (emojiUrl == null || emojiUrl.isEmpty) { - return NativeEmojiGlyph(emoji: reaction.emoji, size: size); + return NativeEmojiGlyph( + emoji: reaction.emoji, + size: size, + opticalBoxSize: size, + centerInOpticalBox: true, + ); } final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1); - return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size); + return SizedBox.square( + dimension: size, + child: FittedBox( + fit: BoxFit.scaleDown, + child: CustomEmojiImage( + shortcode: shortcode, + url: emojiUrl, + size: size, + ), + ), + ); } } @@ -366,6 +457,7 @@ Future showReactionDetailSheet({ required String channelId, required ValueListenable> reactions, required ValueChanged onToggle, + VoidCallback? onAddReaction, }) async { if (reactions.value.isEmpty) return; if (await _showNativeReactionDetails( @@ -373,6 +465,7 @@ Future showReactionDetailSheet({ channelId, reactions, onToggle, + onAddReaction, )) { return; } @@ -398,6 +491,7 @@ Future showReactionDetailSheet({ channelId: channelId, reactions: value, onToggle: onToggle, + onAddReaction: onAddReaction, ); }, ), @@ -408,11 +502,13 @@ class _ReactionDetailSheet extends HookConsumerWidget { final String channelId; final List reactions; final ValueChanged onToggle; + final VoidCallback? onAddReaction; const _ReactionDetailSheet({ required this.channelId, required this.reactions, required this.onToggle, + this.onAddReaction, }); @override @@ -446,6 +542,16 @@ class _ReactionDetailSheet extends HookConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ + if (onAddReaction != null) + IconButton( + key: const ValueKey('reaction-sheet-add'), + tooltip: 'Add reaction', + icon: const Icon(BuzzIcons.smilePlus), + onPressed: () { + Navigator.of(context).pop(); + onAddReaction!(); + }, + ), for (final reaction in reactions) Padding( padding: const EdgeInsets.only(right: Grid.half), @@ -613,9 +719,11 @@ Future _showNativeReactionDetails( String channelId, ValueListenable> reactions, ValueChanged onToggle, + VoidCallback? onAddReaction, ) async { if (!NativeMessagePresentation.isSupportedPlatform) return false; - return NativeMessagePresentation.withLease(() async { + var addRequested = false; + final handled = await NativeMessagePresentation.withLease(() async { final container = ProviderScope.containerOf(context, listen: false); final requestId = DateTime.now().microsecondsSinceEpoch.toString(); List pubkeys() => @@ -743,11 +851,15 @@ Future _showNativeReactionDetails( context, ).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(), 'dark': Theme.of(context).brightness == Brightness.dark, + 'canAddReaction': onAddReaction != null, + 'addReactionIconCodePoint': BuzzIcons.smilePlus.codePoint, 'profiles': profiles(), 'reactions': reactionData(), }, onReaction: onToggle); container.read(userCacheProvider.notifier).preload(pubkeys()); - return await pending != null; + final result = await pending; + addRequested = result?['action'] == 'addReaction'; + return result != null; } finally { disposed = true; reactions.removeListener(updateReactions); @@ -756,4 +868,6 @@ Future _showNativeReactionDetails( namesSubscription.close(); } }); + if (addRequested && context.mounted) onAddReaction?.call(); + return handled; } diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index e3b6f10c62..7e519ca938 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,3 +1,5 @@ +import '../../shared/widgets/ios_scroll_to_top.dart'; +import 'message_footer_layout.dart'; import 'dart:async'; import 'dart:math' as math; @@ -25,6 +27,7 @@ import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; import 'android_ime_lift.dart'; import 'channel.dart'; +import 'channel_management_provider.dart' show currentPubkeyProvider; import 'channel_identity_names_provider.dart'; import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; @@ -104,6 +107,10 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + // A route can open before profile metadata arrives. Follow the active + // signing identity instead of freezing that incomplete route snapshot. + final currentPubkey = + ref.watch(currentPubkeyProvider) ?? this.currentPubkey; final appView = View.of(context); final composerDockHeight = useState(0.0); final composerFocusNode = useFocusNode(); @@ -714,7 +721,7 @@ class ThreadDetailPage extends HookConsumerWidget { .where( (e) => currentPubkey == null || - e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), + e.pubkey.toLowerCase() != currentPubkey.toLowerCase(), ) .toList(); diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart index dd1a1a4d5d..d4894bfe29 100644 --- a/mobile/lib/features/channels/thread_detail_page/message_list.dart +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -150,6 +150,27 @@ class _ThreadMessageList extends HookWidget { const headIndex = 0; final tailAnchorIndex = replies.length + 1; + useIosScrollToTop(context, () { + if (!itemScrollController.isAttached) return; + onUserScrollStart(); + if (MediaQuery.disableAnimationsOf(context)) { + itemScrollController.jumpTo(index: 0); + onUserScrollEnd(); + } else { + unawaited( + itemScrollController + .scrollTo( + index: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ) + .whenComplete(() { + if (context.mounted) onUserScrollEnd(); + }), + ); + } + }); + return LaidOutViewportReporter( viewport: viewport, child: KeyboardDismissOnDrag( @@ -221,12 +242,27 @@ class _ThreadMessageList extends HookWidget { reply.createdAt, ); final showAuthor = + reply.hasAttachments || + previousReply?.hasAttachments == true || previousReply == null || showDayDivider || previousReply.pubkey.toLowerCase() != reply.pubkey.toLowerCase() || (reply.createdAt - previousReply.createdAt) > 300; + final nextReply = + chronologicalIndex + 1 < replies.length + ? replies[chronologicalIndex + 1] + : null; + final showAvatar = + reply.hasAttachments || + nextReply?.hasAttachments == true || + nextReply == null || + !isSameDay(reply.createdAt, nextReply.createdAt) || + nextReply.pubkey.toLowerCase() != + reply.pubkey.toLowerCase() || + nextReply.createdAt - reply.createdAt > 300; + // Check if this reply itself has children (nested thread). final nestedChildren = childrenByParent[reply.id]; final nestedSummary = @@ -236,6 +272,9 @@ class _ThreadMessageList extends HookWidget { return trackActiveScrollPosition( LocalMessageSendTransition( + messageId: reply.id, + channelId: channelId, + threadHeadId: head.id, key: ValueKey('thread-message-send-${reply.id}'), animate: isRecentLocalMessageSendAnimation( localSendAnimations, @@ -265,6 +304,7 @@ class _ThreadMessageList extends HookWidget { channelId: channelId, currentPubkey: currentPubkey, showAuthor: showAuthor, + showAvatar: showAvatar, isHighlighted: reply.id == highlightedMessageId, allMessages: allMessages, diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 2334be5dd3..b28da14bd9 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -6,6 +6,7 @@ class _ThreadMessage extends HookConsumerWidget { final String channelId; final String? currentPubkey; final bool showAuthor; + final bool showAvatar; final bool isHighlighted; final List? allMessages; final bool isMember; @@ -23,6 +24,7 @@ class _ThreadMessage extends HookConsumerWidget { required this.channelId, required this.currentPubkey, required this.showAuthor, + this.showAvatar = true, this.isHighlighted = false, this.allMessages, this.isMember = false, @@ -148,19 +150,213 @@ class _ThreadMessage extends HookConsumerWidget { snapshotKey: messageSnapshotKey, child: Padding( padding: EdgeInsets.only( - top: showAuthor ? 0 : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - RepaintBoundary( - key: messageSnapshotKey, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( + child: MessageFooterLayout( + animateSize: true, + overlap: message.reactions.isNotEmpty ? Grid.xs : 0, + footer: (isThreadHead || message.reactions.isNotEmpty) + ? Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ) + : const SizedBox.shrink(), + child: RepaintBoundary( + key: messageSnapshotKey, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + left: Grid.xs, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + showTimestamp: true, + nameStyle: + messageUsernameTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + metadataStyle: + messageMetadataTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + timestampStyle: + messageTimestampTextStyle + .copyWith( + fontSize: 11, + height: 17 / 11, + ), + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: context + .colors + .onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + names: + channelIdentityNamesProvider( + channelId, + ), + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), + ), + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context + .textTheme + .labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + MessageContent( + content: message.content, + bubble: true, + hasReactions: message.reactions.isNotEmpty, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + channelId, + ), + ), + ), + ], + ), + ), + ), + ], + ), + if (showAvatar) + Positioned( + left: 0, + bottom: 0, + child: GestureDetector( onTap: () => showUserProfileSheet( context, message.pubkey, @@ -171,162 +367,11 @@ class _ThreadMessage extends HookConsumerWidget { pubkey: message.pubkey, isAgent: isAgent, ), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel( - profile, - ), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => - showUserProfileSheet( - context, - message.pubkey, - names: - channelIdentityNamesProvider( - channelId, - ), - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - channelId, - ), - ), - ), - ], - ), ), ), - ], - ), + ], ), - if (isThreadHead || message.reactions.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - ), - child: ReactionRow( - messageId: message.id, - channelId: channelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, - ), - ), - ), - ], + ), ), ), ), diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 81bd0537f6..3e2e72005b 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -6,6 +6,8 @@ import '../../shared/relay/relay.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/mentions/mention_tags.dart'; import 'channel_window.dart'; +import 'message_media.dart'; +import 'message_content/bubble_segments.dart'; enum SystemEventType { memberJoined, @@ -111,7 +113,7 @@ class SystemEvent { return '$actor joined the channel'; } final target = resolveLabel(targetPubkey); - return '$target was added by $actor'; + return '$actor added $target to the channel'; }(), SystemEventType.memberLeft => '$actor left the channel', SystemEventType.memberRemoved => () { @@ -212,7 +214,11 @@ class TimelineMessage { /// Attachment messages stay visually distinct from surrounding messages, /// even when several are sent by the same author in quick succession. bool get hasAttachments => - tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta'); + tags.any((tag) => tag.isNotEmpty && tag.first == 'imeta') || + messageBubbleSegments( + content, + parseImetaTags(tags), + ).any((segment) => segment.attachment); } @immutable @@ -255,13 +261,20 @@ const _membershipGroupWindowSeconds = 5 * 60; class _MembershipChange { final String? actor; final bool isSelfJoin; + final SystemEventType type; - const _MembershipChange({required this.actor, required this.isSelfJoin}); + const _MembershipChange({ + required this.actor, + required this.isSelfJoin, + required this.type, + }); } _MembershipChange? _membershipChange(MainTimelineEntry entry) { final event = entry.message.systemEvent; - if (!entry.message.isSystem || event?.type != SystemEventType.memberJoined) { + if (!entry.message.isSystem || + (event?.type != SystemEventType.memberJoined && + event?.type != SystemEventType.memberRemoved)) { return null; } @@ -271,8 +284,10 @@ _MembershipChange? _membershipChange(MainTimelineEntry entry) { return null; } - final isSelfJoin = actor == target; + final isSelfJoin = + event!.type == SystemEventType.memberJoined && actor == target; return _MembershipChange( + type: event.type, actor: isSelfJoin ? null : actor, isSelfJoin: isSelfJoin, ); @@ -282,7 +297,8 @@ bool _membershipChangesCanGroup( _MembershipChange first, _MembershipChange second, ) { - return first.isSelfJoin == second.isSelfJoin && + return first.type == second.type && + first.isSelfJoin == second.isSelfJoin && (first.isSelfJoin || first.actor == second.actor); } @@ -294,10 +310,9 @@ bool _isSameLocalDay(int firstTimestamp, int secondTimestamp) { first.day == second.day; } -/// Groups consecutive membership arrivals using the same display rule as -/// desktop: matching additions (or self-joins) within a fixed five-minute -/// window become one render item. Other events and local day boundaries break -/// the group. +/// Groups consecutive matching additions, removals, or self-joins within a +/// five-minute window into one render item. Other events and local day +/// boundaries break the group. /// /// Each inner list is one renderable timeline item. Non-grouped entries are /// returned as single-item lists. @@ -462,6 +477,9 @@ List formatTimeline( if (event.kind == EventKind.systemMessage) { final systemEvent = SystemEvent.fromContent(event.content); + // Purpose is legacy metadata with no mobile surface. Omit its activity + // before grouping so it cannot leave blank rows or split sender groups. + if (systemEvent?.type == SystemEventType.purposeChanged) continue; if (systemEvent != null) { result.add( TimelineMessage( diff --git a/mobile/lib/features/channels/voice_note_attachment.dart b/mobile/lib/features/channels/voice_note_attachment.dart index c62bd35601..064355a277 100644 --- a/mobile/lib/features/channels/voice_note_attachment.dart +++ b/mobile/lib/features/channels/voice_note_attachment.dart @@ -1,3 +1,4 @@ +import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:async'; import 'dart:math' as math; @@ -147,7 +148,9 @@ class VoiceNoteAttachment extends HookConsumerWidget { padding: const EdgeInsets.all(Grid.twelve), decoration: BoxDecoration( color: context.colors.surface, - borderRadius: BorderRadius.circular(radius), + borderRadius: + MessageAttachmentCorners.of(context) ?? + BorderRadius.circular(radius), border: Border.all(color: context.colors.outlineVariant), ), child: Row( diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart index 40b6f81b79..affe6c69e4 100644 --- a/mobile/lib/shared/emoji/native_emoji_glyph.dart +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -3,9 +3,9 @@ import 'package:flutter/material.dart'; /// A standalone system emoji whose visual centre matches its surrounding UI. /// -/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the -/// layout box unchanged and lift only the painted glyph on iOS; Android's -/// system emoji metrics are already visually centred. +/// Inline glyphs retain their platform baseline adjustment. Fixed reaction +/// boxes can opt into emoji-font metrics and optical centering independently +/// of the surrounding text font. class NativeEmojiGlyph extends StatelessWidget { /// Creates a system emoji with optional optical bounds. const NativeEmojiGlyph({ @@ -13,6 +13,7 @@ class NativeEmojiGlyph extends StatelessWidget { required this.emoji, required this.size, this.opticalBoxSize, + this.centerInOpticalBox = false, }); /// Emoji text rendered by the platform font. @@ -24,6 +25,9 @@ class NativeEmojiGlyph extends StatelessWidget { /// Optional square that normalizes the apparent bounds of wide emoji. final double? opticalBoxSize; + /// Centers reaction artwork in its fixed box instead of a surrounding line. + final bool centerInOpticalBox; + @override Widget build(BuildContext context) { Widget glyph = Text( @@ -31,9 +35,19 @@ class NativeEmojiGlyph extends StatelessWidget { maxLines: 1, softWrap: false, textScaler: TextScaler.noScaling, - style: TextStyle(fontSize: size, height: 1), + style: TextStyle( + fontSize: size, + height: 1, + leadingDistribution: centerInOpticalBox + ? TextLeadingDistribution.even + : null, + fontFamily: + centerInOpticalBox && defaultTargetPlatform == TargetPlatform.iOS + ? 'Apple Color Emoji' + : null, + ), ); - if (defaultTargetPlatform == TargetPlatform.iOS) { + if (defaultTargetPlatform == TargetPlatform.iOS && !centerInOpticalBox) { glyph = Transform.translate(offset: const Offset(0, -1), child: glyph); } final boxSize = opticalBoxSize; @@ -42,9 +56,16 @@ class NativeEmojiGlyph extends StatelessWidget { // Emoji sequences have very different typographic advances. Constrain // them to the same square optical box so wide glyphs scale down around the // same center instead of appearing to lean toward one edge. - return SizedBox.square( - dimension: boxSize, - child: FittedBox(fit: BoxFit.scaleDown, child: glyph), - ); + Widget fitted = FittedBox(fit: BoxFit.scaleDown, child: glyph); + if (defaultTargetPlatform == TargetPlatform.iOS && centerInOpticalBox) { + // Apple emoji artwork sits below its line-box center on the device. + // Apply the optical lift after fitting so wide sequences get the same + // correction as a single glyph; the surrounding box stays centered. + fitted = Transform.translate( + offset: Offset(0, -boxSize * 0.1), + child: fitted, + ); + } + return SizedBox.square(dimension: boxSize, child: fitted); } } diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 160d9ab127..1c6b2b211f 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'ios_navigation_bar.dart'; +import 'ios_scroll_to_top.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../theme/theme.dart'; @@ -176,7 +177,9 @@ class FrostedScaffold extends HookWidget { // rotation) can apply its new collapse range without another scroll. final next = topContentDepth.clamp(0.0, double.infinity); if ((scrollOffset.value - next).abs() > 0.1) { - scrollOffset.value = next; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) scrollOffset.value = next; + }); } } final next = topContentDepth > 0.5; @@ -185,20 +188,36 @@ class FrostedScaffold extends HookWidget { }, child: body, ); - final scaffold = IosNavigationScrollScope( - offset: scrollOffset, - child: Scaffold( - backgroundColor: backgroundColor, - resizeToAvoidBottomInset: resizeToAvoidBottomInset, - floatingActionButton: floatingActionButton, - body: FrostedScrollUnderScope( - isScrolledUnder: isScrolledUnder.value, - child: Stack( - children: _stackChildren( - observedBody, - pinnedController, - pinnedStorage, - pinnedKey, + final scaffold = IosScrollToTop( + onScrollToTop: nativePinned + ? () { + if (!pinnedController.hasClients) return; + if (MediaQuery.disableAnimationsOf(context)) { + pinnedController.jumpTo(0); + } else { + pinnedController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ); + } + } + : null, + child: IosNavigationScrollScope( + offset: scrollOffset, + child: Scaffold( + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: resizeToAvoidBottomInset, + floatingActionButton: floatingActionButton, + body: FrostedScrollUnderScope( + isScrolledUnder: isScrolledUnder.value, + child: Stack( + children: _stackChildren( + observedBody, + pinnedController, + pinnedStorage, + pinnedKey, + ), ), ), ), diff --git a/mobile/lib/shared/widgets/ios_scroll_to_top.dart b/mobile/lib/shared/widgets/ios_scroll_to_top.dart new file mode 100644 index 0000000000..e703eaa5fa --- /dev/null +++ b/mobile/lib/shared/widgets/ios_scroll_to_top.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Handles the native status-bar gesture for the visible page's vertical list. +class IosScrollToTop extends HookWidget { + final Widget child; + + /// Used when a page coordinates multiple scroll positions, such as a title. + final VoidCallback? onScrollToTop; + + const IosScrollToTop({super.key, required this.child, this.onScrollToTop}); + + @override + Widget build(BuildContext context) { + final target = useMemoized(_ScrollTarget.new); + final marker = useMemoized(GlobalKey.new); + final position = useRef(null); + final callback = useRef(() {}); + callback.value = () { + if (!context.mounted || ModalRoute.of(context)?.isCurrent == false) { + return; + } + final renderObject = marker.currentContext?.findRenderObject(); + if (renderObject == null || !renderObject.attached) return; + final hits = HitTestResult(); + WidgetsBinding.instance.hitTestInView( + hits, + Offset.zero, + View.of(context).viewId, + ); + if (!hits.path.any((hit) => hit.target == renderObject)) return; + if (target.callback != null) { + target.callback!(); + } else if (onScrollToTop != null) { + onScrollToTop!(); + } else { + final scroll = position.value; + if (scroll == null || + scroll.context.notificationContext?.mounted != true || + !scroll.hasContentDimensions) { + return; + } + final top = scroll.axisDirection == AxisDirection.up + ? scroll.maxScrollExtent + : scroll.minScrollExtent; + if (MediaQuery.disableAnimationsOf(context)) { + scroll.jumpTo(top); + } else { + unawaited( + scroll.animateTo( + top, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + ), + ); + } + } + }; + useEffect(() { + if (defaultTargetPlatform != TargetPlatform.iOS) return null; + final observer = _StatusBarObserver(() => callback.value()); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, const []); + if (defaultTargetPlatform != TargetPlatform.iOS) return child; + return _ScrollTargetScope( + target: target, + child: Listener( + key: marker, + behavior: HitTestBehavior.translucent, + child: NotificationListener( + onNotification: (notification) { + final BuildContext? source; + final int depth; + if (notification is ScrollNotification) { + source = notification.context; + depth = notification.depth; + } else if (notification is ScrollMetricsNotification) { + source = notification.context; + depth = notification.depth; + } else { + return false; + } + if (depth != 0 || + source == null || + source.findAncestorWidgetOfExactType() != null) { + return false; + } + final scroll = Scrollable.maybeOf(source)?.position; + if (scroll?.axis == Axis.vertical) position.value = scroll; + return false; + }, + // This page handles the native event, including custom/reversed lists. + child: PrimaryScrollController.none(child: child), + ), + ), + ); + } +} + +/// Registers an indexed list's top target with its enclosing page. +void useIosScrollToTop(BuildContext context, VoidCallback callback) { + final target = context + .dependOnInheritedWidgetOfExactType<_ScrollTargetScope>() + ?.target; + useEffect(() { + if (target == null) return null; + target.callback = callback; + return () { + if (target.callback == callback) target.callback = null; + }; + }, [target, callback]); +} + +class _ScrollTarget { + VoidCallback? callback; +} + +class _ScrollTargetScope extends InheritedWidget { + final _ScrollTarget target; + const _ScrollTargetScope({required this.target, required super.child}); + @override + bool updateShouldNotify(_ScrollTargetScope oldWidget) => + target != oldWidget.target; +} + +class _StatusBarObserver with WidgetsBindingObserver { + final VoidCallback onTap; + _StatusBarObserver(this.onTap); + @override + void handleStatusBarTap() => onTap(); +} diff --git a/mobile/lib/shared/widgets/message_attachment_corners.dart b/mobile/lib/shared/widgets/message_attachment_corners.dart new file mode 100644 index 0000000000..d9b609c60c --- /dev/null +++ b/mobile/lib/shared/widgets/message_attachment_corners.dart @@ -0,0 +1,21 @@ +import 'package:flutter/widgets.dart'; + +/// Shares conversation corner geometry with attachment surfaces. +class MessageAttachmentCorners extends InheritedWidget { + final BorderRadius borderRadius; + + const MessageAttachmentCorners({ + super.key, + required this.borderRadius, + required super.child, + }); + + /// Returns the conversation corners, or null outside a message bubble. + static BorderRadius? of(BuildContext context) => context + .dependOnInheritedWidgetOfExactType() + ?.borderRadius; + + @override + bool updateShouldNotify(MessageAttachmentCorners oldWidget) => + borderRadius != oldWidget.borderRadius; +} diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart index ed1e3a6e2f..2de13ad5b5 100644 --- a/mobile/lib/shared/widgets/message_author_meta.dart +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -13,6 +13,9 @@ class MessageAuthorMeta extends StatelessWidget { /// Timestamp label shown after the author metadata. final String timestamp; + /// Whether metadata includes the timestamp alongside the author. + final bool showTimestamp; + /// Color applied to [displayName]. final Color nameColor; @@ -48,6 +51,7 @@ class MessageAuthorMeta extends StatelessWidget { required this.nameColor, required this.metadataColor, this.username, + this.showTimestamp = true, this.onAuthorTap, this.displayNameKey, this.usernameKey, @@ -104,17 +108,19 @@ class MessageAuthorMeta extends StatelessWidget { ), ), ], - const SizedBox(width: Grid.xxs), - ConstrainedBox( - constraints: BoxConstraints(maxWidth: metadataMaxWidth), - child: Text( - timestamp, - key: timestampKey, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: resolvedTimestampStyle, + if (showTimestamp) ...[ + const SizedBox(width: Grid.xxs), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: metadataMaxWidth), + child: Text( + timestamp, + key: timestampKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: resolvedTimestampStyle, + ), ), - ), + ], ], ); }, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index cf746acde5..2286bbb572 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -45,6 +45,7 @@ import 'package:buzz/features/channels/day_divider.dart'; 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/local_send_bubble_surface.dart'; import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/mobile_huddle_controller.dart'; @@ -84,6 +85,7 @@ part 'thread_title_capsule_cases.dart'; part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart'; +part 'channel_detail_page_test/bubble_tests.dart'; const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; @@ -219,6 +221,9 @@ NostrEvent _edit({ ); Widget _buildTestable({ + ProfileNotifier? profileNotifier, + String? signingPubkey, + bool darkTheme = false, required List messages, PresenceCacheNotifier? presenceCache, List typing = const [], @@ -294,7 +299,11 @@ Widget _buildTestable({ userCacheProvider.overrideWith( () => userCacheNotifier ?? _FakeUserCacheNotifier(users), ), - profileProvider.overrideWith(() => _FakeProfileNotifier()), + profileProvider.overrideWith( + () => profileNotifier ?? _FakeProfileNotifier(), + ), + if (signingPubkey != null) + myPubkeyProvider.overrideWithValue(signingPubkey), if (presenceCache != null) presenceCacheProvider.overrideWith(() => presenceCache), channelsProvider.overrideWith(() => fakeChannelsNotifier), @@ -404,7 +413,7 @@ Widget _buildTestable({ ], child: MaterialApp( navigatorKey: navigatorKey, - theme: AppTheme.light(), + theme: darkTheme ? AppTheme.dark() : AppTheme.light(), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( textScaler: textScaler, @@ -506,6 +515,7 @@ void main() { threadTitleCapsuleTests(); presenceTests(); actionRowTests(); + bubbleLayoutTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); @@ -653,7 +663,14 @@ void main() { await tester.pumpAndSettle(); AvatarImage avatarIn(Finder row) => tester.widget( - find.descendant(of: row, matching: find.byType(AvatarImage)), + find.descendant( + of: row, + matching: find.byWidgetPredicate( + (widget) => + widget is AvatarImage && + widget.radius == messageAvatarSize / 2, + ), + ), ); expect( avatarIn( @@ -3191,23 +3208,26 @@ void main() { } final aliceName = find.text('Alice'); final aliceText = tester.widget(aliceName); - expect(aliceText.style?.fontSize, messageUsernameTextStyle.fontSize); + expect(aliceText.style?.fontSize, 13.1); expect(aliceText.style?.fontWeight, messageUsernameTextStyle.fontWeight); - expect(aliceText.style?.height, messageUsernameTextStyle.height); + expect(aliceText.style?.height, 17 / 13.1); final aliceUsername = tester.widget( find.byKey(const ValueKey('message-username-msg1')), ); final aliceTimestamp = tester.widget( find.byKey(const ValueKey('message-timestamp-msg1')), ); - expect(aliceUsername.style?.fontSize, messageMetadataTextStyle.fontSize); + expect(aliceUsername.style?.fontSize, 13.1); expect(aliceUsername.style?.fontWeight, FontWeight.w400); - expect(aliceUsername.style?.height, messageMetadataTextStyle.height); - expect( - aliceTimestamp.style?.fontSize, - messageTimestampTextStyle.fontSize, - ); + expect(aliceUsername.style?.height, 17 / 13.1); + expect(aliceTimestamp.style?.fontSize, 11); expect(aliceTimestamp.style?.fontWeight, FontWeight.w400); + expect( + tester + .getCenter(find.byKey(const ValueKey('message-timestamp-msg1'))) + .dy, + closeTo(tester.getCenter(aliceName).dy, 0.01), + ); expect( aliceTimestamp.style?.fontSize, lessThan(aliceText.style!.fontSize!), @@ -3509,12 +3529,13 @@ void main() { }, ); - testWidgets('uses larger participant avatars in reply summaries', ( + testWidgets('attaches replies after reactions with an eight pixel gap', ( tester, ) async { await tester.pumpWidget( _buildTestable( messages: [ + _reaction(id: 'reaction-root', targetId: 'root'), _textMsg( id: 'root', pubkey: 'alice', @@ -3544,33 +3565,30 @@ void main() { ); await tester.pumpAndSettle(); - expect( - findRichText( - '2 replies · last reply ' - '${formatThreadSummaryLastReplyTime(1200)}', - ), - findsOneWidget, - ); - expect(find.byIcon(BuzzIcons.chevronRight), findsNothing); - final replyAvatars = find.byType(SmallAvatar); - expect(replyAvatars, findsNWidgets(2)); - for (final avatar in replyAvatars.evaluate()) { + expect(find.text('2 replies'), findsOneWidget); + expect(find.byIcon(BuzzIcons.messageSquareReply), findsNothing); + expect(find.byType(SmallAvatar), findsNWidgets(2)); + for (final avatar in find.byType(SmallAvatar).evaluate()) { expect( tester.getSize(find.byWidget(avatar.widget)), - const Size.square(32), + const Size.square(20), ); } - final summaryPadding = tester.widget( - find.byKey(const ValueKey('thread-summary-root')), - ); + final pill = find.byKey(const ValueKey('thread-summary-root')); + final tray = find.byKey(const ValueKey('combined-reaction-tray')); + expect(tester.getTopLeft(pill).dx - tester.getTopRight(tray).dx, 8); expect( - summaryPadding.padding, - const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.half, - bottom: Grid.xs, - ), + tester.getCenter(pill).dy, + closeTo(tester.getCenter(tray).dy, 0.1), ); + final body = find.text('Thread head', findRichText: true); + expect( + tester.getTopLeft(pill).dy, + lessThan(tester.getBottomLeft(body).dy + 24), + ); + await tester.tap(pill); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); }); testWidgets('constrains reply summaries at accessibility text sizes', ( @@ -3592,7 +3610,7 @@ void main() { content: 'Thread head', createdAt: lastReplyAt - 300, ), - for (var i = 0; i < 3; i++) + for (var i = 0; i < 4; i++) _textMsg( id: 'reply-$i', pubkey: 'participant-$i', @@ -3609,9 +3627,10 @@ void main() { ); await tester.pumpAndSettle(); - final summaryText = tester.widget(findRichText('3 replies')); + final summaryText = tester.widget(findRichText('4 replies')); expect(summaryText.maxLines, 2); expect(summaryText.overflow, TextOverflow.ellipsis); + expect(find.byType(SmallAvatar), findsNWidgets(3)); expect(tester.takeException(), isNull); }); @@ -4719,6 +4738,162 @@ void main() { ); }); + for (final dark in [false, true]) { + for (final thread in [false, true]) { + testWidgets( + 'own bubble colors survive profile reloads dark=$dark thread=$thread', + (tester) async { + final events = [ + _textMsg( + id: 'own-color', + pubkey: 'self', + content: 'My message', + createdAt: 2000, + ), + _textMsg( + id: 'other-color', + pubkey: 'other', + content: 'Their message', + createdAt: 2001, + ), + ]; + final profile = _MutableColorProfileNotifier(); + final timeline = formatTimeline(events, currentPubkey: 'self'); + await tester.pumpWidget( + _buildTestable( + messages: events, + profileNotifier: profile, + signingPubkey: 'SELF', + darkTheme: dark, + home: thread + ? ThreadDetailPage( + threadHead: timeline.first, + allMessages: timeline, + channelId: _channelId, + currentPubkey: null, + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + Color ownColor() => tester + .widget( + find + .descendant( + of: find + .ancestor( + of: findRichText('My message'), + matching: find.byType(MessageContent), + ) + .first, + matching: find.byType(LocalSendBubbleSurface), + ) + .first, + ) + .color; + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent).first), + ); + final subscription = container.listen(profileProvider, (_, _) {}); + addTearDown(subscription.close); + final expected = dark ? const Color(0xFFF2F2F2) : Colors.black; + expect(ownColor(), expected); + for (final state in >[ + const AsyncLoading(), + AsyncError(StateError('Profile unavailable'), StackTrace.current), + const AsyncData(null), + const AsyncData( + UserProfile(pubkey: 'other', displayName: 'Stale profile'), + ), + const AsyncData(UserProfile(pubkey: 'self', displayName: 'Self')), + ]) { + profile.setProfileState(state); + await tester.pump(); + expect(ownColor(), expected); + } + await tester.pumpAndSettle(); + }, + ); + } + } + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + for (final multiline in [false, true]) { + testWidgets( + 'group avatar stays fixed throughout a local send: $platform, multiline=$multiline', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = platform; + try { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final initial = [ + _textMsg( + id: 'first', + pubkey: 'self', + content: 'First message', + createdAt: 2000, + ), + ]; + final notifier = _FakeMessagesNotifier(initial); + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: notifier, + users: const { + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + ), + ); + await tester.pumpAndSettle(); + Finder avatar(String id) => find.descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: find.byType(AvatarImage), + ); + final before = tester.getRect(avatar('first')); + final container = ProviderScope.containerOf( + tester.element(find.byType(ChannelDetailPage)), + ); + container + .read(localMessageSendAnimationProvider(_channelId).notifier) + .mark('second'); + notifier.setMessages([ + ...initial, + _textMsg( + id: 'second', + pubkey: 'self', + content: multiline + ? 'Second message\nwith more lines\nand more text' + : 'Second message', + createdAt: 2001, + ), + ]); + for (var frame = 0; frame < 32; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final visibleAvatar = avatar('second').evaluate().isNotEmpty + ? avatar('second') + : avatar('first'); + expect(visibleAvatar, findsOneWidget); + final current = tester.getRect(visibleAvatar); + expect( + current.top, + closeTo(before.top, 1), + reason: 'avatar frame $frame', + ); + } + await tester.pumpAndSettle(); + expect(avatar('second'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + } + } + testWidgets( 'a same-second local send follows its inserted row when the tail ID stays unchanged', (tester) async { @@ -5418,35 +5593,26 @@ void main() { expect(find.text('Alice'), findsOneWidget); final createdAction = findRichText('created this channel'); expect(createdAction, findsOneWidget); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); - final nameRect = tester.getRect(find.text('Alice')); + expect(find.byType(CircleAvatar), findsNothing); final nameText = tester.widget(find.text('Alice')); - expect(nameText.style?.fontSize, systemMessageHeadingTextStyle.fontSize); - expect( - nameText.style?.fontWeight, - systemMessageHeadingTextStyle.fontWeight, - ); + expect(nameText.style?.fontSize, 14); + expect(nameText.style?.fontWeight, FontWeight.w400); expect( find.byKey(const ValueKey('system-message-username-alice')), findsNothing, ); - final timestampRect = tester.getRect( + expect( find.byKey(const ValueKey('system-message-timestamp-alice')), + findsNothing, ); - expect(timestampRect.left, greaterThan(nameRect.right)); final createdText = tester.widget(createdAction); expect( effectiveFontSizeForText(createdText.text, 'created this channel'), - systemMessageBodyTextStyle.fontSize, + 14, ); }); - testWidgets('renders a huddle event like a regular message row', ( - tester, - ) async { + testWidgets('renders a compact huddle activity row', (tester) async { await tester.pumpWidget( _buildTestable( messages: [_huddleMsg(id: 'huddle-1', kind: EventKind.huddleStarted)], @@ -5459,13 +5625,10 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(findRichText('started a huddle'), findsOneWidget); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); + expect(find.byType(CircleAvatar), findsNothing); expect( find.byKey(const ValueKey('system-message-timestamp-alice')), - findsOneWidget, + findsNothing, ); }); @@ -8282,7 +8445,7 @@ void main() { (kind: EventKind.huddleEnded, action: 'ended the huddle'), ]) { testWidgets( - '${huddleEvent.action} aligns its author and body with a regular message', + '${huddleEvent.action} retains its system layout beside bubble messages', (tester) async { await tester.pumpWidget( _buildTestable( @@ -8322,34 +8485,23 @@ void main() { .descendant(of: regularRow, matching: find.byType(CircleAvatar)) .first, ); - final huddleAvatar = tester.getRect( - find - .descendant(of: huddleRow, matching: find.byType(CircleAvatar)) - .first, - ); - final regularAuthor = tester.getRect( - find.byKey(const ValueKey('message-author-regular-message')), + expect( + find.descendant(of: huddleRow, matching: find.byType(CircleAvatar)), + findsNothing, ); final huddleAuthor = tester.getRect( find.byKey(const ValueKey('system-message-author-bob')), ); - final regularBody = tester.getRect(findRichText('Regular message')); final huddleBody = tester.getRect(findRichText(huddleEvent.action)); - - expect( - huddleAuthor.top - huddleAvatar.top, - closeTo(regularAuthor.top - regularAvatar.top, 0.01), - ); - expect( - huddleBody.top - huddleAuthor.bottom, - closeTo(regularBody.top - regularAuthor.bottom, 0.01), - ); + expect(huddleAuthor.left, greaterThanOrEqualTo(huddleBody.left)); + expect(huddleBody.top, greaterThan(regularAvatar.bottom)); + expect(huddleAuthor.top, greaterThanOrEqualTo(huddleBody.top)); }, ); } testWidgets( - 'keeps membership and huddle rows evenly spaced with authored messages', + 'keeps membership and huddle rows separated from bubble messages', (tester) async { await tester.pumpWidget( _buildTestable( @@ -8396,29 +8548,26 @@ void main() { ); await tester.pumpAndSettle(); - Rect avatarRect(String rowKey) => tester.getRect( - find - .descendant( - of: find.byKey(ValueKey(rowKey)), - matching: find.byType(CircleAvatar), - ) - .first, - ); - - final avatars = [ - avatarRect('message-row-message-alice'), - avatarRect('message-row-message-bob'), - avatarRect('system-message-row-membership-carol'), - avatarRect('system-message-row-huddle-dave'), - avatarRect('message-row-message-erin'), - ]; - final authoredMessageGap = avatars[1].top - avatars[0].bottom; - - for (var index = 2; index < avatars.length; index++) { + final rows = [ + 'message-row-message-alice', + 'message-row-message-bob', + 'system-message-row-membership-carol', + 'system-message-row-huddle-dave', + 'message-row-message-erin', + ].map((key) => tester.getRect(find.byKey(ValueKey(key)))).toList(); + for (var index = 1; index < rows.length; index++) { + expect(rows[index].top, greaterThanOrEqualTo(rows[index - 1].bottom)); + } + for (final key in [ + 'system-message-row-membership-carol', + 'system-message-row-huddle-dave', + ]) { expect( - avatars[index].top - avatars[index - 1].bottom, - closeTo(authoredMessageGap, 1), - reason: 'row $index should use the authored-message gap', + find.descendant( + of: find.byKey(ValueKey(key)), + matching: find.byType(CircleAvatar), + ), + findsNothing, ); } }, @@ -8444,13 +8593,10 @@ void main() { expect(find.text('Bob'), findsOneWidget); expect(findRichText('joined the channel'), findsOneWidget); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); + expect(find.byType(CircleAvatar), findsNothing); }); - testWidgets('opens a profile sheet from a membership system avatar', ( + testWidgets('opens a profile sheet from a membership activity name', ( tester, ) async { const alicePubkey = @@ -8512,7 +8658,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-$bobPubkey')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8557,7 +8705,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-$invalidPubkey')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8647,7 +8797,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-$bobPubkey')), + ); await tester.pumpAndSettle(); expect(find.byType(UserProfileSheet), findsOneWidget); @@ -8676,7 +8828,7 @@ void main() { }, ); - testWidgets('opens a profile sheet from a huddle system avatar', ( + testWidgets('opens a profile sheet from a huddle activity name', ( tester, ) async { await tester.pumpWidget( @@ -8695,7 +8847,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8738,7 +8892,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect(find.byType(UserProfileSheet), findsOneWidget); @@ -8803,9 +8959,7 @@ void main() { }, ); - testWidgets('opens a profile sheet from a generic system avatar', ( - tester, - ) async { + testWidgets('opens a profile sheet from an activity name', (tester) async { await tester.pumpWidget( _buildTestable( messages: [ @@ -8826,7 +8980,9 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar).first); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); @@ -8855,101 +9011,153 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Bob'), findsOneWidget); - final addedAction = findRichText('added by Alice'); + final addedAction = findRichText('added '); expect(addedAction, findsOneWidget); expect(find.text('Alice added Bob to the channel'), findsNothing); - expect( - tester.getSize(find.byType(CircleAvatar)), - const Size.square(messageAvatarSize), - ); - final nameRect = tester.getRect(find.text('Bob')); + expect(find.byType(CircleAvatar), findsNothing); expect( find.byKey(const ValueKey('system-message-username-bob')), findsNothing, ); - final timestampRect = tester.getRect( + expect( find.byKey(const ValueKey('system-message-timestamp-bob')), + findsNothing, ); - expect(timestampRect.left, greaterThan(nameRect.right)); - final addedText = tester.widget(addedAction); + final card = find.byKey(const ValueKey('activity-card-sys1')); expect( - effectiveFontSizeForText(addedText.text, 'added by Alice'), - systemMessageBodyTextStyle.fontSize, - ); - }); - - testWidgets('groups member additions with tappable overflow names', ( - tester, - ) async { - final messages = [ - _systemMsg( - id: 'sys1', - payload: {'type': 'member_joined', 'actor': 'alice', 'target': 'bob'}, - createdAt: 1000, - ), - _systemMsg( - id: 'sys2', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'carol', - }, - createdAt: 1060, - ), - _systemMsg( - id: 'sys3', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'dave', - }, - createdAt: 1120, - ), - _systemMsg( - id: 'sys4', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'erin', - }, - createdAt: 1180, - ), - _systemMsg( - id: 'sys5', - payload: { - 'type': 'member_joined', - 'actor': 'alice', - 'target': 'frank', - }, - createdAt: 1240, - ), - ]; - - await tester.pumpWidget( - _buildTestable( - messages: messages, - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), - 'carol': const UserProfile(pubkey: 'carol', displayName: 'Carol'), - 'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'), - 'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'), - 'frank': const UserProfile(pubkey: 'frank', displayName: 'Frank'), - }, - ), + find.descendant(of: card, matching: find.byType(Icon)), + findsNothing, ); + final actor = find.byKey(const ValueKey('system-message-author-alice')); + final target = find.byKey(const ValueKey('system-message-author-bob')); + expect(tester.getCenter(actor).dx, lessThan(tester.getCenter(target).dx)); + expect(findRichText(' to the channel'), findsOneWidget); + await tester.tap(target); await tester.pumpAndSettle(); - - expect(find.text('Bob'), findsOneWidget); - expect( - findRichText('added by Alice, along with Carol, Dave, Erin, and '), - findsOneWidget, - ); - expect(find.byKey(const Key('membership-overflow')), findsOneWidget); - expect(find.text('1 others'), findsOneWidget); - expect(find.byTooltip('Frank'), findsOneWidget); + expect(find.byType(UserProfileSheet), findsOneWidget); + Navigator.of(tester.element(find.byType(UserProfileSheet))).pop(); + await tester.pumpAndSettle(); + final addedText = tester.widget(addedAction); + expect(effectiveFontSizeForText(addedText.text, 'added '), 14); }); + for (final removal in [false, true]) { + testWidgets( + 'limits activity names and opens all members, removal=$removal', + (tester) async { + final messages = [ + _systemMsg( + id: 'sys1', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'bob', + }, + createdAt: 1000, + ), + _systemMsg( + id: 'sys2', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'carol', + }, + createdAt: 1060, + ), + _systemMsg( + id: 'sys3', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'dave', + }, + createdAt: 1120, + ), + _systemMsg( + id: 'sys4', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'erin', + }, + createdAt: 1180, + ), + _systemMsg( + id: 'sys5', + payload: { + 'type': removal ? 'member_removed' : 'member_joined', + 'actor': 'alice', + 'target': 'frank', + }, + createdAt: 1240, + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: messages, + users: { + 'alice': const UserProfile( + pubkey: 'alice', + displayName: 'Alice', + ), + 'bob': const UserProfile(pubkey: 'bob', displayName: 'Bob'), + 'carol': const UserProfile( + pubkey: 'carol', + displayName: 'Carol', + ), + 'dave': const UserProfile(pubkey: 'dave', displayName: 'Dave'), + 'erin': const UserProfile(pubkey: 'erin', displayName: 'Erin'), + 'frank': const UserProfile( + pubkey: 'frank', + displayName: 'Frank', + ), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Bob'), findsOneWidget); + for (final pubkey in ['alice', 'bob', 'carol']) { + final name = find.byKey(ValueKey('system-message-author-$pubkey')); + expect(name, findsOneWidget); + expect( + tester.widget(name).style?.fontWeight, + FontWeight.w400, + ); + } + expect(find.text('Dave'), findsNothing); + expect(find.text('Erin'), findsNothing); + expect(find.text('Frank'), findsNothing); + expect(findRichText('and 3 others'), findsOneWidget); + expect( + tester.widget(find.text('See all')).style?.fontWeight, + FontWeight.w600, + ); + await tester.tap(find.text('See all')); + await tester.pumpAndSettle(); + expect( + find.text(removal ? 'Removed members' : 'Added members'), + findsOneWidget, + ); + for (final pubkey in ['bob', 'carol', 'dave', 'erin', 'frank']) { + final tile = find.byKey(ValueKey('activity-member-$pubkey')); + await tester.ensureVisible(tile); + await tester.tap(tile); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(UserProfileSheet)) + .pubkey, + pubkey, + ); + await tester.tap(find.byTooltip('Close sheet').last); + await tester.pumpAndSettle(); + } + }, + ); + } + testWidgets('aligns grouped reactions with the system message content', ( tester, ) async { @@ -8983,11 +9191,12 @@ void main() { ); await tester.pumpAndSettle(); - final avatarRect = tester.getRect(find.byType(CircleAvatar)); - final reactionRect = tester.getRect(find.byType(ReactionRow)); + final card = tester.getRect( + find.byKey(const ValueKey('activity-card-sys1')), + ); expect( - reactionRect.left, - avatarRect.left + messageAvatarSize + messageAvatarContentGap, + tester.getCenter(find.byType(ReactionRow)).dx, + closeTo(card.center.dx, 0.01), ); }); @@ -9009,14 +9218,25 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('add-reaction-pill')), findsOneWidget); - - await tester.tap(find.byKey(const ValueKey('add-reaction-pill'))); + expect( + find.byKey(const ValueKey('combined-reaction-tray')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('reaction-sheet-add'))); // Not pumpAndSettle: with no dataset asset in a widget test the sheet // shows its loading spinner, which animates forever. await tester.pump(); await tester.pump(const Duration(milliseconds: 400)); expect(find.byType(EmojiPickerSheet), findsOneWidget); + expect( + find.byKey( + const ValueKey('reaction-details-sheet'), + skipOffstage: false, + ), + findsNothing, + ); }); testWidgets('an unreacted message keeps the timeline free of chrome', ( @@ -9052,50 +9272,10 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Bob left the channel'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + expect(findRichText('left the channel'), findsOneWidget); }); - testWidgets( - 'constrains generic system timestamps at accessibility text sizes', - (tester) async { - tester.view.physicalSize = const Size(240, 600); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - _buildTestable( - messages: [ - _systemMsg( - id: 'sys-accessible', - payload: {'type': 'member_left', 'actor': 'alice'}, - createdAt: - DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/ - 1000, - ), - ], - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - textScaler: const TextScaler.linear(3), - ), - ); - await tester.pumpAndSettle(); - - final timestampFinder = find.byKey( - const ValueKey('system-message-timestamp-sys-accessible'), - ); - final timestamp = tester.widget(timestampFinder); - expect(timestamp.maxLines, 1); - expect(timestamp.overflow, TextOverflow.ellipsis); - expect( - tester.getSize(timestampFinder).width, - lessThanOrEqualTo(Grid.xxl), - ); - expect(tester.takeException(), isNull); - }, - ); - testWidgets('renders member_removed system event', (tester) async { final messages = [ _systemMsg( @@ -9121,7 +9301,10 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(findRichText('removed '), findsOneWidget); - expect(find.widgetWithText(MessageMentionPill, 'Bob'), findsOneWidget); + expect( + find.byKey(const ValueKey('system-message-author-bob')), + findsOneWidget, + ); }); testWidgets('renders topic_changed system event', (tester) async { @@ -9152,34 +9335,6 @@ void main() { ); }); - testWidgets('renders purpose_changed system event', (tester) async { - final messages = [ - _systemMsg( - id: 'sys1', - payload: { - 'type': 'purpose_changed', - 'actor': 'alice', - 'purpose': 'Team standup notes', - }, - ), - ]; - - await tester.pumpWidget( - _buildTestable( - messages: messages, - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, - ), - ); - await tester.pumpAndSettle(); - - expect( - findRichText('changed the purpose to "Team standup notes"'), - findsOneWidget, - ); - }); - testWidgets('system message breaks author grouping', (tester) async { final messages = [ _textMsg( @@ -11933,10 +12088,7 @@ void main() { final threadTimestamp = tester.widget( find.byKey(const ValueKey('thread-message-timestamp-thread-root')), ); - expect( - threadTimestamp.style?.fontSize, - messageTimestampTextStyle.fontSize, - ); + expect(threadTimestamp.style?.fontSize, 11); expect( find.descendant( of: find.byKey(const ValueKey('thread-message-row-thread-root')), @@ -12419,7 +12571,7 @@ void main() { id: 'reply-$i', pubkey: 'bob', content: i == 29 - ? List.filled(33, 'Tall latest reply').join('\n') + ? List.filled(31, 'Tall latest reply').join('\n') : 'Reply $i', createdAt: 1100 + i, extraTags: const [ @@ -12602,7 +12754,7 @@ void main() { findsNothing, ); - for (var i = 0; i < 12; i++) { + for (var i = 0; i < 20; i++) { await tester.drag(list, const Offset(0, -100)); await tester.pumpAndSettle(); } @@ -12785,7 +12937,7 @@ void main() { id: 'reply-$i', pubkey: 'bob', content: i == 29 - ? List.filled(15, 'Tall latest reply').join('\n') + ? List.filled(14, 'Tall latest reply').join('\n') : 'Reply $i', createdAt: 1100 + i, extraTags: const [ @@ -15050,7 +15202,10 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('reaction-pill-👍')), findsNothing); + expect( + find.byKey(const ValueKey('combined-reaction-tray')), + findsNothing, + ); // The reaction arrives over the channel socket, as it does on device. messagesNotifier.setMessages([ @@ -15061,7 +15216,10 @@ void main() { // Still on the thread route — no pop needed for the pill to appear. expect(find.byType(ThreadDetailPage), findsOneWidget); - expect(find.byKey(const ValueKey('reaction-pill-👍')), findsOneWidget); + expect( + find.byKey(const ValueKey('combined-reaction-tray')), + findsOneWidget, + ); }); testWidgets('a live deletion does not restore the routed thread head', ( @@ -15190,9 +15348,8 @@ void main() { await tester.pumpAndSettle(); // The head keeps a standing +; the bare reply gets none, so a thread - // reads as quietly as the channel does. Two + pills, not three: the head - // and the reacted reply. - expect(find.byKey(const ValueKey('add-reaction-pill')), findsNWidgets(2)); + // reads quietly. Reacted replies open details, whose first control adds emoji. + expect(find.byKey(const ValueKey('add-reaction-pill')), findsOneWidget); final headRow = find.descendant( of: find.byKey(const ValueKey('thread-message-group-thread-root')), matching: find.byKey(const ValueKey('add-reaction-pill')), @@ -15208,7 +15365,7 @@ void main() { expect( find.descendant( of: find.byKey(const ValueKey('thread-message-group-reply-reacted')), - matching: find.byKey(const ValueKey('add-reaction-pill')), + matching: find.byKey(const ValueKey('combined-reaction-tray')), ), findsOneWidget, ); @@ -16198,3 +16355,10 @@ String _previewRowAvatarInitial(WidgetTester tester, String pubkey) { ); return initial.data!; } + +class _MutableColorProfileNotifier extends ProfileNotifier { + @override + Future build() => SynchronousFuture(null); + + void setProfileState(AsyncValue value) => state = value; +} 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 3e5919cec4..824b07a4fa 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 @@ -14,7 +14,7 @@ void actionRowTests() { payload: {'type': 'member_removed'}, action: 'removed Someone from the channel', ), - for (final field in ['topic', 'purpose']) ...[ + for (final field in ['topic']) ...[ ( payload: { 'type': '${field}_changed', @@ -33,7 +33,9 @@ void actionRowTests() { group('Channel action rows', () { for (var index = 0; index < cases.length; index++) { final scenario = cases[index]; - testWidgets('uses message layout for action $index', (tester) async { + testWidgets('uses compact activity layout for action $index', ( + tester, + ) async { await tester.pumpWidget( _buildTestable( messages: [ @@ -60,15 +62,14 @@ void actionRowTests() { of: row, matching: find.byType(CircleAvatar), ); - expect(avatars, findsOneWidget); - expect(tester.getSize(avatars), const Size.square(messageAvatarSize)); + expect(avatars, findsNothing); final author = find.byKey( const ValueKey('system-message-author-alice'), ); expect(author, findsOneWidget); expect( find.byKey(const ValueKey('system-message-author-bob')), - findsNothing, + scenario.payload['target'] == 'bob' ? findsOneWidget : findsNothing, ); expect(findRichText('Alice ${scenario.action}'), findsNothing); final body = findRichText( @@ -76,10 +77,10 @@ void actionRowTests() { ); if (scenario.payload['target'] == 'bob') { expect( - find.widgetWithText(MessageMentionPill, 'Bob'), + find.byKey(const ValueKey('system-message-author-bob')), findsOneWidget, ); - expect(find.text('@'), findsOneWidget); + expect(find.text('@'), findsNothing); } expect(body, findsOneWidget); final regularBody = tester.getRect(findRichText('Regular message')); @@ -88,23 +89,23 @@ void actionRowTests() { ); final actionBody = tester.getRect(body); final actionAuthor = tester.getRect(author); - expect(actionBody.left, regularBody.left); - expect(actionAuthor.left, regularAuthor.left); - expect( - actionBody.top - actionAuthor.bottom, - closeTo(regularBody.top - regularAuthor.bottom, 0.01), + final card = tester.getRect( + find.byKey(const ValueKey('activity-card-action')), ); + expect(card.center.dx, closeTo(tester.getRect(row).center.dx, 0.01)); + expect(actionAuthor.left, greaterThanOrEqualTo(actionBody.left)); + expect(regularAuthor.left, regularBody.left); + expect(actionAuthor.top, greaterThanOrEqualTo(actionBody.top)); final timestamp = find.byKey( const ValueKey('system-message-timestamp-alice'), ); - expect(tester.widget(timestamp).data, formatMessageTime(1010)); - expect(tester.getRect(timestamp).top, lessThan(actionBody.top)); + expect(timestamp, findsNothing); final reactions = find.descendant( of: row, matching: find.byType(ReactionRow), ); expect(reactions, findsOneWidget); - expect(tester.getTopLeft(reactions).dx, actionBody.left); + expect(tester.getCenter(reactions).dx, closeTo(card.center.dx, 0.01)); expect(tester.takeException(), isNull); }); @@ -118,11 +119,18 @@ void actionRowTests() { ), ); await tester.pumpAndSettle(); - expect(find.text('Someone ${scenario.action}'), findsOneWidget); - expect(find.byIcon(BuzzIcons.arrowLeftRight), findsOneWidget); + expect(findRichText('Someone ${scenario.action}'), findsOneWidget); + expect( + find.byIcon( + scenario.payload['type'] == 'member_removed' + ? BuzzIcons.userMinus + : BuzzIcons.pencil, + ), + findsNothing, + ); expect( find.byKey(const ValueKey('system-message-timestamp-fallback')), - findsOneWidget, + findsNothing, ); }); } @@ -146,7 +154,9 @@ void actionRowTests() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.byType(CircleAvatar)); + await tester.tap( + find.byKey(const ValueKey('system-message-author-alice')), + ); await tester.pumpAndSettle(); expect( tester.widget(find.byType(UserProfileSheet)).pubkey, @@ -162,7 +172,7 @@ void actionRowTests() { ), isTrue, ); - await tester.tap(find.widgetWithText(MessageMentionPill, 'Bob')); + await tester.tap(find.byKey(const ValueKey('system-message-author-bob'))); await tester.pumpAndSettle(); expect( tester.widget(find.byType(UserProfileSheet)).pubkey, @@ -180,7 +190,7 @@ void actionRowTests() { }); for (final source in ['directory', 'profile', 'channel role']) { - testWidgets('removed agent uses chat mention pill from $source', ( + testWidgets('removed agent has a plain tappable name from $source', ( tester, ) async { await tester.pumpWidget( @@ -209,17 +219,9 @@ void actionRowTests() { ), ); await tester.pumpAndSettle(); - final pill = find.widgetWithText(MessageMentionPill, 'Helper Bot'); + final pill = find.byKey(const ValueKey('system-message-author-bob')); expect(pill, findsOneWidget); - expect(tester.widget(pill).isAgent, isTrue); - expect( - find.descendant(of: pill, matching: find.byIcon(BuzzIcons.bot)), - findsOneWidget, - ); - expect( - find.descendant(of: pill, matching: find.text('@')), - findsNothing, - ); + expect(find.byType(MessageMentionPill), findsNothing); expect( tester.widget(find.text('Helper Bot')).style?.decoration, isNot(TextDecoration.underline), @@ -290,10 +292,7 @@ void actionRowTests() { .data, names.labelFor(first), ); - final pill = find.widgetWithText( - MessageMentionPill, - names.labelFor(second), - ); + final pill = find.byKey(ValueKey('system-message-author-$second')); expect(pill, findsOneWidget); expect(names.labelFor(first), isNot(names.labelFor(second))); expect(find.text('Scout'), findsNothing); @@ -345,7 +344,7 @@ void actionRowTests() { ); expect( find.byKey(const ValueKey('system-message-timestamp-alice')), - findsOneWidget, + findsNothing, ); expect(tester.takeException(), isNull); }); diff --git a/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart new file mode 100644 index 0000000000..627ebe9839 --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/bubble_tests.dart @@ -0,0 +1,733 @@ +part of '../channel_detail_page_test.dart'; + +void bubbleLayoutTests() { + testWidgets('large activity members build and fetch only visible rows', ( + tester, + ) async { + final loaded = {}; + final cache = _FakeUserCacheNotifier( + {}, + preload: (pubkeys) async { + loaded.addAll(pubkeys); + return true; + }, + ); + await tester.pumpWidget( + _buildTestable( + messages: [ + for (var index = 0; index < 1000; index++) + _systemMsg( + id: 'bulk-${index.toString().padLeft(4, '0')}', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'target-$index', + }, + ), + ], + userCacheNotifier: cache, + ), + ); + await tester.pumpAndSettle(); + loaded.clear(); + await tester.tap(find.text('See all')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('activity-member-target-999')), + findsNothing, + ); + expect(loaded.length, allOf(greaterThan(0), lessThan(20))); + final list = find.byKey(const ValueKey('activity-members-list')); + final scrollable = find.descendant( + of: list, + matching: find.byType(Scrollable), + ); + await tester.scrollUntilVisible( + find.byKey(const ValueKey('activity-member-target-50')), + 300, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + expect(loaded, contains('target-50')); + expect(loaded.length, lessThan(100)); + final beforeUpdate = {...loaded}; + cache.replace( + const UserProfile(pubkey: 'target-50', displayName: 'Updated target'), + ); + await tester.pumpAndSettle(); + expect(find.text('Updated target'), findsOneWidget); + expect(loaded, beforeUpdate); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'status bar pagination is capped and later taps continue', + (tester) async { + final notifier = _FakeMessagesNotifier( + [ + for (var index = 0; index < 30; index++) + _textMsg( + id: 'recent-$index', + pubkey: 'alice', + content: 'Recent $index', + createdAt: 3000 + index, + ), + ], + olderPages: [ + for (var index = 1; index <= 10; index++) + [ + _textMsg( + id: 'page-$index', + pubkey: 'alice', + content: 'Older page $index', + createdAt: 3000 - index * 100, + ), + ], + ], + ); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, 0); + for (final expected in [3, 6, 9, 10]) { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, expected); + expect(find.text('Older page $expected').hitTestable(), findsOneWidget); + await tester.pumpAndSettle(); + expect( + notifier.fetchOlderCalls, + expected, + reason: 'landing must not bypass the gesture cap', + ); + } + expect(notifier.reachedOldest, isTrue); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'grouped removal quick reactions remove reactions on later events', + (tester) async { + final actions = _GroupedReactionActions(); + await tester.pumpWidget( + _buildTestable( + signingPubkey: 'self', + messages: [ + for (var index = 0; index < 3; index++) + _systemMsg( + id: 'removal-$index', + payload: { + 'type': 'member_removed', + 'actor': 'alice', + 'target': 'target-$index', + }, + createdAt: 1000 + index, + ), + for (var index = 1; index < 3; index++) + _reaction( + id: 'own-$index', + targetId: 'removal-$index', + pubkey: 'self', + ), + ], + createChannelActions: (_) => actions, + ), + ); + await tester.pumpAndSettle(); + await tester.longPress( + find.byKey(const ValueKey('system-message-row-removal-0')), + ); + await tester.pumpAndSettle(); + final quick = find.byKey(const ValueKey('quick-reaction-👍')); + expect(quick, findsOneWidget); + expect( + find.descendant( + of: quick, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.selected == true, + ), + ), + findsOneWidget, + ); + await tester.tap(quick); + await tester.pumpAndSettle(); + expect(actions.removed, ['own-1', 'own-2']); + expect(actions.added, isEmpty); + }, + ); + + testWidgets( + 'status bar navigation fetches short history and stops on failure', + (tester) async { + for (final fail in [false, true]) { + final notifier = _FakeMessagesNotifier( + [ + for (var index = 0; index < 30; index++) + _textMsg( + id: 'recent-$index', + pubkey: 'alice', + content: 'Recent $index', + createdAt: 3000 + index, + ), + ], + olderPages: [ + [ + _textMsg( + id: 'middle', + pubkey: 'alice', + content: 'Middle page', + createdAt: 2000, + ), + ], + [ + _textMsg( + id: 'oldest', + pubkey: 'alice', + content: 'First page', + createdAt: 1000, + ), + ], + ], + failOlderFetch: fail, + ); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, 0); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(notifier.fetchOlderCalls, fail ? 1 : 2); + if (!fail) { + expect(find.text('First page').hitTestable(), findsOneWidget); + } + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + } + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + for (final thread in [false, true]) { + for (final tagged in [false, true]) { + testWidgets( + 'attachments isolate both group boundaries, thread=$thread tagged=$tagged', + (tester) async { + final events = [ + if (thread) + _textMsg( + id: 'root', + pubkey: 'bob', + content: 'Root', + createdAt: 900, + ), + for (var index = 0; index < 3; index++) + _textMsg( + id: 'boundary-$index', + pubkey: 'alice', + createdAt: 1000 + index, + content: index == 1 + ? '![photo](https://example.com/photo.png)' + : 'Text $index', + extraTags: [ + if (thread) ['e', 'root', '', 'reply'], + if (tagged && index == 1) + [ + 'imeta', + 'url https://example.com/photo.png', + 'm image/png', + ], + ], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.firstWhere( + (message) => message.id == 'root', + ), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + for (var index = 0; index < 3; index++) { + if (thread) { + final list = tester.widget( + find.byKey(const ValueKey('thread-message-list')), + ); + list.itemScrollController!.jumpTo(index: index + 1); + await tester.pumpAndSettle(); + } + final row = find.byKey( + ValueKey( + '${thread ? 'thread-message-row' : 'message-row'}-boundary-$index', + ), + ); + final content = tester.widget( + find.descendant(of: row, matching: find.byType(MessageContent)), + ); + expect(content.bubbleStart, isTrue, reason: 'start $index'); + expect(content.bubbleEnd, isTrue, reason: 'end $index'); + expect( + find.descendant(of: row, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + } + }, + ); + } + } + + testWidgets( + 'legacy purpose activity is hidden without splitting the sender group', + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'before-purpose', + pubkey: 'alice', + content: 'Before', + createdAt: 1000, + ), + _systemMsg( + id: 'purpose-change', + payload: { + 'type': 'purpose_changed', + 'actor': 'alice', + 'purpose': 'Legacy purpose', + }, + createdAt: 1010, + ), + _reaction(id: 'purpose-reaction', targetId: 'purpose-change'), + _textMsg( + id: 'after-purpose', + pubkey: 'alice', + content: 'After', + createdAt: 1020, + ), + _systemMsg( + id: 'topic-change', + payload: { + 'type': 'topic_changed', + 'actor': 'alice', + 'topic': 'Current topic', + }, + createdAt: 1030, + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('system-message-row-purpose-change')), + findsNothing, + ); + expect(findRichText('Legacy purpose'), findsNothing); + expect(find.byType(ReactionRow), findsNothing); + expect( + find.byKey(const ValueKey('message-author-before-purpose')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('message-author-after-purpose')), + findsNothing, + ); + expect( + findRichText('changed the topic to "Current topic"'), + findsOneWidget, + ); + }, + ); + + testWidgets('single-line activity uses text without an icon', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'single-line', + payload: {'type': 'member_left', 'actor': 'alice'}, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + final card = find.byKey(const ValueKey('activity-card-single-line')); + final icon = find.descendant(of: card, matching: find.byType(Icon)); + expect(icon, findsNothing); + expect(findRichText('left the channel'), findsOneWidget); + }); + + testWidgets( + 'keeps centered activities timestamp-free at accessibility text sizes', + (tester) async { + tester.view.physicalSize = const Size(240, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'sys-accessible', + payload: {'type': 'member_left', 'actor': 'alice'}, + createdAt: + DateTime(2026, 7, 28, 12, 34).millisecondsSinceEpoch ~/ 1000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + textScaler: const TextScaler.linear(3), + ), + ); + await tester.pumpAndSettle(); + + final timestampFinder = find.byKey( + const ValueKey('system-message-timestamp-alice'), + ); + expect(timestampFinder, findsNothing); + final card = find.byKey(const ValueKey('activity-card-sys-accessible')); + final icon = find.descendant(of: card, matching: find.byType(Icon)); + expect(icon, findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + for (final thread in [false, true]) { + testWidgets( + 'status bar tap reaches oldest content, thread=$thread', + (tester) async { + final events = [ + _textMsg( + id: 'top-root', + pubkey: 'bob', + content: 'Oldest content', + createdAt: 900, + ), + for (var index = 0; index < 60; index++) + _textMsg( + id: 'top-$index', + pubkey: 'alice', + content: 'Message $index', + createdAt: 1000 + index, + extraTags: thread + ? const [ + ['e', 'top-root', '', 'reply'], + ] + : const [], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: thread + ? ThreadDetailPage( + threadHead: messages.firstWhere((m) => m.id == 'top-root'), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final list = tester.widget( + find.byKey( + ValueKey(thread ? 'thread-message-list' : 'channel-message-list'), + ), + ); + list.itemScrollController!.jumpTo(index: thread ? 55 : 0); + await tester.pumpAndSettle(); + expect(find.text('Oldest content').hitTestable(), findsNothing); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.text('Oldest content').hitTestable(), findsOneWidget); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + + testWidgets('newest short message keeps the same group gap', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + for (var index = 0; index < 3; index++) + _textMsg( + id: 'gap-$index', + pubkey: 'alice', + content: 'Short', + createdAt: 1000 + index, + ), + ], + ), + ); + await tester.pumpAndSettle(); + Rect bubble(int index) => tester.getRect( + find.descendant( + of: find.byKey(ValueKey('message-row-gap-$index')), + matching: find.byKey(const ValueKey('message-text-bubble-0')), + ), + ); + final firstGap = bubble(1).top - bubble(0).bottom; + final newestGap = bubble(2).top - bubble(1).bottom; + expect(newestGap, closeTo(firstGap, 0.01)); + }); + + testWidgets('newest short thread reply keeps the same group gap', ( + tester, + ) async { + final events = [ + _textMsg(id: 'root', pubkey: 'bob', content: 'Root', createdAt: 900), + for (var index = 0; index < 3; index++) + _textMsg( + id: 'gap-$index', + pubkey: 'alice', + content: 'Short', + createdAt: 1000 + index, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: ThreadDetailPage( + threadHead: messages.firstWhere((m) => m.id == 'root'), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + Rect bubble(int index) => tester.getRect( + find.descendant( + of: find.byKey(ValueKey('thread-message-row-gap-$index')), + matching: find.byKey(const ValueKey('message-text-bubble-0')), + ), + ); + expect( + bubble(2).top - bubble(1).bottom, + closeTo(bubble(1).top - bubble(0).bottom, 0.01), + ); + }); + + testWidgets('channel groups keep one avatar on the last message', ( + tester, + ) async { + final notifier = _FakeMessagesNotifier([ + _textMsg(id: 'first', pubkey: 'alice', content: 'First'), + _textMsg(id: 'last', pubkey: 'alice', content: 'Last', createdAt: 1010), + ]); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + final first = find.byKey(const ValueKey('message-row-first')); + final last = find.byKey(const ValueKey('message-row-last')); + expect( + find.descendant(of: first, matching: find.byType(AvatarImage)), + findsNothing, + ); + expect( + find.descendant(of: last, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('message-author-first')), findsOneWidget); + expect(find.byKey(const ValueKey('message-author-last')), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'message-text-bubble-', + ), + ), + findsNWidgets(2), + ); + }); + + testWidgets('live appends move the group avatar and author changes end it', ( + tester, + ) async { + final first = _textMsg(id: 'first', pubkey: 'alice', content: 'First'); + final next = _textMsg( + id: 'next', + pubkey: 'alice', + content: 'Next', + createdAt: 1010, + ); + final other = _textMsg( + id: 'other', + pubkey: 'bob', + content: 'Other', + createdAt: 1020, + ); + final notifier = _FakeMessagesNotifier([first]); + await tester.pumpWidget( + _buildTestable(messages: const [], messagesNotifier: notifier), + ); + await tester.pumpAndSettle(); + Finder avatar(String id) => find.descendant( + of: find.byKey(ValueKey('message-row-$id')), + matching: find.byType(AvatarImage), + ); + expect(avatar('first'), findsOneWidget); + notifier.setMessages([first, next]); + await tester.pumpAndSettle(); + expect(avatar('first'), findsNothing); + expect(avatar('next'), findsOneWidget); + notifier.setMessages([first, next, other]); + await tester.pumpAndSettle(); + expect(avatar('next'), findsOneWidget); + expect(avatar('other'), findsOneWidget); + notifier.setMessages([first]); + await tester.pumpAndSettle(); + expect(avatar('first'), findsOneWidget); + }); + + for (final gap in [301, 86400]) { + testWidgets('author groups end across a $gap second gap', (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'first', pubkey: 'alice', content: 'First'), + _textMsg( + id: 'next', + pubkey: 'alice', + content: 'Next', + createdAt: 1000 + gap, + ), + ], + ), + ); + await tester.pumpAndSettle(); + for (final id in ['first', 'next']) { + final row = find.byKey(ValueKey('message-row-$id')); + expect( + find.descendant(of: row, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + expect(find.byKey(ValueKey('message-author-$id')), findsOneWidget); + } + }); + } + + testWidgets( + 'thread replies use the same bubble and group-end avatar layout', + (tester) async { + final events = [ + _textMsg(id: 'root', pubkey: 'bob', content: 'Root'), + _textMsg( + id: 'first', + pubkey: 'alice', + content: 'First reply', + createdAt: 1010, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + _textMsg( + id: 'last', + pubkey: 'alice', + content: 'Last reply', + createdAt: 1020, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ]; + final messages = formatTimeline(events); + await tester.pumpWidget( + _buildTestable( + messages: events, + home: ThreadDetailPage( + threadHead: messages.firstWhere((m) => m.id == 'root'), + allMessages: messages, + channelId: _channelId, + currentPubkey: 'me', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + final first = find.byKey(const ValueKey('thread-message-row-first')); + final last = find.byKey(const ValueKey('thread-message-row-last')); + expect( + find.descendant(of: first, matching: find.byType(AvatarImage)), + findsNothing, + ); + expect( + find.descendant(of: last, matching: find.byType(AvatarImage)), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'message-text-bubble-', + ), + ), + findsNWidgets(3), + ); + }, + ); +} + +class _GroupedReactionActions implements ChannelActions { + final removed = []; + final added = []; + @override + Future removeReaction(String id, String emoji) async => removed.add(id); + @override + Future addReaction(String id, String emoji) async => added.add(id); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 38c896be48..9abb9caddb 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:buzz/features/channels/composer_send_origin.dart'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -3162,6 +3163,79 @@ void main() { ); }); + testWidgets('ComposeBar retains submitted origin through delayed upload', ( + tester, + ) async { + final uploadResponse = Completer(); + Rect? submittedOrigin; + var sent = false; + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((_) => uploadResponse.future), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickGalleryImages: () async => [ + XFile.fromData(_pngBytes, name: 'tiny.png'), + ], + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async { + sent = true; + submittedOrigin = ComposerSendOrigin.current; + }, + ), + ); + await _openSystemPhotoPicker(tester); + await tester.pumpAndSettle(); + await _expandComposer(tester); + final composer = tester.getRect( + find.byKey(const ValueKey('composer-surface')), + ); + await tester.tap( + find + .ancestor( + of: find.byIcon(BuzzIcons.arrowUp), + matching: find.byType(IconButton), + ) + .hitTestable(), + ); + await tester.pump(const Duration(seconds: 5)); + expect(sent, isFalse); + // Layout changes during upload must not replace the submitted geometry. + tester.view.physicalSize = Size( + tester.view.physicalSize.width, + tester.view.physicalSize.height + 200, + ); + addTearDown(tester.view.resetPhysicalSize); + await tester.pump(); + uploadResponse.complete( + http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.png', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': 16, + 'type': 'image/png', + 'uploaded': 1, + }), + 200, + ), + ); + await tester.pumpAndSettle(); + expect(sent, isTrue); + expect(submittedOrigin, isNotNull); + expect(submittedOrigin, composer); + expect(ComposerSendOrigin.current, isNull); + }); + testWidgets( 'shows post-send upload progress above the composer and cancels', (tester) async { diff --git a/mobile/test/features/channels/composer_send_origin_test.dart b/mobile/test/features/channels/composer_send_origin_test.dart new file mode 100644 index 0000000000..c99f553f15 --- /dev/null +++ b/mobile/test/features/channels/composer_send_origin_test.dart @@ -0,0 +1,73 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/composer_send_origin.dart'; +import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; +import 'package:buzz/features/channels/local_message_send_transition.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('slow sends bind submitted geometry to their own local claim', ( + tester, + ) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final notifier = container.read( + localMessageSendAnimationProvider('channel').notifier, + ); + final key = GlobalKey(); + expect(ComposerSendOrigin.capture(key), isNull); + Future layout(double height) => tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.bottomCenter, + child: SizedBox(key: key, width: 320, height: height), + ), + ), + ); + await layout(120); + final submitted = ComposerSendOrigin.capture(key); + expect(submitted, tester.getRect(find.byKey(key))); + final waitForUpload = Completer(); + final upload = ComposerSendOrigin.run(submitted, () async { + await waitForUpload.future; + notifier.mark('upload'); + }); + await layout(44); + final secondOrigin = ComposerSendOrigin.capture(key); + expect(secondOrigin, tester.getRect(find.byKey(key))); + expect(secondOrigin, isNot(submitted)); + final invite = ComposerSendOrigin.run(secondOrigin, () async { + await Future.delayed(const Duration(seconds: 3)); + notifier.mark('invite'); + }); + await tester.pump(const Duration(seconds: 3)); + await invite; + waitForUpload.complete(); + await upload; + expect(notifier.originFor('upload'), submitted); + expect(notifier.originFor('invite'), secondOrigin); + expect(ComposerSendOrigin.current, isNull); + notifier.mark('unrelated'); + expect(notifier.originFor('unrelated'), isNull); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: LocalMessageSendTransition( + animate: true, + channelId: 'channel', + messageId: 'upload', + child: SizedBox(width: 100, height: 80), + ), + ), + ), + ); + expect( + tester.widget(find.byType(LocalSendProgress)).origin, + submitted, + ); + await tester.pumpAndSettle(); + }); +} diff --git a/mobile/test/features/channels/local_message_send_transition_test.dart b/mobile/test/features/channels/local_message_send_transition_test.dart index 574c12fc51..dfde7864f6 100644 --- a/mobile/test/features/channels/local_message_send_transition_test.dart +++ b/mobile/test/features/channels/local_message_send_transition_test.dart @@ -1,107 +1,144 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; import 'package:buzz/features/channels/local_message_send_transition.dart'; +import 'package:buzz/features/channels/local_send_row.dart'; -Widget _testable({ - bool animate = true, - bool reduceMotion = false, - double startOffsetFactor = localMessageSendTransitionStartOffset, -}) => MaterialApp( - home: MediaQuery( - data: MediaQueryData(disableAnimations: reduceMotion), - child: Scaffold( - body: LocalMessageSendTransition( - animate: animate, - startOffsetFactor: startOffsetFactor, - child: const SizedBox(key: ValueKey('message'), width: 100, height: 40), +Widget _testable({bool animate = true, bool reduceMotion = false}) => + ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: Align( + alignment: Alignment.bottomLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + key: ValueKey('history'), + width: 100, + height: 40, + ), + LocalMessageSendTransition( + animate: animate, + child: const SizedBox( + width: 100, + height: 80, + child: Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + key: ValueKey('avatar'), + width: 40, + height: 40, + ), + ), + ), + ), + ], + ), + ), + ), + ), ), - ), - ), -); + ); void main() { - Finder sendTranslation() => find.descendant( - of: find.byType(LocalMessageSendTransition), - matching: find.byType(FractionalTranslation), - ); - - test('local send claims expire before a later navigation', () { - final markedAt = DateTime(2026); - final animations = {'event': markedAt}; - + test('local send claims expire before later navigation', () { + final now = DateTime(2026); expect( isRecentLocalMessageSendAnimation( - animations, - 'event', - now: markedAt.add(const Duration(milliseconds: 999)), + {'id': now}, + 'id', + now: now.add(const Duration(milliseconds: 999)), ), isTrue, ); expect( isRecentLocalMessageSendAnimation( - animations, - 'event', - now: markedAt.add(const Duration(seconds: 2)), + {'id': now}, + 'id', + now: now.add(const Duration(seconds: 2)), ), isFalse, ); }); - - testWidgets('local send rises from behind the composer', (tester) async { - await tester.pumpWidget(_testable()); - - var transition = tester.widget(sendTranslation()); - expect( - transition.translation, - const Offset(0, localMessageSendTransitionStartOffset), - ); - expect( - find.descendant( - of: find.byType(LocalMessageSendTransition), - matching: find.byType(FadeTransition), - ), - findsNothing, - ); - - await tester.pump(localMessageSendTransitionDuration ~/ 2); - transition = tester.widget(sendTranslation()); - expect(transition.translation.dy, greaterThan(0)); - expect( - transition.translation.dy, - lessThan(localMessageSendTransitionStartOffset), - ); - - await tester.pump(localMessageSendTransitionDuration); - transition = tester.widget(sendTranslation()); - expect(transition.translation, Offset.zero); - }); - - testWidgets('an avatar row starts deeper behind the composer', ( + testWidgets('history moves up while the avatar remains anchored', ( tester, ) async { - await tester.pumpWidget( - _testable(startOffsetFactor: localMessageSendTransitionAvatarStartOffset), - ); - - final transition = tester.widget(sendTranslation()); + await tester.pumpWidget(_testable()); + final avatar = tester.getRect(find.byKey(const ValueKey('avatar'))); + final history = tester.getRect(find.byKey(const ValueKey('history'))); + expect(tester.getSize(find.byType(LocalSendRow)).height, closeTo(0, 0.01)); + await tester.pump(localMessageSendTransitionDuration ~/ 2); expect( - transition.translation, - const Offset(0, localMessageSendTransitionAvatarStartOffset), + tester.getSize(find.byType(LocalSendRow)).height, + inExclusiveRange(15, 35), + ); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); + expect( + tester.getTopLeft(find.byKey(const ValueKey('history'))).dy, + closeTo( + history.top - tester.getSize(find.byType(LocalSendRow)).height, + 0.1, + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); + expect( + tester.getTopLeft(find.byKey(const ValueKey('history'))).dy, + closeTo(history.top - 80, 0.01), ); }); - - testWidgets('ordinary messages do not animate', (tester) async { - await tester.pumpWidget(_testable(animate: false)); - - final transition = tester.widget(sendTranslation()); - expect(transition.translation, Offset.zero); - }); - - testWidgets('reduced motion skips the send translation', (tester) async { - await tester.pumpWidget(_testable(reduceMotion: true)); - - final transition = tester.widget(sendTranslation()); - expect(transition.translation, Offset.zero); + testWidgets('spring contraction leads the overlapping lift', (tester) async { + await tester.pumpWidget(_testable()); + LocalSendProgress motion() => tester.widget(find.byType(LocalSendProgress)); + await tester.pump(localMessageSendTransitionDuration ~/ 4); + expect(motion().widthProgress, greaterThan(motion().progress)); + expect(motion().progress, greaterThan(0)); + await tester.pump(localMessageSendTransitionDuration * 0.375); + expect(motion().widthProgress, greaterThan(1)); + expect(motion().progress, inExclusiveRange(0, 1)); + await tester.pump(localMessageSendTransitionDuration * 0.375); + expect(motion().progress, 1); }); + testWidgets( + 'spring gently overshoots width and settles with the avatar fixed', + (tester) async { + await tester.pumpWidget(_testable()); + LocalSendProgress motion() => + tester.widget(find.byType(LocalSendProgress)); + final avatar = tester.getRect(find.byKey(const ValueKey('avatar'))); + expect(motion().widthProgress, 0); + await tester.pump(localMessageSendTransitionDuration * 0.65); + final peak = motion().widthProgress!; + expect(peak, inExclusiveRange(1, 1.02)); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); + await tester.pump(localMessageSendTransitionDuration * 0.2); + expect(motion().widthProgress, inExclusiveRange(1, peak)); + await tester.pump(localMessageSendTransitionDuration * 0.15); + expect(motion().widthProgress, 1); + expect(tester.getRect(find.byKey(const ValueKey('avatar'))), avatar); + }, + ); + for (final reduced in [false, true]) { + testWidgets('settled or reduced motion does not expand, reduced=$reduced', ( + tester, + ) async { + await tester.pumpWidget( + _testable(animate: reduced, reduceMotion: reduced), + ); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); + expect( + tester + .widget(find.byType(LocalSendProgress)) + .widthProgress, + 1, + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.getSize(find.byType(LocalSendRow)).height, 80); + }); + } } diff --git a/mobile/test/features/channels/local_send_bubble_surface_test.dart b/mobile/test/features/channels/local_send_bubble_surface_test.dart new file mode 100644 index 0000000000..723334df8e --- /dev/null +++ b/mobile/test/features/channels/local_send_bubble_surface_test.dart @@ -0,0 +1,230 @@ +import 'dart:ui' as ui; +import 'package:buzz/features/channels/local_message_send_transition.dart'; +import 'package:buzz/features/channels/local_send_bubble_surface.dart'; +import 'package:buzz/features/channels/local_send_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final withOrigin in [false, true]) { + for (final progress in [0.25, 0.5, 1.0]) { + testWidgets( + 'flight transforms, hit bounds and semantics agree at $progress origin=$withOrigin', + (tester) async { + final semantics = tester.ensureSemantics(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: SizedBox( + width: 300, + height: 300, + child: Align( + alignment: Alignment.topLeft, + child: LocalSendProgress( + progress: progress, + startOffset: 100, + origin: withOrigin + ? const Rect.fromLTWH(300, 250, 100, 100) + : null, + child: LocalSendRow( + progress: progress, + child: LocalSendBubbleSurface( + color: Colors.black, + shape: const RoundedRectangleBorder(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: Semantics( + label: 'Flying link', + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + final surface = tester.renderObject( + find.byType(LocalSendBubbleSurface), + ); + final row = tester.renderObject(find.byType(LocalSendRow)); + final target = tester.renderObject( + find.byType(GestureDetector), + ); + final rowOffset = Offset(0, row.size.height - 100); + final start = withOrigin + ? const Offset(300, 250) - surface.localToGlobal(Offset.zero) + : const Offset(0, 100); + final bubbleOffset = start * (1 - progress); + final visibleCenter = rowOffset + bubbleOffset + const Offset(50, 50); + expect( + target.localToGlobal(Offset.zero), + row.localToGlobal(rowOffset + bubbleOffset), + ); + final rowHits = BoxHitTestResult(); + expect(row.hitTest(rowHits, position: visibleCenter), isTrue); + expect( + rowHits.path.any((entry) => identical(entry.target, target)), + isTrue, + ); + final bubbleHits = BoxHitTestResult(); + expect( + surface.hitTest( + bubbleHits, + position: bubbleOffset + const Offset(50, 50), + ), + isTrue, + ); + expect( + surface.hitTest( + BoxHitTestResult(), + position: const Offset(-10, -10), + ), + isFalse, + ); + final node = tester.getSemantics( + find.bySemanticsLabel('Flying link'), + ); + expect(node.rect.size, const Size(100, 100)); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + } + } + + for (final text in [ + 'Hello', + 'A longer message\nwith several lines\nof text', + ]) { + testWidgets( + 'send background contracts without changing text layout: $text', + (tester) async { + final key = GlobalKey(); + Rect? origin; + Future draw(double progress, {double? widthProgress}) async { + await tester.pumpWidget( + MaterialApp( + home: Center( + child: RepaintBoundary( + key: key, + child: SizedBox( + width: 300, + height: 120, + child: ColoredBox( + color: Colors.white, + child: Align( + alignment: Alignment.topLeft, + child: LocalSendProgress( + progress: progress, + widthProgress: widthProgress, + startOffset: 0, + origin: origin, + child: LocalSendBubbleSurface( + color: const Color(0xFFFF0000), + shape: const RoundedRectangleBorder(), + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + text, + style: const TextStyle(fontSize: 12), + textWidthBasis: TextWidthBasis.longestLine, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + } + + Future greenAt(int x) async { + final boundary = + key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final green = bytes.getUint8((4 * 300 + x) * 4 + 1); + image.dispose(); + return green; + }))!; + } + + await draw(0); + final textRect = tester.getRect(find.text(text)); + final finalWidth = tester + .getSize(find.byType(LocalSendBubbleSurface)) + .width; + expect(finalWidth, lessThan(280)); + expect(await greenAt(280), 0); + await draw(0.5); + expect(tester.getRect(find.text(text)), textRect); + final middle = ((300 + finalWidth) / 2).floor(); + expect(await greenAt(middle - 2), 0); + expect(await greenAt(middle + 2), 255); + await draw(1); + expect(tester.getRect(find.text(text)), textRect); + expect(await greenAt(finalWidth.ceil() + 2), 255); + await draw(1, widthProgress: 1.012); + expect( + tester.getSize(find.byType(LocalSendBubbleSurface)).width, + finalWidth, + ); + // The fractional spring edge can partially cover this pixel. + expect(await greenAt(finalWidth.floor() - 1), greaterThan(128)); + expect( + await greenAt((finalWidth - (300 - finalWidth) * 0.012).floor() - 2), + 0, + ); + await draw(1); + expect(await greenAt(finalWidth.floor() - 1), 0); + final boundary = key.currentContext!.findRenderObject()! as RenderBox; + origin = Rect.fromLTWH( + 20, + 50, + 250, + 30, + ).shift(boundary.localToGlobal(Offset.zero)); + await draw(0); + final image = await tester.runAsync(() async { + final result = await (boundary as RenderRepaintBoundary).toImage(); + final bytes = (await result.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final inside = bytes.getUint8((55 * 300 + 25) * 4 + 1); + final outside = bytes.getUint8((55 * 300 + 275) * 4 + 1); + result.dispose(); + return (inside, outside); + }); + expect(image, (0, 255)); + // Width can settle while the bubble is still partway up its flight. + await draw(0.5, widthProgress: 1); + final staged = await tester.runAsync(() async { + final result = await (boundary as RenderRepaintBoundary).toImage(); + final bytes = (await result.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final inside = bytes.getUint8((28 * 300 + 12) * 4 + 1); + final outside = bytes.getUint8( + (28 * 300 + finalWidth.ceil() + 14) * 4 + 1, + ); + result.dispose(); + return (inside, outside); + }); + expect(staged, (0, 255)); + await draw(1); + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart index b6f2a5e30f..d4c47eaac2 100644 --- a/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart +++ b/mobile/test/features/channels/media_viewer_page/video_viewer_test.dart @@ -77,6 +77,8 @@ class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { // _creatingCompleter is never completed and dispose() deadlocks waiting // on it. This flag exercises the F2r(d) production fix. final bool forceCreateError; + final created = Completer(); + final disposed = Completer(); int disposeCallCount = 0; int nextPlayerId = 0; final Map> _streams = {}; @@ -106,6 +108,7 @@ class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { final id = nextPlayerId++; final controller = StreamController( onListen: () { + if (!created.isCompleted) created.complete(); // Emit the event/error only when the stream is first subscribed so that // VideoPlayerController.initialize() is already listening. Emitting // before the subscription means the event is dropped and initialize() @@ -136,6 +139,7 @@ class _FakeVideoPlayerPlatform extends VideoPlayerPlatform { @override Future dispose(int playerId) async { disposeCallCount++; + if (!disposed.isCompleted) disposed.complete(); // Record the dispose call and close the event stream. // // Note: an error injected here does NOT reach initialize()'s pending @@ -604,8 +608,7 @@ void main() { testWidgets( 'F2r(c): controller created but never initialized is disposed on unmount', (tester) async { - final fakePlayer = _FakeVideoPlayerPlatform(neverInitialize: true); - VideoPlayerPlatform.instance = fakePlayer; + late _FakeVideoPlayerPlatform fakePlayer; final fakeClient = _FinalizingFakeClient( responseBuilder: () => @@ -614,6 +617,9 @@ void main() { addTearDown(fakeClient.close); await tester.runAsync(() async { + // Create completion signals in the real async zone used by file I/O. + fakePlayer = _FakeVideoPlayerPlatform(neverInitialize: true); + VideoPlayerPlatform.instance = fakePlayer; await tester.pumpWidget( WidgetHelpers.testable( disableAnimations: true, @@ -626,10 +632,9 @@ void main() { ), ), ); - // Allow enough time for the download to complete and for - // VideoPlayerController.file() to be constructed, but NOT long - // enough for initialize() to complete (it never will). - await Future.delayed(const Duration(milliseconds: 300)); + // Wait for the pending controller to exist, regardless of download + // scheduling under concurrent suite load. Initialization never completes. + await fakePlayer.created.future.timeout(const Duration(seconds: 10)); // Now unmount — this triggers the effect cleanup with the pending // controller still in pendingController.value (never reached play()). @@ -645,7 +650,7 @@ void main() { child: const SizedBox.shrink(), ), ); - await Future.delayed(const Duration(milliseconds: 100)); + await fakePlayer.disposed.future.timeout(const Duration(seconds: 10)); }); await tester.pump(); diff --git a/mobile/test/features/channels/message_content_bubble_cases.dart b/mobile/test/features/channels/message_content_bubble_cases.dart new file mode 100644 index 0000000000..5baf1154bb --- /dev/null +++ b/mobile/test/features/channels/message_content_bubble_cases.dart @@ -0,0 +1,273 @@ +part of 'message_content_test.dart'; + +void bubbleContentTests() { + final bubbles = find.byWidgetPredicate( + (widget) => + widget.key is ValueKey && + (widget.key! as ValueKey).value.startsWith( + 'message-text-bubble-', + ), + ); + testWidgets('message shapes remain inside their bounds without tails', ( + tester, + ) async { + for (final tail in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent(content: 'Hello', bubble: true, bubbleEnd: tail), + ), + ); + await tester.pumpAndSettle(); + final decoration = tester.widget( + find.descendant( + of: bubbles, + matching: find.byType(LocalSendBubbleSurface), + ), + ); + final bounds = decoration.shape + .getOuterPath(Offset.zero & tester.getSize(bubbles)) + .getBounds(); + expect(bounds.left, 0); + expect(bounds.bottom, tester.getSize(bubbles).height); + } + }); + + for (final dark in [false, true]) { + testWidgets('outgoing headings, mentions and links contrast in dark=$dark', ( + tester, + ) async { + String? tapped; + await tester.pumpWidget( + _testable( + MessageContent( + content: + '# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6\n\n@Alice @Helper [Website](https://example.com)', + bubble: true, + ownMessage: true, + mentionNames: const {'person': 'Alice', 'agent': 'Helper'}, + agentMentionPubkeys: const {'agent'}, + onMentionTap: (pubkey) => tapped = pubkey, + ), + dark: dark, + ), + ); + await tester.pumpAndSettle(); + final foreground = dark ? Colors.black : Colors.white; + for (final heading in ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']) { + final text = tester.widget(_findRich(heading).last); + final colors = []; + void inspect(InlineSpan span, TextStyle? inherited) { + final effective = inherited?.merge(span.style) ?? span.style; + if (span is TextSpan) { + if (span.text?.contains(heading) == true) { + colors.add(effective?.color); + } + for (final child in span.children ?? const []) { + inspect(child, effective); + } + } + } + + inspect(text.text, null); + expect(colors, [foreground]); + } + for (final label in ['Alice', 'Helper']) { + expect(tester.widget(find.text(label)).style?.color, foreground); + } + expect(tester.widget(find.byIcon(BuzzIcons.bot)).color, foreground); + final link = tester.widget( + find.byWidgetPredicate( + (w) => w is Text && w.textSpan?.toPlainText() == 'Website', + ), + ); + expect(link.textSpan?.style?.color, foreground); + await tester.tap(find.text('Alice')); + expect(tapped, 'person'); + await tester.tap(find.text('Helper')); + expect(tapped, 'agent'); + }); + } + + for (final content in [ + '- screenshots:\n ![result](https://example.com/result.png)\n explanation', + '1. screenshots:\n ![result](https://example.com/result.png)\n explanation', + '> screenshots:\n> ![result](https://example.com/result.png)\n> explanation', + '> screenshots:\n![result](https://example.com/result.png)\nexplanation', + ]) { + testWidgets('nested media retains its Markdown block: $content', ( + tester, + ) async { + await tester.pumpWidget( + _testable(MessageContent(content: content, bubble: true)), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsOneWidget); + final markdown = tester.widget(find.byType(GptMarkdown)); + expect(markdown.data, content); + final media = find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/result.png', + ), + ); + expect(find.descendant(of: bubbles, matching: media), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('image corners follow the conversation group', (tester) async { + for (final grouped in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent( + content: '![photo](https://example.com/photo.png)', + bubble: true, + bubbleStart: !grouped, + bubbleEnd: !grouped, + ), + ), + ); + await tester.pumpAndSettle(); + final frame = tester.widget( + find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/photo.png', + ), + ), + ); + final corners = + (frame.decoration! as BoxDecoration).borderRadius! as BorderRadius; + expect(corners.topLeft.x, grouped ? Radii.sm : Radii.container); + expect(corners.bottomLeft.x, grouped ? Radii.sm : Radii.container); + expect(corners.topRight.x, Radii.container); + expect(corners.bottomRight.x, Radii.container); + } + }); + + testWidgets('photo gallery stays outside its caption bubble', (tester) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Photos\n![one](https://example.com/one.png)\n![two](https://example.com/two.png)', + bubble: true, + ), + ), + ); + await tester.pumpAndSettle(); + final gallery = find.byKey(const ValueKey('message-media-carousel')); + expect(gallery, findsOneWidget); + expect(bubbles, findsOneWidget); + expect(find.descendant(of: bubbles, matching: gallery), findsNothing); + expect( + tester.getTopLeft(gallery).dy, + greaterThanOrEqualTo(tester.getBottomLeft(bubbles).dy), + ); + }); + + testWidgets('neutral bubbles hug short text and distinguish own messages', ( + tester, + ) async { + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent(content: 'Hello', bubble: true, ownMessage: own), + ), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsOneWidget); + expect(tester.getSize(bubbles).width, lessThan(160)); + final decoration = tester.widget( + find.descendant( + of: bubbles, + matching: find.byType(LocalSendBubbleSurface), + ), + ); + expect(decoration.color, isNotNull); + expect( + find.descendant(of: bubbles, matching: _findRich('Hello')), + findsWidgets, + ); + } + }); + + testWidgets('attachments stay outside bubbles and text order is preserved', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: + 'Before\n\n[file.txt](https://example.com/file.txt)\n\nAfter', + tags: [ + ['imeta', 'url https://example.com/file.txt', 'm text/plain'], + ], + bubble: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsNWidgets(2)); + expect( + find.descendant(of: bubbles, matching: _findRich('file.txt')), + findsNothing, + ); + expect( + tester.getTopLeft(_findRich('Before')).dy, + lessThan(tester.getTopLeft(_findRich('file.txt')).dy), + ); + expect( + tester.getTopLeft(_findRich('file.txt')).dy, + lessThan(tester.getTopLeft(_findRich('After')).dy), + ); + }); + + testWidgets('fenced attachment examples remain in the text bubble', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: '```text\n![example](https://example.com/image.png)\n```', + bubble: true, + ), + ), + ); + await tester.pumpAndSettle(); + expect(bubbles, findsOneWidget); + expect( + find.descendant( + of: bubbles, + matching: _findRich('![example](https://example.com/image.png)'), + ), + findsWidgets, + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('bubble text reflows at narrow width and large text', ( + tester, + ) async { + _setSurfaceSize(tester, const Size(320, 800)); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _testable( + MediaQuery( + data: const MediaQueryData(textScaler: TextScaler.linear(2)), + child: const SizedBox( + width: 230, + child: MessageContent( + content: + 'A longer message that wraps across multiple lines without clipping.', + bubble: true, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(bubbles).width, lessThanOrEqualTo(230)); + expect(tester.getSize(bubbles).height, greaterThan(80)); + expect(tester.takeException(), isNull); + }); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 4cba2278ef..6cdf1e327a 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/features/channels/local_send_bubble_surface.dart'; import 'dart:async'; import 'dart:convert'; import 'package:http/testing.dart'; @@ -34,10 +35,13 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/buzz_loading_indicator.dart'; +part 'message_content_bubble_cases.dart'; + Widget _testable( Widget child, { List overrides = const [], bool disableAnimations = false, + bool dark = false, VideoPreviewFrameLoader? videoPreviewFrameLoader, }) { return ProviderScope( @@ -48,7 +52,7 @@ Widget _testable( ...overrides, ], child: MaterialApp( - theme: AppTheme.light(), + theme: dark ? AppTheme.dark() : AppTheme.light(), // Use MaterialApp.builder so the MediaQuery override (including // disableAnimations) applies to every pushed route, not just the // home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage) @@ -373,6 +377,7 @@ class _TestChannelsNotifier extends ChannelsNotifier { } void main() { + bubbleContentTests(); test('wide voice-note waveforms distribute bars across their full width', () { const width = 320.0; const sampleCount = 48; diff --git a/mobile/test/features/channels/message_footer_layout_test.dart b/mobile/test/features/channels/message_footer_layout_test.dart new file mode 100644 index 0000000000..39b1cf3bf0 --- /dev/null +++ b/mobile/test/features/channels/message_footer_layout_test.dart @@ -0,0 +1,120 @@ +import 'package:buzz/features/channels/message_footer_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('footer global transforms and semantics include overlap offset', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: MessageFooterLayout( + overlap: 8, + footer: Semantics( + label: 'Reaction footer', + child: const SizedBox( + key: ValueKey('footer'), + width: 100, + height: 32, + ), + ), + child: const SizedBox( + key: ValueKey('content'), + width: 200, + height: 60, + ), + ), + ), + ), + ); + expect( + tester.getTopLeft(find.byKey(const ValueKey('footer'))), + tester.getTopLeft(find.byKey(const ValueKey('content'))) + + const Offset(0, 52), + ); + final node = tester.getSemantics(find.bySemanticsLabel('Reaction footer')); + expect(node.transform?.getTranslation().y, 52); + semantics.dispose(); + }); + + for (final reduced in [false, true]) { + testWidgets('reaction spacing animates both ways, reduced=$reduced', ( + tester, + ) async { + Widget view(bool reaction) => MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduced), + child: Align( + alignment: Alignment.topLeft, + child: MessageFooterLayout( + animateSize: true, + overlap: reaction ? 8 : 0, + footer: SizedBox(width: 100, height: reaction ? 32 : 0), + child: const SizedBox(width: 200, height: 60), + ), + ), + ), + ); + double height() => + tester.getSize(find.byType(MessageFooterLayout)).height; + await tester.pumpWidget(view(false)); + await tester.pumpWidget(view(true)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + height(), + reduced ? equals(84) : allOf(greaterThan(60), lessThan(84)), + ); + await tester.pumpAndSettle(); + expect(height(), 84); + await tester.pumpWidget(view(false)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + height(), + reduced ? equals(60) : allOf(greaterThan(60), lessThan(84)), + ); + await tester.pumpAndSettle(); + expect(height(), 60); + }); + } + + testWidgets( + 'overlapping reaction area is tappable and reserves its protrusion', + (tester) async { + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: MessageFooterLayout( + overlap: 8, + footer: GestureDetector( + onTap: () => taps++, + behavior: HitTestBehavior.opaque, + child: const SizedBox( + key: ValueKey('reactions'), + width: 100, + height: 32, + ), + ), + child: const SizedBox( + key: ValueKey('body'), + width: 200, + height: 60, + ), + ), + ), + ), + ); + final body = tester.getRect(find.byKey(const ValueKey('body'))); + final badge = tester.getRect(find.byKey(const ValueKey('reactions'))); + expect(badge.top, body.bottom - 8); + expect(tester.getSize(find.byType(MessageFooterLayout)).height, 84); + await tester.tapAt(Offset(badge.left + 10, badge.top + 2)); + expect(taps, 1); + }, + ); +} diff --git a/mobile/test/features/channels/reaction_row_test.dart b/mobile/test/features/channels/reaction_row_test.dart index 3fc3257730..1875d28b30 100644 --- a/mobile/test/features/channels/reaction_row_test.dart +++ b/mobile/test/features/channels/reaction_row_test.dart @@ -1,5 +1,13 @@ +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart'; +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:buzz/shared/relay/media_image.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:buzz/shared/emoji/emoji_avatar.dart'; import 'dart:async'; +import 'dart:ui' show SemanticsAction; +import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:buzz/features/channels/channel_identity_names_provider.dart'; import 'package:buzz/shared/identity_names/identity_names.dart'; import 'package:buzz/features/channels/message_long_press_region.dart'; @@ -19,6 +27,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/widget_helpers.dart'; +part 'reaction_row_test/combined_tests.dart'; + const _alice = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -66,6 +76,7 @@ Future _pumpRow( required List reactions, void Function(String emoji)? onToggle, bool showAddButton = false, + bool combined = false, VoidCallback? onAddReaction, String messageId = _messageId, VoidCallback? onMessageLongPress, @@ -84,6 +95,7 @@ Future _pumpRow( onLongPress: (_) => onMessageLongPress?.call(), child: ReactionRow( messageId: messageId, + combined: combined, channelId: 'channel', reactions: reactions, onToggle: onToggle ?? (_) {}, @@ -111,7 +123,22 @@ class _FakeUserCacheNotifier extends UserCacheNotifier { Future preload(List pubkeys) async => true; } +class _RecordingReactionActions implements ChannelActions { + final removed = []; + final added = []; + @override + Future removeReaction(String eventId, String emoji) async => + removed.add(eventId); + @override + Future addReaction(String eventId, String emoji) async => + added.add(eventId); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + void main() { + combinedReactionTests(); + testWidgets('removing the sole reaction closes the sheet', (tester) async { final reactions = ValueNotifier>([ _reaction(reactedByCurrentUser: true), @@ -169,72 +196,71 @@ void main() { 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( + testWidgets('native combined tray toggles and streams 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, - (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; - }, - ); + null, + ), + ); + await _pumpRow( + tester, + combined: true, + reactions: [_reaction()], + onToggle: (emoji) => toggles.add('old:$emoji'), + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + await _pumpRow( + tester, + combined: true, + 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', diff --git a/mobile/test/features/channels/reaction_row_test/combined_tests.dart b/mobile/test/features/channels/reaction_row_test/combined_tests.dart new file mode 100644 index 0000000000..058f6c62fe --- /dev/null +++ b/mobile/test/features/channels/reaction_row_test/combined_tests.dart @@ -0,0 +1,297 @@ +part of '../reaction_row_test.dart'; + +void combinedReactionTests() { + for (final selected in [false, true]) { + for (final count in [1, 2]) { + testWidgets( + 'combined tray has one actionable semantics label, selected=$selected count=$count', + (tester) async { + final semantics = tester.ensureSemantics(); + + await _pumpRow( + tester, + combined: true, + reactions: [ + _reaction(count: count, reactedByCurrentUser: selected), + ], + onToggle: (_) {}, + ); + final label = + 'View $count ${count == 1 ? 'reaction' : 'reactions'}${selected ? ', including yours' : ''}'; + final tray = find.bySemanticsLabel(label); + expect(tray, findsOneWidget); + final node = tester.getSemantics(tray); + expect(node.label, label); + expect( + node.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + expect(node.childrenCount, 0); + semantics.dispose(); + }, + ); + } + } + + testWidgets('Android combined tray removes own reaction and stays live', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final actions = _RecordingReactionActions(); + Future draw(bool selected) async { + final message = TimelineMessage( + id: _messageId, + pubkey: _alice, + createdAt: 1, + content: '', + reactions: [ + TimelineReaction( + emoji: _fire, + count: selected ? 2 : 1, + reactedByCurrentUser: selected, + currentUserReactionId: selected ? 'own-reaction' : null, + userPubkeys: selected ? [_alice, 'other'] : ['other'], + ), + ], + ); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + channelActionsProvider.overrideWithValue(actions), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + ], + child: Consumer( + builder: (context, ref, _) => ReactionRow( + combined: true, + messageId: message.id, + channelId: 'channel', + reactions: message.reactions, + onToggle: (emoji) => toggleReaction(ref, message, emoji), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await draw(true); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + final chip = find.byKey(const ValueKey('reaction-toggle-$_fire')); + expect(tester.widget(chip).selected, isTrue); + await tester.tap(chip); + await tester.pump(); + expect(actions.removed, ['own-reaction']); + expect(actions.added, isEmpty); + await draw(false); + expect(tester.widget(chip).selected, isFalse); + expect( + find.byKey(const ValueKey('reaction-details-sheet')), + findsOneWidget, + ); + expect(find.text('2'), findsNothing); + expect(find.text('1'), findsNWidgets(2)); + debugDefaultTargetPlatformOverride = null; + }); + + for (final loadCustom in [false, true]) { + for (final scale in [1.0, 3.0]) { + testWidgets( + 'mixed reaction glyphs stay centered, image=$loadCustom scale=$scale', + (tester) async { + final imageBytes = (await rootBundle.load( + 'assets/images/buzz-icon.png', + )).buffer.asUint8List(); + final client = MockClient( + (_) async => loadCustom + ? http.Response.bytes( + imageBytes, + 200, + headers: {'content-type': 'image/png'}, + ) + : http.Response('missing', 404), + ); + addTearDown(client.close); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [mediaHttpClientProvider.overrideWithValue(client)], + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: ReactionRow( + combined: true, + messageId: 'alignment', + channelId: 'channel', + onToggle: (_) {}, + reactions: [ + for (final emoji in [ + '🙂', + '👀', + '❤️', + '👩‍👩‍👧‍👦', + '🇺🇸', + '✅', + '🎥', + '🎯', + '🙌', + ]) + _reaction(emoji: emoji), + TimelineReaction( + emoji: ':custom:', + emojiUrl: + 'https://example.com/align-$loadCustom-$scale.png', + count: 1, + reactedByCurrentUser: false, + userPubkeys: const [_alice], + ), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + if (loadCustom) { + final source = tester.widget( + find.descendant( + of: find.byType(CustomEmojiImage), + matching: find.byType(Image), + ), + ); + await tester.runAsync( + () => precacheImage( + source.image, + tester.element(find.byType(CustomEmojiImage)), + ), + ); + await tester.pumpAndSettle(); + final image = tester.widget( + find.descendant( + of: find.byType(CustomEmojiImage), + matching: find.byType(RawImage), + ), + ); + expect(image.image, isNotNull); + } else { + expect(find.text(':custom:'), findsOneWidget); + } + final tray = find.byKey(const ValueKey('combined-reaction-tray')); + final center = tester.getCenter(tray).dy; + for (final glyph in find.byType(NativeEmojiGlyph).evaluate()) { + final finder = find.byWidget(glyph.widget); + expect(tester.getSize(finder), const Size.square(20)); + expect(tester.getCenter(finder).dy, closeTo(center, 0.01)); + final paintedText = find.descendant( + of: finder, + matching: find.byType(Text), + ); + // This checks the device-calibrated optical correction, not the + // pixel bounds of the font (host rendering differs from iOS). + final opticalLift = defaultTargetPlatform == TargetPlatform.iOS + ? 2.0 + : 0.0; + expect( + tester.getCenter(paintedText).dy, + closeTo(center - opticalLift, 0.01), + ); + } + final customBox = find + .ancestor( + of: find.byType(CustomEmojiImage), + matching: find.byType(SizedBox), + ) + .first; + expect(tester.getSize(customBox), const Size.square(20)); + expect(tester.getCenter(customBox).dy, closeTo(center, 0.01)); + expect(tester.getCenter(find.text('10')).dy, closeTo(center, 0.01)); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + ); + } + } + + testWidgets( + 'combined capsule shows total count and opens details without toggling', + (tester) async { + final toggled = []; + var added = false; + await _pumpRow( + tester, + combined: true, + reactions: [ + _reaction(count: 3), + _reaction(emoji: _eyes), + ], + onToggle: toggled.add, + showAddButton: true, + onAddReaction: () => added = true, + ); + expect(find.text('1'), findsNothing); + expect(find.text('4'), findsOneWidget); + expect(find.byKey(const ValueKey('add-reaction-pill')), findsNothing); + expect( + tester + .getSize(find.byKey(const ValueKey('combined-reaction-tray'))) + .width, + lessThan(100), + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pumpAndSettle(); + final sheet = find.byKey(const ValueKey('reaction-details-sheet')); + expect(sheet, findsOneWidget); + expect(toggled, isEmpty); + await tester.tap(find.byKey(const ValueKey('reaction-sheet-add'))); + await tester.pumpAndSettle(); + expect(added, isTrue); + }, + ); + + testWidgets('native add action waits for reaction sheet dismissal', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final completion = Completer>(); + Map? payload; + var added = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + (call) async { + if (call.method == 'reactions') { + payload = call.arguments as Map; + return completion.future; + } + return {}; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + NativeMessagePresentation.channel, + null, + ), + ); + await _pumpRow( + tester, + combined: true, + reactions: [_reaction()], + showAddButton: true, + onAddReaction: () => added++, + ); + await tester.tap(find.byKey(const ValueKey('combined-reaction-tray'))); + await tester.pump(); + expect(payload!['canAddReaction'], isTrue); + expect(payload!['addReactionIconCodePoint'], BuzzIcons.smilePlus.codePoint); + await NativeMessagePresentation.present('updateProfiles', { + 'requestId': payload!['requestId'], + 'profiles': {}, + }); + expect(added, 0); + completion.complete({'action': 'addReaction'}); + await tester.pumpAndSettle(); + expect(added, 1); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 7db7e3e4aa..8f0798a8ce 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -244,7 +244,7 @@ void main() { actorPubkey: 'pk1', targetPubkey: 'pk2', ); - expect(event.describe(resolve), 'Bob was added by Alice'); + expect(event.describe(resolve), 'Alice added Bob to the channel'); }); test('member_left', () { diff --git a/mobile/test/shared/widgets/ios_scroll_to_top_test.dart b/mobile/test/shared/widgets/ios_scroll_to_top_test.dart new file mode 100644 index 0000000000..52c141deb2 --- /dev/null +++ b/mobile/test/shared/widgets/ios_scroll_to_top_test.dart @@ -0,0 +1,166 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; +import 'package:buzz/shared/widgets/frosted_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future tapStatusBar(WidgetTester tester) async { + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/status_bar', + const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ), + (_) {}, + ); + await tester.pumpAndSettle(); +} + +void main() { + for (final reduced in [false, true]) { + testWidgets( + 'status tap scrolls an explicit controller to top, reduced=$reduced', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + await tester.pumpWidget( + _testApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: reduced), + child: child!, + ), + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Test')), + body: ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Row $index')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + controller.jumpTo(900); + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controller.offset, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + + testWidgets( + 'status tap does not scroll a page under a modal', + (tester) async { + final controller = ScrollController(); + addTearDown(controller.dispose); + late BuildContext pageContext; + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Test')), + body: Builder( + builder: (context) { + pageContext = context; + return ListView.builder( + controller: controller, + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Row $index')), + ); + }, + ), + ), + ), + ); + await tester.pumpAndSettle(); + controller.jumpTo(900); + await tester.pumpAndSettle(); + showModalBottomSheet( + context: pageContext, + builder: (_) => const SizedBox(height: 200), + ); + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controller.offset, 900); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + testWidgets( + 'status tap expands a pinned title and returns its list to top', + (tester) async { + await tester.pumpWidget( + _testApp( + home: FrostedScaffold( + nativePinnedBody: true, + appBar: const FrostedAppBar( + title: Text('Title'), + nativeLargeTitle: true, + ), + body: ListView.builder( + itemCount: 100, + itemBuilder: (_, index) => + SizedBox(height: 60, child: Text('Pinned $index')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -900)); + await tester.pumpAndSettle(); + final nested = tester.state( + find.byType(NestedScrollView), + ); + expect(nested.innerController.offset, greaterThan(0)); + await tapStatusBar(tester); + expect(nested.innerController.offset, 0); + expect(nested.outerController.offset, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'status tap leaves an inactive tab alone', + (tester) async { + final controllers = [ScrollController(), ScrollController()]; + addTearDown(() { + for (final controller in controllers) { + controller.dispose(); + } + }); + await tester.pumpWidget( + _testApp( + home: IndexedStack( + index: 1, + children: [ + for (var index = 0; index < 2; index++) + FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Tab')), + body: ListView.builder( + controller: controllers[index], + itemCount: 100, + itemBuilder: (_, row) => + SizedBox(height: 60, child: Text('Row $row')), + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + for (final controller in controllers) { + controller.jumpTo(900); + } + await tester.pumpAndSettle(); + await tapStatusBar(tester); + expect(controllers[0].offset, 900); + expect(controllers[1].offset, 0); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); +} + +Widget _testApp({Widget? home, TransitionBuilder? builder}) => ProviderScope( + child: MaterialApp(home: home, builder: builder), +); From a83ea69b722a8dcfcacf7b560157651d208a1018 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 14 Oct 2026 16:37:59 -0400 Subject: [PATCH 024/146] fix(mobile): replay native navigation state when returning Home (#8314) Restore the iOS navigation bar after any covering route pops, including opaque custom Settings and community routes. Use the shared app RouteObserver and didPopNext, then replay scroll depth and request native layout after Flutter paints the uncovered view. Voice recording retains the same observer through its existing alias. Regression coverage uses the real HomePage with both MaterialPageRoute and opaque PageRouteBuilder, at scrolled and top-of-list positions. The custom-route case fails before this change. Validation: 107 focused tests and analysis passed. Full mobile runs stalled under local disk pressure, so full-suite validation is incomplete. On-device verification of this follow-up remains pending. --------- Signed-off-by: kenny lopez --- mobile/lib/app.dart | 4 +- .../channels/voice_note_recording.dart | 3 +- .../shared/widgets/app_route_observer.dart | 4 + .../shared/widgets/ios_navigation_bar.dart | 51 +++++ .../features/channels/channels_page_test.dart | 200 ++++++++++++++++-- 5 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 mobile/lib/shared/widgets/app_route_observer.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 03c73bb915..3c16eb5dba 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -27,7 +27,7 @@ import 'features/pairing/pairing_provider.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/channel_detail_page.dart'; import 'features/channels/deep_link_dispatcher.dart'; -import 'features/channels/voice_note_recording.dart'; +import 'shared/widgets/app_route_observer.dart'; import 'features/profile/user_status_cache_provider.dart'; import 'features/profile/settings_profile_header.dart'; import 'features/profile/set_status_sheet.dart'; @@ -392,7 +392,7 @@ class App extends HookConsumerWidget { return MaterialApp( navigatorKey: _mobileRootNavigatorKey, - navigatorObservers: [voiceNoteRouteObserver], + navigatorObservers: [appRouteObserver], title: 'Buzz', theme: AppTheme.light( colorScheme: lightScheme, diff --git a/mobile/lib/features/channels/voice_note_recording.dart b/mobile/lib/features/channels/voice_note_recording.dart index c34565c341..772232978b 100644 --- a/mobile/lib/features/channels/voice_note_recording.dart +++ b/mobile/lib/features/channels/voice_note_recording.dart @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../../shared/relay/media_image.dart'; +import '../../shared/widgets/app_route_observer.dart'; /// Maximum duration accepted for a recorded voice note. const voiceNoteMaxDuration = Duration(minutes: 5); @@ -25,7 +26,7 @@ const voiceNoteMaxDownloadBytes = 32 * 1024 * 1024; const voiceNotePlaybackRates = [1, 1.5, 2, 0.5]; /// Route observer used to cancel recording when its composer is covered. -final voiceNoteRouteObserver = RouteObserver>(); +final voiceNoteRouteObserver = appRouteObserver; /// Returns the playback rate following [current] in the supported rate cycle. double nextVoiceNotePlaybackRate(double current) { diff --git a/mobile/lib/shared/widgets/app_route_observer.dart b/mobile/lib/shared/widgets/app_route_observer.dart new file mode 100644 index 0000000000..74a8bbd7cc --- /dev/null +++ b/mobile/lib/shared/widgets/app_route_observer.dart @@ -0,0 +1,4 @@ +import 'package:flutter/widgets.dart'; + +/// Shared route lifecycle for native navigation and recording cleanup. +final appRouteObserver = RouteObserver>(); diff --git a/mobile/lib/shared/widgets/ios_navigation_bar.dart b/mobile/lib/shared/widgets/ios_navigation_bar.dart index 081a94722d..42cf258d17 100644 --- a/mobile/lib/shared/widgets/ios_navigation_bar.dart +++ b/mobile/lib/shared/widgets/ios_navigation_bar.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; import 'avatar_image.dart'; +import 'app_route_observer.dart'; import 'ios_navigation_metrics.dart'; export 'ios_navigation_metrics.dart'; @@ -343,6 +344,47 @@ class IosNavigationBar extends HookConsumerWidget { return () => offset?.removeListener(sync); }, [channel.value, offset, collapseRange]); + final route = ModalRoute.of(context); + useEffect(() { + var active = true; + Future restoreOnReveal() async { + final current = channel.value; + if (!active || + !context.mounted || + route?.isCurrent != true || + current == null) { + return; + } + try { + // Home stays mounted while another route covers its platform view. + // Returning does not emit a scroll notification, so replay the live + // depth before asking UIKit to lay out the revealed navigation bar. + await current.invokeMethod( + 'scroll', + (offset?.value ?? 0).clamp(0.0, collapseRange), + ); + if (!active || route?.isCurrent != true) return; + await current.invokeMethod('prepareForReveal'); + } on PlatformException catch (error) { + debugPrint('Native navigation reveal failed: $error'); + } + } + + final observer = _NavigationRevealObserver(() { + // didPopNext is independent of route transition compatibility. Defer + // until Flutter has painted the uncovered platform view for this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(restoreOnReveal()); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + }); + if (route != null) appRouteObserver.subscribe(observer, route); + return () { + active = false; + appRouteObserver.unsubscribe(observer); + }; + }, [channel.value, offset, collapseRange, route]); + return UiKitView( key: viewKey, viewType: viewType, @@ -354,3 +396,12 @@ class IosNavigationBar extends HookConsumerWidget { ); } } + +class _NavigationRevealObserver extends RouteAware { + _NavigationRevealObserver(this.onReveal); + + final VoidCallback onReveal; + + @override + void didPopNext() => onReveal(); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 1a543e3fd1..0a34bf2957 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -1,3 +1,5 @@ +import 'package:buzz/features/home/home_page.dart'; +import 'package:buzz/shared/widgets/app_route_observer.dart'; import 'package:buzz/features/pairing/pairing_provider.dart'; import 'package:buzz/shared/community/paired_community_landing.dart'; import 'dart:async'; @@ -80,6 +82,7 @@ void main() { ValueChanged? onSettingsTransitionProgress, ValueListenable? tabReselection, _FakeProfileNotifier? profile, + bool realHome = false, }) { return ProviderScope( overrides: [ @@ -100,6 +103,7 @@ void main() { ...overrides, ], child: MaterialApp( + navigatorObservers: [appRouteObserver], theme: brightness == Brightness.dark ? AppTheme.dark() : AppTheme.light(topSectionGradient: topSectionGradient), @@ -112,30 +116,35 @@ void main() { ), child: child!, ), - home: Stack( - children: [ - ChannelsPage( - settingsPageBuilder: _buildSettingsPage, - communityInvitePageBuilder: (_) => - const Scaffold(body: Text('Invite destination')), - communityAppearancePageBuilder: (_) => - const Scaffold(body: Text('Appearance destination')), - onSettingsTransitionProgress: - onSettingsTransitionProgress ?? (_) {}, - tabReselection: tabReselection, - ), - const Positioned.fill( - child: ChannelQuickActionsLauncher( - visible: true, - navigationBarHeight: 60, - navigationBarBottomGap: 12, - navigationBarWidth: 218, - systemBottomInset: 0, - rightInset: 16, + home: realHome + ? HomePage( + settingsPageBuilder: _buildSettingsPage, + hasUnreadInbox: false, + ) + : Stack( + children: [ + ChannelsPage( + settingsPageBuilder: _buildSettingsPage, + communityInvitePageBuilder: (_) => + const Scaffold(body: Text('Invite destination')), + communityAppearancePageBuilder: (_) => + const Scaffold(body: Text('Appearance destination')), + onSettingsTransitionProgress: + onSettingsTransitionProgress ?? (_) {}, + tabReselection: tabReselection, + ), + const Positioned.fill( + child: ChannelQuickActionsLauncher( + visible: true, + navigationBarHeight: 60, + navigationBarBottomGap: 12, + navigationBarWidth: 218, + systemBottomInset: 0, + rightInset: 16, + ), + ), + ], ), - ), - ], - ), ), ); } @@ -666,6 +675,151 @@ void main() { expect(appBar.frostedBlurSigma, 23.12); }); + for (final customRoute in [false, true]) { + testWidgets( + 'Home replays native scroll state on return: custom=$customRoute', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + tester.view.physicalSize = const Size(390, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final messenger = tester.binding.defaultBinaryMessenger; + final calls = >{}; + messenger.setMockMethodCallHandler(SystemChannels.platform_views, ( + call, + ) async { + if (call.method == 'create') { + final args = call.arguments as Map; + if (args['viewType'] == IosNavigationBar.viewType) { + final id = args['id'] as int; + calls[id] = []; + messenger.setMockMethodCallHandler( + MethodChannel('${IosNavigationBar.viewType}/$id'), + (call) async { + calls[id]!.add(call); + return null; + }, + ); + } + } + return null; + }); + addTearDown(() { + messenger.setMockMethodCallHandler( + SystemChannels.platform_views, + null, + ); + for (final id in calls.keys) { + messenger.setMockMethodCallHandler( + MethodChannel('${IosNavigationBar.viewType}/$id'), + null, + ); + } + }); + await tester.pumpWidget( + buildTestable( + realHome: true, + overrides: [ + channelsProvider.overrideWith( + () => _FakeNotifier( + List.generate( + 40, + (i) => Channel( + id: 'channel-$i', + description: '', + createdBy: 'test', + createdAt: DateTime(2026), + memberCount: 1, + visibility: 'open', + name: 'channel-$i', + channelType: 'stream', + isMember: true, + ), + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + final homeCalls = calls.values.single; + final position = tester + .state( + find + .descendant( + of: find.byType(CustomScrollView), + matching: find.byType(Scrollable), + ) + .first, + ) + .position; + position.jumpTo(200); + await tester.pumpAndSettle(); + final scrolledOffset = + homeCalls.where((call) => call.method == 'scroll').last.arguments + as double; + expect(scrolledOffset, greaterThan(0)); + final navigator = tester.state( + find.byType(Navigator).first, + ); + unawaited( + navigator.push( + customRoute + ? PageRouteBuilder( + opaque: true, + pageBuilder: (_, _, _) => + const Scaffold(body: Text('Custom route')), + ) + : MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Channel')), + ), + ), + ); + await tester.pumpAndSettle(); + homeCalls.clear(); + navigator.pop(); + await tester.pumpAndSettle(); + expect(position.pixels, 200); + expect( + homeCalls + .where((call) => call.method == 'scroll') + .map((call) => call.arguments), + contains(scrolledOffset), + ); + expect(homeCalls.last.method, 'prepareForReveal'); + // The replay must use the live offset, including returning at the top. + position.jumpTo(0); + await tester.pumpAndSettle(); + unawaited( + navigator.push( + customRoute + ? PageRouteBuilder( + opaque: true, + pageBuilder: (_, _, _) => + const Scaffold(body: Text('Custom route')), + ) + : MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Channel')), + ), + ), + ); + await tester.pumpAndSettle(); + homeCalls.clear(); + navigator.pop(); + await tester.pumpAndSettle(); + expect( + homeCalls + .where((call) => call.method == 'scroll') + .map((call) => call.arguments), + contains(0.0), + ); + await tester.pumpWidget(const SizedBox()); + debugDefaultTargetPlatformOverride = null; + }, + ); + } + testWidgets('scrolls Home to the top when its tab is selected again', ( tester, ) async { From b3795236ccde4cefaebfd3da06ce4c838b76e78a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 14 Oct 2026 17:18:38 -0400 Subject: [PATCH 025/146] fix(relay): reject unreadable write targets as missing (#8249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 A sender who cannot use or read a referenced event, artifact or workflow now gets exactly the response it would get for an ID that does not exist. #8197 (merged) set this rule for deletions; this applies it to the other writes that look up a target, plus the workflow read and webhook endpoints. Artifact `create` and workflow definition saves are the exceptions, described below. Access is checked before any message that depends on the target, readability goes through `event_visible_to_reader` (including #8197's private-deletion rule), and soft-deleted targets keep matching missing ones. | Path | Checks, in order | Response for any unusable target | |---|---|---| | Reaction (kind 7) | target lookup → `event_visible_to_reader` → every channel gate for the derived channel, including archived | `invalid: reaction target event not found` | | Edit (kind 40003) | target in the edit's `h` channel and readable → author / agent-owner check, including an author since removed from a private channel | `invalid: edit target not found or not editable by you` | | Forum vote (kind 45002) | target in the vote's `h` channel and readable → kind check | `invalid: vote target event not found` | | Report by event (kind 1984 `e`) | `event_visible_to_reader` → token channel scope → channel membership or open | `invalid: report target event not found` | | Reply (NIP-10 and workflow `send_message` replies) | parent in the reply's channel and readable | `reply parent not found` | | Artifact update / delete / restore / move | head exists and is homed in `h` (move: in the source the sender can write) → revision, type, deleted and root checks | `conflict: artifact head unavailable` | | Workflow trigger (kind 46020) | workflow exists and sender is its owner | `invalid: workflow not found or not triggerable by you` | | `GET /workflows/{id}/runs` and `/runs/{run_id}/approvals` | workflow exists, has a channel, and the caller can read it | `404 {"error":"workflow not found"}` | | `POST /hooks/{id}` | workflow exists and the provided secret matches its stored secret → definition and trigger-type checks | `404 {"error":"workflow not found"}` | Two of these reject writes that used to be accepted: a reaction to an event the sender cannot read (such as someone else's author-only event), and a report on an event the reporter cannot read. Reports by pubkey and by blob are unchanged. In an archived channel, writes whose channel comes from their target (reactions and deletions) get that target's not-found response, and an artifact move out of an archived source channel gets `conflict: artifact head unavailable` like any other source the sender cannot write; every other write keeps `invalid: channel is archived`. A webhook call with an absent or wrong secret, or to a workflow without a webhook secret, now gets `404` instead of `401`/`400`. Artifact `create` and workflow definition saves still report a taken ID, as NIP-AR allows for artifacts. Removed messages, none of which any client depends on from these endpoints: `edit target event not found`, `must be event author to edit`, `target event belongs to a different channel`, `target event has no channel`, `parent event belongs to a different channel`, `parent event has no channel association`, `artifact home changed`, `workflow is not accessible`, `workflow is not channel-scoped`, the webhook's `authentication failed` and `webhook secret required but not configured`, `forbidden: not authorized to trigger this workflow` for non-owners (it remains for an owner whose channel authority lapsed), and `restricted: not a channel member` for an edit by an author removed from a private channel. `e2e_existence_oracle` checks each event write over HTTP and WS, and the workflow read and webhook endpoints over HTTP, against missing, soft-deleted, other-channel, private-channel, archived, author-only and wrong-kind targets as applicable, with a positive control per path. It runs in the Backend Integration job alongside `e2e_author_only_deletion`. `removed_author_edit_does_not_reveal_target` checks that a removed author's edits of their remaining message, their deleted message and a missing ID get identical answers. In the PostgreSQL suite, `head_outside_authorized_channel_matches_missing_head` covers the artifact transaction, `workflow_reply_to_unusable_parent_matches_missing` covers workflow replies through `RelayActionSink::send_message`, `scoped_token_report_does_not_reveal_target` and `scoped_token_reaction_does_not_reveal_target` cover channel-scoped tokens, and `channelless_workflow_runs_read_as_missing` covers the runs read for a workflow with no channel. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- .github/workflows/_ci-relay.yml | 3 +- crates/buzz-db/src/store/artifact.rs | 21 +- .../src/store/artifact_postgres_tests.rs | 101 +++ .../src/api/artifact_postgres_tests.rs | 23 +- crates/buzz-relay/src/api/bridge.rs | 109 ++- crates/buzz-relay/src/api/workflows.rs | 28 +- crates/buzz-relay/src/handlers/artifact.rs | 14 +- .../src/handlers/command_executor.rs | 11 +- crates/buzz-relay/src/handlers/ingest.rs | 339 ++++++-- crates/buzz-relay/src/handlers/report.rs | 48 +- crates/buzz-relay/src/workflow_sink.rs | 121 +++ .../tests/e2e_existence_oracle.rs | 823 ++++++++++++++++++ 12 files changed, 1501 insertions(+), 140 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_existence_oracle.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index dff24c5f75..a0fa0a4ae8 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -111,6 +111,7 @@ jobs: --lib \ --test e2e_event_reminder \ --test e2e_author_only_deletion \ + --test e2e_existence_oracle \ --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' @@ -581,7 +582,7 @@ jobs: run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion)' \ + -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion) | binary(e2e_existence_oracle)' \ --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 diff --git a/crates/buzz-db/src/store/artifact.rs b/crates/buzz-db/src/store/artifact.rs index 988fb86135..a8a49ac602 100644 --- a/crates/buzz-db/src/store/artifact.rs +++ b/crates/buzz-db/src/store/artifact.rs @@ -25,6 +25,10 @@ pub enum ArtifactOutcome { Rejected(&'static str), } +/// Conflict for a non-create revision whose artifact is missing or lives +/// outside every channel the sender was authorized for. +pub const HEAD_UNAVAILABLE: &str = "artifact head unavailable"; + fn invalid(message: &str) -> DbError { DbError::InvalidData(message.into()) } @@ -133,7 +137,19 @@ impl Db { return Ok(ArtifactOutcome::Conflict("artifact identity is taken")) } (None, ArtifactOp::Create) => {} - (None, _) => return Ok(ArtifactOutcome::Conflict("artifact head unavailable")), + (None, _) => return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)), + // The caller authorized writes only in `h`, or for a move in the + // source it read before this transaction. A head anywhere else + // answers like a missing one, before any check that describes it. + (Some(_), op) + if source + != match op { + ArtifactOp::Move => authorized_source, + _ => Some(env.home), + } => + { + return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)) + } (Some(head), op) => { let current: Vec = head.get("event_id"); if env.prev.as_deref() != Some(current.as_slice()) { @@ -152,9 +168,6 @@ impl Db { "only move changes home and move must change home", )); } - if op == ArtifactOp::Move && authorized_source != source { - return Ok(ArtifactOutcome::Conflict("artifact home changed")); - } if op == ArtifactOp::Delete && env.root != old_root { return Ok(ArtifactOutcome::Rejected("delete preserves root")); } diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 4ac03a06f4..f1b0af4362 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -436,3 +436,104 @@ async fn quiescing_community_rejects_artifact_at_admission_before_coordinate_loc "a rejected artifact must persist nothing" ); } + +/// A non-create revision whose head lives outside the channel the caller +/// authorized answers exactly like a missing artifact, before the revision, +/// type, deleted and root checks could describe the head. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn head_outside_authorized_channel_matches_missing_head() { + let f = Fixture::new().await; + let d = Uuid::new_v4(); + let create = f.revision(d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&create, None).await, + ArtifactOutcome::Accepted(_) + )); + let gone = Uuid::new_v4(); + let deleted_d = Uuid::new_v4(); + let deleted_create = f.revision(deleted_d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&deleted_create, None).await, + ArtifactOutcome::Accepted(_) + )); + let deleted = f.revision( + deleted_d, + "delete", + f.a, + Some(&deleted_create), + &f.owner, + vec![], + ); + assert!(matches!( + f.accept(&deleted, None).await, + ArtifactOutcome::Accepted(_) + )); + let retyped = { + let mut tags: Vec> = vec![ + vec!["ar".into(), "1".into()], + vec!["d".into(), d.to_string()], + vec!["h".into(), f.b.to_string()], + vec!["type".into(), "buzz.note".into()], + vec!["op".into(), "update".into()], + vec!["title".into(), "Test".into()], + ]; + tags.push(vec!["prev".into(), create.id.to_hex()]); + EventBuilder::new(Kind::Custom(45010), "secret") + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .sign_with_keys(&f.peer) + .unwrap() + }; + let cases = [ + ( + "missing", + f.revision(gone, "update", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "current prev", + f.revision(d, "update", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "stale prev", + f.revision(d, "update", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ("other type", retyped, None), + ( + "delete", + f.revision(d, "delete", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "restore deleted", + f.revision(deleted_d, "restore", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ( + "update deleted", + f.revision(deleted_d, "update", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ( + "move from unauthorized source", + f.revision(d, "move", f.b, Some(&create), &f.peer, vec![]), + Some(f.b), + ), + ]; + for (case, event, source) in cases { + match f.accept(&event, source).await { + ArtifactOutcome::Conflict(reason) => { + assert_eq!(reason, super::artifact::HEAD_UNAVAILABLE, "{case}") + } + other => panic!("{case}: {other:?}"), + } + } + // Positive control: the same revision homed where the head lives. + let update = f.revision(d, "update", f.a, Some(&create), &f.peer, vec![]); + assert!(matches!( + f.accept(&update, None).await, + ArtifactOutcome::Accepted(_) + )); +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index e60010cc8a..b962d3f1c7 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -311,21 +311,36 @@ async fn writes_use_channel_gates_for_home_and_move_source() { .as_array() .unwrap() .contains(&json!(["prev", peer_edit.id.to_hex()]))); + // An unwritable source answers exactly like a move of a missing artifact. + let missing_move = + |key: &Keys| f.revision_as(key, f.home, Uuid::new_v4(), "move", Some(&moved)); + let (status, body) = f.try_publish(&missing_move(&f.peer)).await; + let peer_missing = (status, without_event_id(body)); + assert!(!peer_missing.0.is_success(), "{peer_missing:?}"); // The peer can write the destination but not the private source. let back = f.revision_as(&f.peer, f.home, d, "move", Some(&moved)); let (status, body) = f.try_publish(&back).await; - assert!(!status.is_success() || body["accepted"] == false, "{body}"); - assert!(body.to_string().contains("not a channel member"), "{body}"); + assert_eq!((status, without_event_id(body)), peer_missing); sqlx::query("UPDATE channels SET archived_at=now() WHERE community_id=$1 AND id=$2") .bind(f.community.as_uuid()) .bind(f.private) .execute(&f.pool) .await .unwrap(); + let (status, body) = f.try_publish(&missing_move(&f.owner)).await; + let owner_missing = (status, without_event_id(body)); + assert!(!owner_missing.0.is_success(), "{owner_missing:?}"); let archived_source = f.revision_as(&f.owner, f.home, d, "move", Some(&moved)); let (status, body) = f.try_publish(&archived_source).await; - assert!(!status.is_success() || body["accepted"] == false, "{body}"); - assert!(body.to_string().contains("archived"), "{body}"); + assert_eq!((status, without_event_id(body)), owner_missing); +} + +/// Drop the per-event id so rejections of different events compare exactly. +fn without_event_id(mut body: Value) -> Value { + if let Some(object) = body.as_object_mut() { + object.remove("event_id"); + } + body } /// Re-sign an artifact revision with extra tags and an explicit timestamp. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index be6743599b..4e90a20e15 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2449,6 +2449,24 @@ pub async fn workflow_webhook( .await .map_err(|_| not_found("workflow not found"))?; + // Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. + // Until the caller proves the secret, every failure answers like a missing + // workflow, so the response never reveals that the workflow exists, its + // trigger type, or whether a secret was configured. + let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); + let provided_secret = headers + .get("x-webhook-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| query.secret.clone()) + .unwrap_or_default(); + if !stored_secret + .is_some_and(|secret| crate::webhook_secret::verify_secret(&provided_secret, &secret)) + { + tracing::warn!("webhook: missing or invalid secret for workflow {id}"); + return Err(not_found("workflow not found")); + } + let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) .map_err(|e| super::internal_error(&format!("corrupt workflow definition: {e}")))?; @@ -2459,30 +2477,6 @@ pub async fn workflow_webhook( )); } - // Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. - let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); - let provided_secret = headers - .get("x-webhook-secret") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - .or_else(|| query.secret.clone()) - .unwrap_or_default(); - - match &stored_secret { - Some(secret) => { - if !crate::webhook_secret::verify_secret(&provided_secret, secret) { - tracing::warn!("webhook: invalid secret for workflow {id}"); - return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); - } - } - None => { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "webhook secret required but not configured — re-save the workflow to generate one", - )); - } - } - // Parse optional JSON body as trigger context. let body_json: Option = if body.is_empty() { @@ -4441,6 +4435,73 @@ pub(crate) mod postgres_tests { (status, String::from_utf8_lossy(&bytes).into_owned()) } + /// A workflow row without a channel has no readers, so even its owner + /// reads its runs exactly like a workflow that does not exist. Ingest + /// refuses channelless definitions, so the row is seeded directly. + /// Mutation: let a `None` channel through `authorize_workflow_read` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channelless_workflow_runs_read_as_missing() { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let state = bridge_handler_test_state() + .await + .expect("local Postgres not reachable"); + let host = format!("channelless-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let owner = Keys::generate(); + state + .db + .ensure_user(community, owner.public_key().as_bytes()) + .await + .expect("ensure user"); + let workflow = state + .db + .create_workflow( + community, + None, + owner.public_key().as_bytes(), + "channelless", + "{}", + &[0u8; 32], + ) + .await + .expect("seed workflow"); + let read = |id: uuid::Uuid| { + let state = state.clone(); + let host = host.clone(); + let owner = owner.public_key().to_hex(); + async move { + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .uri(format!("/workflows/{id}/runs")) + .header(header::HOST, host) + .header("x-pubkey", owner) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + (status, body) + } + }; + let missing = read(uuid::Uuid::new_v4()).await; + assert_eq!(missing.0, axum::http::StatusCode::NOT_FOUND, "{missing:?}"); + assert_eq!(read(workflow).await, missing); + } + /// Collect buzz_events_rejected_total with (transport, reason) labels from /// a DebuggingRecorder snapshot. fn http_reject_counts( diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index d957965bca..b6a1f98e00 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -102,19 +102,19 @@ async fn authorize_workflow_read( .await .map_err(|e| e.into_response())?; - let workflow = state - .db - .get_workflow(tenant.community(), workflow_id) - .await - .map_err(|error| match error { - buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow not found").into_response() - } - other => internal_error(&format!("get workflow for run read: {other}")).into_response(), - })?; - let channel_id = workflow.channel_id.ok_or_else(|| { - api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped").into_response() - })?; + // A workflow the caller cannot read answers exactly like a missing one, so + // the response never reveals which workflow IDs exist. + let not_found = || api_error(StatusCode::NOT_FOUND, "workflow not found").into_response(); + let workflow = match state.db.get_workflow(tenant.community(), workflow_id).await { + Ok(workflow) => workflow, + Err(buzz_db::error::DbError::NotFound(_)) => return Err(not_found()), + Err(other) => { + return Err( + internal_error(&format!("get workflow for run read: {other}")).into_response(), + ) + } + }; + let channel_id = workflow.channel_id.ok_or_else(not_found)?; let accessible = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await @@ -122,7 +122,7 @@ async fn authorize_workflow_read( internal_error(&format!("workflow channel access lookup: {error}")).into_response() })?; if !accessible.contains(&channel_id) { - return Err(api_error(StatusCode::FORBIDDEN, "workflow is not accessible").into_response()); + return Err(not_found()); } Ok(tenant) diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index 3e186f9338..772e032e75 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -24,10 +24,14 @@ pub(crate) async fn accept( .artifact_home(tenant.community(), env.id) .await .map_err(|e| IngestError::Internal(format!("artifact storage: {e}")))? - .ok_or_else(|| { - IngestError::CanvasConflict("conflict: artifact head unavailable".into()) + .ok_or_else(head_unavailable)?; + // A source the sender cannot write answers like a missing artifact. + super::ingest::check_channel_write(tenant, state, auth, source) + .await + .map_err(|e| match e { + IngestError::Internal(_) => e, + _ => head_unavailable(), })?; - super::ingest::check_channel_write(tenant, state, auth, source).await?; Some(source) } else { None @@ -64,6 +68,10 @@ pub(crate) async fn accept( }) } +fn head_unavailable() -> IngestError { + IngestError::CanvasConflict(format!("conflict: {}", buzz_db::artifact::HEAD_UNAVAILABLE)) +} + /// Live delivery only; unlike kind 9 there are no audit, workflow, or thread /// side effects. Missed deliveries are recovered by replaying stored events. async fn publish(state: &Arc, tenant: &TenantContext, stored: &StoredEvent) { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 460323882a..bab57a3809 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -809,6 +809,9 @@ async fn handle_workflow_def( }) } +/// Trigger rejection for a missing workflow and for one the sender does not own. +const WORKFLOW_NOT_FOUND: &str = "invalid: workflow not found or not triggerable by you"; + async fn handle_workflow_trigger( tenant: &TenantContext, state: &Arc, @@ -835,15 +838,15 @@ async fn handle_workflow_trigger( .db .get_workflow(community_id, workflow_id) .await - .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; + .map_err(|_| IngestError::Rejected(WORKFLOW_NOT_FOUND.into()))?; // 3. Manual triggers execute with the workflow owner's authority, so only // the owner may start them. Channel membership alone is insufficient: a // member could otherwise invoke another user's webhook or message actions. + // Anyone else is told the workflow is not found, so the response never + // reveals whether a workflow ID exists. if workflow.owner_pubkey != self_bytes { - return Err(IngestError::Rejected( - "forbidden: not authorized to trigger this workflow".into(), - )); + return Err(IngestError::Rejected(WORKFLOW_NOT_FOUND.into())); } // SEC-006: manual triggers must honor the workflow's lifecycle state and diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0324d6f2cc..9e8311e8f6 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -628,16 +628,24 @@ pub(crate) fn extract_channel_id(event: &Event) -> Option { pub(crate) enum ReactionChannelResult { Channel(Uuid), NoChannel, + /// Missing, soft-deleted, or not readable by the reactor. NotFound, NoTarget, DbError(String), } -/// Derive channel_id from the target event for NIP-25 reactions. +/// Rejection for a reaction whose target is missing or unusable by the sender. +/// Every such target gets this text, so it never reveals whether, or where, +/// the target exists. +const REACTION_TARGET_NOT_FOUND: &str = "reaction target event not found"; + +/// Derive channel_id from the target event for NIP-25 reactions. A target +/// `reader` may not read resolves as `NotFound`. pub(crate) async fn derive_reaction_channel( community_id: CommunityId, db: &buzz_db::Db, event: &Event, + reader: &[u8], ) -> ReactionChannelResult { let target_hex = match event.tags.iter().rev().find_map(|tag| { if tag.kind().to_string() == "e" { @@ -665,6 +673,9 @@ pub(crate) async fn derive_reaction_channel( .get_event_by_id_for_event_write(community_id, &id_bytes) .await { + Ok(Some(target)) if !super::req::event_visible_to_reader(&target.event, reader) => { + ReactionChannelResult::NotFound + } Ok(Some(target)) => match target.channel_id { Some(ch_id) => ReactionChannelResult::Channel(ch_id), None => ReactionChannelResult::NoChannel, @@ -921,6 +932,18 @@ impl ThreadMetadataOwned { } } +/// Whether `target` is in `channel_id` and readable by `reader`. Write paths +/// that reference an event reject a target failing this exactly as a missing +/// one, so the rejection never reveals whether, or where, the target exists. +fn target_readable_in_channel( + target: &buzz_core::StoredEvent, + channel_id: Uuid, + reader: &[u8], +) -> bool { + target.channel_id == Some(channel_id) + && super::req::event_visible_to_reader(&target.event, reader) +} + /// Resolve NIP-10 thread ancestry from e-tags. pub(crate) async fn resolve_nip10_thread_meta( community_id: CommunityId, @@ -947,18 +970,12 @@ pub(crate) async fn resolve_nip10_thread_meta( .get_thread_metadata_by_event(community_id, &parent_bytes), ); + let reader = effective_message_author(event, &state.relay_keypair.public_key()); let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? + .filter(|parent| target_readable_in_channel(parent, channel_id, &reader)) .ok_or_else(|| "reply parent not found".to_string())?; - match parent_event.channel_id { - Some(parent_ch) if parent_ch != channel_id => { - return Err("parent event belongs to a different channel".to_string()); - } - None => return Err("parent event has no channel association".to_string()), - _ => {} - } - let parent_created = chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); @@ -1130,6 +1147,7 @@ pub(crate) async fn resolve_relay_reply_thread_meta( community_id: CommunityId, parent_hex: &str, channel_id: Uuid, + reader: &[u8], state: &AppState, ) -> Result { let parent_bytes = @@ -1146,16 +1164,9 @@ pub(crate) async fn resolve_relay_reply_thread_meta( let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? + .filter(|parent| target_readable_in_channel(parent, channel_id, reader)) .ok_or_else(|| "reply parent not found".to_string())?; - match parent_event.channel_id { - Some(parent_ch) if parent_ch != channel_id => { - return Err("parent event belongs to a different channel".to_string()); - } - None => return Err("parent event has no channel association".to_string()), - _ => {} - } - let parent_created = chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); @@ -1253,6 +1264,10 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ event.pubkey.to_bytes().to_vec() } +/// Rejection for an edit whose target is missing or not editable by the +/// sender, so the error never reveals whether, or where, the target exists. +const EDIT_TARGET_DENIED: &str = "edit target not found or not editable by you"; + /// Validate kind:40003 edit ownership — event.pubkey must match target's effective author, /// or the actor must be the owning human of the agent that authored the target message. async fn validate_edit_ownership( @@ -1280,27 +1295,21 @@ async fn validate_edit_ownership( let target_bytes = hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; + // Kind 40003 skips the generic membership gate, so this validator is the + // only authority: a target outside the edit's `h` channel, unreadable, or + // not the actor's to edit is denied exactly like a missing one. + let edit_channel_id = + extract_channel_id(event).ok_or_else(|| "missing h tag for edit".to_string())?; + let actor = event.pubkey.to_bytes().to_vec(); let target_event = state .db .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? - .ok_or_else(|| "edit target event not found".to_string())?; - - // Verify target belongs to the same channel as the edit event. - let edit_channel_id = extract_channel_id(event); - match (edit_channel_id, target_event.channel_id) { - (Some(edit_ch), Some(target_ch)) if edit_ch != target_ch => { - return Err("target event belongs to a different channel".to_string()); - } - (Some(_), None) => { - return Err("target event has no channel".to_string()); - } - _ => {} // Same channel or no channel context — OK - } + .filter(|target| target_readable_in_channel(target, edit_channel_id, &actor)) + .ok_or_else(|| EDIT_TARGET_DENIED.to_string())?; let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - let actor = event.pubkey.to_bytes().to_vec(); if author == actor { // Author editing their own message: re-gate on membership/open visibility so that // a removed private-channel member cannot mutate old messages after access is revoked. @@ -1317,7 +1326,7 @@ async fn validate_edit_ownership( .map(|ch| ch.visibility == "open") .unwrap_or(false); if !is_open { - return Err("restricted: not a channel member".to_string()); + return Err(EDIT_TARGET_DENIED.to_string()); } } } @@ -1329,7 +1338,7 @@ async fn validate_edit_ownership( .await .map_err(|e| format!("db error checking agent ownership: {e}"))?; if !is_owner { - return Err("must be event author to edit".to_string()); + return Err(EDIT_TARGET_DENIED.to_string()); } } Ok(()) @@ -1358,6 +1367,9 @@ async fn validate_forum_vote_target( } }) .ok_or_else(|| "missing e tag for vote target".to_string())?; + let vote_channel_id = + extract_channel_id(event).ok_or_else(|| "missing h tag for vote".to_string())?; + let reader = effective_message_author(event, &state.relay_keypair.public_key()); let target_bytes = hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; @@ -1366,24 +1378,15 @@ async fn validate_forum_vote_target( .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? + // Readability first: the kind diagnostic below would otherwise reveal + // an inaccessible event's existence and kind. + .filter(|target| target_readable_in_channel(target, vote_channel_id, &reader)) .ok_or_else(|| "vote target event not found".to_string())?; let target_kind = event_kind_u32(&target_event.event); if target_kind != KIND_FORUM_POST && target_kind != KIND_FORUM_COMMENT { return Err("vote target must be a forum post or comment".to_string()); } - - // Verify target belongs to the same channel as the vote event. - let vote_channel_id = extract_channel_id(event); - match (vote_channel_id, target_event.channel_id) { - (Some(vote_ch), Some(target_ch)) if vote_ch != target_ch => { - return Err("target event belongs to a different channel".to_string()); - } - (Some(_), None) => { - return Err("target event has no channel".to_string()); - } - _ => {} - } Ok(()) } @@ -2569,7 +2572,7 @@ async fn ingest_event_inner( // report; that is tolerated because reports are non-actioning signals and // remain visible only to moderators. if kind_u32 == KIND_REPORT { - super::report::handle_report_event(tenant, &event, state) + super::report::handle_report_event(tenant, &event, state, auth.channel_ids()) .await .map_err(IngestError::Rejected)?; return Ok(IngestResult { @@ -2614,25 +2617,27 @@ async fn ingest_event_inner( } } - // 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 - )) + // A reaction, or 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 kind's + // not-found denial. + let target_denial = if kind_u32 == KIND_REACTION { + Some(REACTION_TARGET_NOT_FOUND) + } else if kind_u32 == KIND_DELETION + && !crate::handlers::side_effects::extract_target_event_ids(&event).is_empty() + { + Some(crate::handlers::side_effects::DELETION_TARGET_DENIED) + } else { + None }; + let target_denied = |reason: &str| IngestError::Rejected(format!("invalid: {reason}")); let mut channel_id = if kind_u32 == KIND_REACTION { - match derive_reaction_channel(tenant.community(), &state.db, &event).await { + let reader = effective_message_author(&event, &state.relay_keypair.public_key()); + match derive_reaction_channel(tenant.community(), &state.db, &event, &reader).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), ReactionChannelResult::NoChannel => None, ReactionChannelResult::NotFound => { - return Err(IngestError::Rejected( - "invalid: reaction target event not found".into(), - )); + return Err(target_denied(REACTION_TARGET_NOT_FOUND)); } ReactionChannelResult::NoTarget => { return Err(IngestError::Rejected( @@ -2663,7 +2668,9 @@ async fn ingest_event_inner( .map_err(|e| { IngestError::Internal(format!("error: looking up deletion target: {e}")) })? - .ok_or_else(deletion_denied)? + .ok_or_else(|| { + target_denied(crate::handlers::side_effects::DELETION_TARGET_DENIED) + })? .channel_id } None => None, @@ -2683,15 +2690,12 @@ async fn ingest_event_inner( } if let Some(ch_id) = channel_id { - check_token_channel_access(&auth, ch_id).map_err(|e| { - if deletion_targets_event { - deletion_denied() - } else { - IngestError::AuthFailed(e) - } + check_token_channel_access(&auth, ch_id).map_err(|e| match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::AuthFailed(e), })?; - } else if deletion_targets_event && auth.channel_ids().is_some() { - return Err(deletion_denied()); + } else if let (Some(reason), Some(_)) = (target_denial, auth.channel_ids()) { + return Err(target_denied(reason)); } 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), @@ -2774,12 +2778,9 @@ async fn ingest_event_inner( }, state_for_request(tenant, auth.pubkey()), ); - auth_result.map_err(|e| { - if deletion_targets_event { - deletion_denied() - } else { - IngestError::Rejected(e) - } + auth_result.map_err(|e| match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::Rejected(e), })?; } } @@ -2920,12 +2921,13 @@ 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 + if let Some(reason) = target_denial { + if channel_row .as_ref() .is_some_and(|ch| ch.archived_at.is_some()) - { - return Err(deletion_denied()); + { + return Err(target_denied(reason)); + } } crate::handlers::side_effects::validate_standard_deletion_event(tenant, &event, state) .await @@ -2943,7 +2945,10 @@ async fn ingest_event_inner( if !is_unarchive { if let Some(channel) = &channel_row { if channel.archived_at.is_some() { - return Err(IngestError::Rejected("invalid: channel is archived".into())); + return Err(match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::Rejected("invalid: channel is archived".into()), + }); } } } @@ -3327,9 +3332,9 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Internal(format!("error: {e}")))? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { - return Err(IngestError::Rejected( - "invalid: reaction target event not found".into(), - )); + return Err(IngestError::Rejected(format!( + "invalid: {REACTION_TARGET_NOT_FOUND}" + ))); } buzz_db::ReactionEventInsertOutcome::Duplicate => { return Ok(IngestResult { @@ -6917,6 +6922,174 @@ mod postgres_tests { assert_eq!(stored.channel_id, Some(channels[0])); } + /// A community with `author`'s live message in an open channel, an + /// author global note, and a second open channel for out-of-scope tokens. + struct ScopedTargetFixture { + state: Arc, + tenant: TenantContext, + tracer: Arc, + author: nostr::Keys, + sender: nostr::Keys, + target_channel: Uuid, + other_channel: Uuid, + live: Event, + global: Event, + } + + impl ScopedTargetFixture { + async fn new(label: &str) -> Self { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + let state = ingest_state().await; + let host = format!("{label}-{}.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, sender) = (nostr::Keys::generate(), nostr::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!("{label}-{}", 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 publish = |kind: u16, tags: Vec| { + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .tags(tags) + .sign_with_keys(&author) + .expect("sign") + }; + let live = publish( + 9, + vec![nostr::Tag::parse(["h", &channels[0].to_string()]).unwrap()], + ); + let global = publish(1, vec![]); + for event in [live.clone(), global.clone()] { + let auth = IngestAuth::Http { + pubkey: author.public_key(), + scopes: vec![Scope::MessagesWrite], + auth_method: HttpAuthMethod::Nip98, + }; + ingest_event_inner(&state, &tracer, &tenant, event, auth) + .await + .expect("store target"); + } + Self { + state, + tenant, + tracer, + author, + sender, + target_channel: channels[0], + other_channel: channels[1], + live, + global, + } + } + + fn scoped(&self, channel: Uuid) -> IngestAuth { + IngestAuth::Nip42 { + pubkey: self.sender.public_key(), + scopes: vec![Scope::MessagesWrite], + channel_ids: Some(vec![channel]), + conn_id: Uuid::new_v4(), + } + } + + fn sign(&self, kind: u16, tags: Vec>) -> Event { + EventBuilder::new(Kind::Custom(kind), "+") + .tags(tags.into_iter().map(|t| nostr::Tag::parse(t).unwrap())) + .sign_with_keys(&self.sender) + .expect("sign") + } + + async fn submit( + &self, + event: Event, + auth: IngestAuth, + ) -> Result { + ingest_event_inner(&self.state, &self.tracer, &self.tenant, event, auth).await + } + } + + /// A report sent with a token scoped to another channel answers exactly + /// like a report on a missing event. + /// Mutation: drop the `token_channels` check in `reporter_can_read` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_report_does_not_reveal_target() { + let f = ScopedTargetFixture::new("scoped-report").await; + let author = f.author.public_key().to_hex(); + let report = |target: String| { + f.sign( + KIND_REPORT as u16, + vec![ + vec!["e".into(), target, "spam".into()], + vec!["p".into(), author.clone()], + ], + ) + }; + for target in ["a".repeat(64), f.live.id.to_hex()] { + match f + .submit(report(target.clone()), f.scoped(f.other_channel)) + .await + { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "invalid: report target event not found", "{target}") + } + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: accepted"), + } + } + f.submit(report(f.live.id.to_hex()), f.scoped(f.target_channel)) + .await + .expect("in-scope token reports the message"); + } + + /// A reaction sent with a token scoped to another channel, to a channel + /// message or to a global note, answers exactly like one to a missing event. + /// Mutation: drop the `(Some(reason), Some(_))` arm, or map the token + /// check to its own error → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_reaction_does_not_reveal_target() { + let f = ScopedTargetFixture::new("scoped-reaction").await; + let react = |target: String| f.sign(KIND_REACTION as u16, vec![vec!["e".into(), target]]); + for target in ["a".repeat(64), f.live.id.to_hex(), f.global.id.to_hex()] { + match f + .submit(react(target.clone()), f.scoped(f.other_channel)) + .await + { + Err(IngestError::Rejected(msg)) => assert_eq!( + msg, + format!("invalid: {REACTION_TARGET_NOT_FOUND}"), + "{target}" + ), + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: accepted"), + } + } + f.submit(react(f.live.id.to_hex()), f.scoped(f.target_channel)) + .await + .expect("in-scope token reacts to the message"); + } + // ── 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/report.rs b/crates/buzz-relay/src/handlers/report.rs index fccf8eb42b..2e9e1aa4d7 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -20,8 +20,10 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; +use buzz_core::StoredEvent; use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; +use uuid::Uuid; use crate::state::AppState; @@ -41,10 +43,13 @@ pub const REPORT_TYPES: &[&str] = &[ /// Rejections use client-safe `invalid:`/`restricted:` reasons. On success /// the report is queued (idempotently, keyed by the signed event id) and the /// event itself is **not** stored or broadcast as a regular event. +/// +/// `token_channels` is the sender's channel-scoped token restriction, if any. pub async fn handle_report_event( tenant: &TenantContext, event: &Event, state: &Arc, + token_channels: Option<&[Uuid]>, ) -> Result<(), String> { let parsed = parse_report(event)?; let reporter_pubkey = event.pubkey.to_bytes(); @@ -55,9 +60,20 @@ pub async fn handle_report_event( .db .get_event_by_id(tenant.community(), &event_id) .await - .map_err(|e| format!("error: database error resolving report target: {e}"))? - .ok_or_else(|| "invalid: report target event not found".to_string())?; - (ReportTarget::Event(event_id), stored.channel_id) + .map_err(|e| format!("error: database error resolving report target: {e}"))?; + // A target the reporter cannot read is rejected exactly like a + // missing one, so the response never reveals whether it exists. + let readable = match &stored { + Some(stored) => { + reporter_can_read(tenant, state, stored, &reporter_pubkey, token_channels) + .await? + } + None => false, + }; + match stored { + Some(stored) if readable => (ReportTarget::Event(event_id), stored.channel_id), + _ => return Err("invalid: report target event not found".to_string()), + } } ParsedReportTarget::Blob { sha256, .. } => { let sha_hex = hex::encode(&sha256); @@ -93,6 +109,32 @@ pub async fn handle_report_event( Ok(()) } +/// Whether `reporter` may read `target`: the event-level read gate, plus the +/// channel's membership-or-open rule and the token's channel scope for a +/// channel event. +async fn reporter_can_read( + tenant: &TenantContext, + state: &AppState, + target: &StoredEvent, + reporter: &[u8], + token_channels: Option<&[Uuid]>, +) -> Result { + if !super::req::event_visible_to_reader(&target.event, reporter) { + return Ok(false); + } + let Some(channel_id) = target.channel_id else { + return Ok(true); + }; + if token_channels.is_some_and(|allowed| !allowed.contains(&channel_id)) { + return Ok(false); + } + match super::ingest::check_channel_membership(tenant, state, channel_id, reporter, None).await { + Ok(()) => Ok(true), + Err(reason) if reason.starts_with("error:") => Err(reason), + Err(_) => Ok(false), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ParsedReport<'a> { target: ParsedReportTarget, diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index e069d7a37d..e757b49d80 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -320,6 +320,7 @@ impl ActionSink for RelayActionSink { tenant.community(), parent_hex, channel_uuid, + &author_pubkey_bytes, &state, ) .await @@ -1401,4 +1402,124 @@ mod postgres_tests { "expected InvalidInput, got {err:?}" ); } + + /// A workflow reply to a parent the owner cannot use, in another channel + /// or unreadable to them, fails exactly like a reply to a missing parent. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_to_unusable_parent_matches_missing() { + let state = test_state().await; + let owner = nostr::Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let stranger = nostr::Keys::generate(); + let host = format!("wf-unusable-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &owner_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + let open_channel = |name: &'static str| { + let state = Arc::clone(&state); + let owner = owner.public_key().to_bytes(); + async move { + state + .db + .create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel") + } + }; + let channel = open_channel("wf-unusable").await; + let elsewhere = open_channel("wf-elsewhere").await; + let insert = |keys: &nostr::Keys, kind: u32, channel_id: Uuid, extra: Vec| { + let state = Arc::clone(&state); + let mut tags = vec![Tag::parse(["h", &channel_id.to_string()]).expect("h tag")]; + tags.extend(extra); + let event = EventBuilder::new(Kind::from(kind as u16), "parent") + .tags(tags) + .sign_with_keys(keys) + .expect("sign parent"); + async move { + state + .db + .insert_event(community, &event, Some(channel_id)) + .await + .expect("insert parent"); + event.id.to_hex() + } + }; + // A kind 5 whose `k` tag names an author-only kind is readable only by + // its author. Production ingest never stores one channel-scoped (its + // target is global), so this is the closest state that isolates the + // resolver's visibility check and the reader identity it is given. + let author_only_deletion = |keys: &nostr::Keys| { + let target = nostr::Keys::generate().public_key().to_hex(); + insert( + keys, + buzz_core::kind::KIND_DELETION, + channel.id, + vec![ + Tag::parse(["e", &target]).expect("e tag"), + Tag::parse(["k", "30300"]).expect("k tag"), + ], + ) + }; + let other_channel = insert(&owner, KIND_STREAM_MESSAGE, elsewhere.id, vec![]).await; + let unreadable = author_only_deletion(&stranger).await; + let owner_only = author_only_deletion(&owner).await; + + let sink = RelayActionSink::new(&state); + let reply = |author_hex: String, parent: String| { + let sink = &sink; + let channel = channel.id.to_string(); + async move { + sink.send_message( + community, + &channel, + "reply", + "reply", + &author_hex, + Some(&parent), + ) + .await + } + }; + let missing = nostr::Keys::generate().public_key().to_hex(); + let other_owner_hex = nostr::Keys::generate().public_key().to_hex(); + for (case, author, parent) in [ + ("missing", &owner_hex, missing), + ("other channel", &owner_hex, other_channel), + ("unreadable", &owner_hex, unreadable), + ( + "readable only by another owner", + &other_owner_hex, + owner_only.clone(), + ), + ] { + match reply(author.clone(), parent).await { + Err(ActionSinkError::InvalidInput(msg)) => { + assert_eq!(msg, "reply parent not found", "{case}") + } + other => panic!("{case}: expected the missing-parent error, got {other:?}"), + } + } + + // Positive control: the resolver reads as the workflow owner, who + // alone can see their author-only parent. + reply(owner_hex.clone(), owner_only) + .await + .expect("owner replies to a parent only they can read"); + } } diff --git a/crates/buzz-test-client/tests/e2e_existence_oracle.rs b/crates/buzz-test-client/tests/e2e_existence_oracle.rs new file mode 100644 index 0000000000..762d6fe472 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_existence_oracle.rs @@ -0,0 +1,823 @@ +//! End-to-end tests: a write that references an event, artifact or workflow +//! the sender cannot use or read gets exactly the response it would get for +//! an ID that does not exist. +//! +//! Each test sends the same write as an unauthorized sender against targets +//! that are missing, soft-deleted, in another channel, in a private channel +//! the sender has not joined, author-only, or of the wrong kind, over HTTP and +//! WS, and requires one identical HTTP status, HTTP error and WS `OK` +//! message. A positive control shows the legitimate write still succeeds. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_existence_oracle -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{EventBuilder, EventId, Keys, Kind, Tag}; +use reqwest::Client; +use serde_json::Value; + +const KIND_EVENT_REMINDER: u16 = 30300; +const MISSING: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +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 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 sign(keys: &Keys, kind: u16, content: &str, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind), content) + .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}"); +} + +/// Submit `event` 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, event: nostr::Event) -> (u16, String, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", sender.public_key().to_hex()) + .json(&event) + .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() + .or_else(|| body["message"].as_str()) + .unwrap_or("") + .to_string(); + let mut ws = BuzzTestClient::connect(&relay_url(), sender) + .await + .expect("connect"); + let ok = ws.send_event(event).await.expect("OK"); + ws.disconnect().await.expect("disconnect"); + assert!(!ok.accepted, "WS must reject the event"); + (status, http_msg, ok.message) +} + +/// Every case must be rejected over HTTP and WS with `expected`, and with +/// one HTTP status across all cases. +async fn assert_uniform( + client: &Client, + sender: &Keys, + expected: &str, + cases: Vec<(&str, nostr::Event)>, +) { + let mut status_seen = None; + let mut mismatches = Vec::new(); + for (case, event) in cases { + let (status, http_msg, ws_msg) = rejection(client, sender, event).await; + if status < 400 && http_msg.is_empty() { + mismatches.push(format!("{case}: HTTP accepted")); + } + if http_msg != expected { + mismatches.push(format!("{case}: HTTP {http_msg:?}")); + } + if ws_msg != expected { + mismatches.push(format!("{case}: WS {ws_msg:?}")); + } + if *status_seen.get_or_insert(status) != status { + mismatches.push(format!("{case}: HTTP status {status}")); + } + } + assert!( + mismatches.is_empty(), + "expected {expected:?}:\n{}", + mismatches.join("\n") + ); +} + +async fn create_channel(client: &Client, owner: &Keys, visibility: &str) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let event = sign( + owner, + 9007, + "", + vec![ + tag(&["h", &id]), + tag(&["name", &format!("eo-{id}")]), + tag(&["channel_type", "stream"]), + tag(&["visibility", visibility]), + ], + ); + submit_ok(client, owner, &event).await; + id +} + +async fn add_member(client: &Client, owner: &Keys, channel: &str, member: &Keys) { + let event = sign( + owner, + 9000, + "", + vec![ + tag(&["h", channel]), + tag(&["p", &member.public_key().to_hex()]), + ], + ); + submit_ok(client, owner, &event).await; +} + +async fn post(client: &Client, author: &Keys, kind: u16, channel: &str) -> nostr::Event { + let event = sign( + author, + kind, + &uuid::Uuid::new_v4().to_string(), + vec![tag(&["h", channel])], + ); + submit_ok(client, author, &event).await; + event +} + +async fn delete(client: &Client, author: &Keys, target: &nostr::Event) { + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![tag(&["e", &target.id.to_hex()])]) + .sign_with_keys(author) + .unwrap(); + submit_ok(client, author, &deletion).await; +} + +/// The author's targets in every place an outsider cannot use them. +struct Targets { + /// An open channel the outsider has not joined. + open: String, + live: nostr::Event, + deleted: nostr::Event, + hidden: nostr::Event, + reminder: nostr::Event, +} + +async fn targets(client: &Client, author: &Keys, kind: u16) -> Targets { + let open = create_channel(client, author, "open").await; + let private = create_channel(client, author, "private").await; + let live = post(client, author, kind, &open).await; + let deleted = post(client, author, kind, &open).await; + delete(client, author, &deleted).await; + let hidden = post(client, author, kind, &private).await; + let reminder = sign( + author, + KIND_EVENT_REMINDER, + "nip44-ciphertext-placeholder", + vec![ + tag(&["d", &uuid::Uuid::new_v4().to_string()]), + tag(&["alt", "Encrypted reminder"]), + ], + ); + submit_ok(client, author, &reminder).await; + Targets { + open, + live, + deleted, + hidden, + reminder, + } +} + +fn hex(event: &nostr::Event) -> String { + event.id.to_hex() +} + +/// Reactions derive their channel from the target, so a target the reactor +/// cannot use, including someone else's author-only event, is rejected as +/// missing. Reacting to another user's author-only event used to be stored. +#[tokio::test] +#[ignore] +async fn reaction_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + // A live message the reactor could read, in an archived channel they belong to. + let archived = create_channel(&client, &author, "open").await; + add_member(&client, &author, &archived, &other).await; + let in_archive = post(&client, &author, 9, &archived).await; + let archive = sign( + &author, + 9002, + "", + vec![tag(&["h", &archived]), tag(&["archived", "true"])], + ); + submit_ok(&client, &author, &archive).await; + let react = |target: &str| sign(&other, 7, "+", vec![tag(&["e", target])]); + assert_uniform( + &client, + &other, + "invalid: reaction target event not found", + vec![ + ("missing", react(MISSING)), + ("soft-deleted", react(&hex(&t.deleted))), + ("private channel", react(&hex(&t.hidden))), + ("author-only", react(&hex(&t.reminder))), + ("archived channel", react(&hex(&in_archive))), + ], + ) + .await; + + // Positive controls: the author reacts to their reminder, and a member + // reacts in the open channel. + submit_ok( + &client, + &author, + &sign(&author, 7, "+", vec![tag(&["e", &hex(&t.reminder)])]), + ) + .await; + add_member(&client, &author, &t.open, &other).await; + submit_ok(&client, &other, &react(&hex(&t.live))).await; +} + +/// An edit of a target that is not the sender's, or not in the edit's +/// channel, or unreadable, is rejected exactly like a missing target. +#[tokio::test] +#[ignore] +async fn edit_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let own = create_channel(&client, &other, "open").await; + add_member(&client, &other, &own, &author).await; + let not_yours = post(&client, &author, 9, &own).await; + let edit = |target: &str| { + sign( + &other, + 40003, + "edited", + vec![tag(&["e", target]), tag(&["h", &own])], + ) + }; + assert_uniform( + &client, + &other, + "invalid: edit target not found or not editable by you", + vec![ + ("missing", edit(MISSING)), + ("soft-deleted", edit(&hex(&t.deleted))), + ("other channel", edit(&hex(&t.live))), + ("private channel", edit(&hex(&t.hidden))), + ("no channel / author-only", edit(&hex(&t.reminder))), + ("not yours", edit(&hex(¬_yours))), + ], + ) + .await; + + // Positive control: the sender edits their own message. + let mine = post(&client, &other, 9, &own).await; + submit_ok(&client, &other, &edit(&hex(&mine))).await; +} + +/// An author removed from a private channel cannot tell whether their old +/// message still exists: editing it is rejected exactly like editing a +/// missing or soft-deleted one. +#[tokio::test] +#[ignore] +async fn removed_author_edit_does_not_reveal_target() { + let client = http_client(); + let owner = Keys::generate(); + let removed = Keys::generate(); + let private = create_channel(&client, &owner, "private").await; + add_member(&client, &owner, &private, &removed).await; + let kept = post(&client, &removed, 9, &private).await; + let deleted = post(&client, &removed, 9, &private).await; + delete(&client, &removed, &deleted).await; + let remove = sign( + &owner, + 9001, + "", + vec![ + tag(&["h", &private]), + tag(&["p", &removed.public_key().to_hex()]), + ], + ); + submit_ok(&client, &owner, &remove).await; + let edit = |target: &str| { + sign( + &removed, + 40003, + "edited", + vec![tag(&["e", target]), tag(&["h", &private])], + ) + }; + assert_uniform( + &client, + &removed, + "invalid: edit target not found or not editable by you", + vec![ + ("missing", edit(MISSING)), + ("soft-deleted", edit(&hex(&deleted))), + ("existing, author removed", edit(&hex(&kept))), + ], + ) + .await; +} + +/// A vote's channel and readability check runs before its kind check, so an +/// unusable target never reveals its kind. +#[tokio::test] +#[ignore] +async fn forum_vote_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 45001).await; + let wrong_kind = post(&client, &author, 9, &t.open).await; + let own = create_channel(&client, &other, "open").await; + let vote = |target: &str| { + sign( + &other, + 45002, + "+", + vec![tag(&["e", target]), tag(&["h", &own])], + ) + }; + assert_uniform( + &client, + &other, + "invalid: vote target event not found", + vec![ + ("missing", vote(MISSING)), + ("soft-deleted", vote(&hex(&t.deleted))), + ("other channel", vote(&hex(&t.live))), + ("private channel", vote(&hex(&t.hidden))), + ("author-only", vote(&hex(&t.reminder))), + ("other channel, wrong kind", vote(&hex(&wrong_kind))), + ], + ) + .await; + + // Positive control: a vote on a forum post in the vote's channel. + let mine = post(&client, &other, 45001, &own).await; + submit_ok(&client, &other, &vote(&hex(&mine))).await; +} + +/// A report on an event the reporter cannot read is rejected exactly like a +/// report on a missing event. Such reports used to be queued. +#[tokio::test] +#[ignore] +async fn report_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let report = |target: &str| { + sign( + &other, + 1984, + "", + vec![ + tag(&["e", target, "spam"]), + tag(&["p", &author.public_key().to_hex()]), + ], + ) + }; + assert_uniform( + &client, + &other, + "invalid: report target event not found", + vec![ + ("missing", report(MISSING)), + ("soft-deleted", report(&hex(&t.deleted))), + ("private channel", report(&hex(&t.hidden))), + ("author-only", report(&hex(&t.reminder))), + ], + ) + .await; + + // Positive controls: a readable event, and a report by pubkey. + submit_ok(&client, &other, &report(&hex(&t.live))).await; + let by_pubkey = sign( + &other, + 1984, + "", + vec![tag(&["p", &author.public_key().to_hex(), "spam"])], + ); + submit_ok(&client, &other, &by_pubkey).await; +} + +/// A reply whose parent is outside the reply's channel or unreadable is +/// rejected exactly like a reply to a missing parent. +#[tokio::test] +#[ignore] +async fn reply_does_not_reveal_parent() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let own = create_channel(&client, &other, "open").await; + let reply = |parent: &str| { + sign( + &other, + 9, + "reply", + vec![tag(&["e", parent, "", "reply"]), tag(&["h", &own])], + ) + }; + let (_, expected, _) = rejection(&client, &other, reply(MISSING)).await; + assert!( + expected.contains("reply parent not found"), + "missing parent: {expected:?}" + ); + assert_uniform( + &client, + &other, + &expected, + vec![ + ("missing", reply(MISSING)), + ("soft-deleted", reply(&hex(&t.deleted))), + ("other channel", reply(&hex(&t.live))), + ("private channel", reply(&hex(&t.hidden))), + ("no channel / author-only", reply(&hex(&t.reminder))), + ], + ) + .await; + + // Positive control: a reply in the parent's channel. + let mine = post(&client, &other, 9, &own).await; + submit_ok(&client, &other, &reply(&hex(&mine))).await; +} + +fn artifact_revision( + author: &Keys, + artifact: &str, + channel: &str, + op: &str, + kind_type: &str, + prev: Option<&EventId>, +) -> nostr::Event { + let mut tags = vec![ + tag(&["ar", "1"]), + tag(&["d", artifact]), + tag(&["h", channel]), + tag(&["type", kind_type]), + tag(&["op", op]), + ]; + if op != "delete" { + tags.push(tag(&["title", "Task"])); + } + tags.extend(prev.map(|p| tag(&["prev", &p.to_hex()]))); + sign(author, 45010, "", tags) +} + +fn d_tag(event: &nostr::Event) -> String { + event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + (s.first().map(String::as_str) == Some("d")).then(|| s[1].clone()) + }) + .expect("d tag") +} + +/// Any artifact head outside a channel the sender can write answers +/// `head unavailable`, before the revision, type and deleted checks. +#[tokio::test] +#[ignore] +async fn artifact_revision_does_not_reveal_head() { + 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; + let own = create_channel(&client, &other, "open").await; + let create = |channel: &str| { + artifact_revision( + &author, + &uuid::Uuid::new_v4().to_string(), + channel, + "create", + "buzz.task", + None, + ) + }; + let live = create(&open); + submit_ok(&client, &author, &live).await; + let hidden = create(&private); + submit_ok(&client, &author, &hidden).await; + let gone = create(&open); + submit_ok(&client, &author, &gone).await; + let gone_delete = artifact_revision( + &author, + &d_tag(&gone), + &open, + "delete", + "buzz.task", + Some(&gone.id), + ); + submit_ok(&client, &author, &gone_delete).await; + + let rev = |head: &nostr::Event, channel: &str, op: &str, ty: &str, prev: &EventId| { + artifact_revision(&other, &d_tag(head), channel, op, ty, Some(prev)) + }; + let missing = artifact_revision( + &other, + &uuid::Uuid::new_v4().to_string(), + &own, + "update", + "buzz.task", + Some(&live.id), + ); + assert_uniform( + &client, + &other, + "conflict: artifact head unavailable", + vec![ + ("missing", missing), + ( + "other channel, current prev", + rev(&live, &own, "update", "buzz.task", &live.id), + ), + ( + "other channel, stale prev", + rev(&live, &own, "update", "buzz.task", &hidden.id), + ), + ( + "other channel, wrong type", + rev(&live, &own, "update", "buzz.note", &live.id), + ), + ( + "other channel, delete", + rev(&live, &own, "delete", "buzz.task", &live.id), + ), + ( + "private channel, update", + rev(&hidden, &own, "update", "buzz.task", &hidden.id), + ), + ( + "private channel, move", + rev(&hidden, &own, "move", "buzz.task", &hidden.id), + ), + ( + "deleted, restore", + rev(&gone, &own, "restore", "buzz.task", &gone_delete.id), + ), + ( + "deleted, update", + rev(&gone, &own, "update", "buzz.task", &gone_delete.id), + ), + ], + ) + .await; + + // Positive control: the sender revises an artifact in their channel. + let mine = artifact_revision( + &other, + &uuid::Uuid::new_v4().to_string(), + &own, + "create", + "buzz.task", + None, + ); + submit_ok(&client, &other, &mine).await; + submit_ok( + &client, + &other, + &rev(&mine, &own, "update", "buzz.task", &mine.id), + ) + .await; +} + +/// Triggering someone else's workflow is rejected exactly like triggering a +/// workflow that does not exist. +#[tokio::test] +#[ignore] +async fn workflow_trigger_does_not_reveal_workflow() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let channel = create_channel(&client, &author, "open").await; + add_member(&client, &author, &channel, &other).await; + let define = |id: &str| { + sign( + &author, + 30620, + "name: existence-oracle\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", id]), tag(&["h", &channel])], + ) + }; + let live = uuid::Uuid::new_v4().to_string(); + submit_ok(&client, &author, &define(&live)).await; + let deleted = uuid::Uuid::new_v4().to_string(); + submit_ok(&client, &author, &define(&deleted)).await; + let coordinate = format!("30620:{}:{deleted}", author.public_key().to_hex()); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![tag(&["a", &coordinate])]) + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, &deletion).await; + + let trigger = |keys: &Keys, id: &str| sign(keys, 46020, "{}", vec![tag(&["d", id])]); + assert_uniform( + &client, + &other, + "invalid: workflow not found or not triggerable by you", + vec![ + ( + "missing", + trigger(&other, &uuid::Uuid::new_v4().to_string()), + ), + ("soft-deleted", trigger(&other, &deleted)), + ("someone else's", trigger(&other, &live)), + ], + ) + .await; + + // Positive control: the owner triggers their workflow. + submit_ok(&client, &author, &trigger(&author, &live)).await; +} + +/// GET `path` as `reader` and return the HTTP status and parsed body. +async fn read_as(client: &Client, reader: &Keys, path: &str) -> (u16, Value) { + let resp = client + .get(format!("{}{path}", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .send() + .await + .expect("read workflow"); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("parse response")) +} + +/// Reading the runs or approvals of a workflow in a channel the caller cannot +/// read answers exactly like reading a workflow that does not exist. +#[tokio::test] +#[ignore] +async fn workflow_read_does_not_reveal_workflow() { + let client = http_client(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let channel = create_channel(&client, &owner, "private").await; + let live = uuid::Uuid::new_v4().to_string(); + let secret = save_webhook_workflow(&client, &owner, &channel, &live).await; + let run = start_webhook_run(&client, &live, &secret).await; + let missing = uuid::Uuid::new_v4().to_string(); + let paths = |id: &str| { + [ + format!("/workflows/{id}/runs"), + format!("/workflows/{id}/runs/{run}/approvals"), + ] + }; + + for (missing_path, live_path) in paths(&missing).iter().zip(paths(&live).iter()) { + let expected = read_as(&client, &outsider, missing_path).await; + assert_eq!(expected.0, 404, "{missing_path}: {:?}", expected.1); + assert_eq!( + read_as(&client, &outsider, live_path).await, + expected, + "{live_path} must answer like a missing workflow" + ); + } + + // Positive control: the owner reads their workflow's run and its approvals. + let [runs_path, approvals_path] = paths(&live); + let (status, body) = read_as(&client, &owner, &runs_path).await; + assert_eq!(status, 200, "owner runs read failed: {body}"); + assert!( + body["runs"] + .as_array() + .is_some_and(|runs| runs.iter().any(|r| r["id"] == run.as_str())), + "owner runs must include {run}: {body}" + ); + let (status, body) = read_as(&client, &owner, &approvals_path).await; + assert_eq!(status, 200, "owner approvals read failed: {body}"); + assert!(body["approvals"].is_array()); +} + +/// Save a webhook-triggered workflow `id` in `channel` and return its secret. +async fn save_webhook_workflow(client: &Client, owner: &Keys, channel: &str, id: &str) -> String { + let definition = sign( + owner, + 30620, + "name: existence-oracle\ntrigger:\n on: webhook\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", id]), tag(&["h", channel])], + ); + let (accepted, message) = submit(client, owner, &definition).await; + assert!(accepted, "webhook workflow rejected: {message}"); + let response: Value = serde_json::from_str( + message + .strip_prefix("response:") + .expect("workflow save response"), + ) + .expect("parse workflow save response"); + response["webhook_secret"] + .as_str() + .expect("webhook secret") + .to_string() +} + +/// Call webhook `id` with the right secret, assert it starts a run exactly as +/// documented, and return the run id. +async fn start_webhook_run(client: &Client, id: &str, secret: &str) -> String { + let (status, body) = call_webhook(client, id, Some(secret)).await; + assert_eq!(status, 202, "valid webhook call failed: {body}"); + body["run_id"] + .as_str() + .and_then(|run| uuid::Uuid::parse_str(run).ok()) + .unwrap_or_else(|| panic!("webhook response lacks a run_id: {body}")) + .to_string() +} + +/// POST the public webhook endpoint for `id` with an optional secret header and +/// return the HTTP status and parsed body. +async fn call_webhook(client: &Client, id: &str, secret: Option<&str>) -> (u16, Value) { + let mut request = client.post(format!("{}/hooks/{id}", relay_http_url())); + if let Some(secret) = secret { + request = request.header("x-webhook-secret", secret); + } + let resp = request.send().await.expect("call webhook"); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("parse response")) +} + +/// Until a caller proves the webhook secret, the unauthenticated webhook +/// endpoint answers every workflow exactly like a missing one, so it never +/// reveals that a workflow exists or what its trigger is. +#[tokio::test] +#[ignore] +async fn webhook_does_not_reveal_workflow() { + let client = http_client(); + let owner = Keys::generate(); + let channel = create_channel(&client, &owner, "open").await; + let webhook = uuid::Uuid::new_v4().to_string(); + let secret = save_webhook_workflow(&client, &owner, &channel, &webhook).await; + let other_trigger = uuid::Uuid::new_v4().to_string(); + let message_posted = sign( + &owner, + 30620, + "name: existence-oracle\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", &other_trigger]), tag(&["h", &channel])], + ); + submit_ok(&client, &owner, &message_posted).await; + + let missing = uuid::Uuid::new_v4().to_string(); + let expected = call_webhook(&client, &missing, Some(&secret)).await; + assert_eq!(expected.0, 404, "missing workflow: {:?}", expected.1); + for (case, id, provided) in [ + ( + "non-webhook workflow", + &other_trigger, + Some(secret.as_str()), + ), + ("absent secret", &webhook, None), + ("wrong secret", &webhook, Some("wrong-secret")), + ] { + assert_eq!( + call_webhook(&client, id, provided).await, + expected, + "{case} must answer like a missing workflow" + ); + } + + // Positive control: the right secret starts a run. + start_webhook_run(&client, &webhook, &secret).await; +} From 2f79374f4867035c33dace91c683440e7e011826 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 14 Oct 2026 17:25:46 -0400 Subject: [PATCH 026/146] feat(relay): channel labels (#8175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary A plugin or agent often needs to tag its own channels and then find them again, for example "my attention-config channel" or "all channels of this workspace". Today a channel has no labels: the relay ignores any `t` tags in create or edit requests. This PR adds channel labels. - A create request can carry up to 8 labels as `["t", "