diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 57ebe8ddc2..59ff41ee87 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -15,6 +15,7 @@ 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 5173c3cb42..ab807ea92b 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -25,6 +25,9 @@ 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( @@ -72,6 +75,14 @@ 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 32e025be18..dc72998ae7 100644 --- a/mobile/lib/shared/crypto/nip44.dart +++ b/mobile/lib/shared/crypto/nip44.dart @@ -88,6 +88,15 @@ 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 f8a7357f22..3ab39d3f4b 100644 --- a/mobile/lib/shared/push/dev_push_lease.dart +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -16,6 +16,51 @@ 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; @@ -331,18 +376,12 @@ Future publishBuzzDevPushLease({ throw const FormatException('Endpoint grant is already expired'); } - 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); + final plaintext = _activeLeasePlaintext( + descriptor: descriptor, + endpoint: grant.endpointGrant, + generation: effectiveLeaseGeneration, + subscriptions: subscriptions, + ); if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { throw const FormatException('lease plaintext exceeds the advertised limit'); } @@ -513,6 +552,9 @@ 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 788a1d3ab2..3b22c34ca3 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -12,6 +12,14 @@ 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 b9625884d1..0d592f5737 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -4,7 +4,9 @@ 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'; @@ -12,60 +14,149 @@ 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. -final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( +/// +/// Yields how many Solo chats the lease had no room for. +final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( ref, ) async { try { if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) { - return; + return 0; } final active = ref.watch(activeCommunityProvider).value; + final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; if (active == null || !active.pushNotificationsEnabled || - ref.watch(currentRelayPushDescriptorProvider).value == null) { - return; + descriptor == null) { + return 0; } final channels = ref.watch(channelsProvider).value; final mutes = ref.watch(channelMutesProvider); if (channels == null || !mutes.isReady) { - return; + return 0; } - final subscriptions = desiredBuzzPushSubscriptions( + // 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( 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 (subscriptions == null) return; + if (desired == null) return 0; await ref .read(communityListProvider.notifier) - .updateDesiredPushSubscriptions(active.id, subscriptions); + .updateDesiredPushSubscriptions(active.id, desired.subscriptions); + return desired.omittedSoloChats; } catch (error, stack) { reportPushSetupError('subscription synchronization', error, stack); rethrow; } }, retry: pushSetupRetry); -List? desiredBuzzPushSubscriptions({ +/// The desired lease, and how many Solo chats it had no room for. +({List subscriptions, int omittedSoloChats})? +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; - 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, + 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, ); } + +/// 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 5cc2f78b38..1d29e35d93 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -12,6 +12,7 @@ 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'; @@ -170,6 +171,8 @@ 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, @@ -191,6 +194,14 @@ 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 be4e7d87bc..ada5aca6c0 100644 --- a/mobile/test/shared/crypto/nip44_interop_test.dart +++ b/mobile/test/shared/crypto/nip44_interop_test.dart @@ -18,4 +18,22 @@ 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 f945f20660..e8bcb78e8f 100644 --- a/mobile/test/shared/push/push_root_isolation_test.dart +++ b/mobile/test/shared/push/push_root_isolation_test.dart @@ -423,8 +423,9 @@ const _descriptor = BuzzPushLeaseDescriptor( executorPubkey: 'pubkey', transport: 'apns', maxLeaseTtlSeconds: 3600, - maxContentLength: 4096, - maxPlaintextLength: 4096, + // The relay's NIP-11 limits, which fit a DM lease at the endpoint limit. + maxContentLength: 65536, + maxPlaintextLength: 32768, 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 d757106594..e709e7a772 100644 --- a/mobile/test/shared/push/push_subscription_provider_test.dart +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -1,8 +1,19 @@ +import 'dart:async'; + import 'package:buzz/features/channels/channel.dart'; -import 'package:buzz/shared/community/community.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/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() { @@ -15,6 +26,7 @@ void main() { () { final nsec = nostr.Keys.generate().nsec; final subscriptions = desiredBuzzPushSubscriptions( + descriptor: _descriptor(), community: Community.create( name: 'Team', relayUrl: 'https://relay.example.com', @@ -26,7 +38,7 @@ void main() { channel(nonMemberID, isMember: false), ], mutedChannelIds: const [activeID, archivedID], - ); + )?.subscriptions; expect(subscriptions, isNotNull); expect(subscriptions, hasLength(1)); @@ -37,8 +49,245 @@ 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', @@ -51,16 +300,130 @@ void main() { }); } -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, - ); +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: '', + ), +);