From a94ebece0eb1a6880482538136c0db75d02a39d9 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 15:36:16 -0700 Subject: [PATCH] Revert "fix(mobile): push every message in your own Solo chats (#8615)" (#8690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts [#8615](https://github.com/block/buzz/pull/8615). Mobile push for Solo chats returns to mention-only, matching buzz-app, which alerts on every message only in DMs. This also removes the lease-size guards #8615 added, since they existed to fit Solo chats into the lease. ### Validation - Clean revert of bad9d26142 with no conflicts. - Mobile lint and the full mobile test suite pass. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Tom Brow --- .../lib/features/settings/settings_page.dart | 1 - .../settings_page/notifications_section.dart | 11 - mobile/lib/shared/crypto/nip44.dart | 9 - mobile/lib/shared/push/dev_push_lease.dart | 66 +-- mobile/lib/shared/push/push_subscription.dart | 8 - .../push/push_subscription_provider.dart | 131 +----- .../features/settings/settings_page_test.dart | 11 - .../shared/crypto/nip44_interop_test.dart | 18 - .../shared/push/push_root_isolation_test.dart | 5 +- .../push/push_subscription_provider_test.dart | 393 +----------------- 10 files changed, 49 insertions(+), 604 deletions(-) diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 70f5bc44e7..b1645926c7 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -16,7 +16,6 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/success_haptic.dart'; import '../../shared/push/push_relay_capability_provider.dart'; import '../../shared/push/push_bootstrap.dart'; -import '../../shared/push/push_subscription_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/utils/string_utils.dart'; import '../pairing/pairing_provider.dart'; diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index ab807ea92b..5173c3cb42 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -25,9 +25,6 @@ class _NotificationsSection extends ConsumerWidget { return const SizedBox.shrink(); } final canToggle = hasCapability || community.pushNotificationsEnabled; - final omittedSoloChats = community.pushNotificationsEnabled - ? ref.watch(pushSubscriptionSyncProvider).value ?? 0 - : 0; void setEnabled(bool enabled) { unawaited( @@ -75,14 +72,6 @@ class _NotificationsSection extends ConsumerWidget { ], ), ), - if (omittedSoloChats > 0) - AppListRow( - key: const ValueKey('push-solo-coverage'), - title: 'Older Solo chats notify only on mention', - subtitle: omittedSoloChats == 1 - ? '1 chat is past this relay’s notification limit.' - : '$omittedSoloChats chats are past this relay’s notification limit.', - ), ], ); } diff --git a/mobile/lib/shared/crypto/nip44.dart b/mobile/lib/shared/crypto/nip44.dart index dc72998ae7..32e025be18 100644 --- a/mobile/lib/shared/crypto/nip44.dart +++ b/mobile/lib/shared/crypto/nip44.dart @@ -88,15 +88,6 @@ String nip44Decrypt(Uint8List conversationKey, String payloadBase64) { return _unpad(padded); } -/// Length of the base64 payload [nip44Encrypt] returns for a plaintext of -/// [plaintextBytes] UTF-8 bytes, or null when NIP-44 cannot encrypt it. -int? nip44PayloadLength(int plaintextBytes) { - if (plaintextBytes < 1 || plaintextBytes > 65535) return null; - // version(1) || nonce(32) || length(2) || padded || mac(32) - final payload = 1 + 32 + 2 + _calcPaddedLen(plaintextBytes) + 32; - return 4 * ((payload + 2) ~/ 3); -} - // ── Padding ───────────────────────────────────────────────────────────────── int _calcPaddedLen(int unpaddedLen) { diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart index 04ff9e9894..449dae5a41 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -17,51 +17,6 @@ const _maxSafeJsonInteger = 9007199254740991; const _maxLeaseLifetimeSeconds = 2592000; const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; const _installationIdPattern = r'^[0-9a-f]{32}$'; -// The gateway's `.` grant grammar, which JSON never escapes. -const _endpointGrantPattern = r'^[A-Za-z0-9_.-]+$'; - -/// Whether an active lease carrying [subscriptions] fits [descriptor]'s -/// plaintext and content limits with any endpoint grant and generation it -/// accepts. Sizes are computed, not built, since the limits come from the relay. -bool buzzPushSubscriptionsFitLease( - BuzzPushLeaseDescriptor descriptor, - List subscriptions, -) { - final withoutEndpoint = utf8 - .encode( - _activeLeasePlaintext( - descriptor: descriptor, - endpoint: '', - generation: _maxSafeJsonInteger, - subscriptions: subscriptions, - ), - ) - .length; - // Publication accepts only grants JSON never escapes, so one at the endpoint - // limit adds exactly it. - final plaintext = withoutEndpoint + descriptor.maxEndpointLength; - final content = nip44PayloadLength(plaintext); - return plaintext <= descriptor.maxPlaintextLength && - content != null && - content <= descriptor.maxContentLength; -} - -String _activeLeasePlaintext({ - required BuzzPushLeaseDescriptor descriptor, - required String endpoint, - required int generation, - required List subscriptions, -}) => jsonEncode({ - 'v': 1, - 'origin': descriptor.origin, - 'transport': descriptor.transport, - 'endpoint': endpoint, - 'generation': generation, - 'active': true, - 'subscriptions': [ - for (final subscription in subscriptions) subscription.toJson(), - ], -}); class BuzzPushLeaseDescriptor { final String origin; @@ -377,12 +332,18 @@ Future publishBuzzDevPushLease({ throw const FormatException('Endpoint grant is already expired'); } - final plaintext = _activeLeasePlaintext( - descriptor: descriptor, - endpoint: grant.endpointGrant, - generation: effectiveLeaseGeneration, - subscriptions: subscriptions, - ); + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'transport': descriptor.transport, + 'endpoint': grant.endpointGrant, + 'generation': effectiveLeaseGeneration, + 'active': true, + 'subscriptions': [ + for (final subscription in subscriptions) subscription.toJson(), + ], + }; + final plaintext = jsonEncode(plaintextMap); if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { throw const FormatException('lease plaintext exceeds the advertised limit'); } @@ -553,9 +514,6 @@ void _validateGrant( 'Endpoint grant violates the advertised endpoint limit', ); } - if (!RegExp(_endpointGrantPattern).hasMatch(grant.endpointGrant)) { - throw const FormatException('Endpoint grant is not a gateway grant'); - } if (grant.endpointEpoch <= 0) { throw const FormatException('Endpoint grant epoch is invalid'); } diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 3b22c34ca3..788a1d3ab2 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -12,14 +12,6 @@ const buzzPushMaxSubscriptions = 16; const buzzPushMaxIgnoreFilters = 8; const buzzPushHellthreadParticipantLimit = 20; -/// Channels one lease can follow channel-wide after its self-directed -/// subscription, and muted channels its ignores can cover after the -/// self-authored ignore. -const buzzPushMaxChannelWideChannels = - (buzzPushMaxSubscriptions - 1) * buzzPushChannelChunkSize; -const buzzPushMaxMutedChannels = - (buzzPushMaxIgnoreFilters - 1) * buzzPushChannelChunkSize; - const _supportedNotificationClasses = {'default'}; const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'}; final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$'); diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart index 0d592f5737..b9625884d1 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -4,9 +4,7 @@ import '../../features/channels/channel.dart'; import '../../features/channels/channel_mutes/channel_mutes_provider.dart'; import '../../features/channels/channels_provider.dart'; import '../auth/auth.dart'; -import '../me/me_sync.dart'; import '../relay/relay_provider.dart'; -import 'dev_push_lease.dart'; import 'push_subscription.dart'; import 'push_relay_capability_provider.dart'; import 'push_setup_recovery.dart'; @@ -14,149 +12,60 @@ import 'push_setup_recovery.dart'; /// Keeps the persisted desired lease and the App Group snapshot aligned with /// the active identity, joined channels, and mute state. This is desired client /// policy only. Relay-accepted authority is introduced by lease publication. -/// -/// Yields how many Solo chats the lease had no room for. -final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( +final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( ref, ) async { try { if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) { - return 0; + return; } final active = ref.watch(activeCommunityProvider).value; - final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; if (active == null || !active.pushNotificationsEnabled || - descriptor == null) { - return 0; + ref.watch(currentRelayPushDescriptorProvider).value == null) { + return; } final channels = ref.watch(channelsProvider).value; final mutes = ref.watch(channelMutesProvider); if (channels == null || !mutes.isReady) { - return 0; + return; } - // Legacy Solo chats are listed only in the Solo record. Wait while it - // loads so an unresolved record never drops them. If it fails without a - // cached copy, keep DMs and mentions current; the Solo tab cannot list - // those chats either, and recovery republishes them. - final soloRecord = ref.watch(meGroupsProvider); - if (soloRecord.isLoading && !soloRecord.hasValue) return 0; - - final desired = desiredBuzzPushSubscriptions( + final subscriptions = desiredBuzzPushSubscriptions( community: active, - descriptor: descriptor, channels: channels, - legacySoloChannelIds: soloRecord.value?.channels ?? const [], mutedChannelIds: [ for (final entry in mutes.store.channels.entries) if (entry.value.muted) entry.key, ], ); - if (desired == null) return 0; + if (subscriptions == null) return; await ref .read(communityListProvider.notifier) - .updateDesiredPushSubscriptions(active.id, desired.subscriptions); - return desired.omittedSoloChats; + .updateDesiredPushSubscriptions(active.id, subscriptions); } catch (error, stack) { reportPushSetupError('subscription synchronization', error, stack); rethrow; } }, retry: pushSetupRetry); -/// The desired lease, and how many Solo chats it had no room for. -({List subscriptions, int omittedSoloChats})? -desiredBuzzPushSubscriptions({ +List? desiredBuzzPushSubscriptions({ required Community community, - required BuzzPushLeaseDescriptor descriptor, required Iterable channels, required Iterable mutedChannelIds, - Iterable legacySoloChannelIds = const [], }) { final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec); if (pubkey == null || pubkey.isEmpty) return null; - final muted = {for (final id in mutedChannelIds) id.toLowerCase()}; - final candidates = _channelWideCandidates( - pubkey: pubkey, - channels: channels, - muted: muted, - legacySoloChannelIds: legacySoloChannelIds, - ); - List lease(int soloCount) => - buildDesiredBuzzPushSubscriptions( - myPubkey: pubkey, - channelIds: [...candidates.dms, ...candidates.solos.take(soloCount)], - mutedChannelIds: mutedChannelIds, - ); - - // Each Solo chat only adds bytes, so keep the most recent ones that fit. - var fitting = candidates.solos.length; - if (!buzzPushSubscriptionsFitLease(descriptor, lease(fitting))) { - // Without Solo chats only DMs and mentions remain. A relay too small for - // those cannot carry push, so fail setup rather than save a lease that - // publication would reject. - if (!buzzPushSubscriptionsFitLease(descriptor, lease(0))) { - throw const FormatException( - 'The relay push limits are too small for a lease.', - ); - } - var low = 0; - var high = fitting - 1; - while (low < high) { - final mid = (low + high + 1) ~/ 2; - if (buzzPushSubscriptionsFitLease(descriptor, lease(mid))) { - low = mid; - } else { - high = mid - 1; - } - } - fitting = low; - } - return ( - subscriptions: lease(fitting), - omittedSoloChats: candidates.omitted + candidates.solos.length - fitting, + return buildDesiredBuzzPushSubscriptions( + myPubkey: pubkey, + channelIds: [ + // Activity surfaces channel-wide traffic only for joined DM channels. + // Other message kinds enter the inbox through an exact #p mention or + // participant-thread tag, so subscribing to every joined channel would + // over-notify compared with the product predicate. + for (final channel in channels) + if (channel.isDm && channel.isMember && !channel.isArchived) channel.id, + ], + mutedChannelIds: mutedChannelIds, ); } - -/// Channel-wide traffic pushes only for joined DMs and the viewer's own Solo -/// chats, which are conversations with them. Other channels push through an -/// exact #p mention, so subscribing to every joined channel would over-notify. -/// -/// DMs always stay, as before. Solo chats follow most recent first, within the -/// lease's channel counts; the rest push only on mention. -({List dms, List solos, int omitted}) _channelWideCandidates({ - required String pubkey, - required Iterable channels, - required Set muted, - required Iterable legacySoloChannelIds, -}) { - bool isMuted(String id) => muted.contains(id.toLowerCase()); - final joined = [ - for (final channel in channels) - if (channel.isMember && !channel.isArchived) channel, - ]; - final dms = [ - for (final channel in joined) - if (channel.isDm) channel.id, - ]; - // Muted Solo chats stay out, like muted channels, so they push only on - // mention. - final ordered = [ - for (final channel in joined) - if (!channel.isDm && - !isMuted(channel.id) && - channel.isPersonalChat(pubkey, legacySoloChannelIds)) - channel, - ]..sort(_mostRecentFirst); - final room = - buzzPushMaxChannelWideChannels - dms.where((id) => !isMuted(id)).length; - final solos = [for (final solo in ordered.take(room < 0 ? 0 : room)) solo.id]; - return (dms: dms, solos: solos, omitted: ordered.length - solos.length); -} - -int _mostRecentFirst(Channel a, Channel b) { - final byRecency = (b.lastMessageAt ?? b.createdAt).compareTo( - a.lastMessageAt ?? a.createdAt, - ); - return byRecency != 0 ? byRecency : a.id.compareTo(b.id); -} diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 1d29e35d93..5cc2f78b38 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -12,7 +12,6 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/push/dev_push_lease.dart'; import 'package:buzz/shared/push/push_relay_capability_provider.dart'; -import 'package:buzz/shared/push/push_subscription_provider.dart'; import 'package:buzz/shared/relay/app_lifecycle_provider.dart'; import 'package:buzz/shared/widgets/app_list.dart'; import 'package:buzz/shared/widgets/app_list_card.dart'; @@ -171,8 +170,6 @@ void main() { savedPrefsProvider.overrideWithValue(prefs), activeCommunityProvider.overrideWith((ref) async => community), currentRelayPushDescriptorProvider.overrideWith((ref) => capability), - // The lease had no room for 12 older Solo chats. - pushSubscriptionSyncProvider.overrideWith((ref) async => 12), appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), buzzPushAuthorizationStatusReaderProvider.overrideWithValue( () async => BuzzPushAuthorizationStatus.authorized, @@ -194,14 +191,6 @@ void main() { findsOneWidget, ); expect(tester.widget(find.byType(Switch)).value, isTrue); - expect( - find.text('Older Solo chats notify only on mention'), - findsOneWidget, - ); - expect( - find.text('12 chats are past this relay’s notification limit.'), - findsOneWidget, - ); debugDefaultTargetPlatformOverride = null; }); diff --git a/mobile/test/shared/crypto/nip44_interop_test.dart b/mobile/test/shared/crypto/nip44_interop_test.dart index ada5aca6c0..be4e7d87bc 100644 --- a/mobile/test/shared/crypto/nip44_interop_test.dart +++ b/mobile/test/shared/crypto/nip44_interop_test.dart @@ -18,22 +18,4 @@ void main() { '{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}', ); }); - - test('predicts the encrypted payload length at every padding boundary', () { - final key = getConversationKey( - '0000000000000000000000000000000000000000000000000000000000000001', - '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', - ); - for (final length in [ - 1, 32, 33, 256, 257, 4096, 4097, 32768, 32769, 65535, // - ]) { - expect( - nip44PayloadLength(length), - nip44Encrypt(key, 'x' * length).length, - reason: '$length bytes', - ); - } - expect(nip44PayloadLength(0), isNull); - expect(nip44PayloadLength(65536), isNull); - }); } diff --git a/mobile/test/shared/push/push_root_isolation_test.dart b/mobile/test/shared/push/push_root_isolation_test.dart index e8bcb78e8f..f945f20660 100644 --- a/mobile/test/shared/push/push_root_isolation_test.dart +++ b/mobile/test/shared/push/push_root_isolation_test.dart @@ -423,9 +423,8 @@ const _descriptor = BuzzPushLeaseDescriptor( executorPubkey: 'pubkey', transport: 'apns', maxLeaseTtlSeconds: 3600, - // The relay's NIP-11 limits, which fit a DM lease at the endpoint limit. - maxContentLength: 65536, - maxPlaintextLength: 32768, + maxContentLength: 4096, + maxPlaintextLength: 4096, maxEndpointLength: 2048, maxStringLength: 512, ); diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart index e709e7a772..d757106594 100644 --- a/mobile/test/shared/push/push_subscription_provider_test.dart +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -1,19 +1,8 @@ -import 'dart:async'; - import 'package:buzz/features/channels/channel.dart'; -import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; -import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/shared/auth/auth.dart'; -import 'package:buzz/shared/me/me_groups.dart'; -import 'package:buzz/shared/me/me_sync.dart'; -import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/push/push_subscription.dart'; -import 'package:buzz/shared/relay/nostr_models.dart'; -import 'package:buzz/shared/push/dev_push_lease.dart'; -import 'package:buzz/shared/push/push_relay_capability_provider.dart'; import 'package:buzz/shared/push/push_subscription_provider.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; void main() { @@ -26,7 +15,6 @@ void main() { () { final nsec = nostr.Keys.generate().nsec; final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _descriptor(), community: Community.create( name: 'Team', relayUrl: 'https://relay.example.com', @@ -38,7 +26,7 @@ void main() { channel(nonMemberID, isMember: false), ], mutedChannelIds: const [activeID, archivedID], - )?.subscriptions; + ); expect(subscriptions, isNotNull); expect(subscriptions, hasLength(1)); @@ -49,245 +37,8 @@ void main() { }, ); - test('subscribes channel-wide to joined DMs and own Solo chats only', () { - final keys = nostr.Keys.generate(); - const dmID = '123e4567-e89b-42d3-a456-426614174003'; - const soloID = '123e4567-e89b-42d3-a456-426614174004'; - const archivedSoloID = '123e4567-e89b-42d3-a456-426614174005'; - const otherSoloID = '123e4567-e89b-42d3-a456-426614174006'; - const streamID = '123e4567-e89b-42d3-a456-426614174007'; - const mutedSoloID = '123e4567-e89b-42d3-a456-426614174008'; - const legacySoloID = '123e4567-e89b-42d3-a456-426614174009'; - Channel solo(String id, {String? createdBy, bool archived = false}) => - channel( - id, - channelType: 'stream', - visibility: 'private', - labels: const ['buzz.solo'], - createdBy: createdBy ?? keys.public, - archived: archived, - ); - final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _descriptor(), - community: Community.create( - name: 'Team', - relayUrl: 'https://relay.example.com', - nsec: keys.nsec, - ), - channels: [ - channel(dmID), - solo(soloID), - solo(archivedSoloID, archived: true), - solo(otherSoloID, createdBy: 'author'), - channel(streamID, channelType: 'stream', visibility: 'private'), - solo(mutedSoloID), - channel(legacySoloID, channelType: 'stream', visibility: 'private'), - ], - legacySoloChannelIds: const [legacySoloID], - mutedChannelIds: const [mutedSoloID], - )?.subscriptions; - - // A muted Solo chat is left out, like a muted channel, so only a - // mention pushes it. - expect(subscriptions, hasLength(2)); - expect(subscriptions!.first.ignore, hasLength(1)); - expect(subscriptions.last.filter.kinds, buzzPushChannelKinds); - expect(subscriptions.last.filter.hTags, [dmID, soloID, legacySoloID]); - }); - - test('fills the lease channel count with the most recent Solo chats', () { - final keys = nostr.Keys.generate(); - const dmCount = 10; - const soloCount = buzzPushMaxChannelWideChannels; - final channels = [ - for (var n = 0; n < dmCount; n++) channel(_id(n)), - for (var n = dmCount; n < dmCount + soloCount; n++) _solo(n, keys.public), - ]; - - final desired = desiredBuzzPushSubscriptions( - descriptor: _descriptor(), - community: _community(keys), - channels: channels, - mutedChannelIds: const [], - )!; - - final followed = { - for (final subscription in desired.subscriptions.skip(1)) - ...?subscription.filter.hTags, - }; - expect(desired.subscriptions, hasLength(buzzPushMaxSubscriptions)); - expect(followed, hasLength(buzzPushMaxChannelWideChannels)); - expect(followed, containsAll([for (var n = 0; n < dmCount; n++) _id(n)])); - expect(followed, contains(_id(channels.length - 1))); - expect(followed, isNot(contains(_id(dmCount + dmCount - 1)))); - expect(desired.omittedSoloChats, dmCount); - - // A relay may advertise any endpoint limit; sizing must not allocate it. - expect( - () => desiredBuzzPushSubscriptions( - descriptor: _descriptor(endpointLength: 1 << 50), - community: _community(keys), - channels: channels, - mutedChannelIds: const [], - ), - throwsFormatException, - ); - }); - - for (final (limit, descriptor, soloCount) in [ - ( - 'plaintext', - _descriptor(contentLength: 65536, plaintextLength: 16384), - 400, - ), - ('content', _descriptor(contentLength: 24000, plaintextLength: 32768), 400), - ('NIP-44 plaintext', _descriptor(endpointLength: 40000), 700), - ]) { - test('keeps the newest Solo chats within the $limit limit', () async { - final keys = nostr.Keys.generate(); - const dmCount = 10; - final channels = [ - for (var n = 0; n < dmCount; n++) channel(_id(n)), - for (var n = dmCount; n < dmCount + soloCount; n++) - _solo(n, keys.public), - ]; - final desired = desiredBuzzPushSubscriptions( - community: _community(keys), - descriptor: descriptor, - channels: channels, - mutedChannelIds: const [], - )!; - - final kept = { - for (final subscription in desired.subscriptions.skip(1)) - ...?subscription.filter.hTags, - }; - final keptSolos = soloCount - desired.omittedSoloChats; - expect(keptSolos, inInclusiveRange(1, soloCount - 1)); - expect(kept, { - for (var n = 0; n < dmCount; n++) _id(n), - for (var n = channels.length - keptSolos; n < channels.length; n++) - _id(n), - }); - // The relay accepts any grant up to its endpoint limit. - final publication = await _publish( - descriptor, - keys, - desired.subscriptions, - ); - expect(publication.eventId, 'accepted-id'); - // A grant JSON must escape would outgrow the fit, so it never publishes. - await expectLater( - _publish( - descriptor, - keys, - desired.subscriptions, - endpointGrant: '"' * descriptor.maxEndpointLength, - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - 'Endpoint grant is not a gateway grant', - ), - ), - ); - }); - } - - for (final (limit, descriptor) in [ - ('content', _descriptor(contentLength: 100)), - ('plaintext', _descriptor(plaintextLength: 100)), - ]) { - test( - 'fails setup when the $limit limit cannot fit DMs and mentions', - () async { - final keys = nostr.Keys.generate(); - final channels = [channel(_id(0)), _solo(1, keys.public)]; - - // Publication rejects even the lease without Solo chats. - await expectLater( - _publish( - descriptor, - keys, - buildDesiredBuzzPushSubscriptions( - myPubkey: keys.public, - channelIds: [_id(0)], - ), - ), - throwsFormatException, - ); - expect( - () => desiredBuzzPushSubscriptions( - community: _community(keys), - descriptor: descriptor, - channels: channels, - mutedChannelIds: const [], - ), - throwsFormatException, - ); - }, - ); - } - - test('waits for the Solo record but not past its failure', () async { - const legacySoloID = '123e4567-e89b-42d3-a456-426614174009'; - final community = Community.create( - name: 'Team', - relayUrl: 'https://relay.example.com', - nsec: nostr.Keys.generate().nsec, - ).copyWith(pushNotificationsEnabled: true); - final communities = _RecordingCommunityList(); - final soloRecord = StreamController(); - addTearDown(soloRecord.close); - final container = ProviderContainer( - retry: (_, _) => null, - overrides: [ - authProvider.overrideWith(_Authenticated.new), - currentRelayPushDescriptorProvider.overrideWith( - (ref) async => _descriptor(), - ), - activeCommunityProvider.overrideWith((ref) async => community), - communityListProvider.overrideWith(() => communities), - channelsProvider.overrideWith( - () => _FixedChannels([ - channel(activeID), - channel(legacySoloID, channelType: 'stream', visibility: 'private'), - ]), - ), - channelMutesProvider.overrideWith(_ReadyMutes.new), - meGroupsProvider.overrideWith((ref) => soloRecord.stream), - ], - ); - addTearDown(container.dispose); - container.listen(pushSubscriptionSyncProvider, (_, _) {}); - await container.read(authProvider.future); - await container.read(currentRelayPushDescriptorProvider.future); - await container.read(activeCommunityProvider.future); - await container.read(channelsProvider.future); - await pumpEventQueue(); - - expect(communities.desired, isEmpty); - - soloRecord.addError(StateError('Solo sync failed')); - await pumpEventQueue(); - - expect(communities.desired, hasLength(1)); - expect(communities.desired.last.last.filter.hTags, [activeID]); - - soloRecord.add(const MeGroups(channels: [legacySoloID])); - await pumpEventQueue(); - - expect(communities.desired, hasLength(2)); - expect(communities.desired.last.last.filter.hTags, [ - activeID, - legacySoloID, - ]); - }); - test('returns no desired subscriptions without a signing identity', () { final subscriptions = desiredBuzzPushSubscriptions( - descriptor: _descriptor(), community: Community.create( name: 'Team', relayUrl: 'https://relay.example.com', @@ -300,130 +51,16 @@ void main() { }); } -Channel channel( - String id, { - bool isMember = true, - bool archived = false, - String channelType = 'dm', - String visibility = 'open', - List labels = const [], - String createdBy = 'author', - DateTime? lastMessageAt, -}) => Channel( - id: id, - name: id, - channelType: channelType, - visibility: visibility, - labels: labels, - description: '', - createdBy: createdBy, - createdAt: DateTime(2026), - memberCount: 1, - isMember: isMember, - archivedAt: archived ? DateTime(2026) : null, - lastMessageAt: lastMessageAt, -); - -class _RecordingCommunityList extends CommunityListNotifier { - final desired = >[]; - - @override - Future> build() async => const []; - - @override - Future updateDesiredPushSubscriptions( - String id, - List desired, - ) async => this.desired.add(desired); -} - -class _FixedChannels extends ChannelsNotifier { - _FixedChannels(this._channels); - - final List _channels; - - @override - Future> build() async => _channels; -} - -class _ReadyMutes extends ChannelMutesNotifier { - @override - ChannelMutesState build() => const ChannelMutesState(isReady: true); -} - -class _Authenticated extends AuthNotifier { - @override - Future build() async => - const AuthState(status: AuthStatus.authenticated); -} - -final _relayKeys = nostr.Keys.generate(); - -String _id(int n) => - '123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}'; - -Channel _solo(int n, String owner) => channel( - _id(n), - channelType: 'stream', - visibility: 'private', - labels: const ['buzz.solo'], - createdBy: owner, - lastMessageAt: DateTime(2026).add(Duration(minutes: n)), -); - -Community _community(nostr.Keys keys) => Community.create( - name: 'Team', - relayUrl: 'https://relay.example.com', - nsec: keys.nsec, -); - -/// Defaults to no limit beyond NIP-44's own. The relay advertises 65536 and -/// 32768 in NIP-11 (`crates/buzz-relay/src/nip11.rs`). -BuzzPushLeaseDescriptor _descriptor({ - int contentLength = 1 << 22, - int plaintextLength = 1 << 21, - int endpointLength = 4096, -}) => BuzzPushLeaseDescriptor( - origin: 'wss://relay.example', - executorKeyId: 'key', - executorPubkey: _relayKeys.public, - transport: 'apns', - maxLeaseTtlSeconds: 3600, - maxContentLength: contentLength, - maxPlaintextLength: plaintextLength, - maxEndpointLength: endpointLength, - maxStringLength: 512, -); - -Future _publish( - BuzzPushLeaseDescriptor descriptor, - nostr.Keys keys, - List subscriptions, { - String? endpointGrant, -}) => publishBuzzDevPushLease( - grant: BuzzPushEndpointGrant( - relayOrigin: descriptor.origin, - relayPubkey: descriptor.executorPubkey, - installationId: 'c' * 32, - endpointGrant: endpointGrant ?? 'x' * descriptor.maxEndpointLength, - endpointHash: 'd' * 64, - endpointEpoch: 1, - generation: 9007199254740991, - expiresAt: 1756212000, - ), - descriptor: descriptor, - nsec: keys.nsec, - memberPubkey: keys.public, - subscriptions: subscriptions, - now: () => DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000), - submit: ({required kind, required content, required tags, createdAt}) async => - const NostrEvent( - id: 'accepted-id', - pubkey: '', - createdAt: 0, - kind: 0, - tags: [], - content: '', - sig: '', - ), -); +Channel channel(String id, {bool isMember = true, bool archived = false}) => + Channel( + id: id, + name: id, + channelType: 'dm', + visibility: 'open', + description: '', + createdBy: 'author', + createdAt: DateTime(2026), + memberCount: 1, + isMember: isMember, + archivedAt: archived ? DateTime(2026) : null, + );