fix(mobile): push every message in your own Solo chats (#8615)

## Summary

Mobile push only covered Solo chats through explicit mentions. Solo
chats are private streams rather than DMs, and the push lease subscribed
channel-wide only to DMs. Agent replies in your own Solo chats therefore
never woke the phone.

The lease now subscribes channel-wide to the viewer's own joined,
unarchived Solo chats, using the same predicate as the Solo tab,
including older unlabelled Solo chats listed in the encrypted Solo
record. Self-authored messages stay excluded through the existing lease
ignores. Muted Solo chats stay out of the lease, like muted channels, so
they push only on mention. The lease waits while the Solo record loads
so an unresolved record never drops legacy Solo chats. If that record
fails with no cached copy, DM and mention updates proceed without the
legacy chats, which the Solo tab cannot list either, and they return
when it recovers. Solo chats created by someone else still push only on
mention, matching how they appear in the channel list.

A lease is bounded by its channel count (750), by the relay's advertised
plaintext and content sizes, and by NIP-44's 65535-byte plaintext
ceiling. DMs always stay, as before. Solo chats fill the remaining room
most recent first, sized with the publisher's own lease encoding, the
exact NIP-44 payload length and the largest endpoint the relay accepts.
Sizes are computed rather than built, so a relay's advertised limits
never drive an allocation. Solo chats beyond the room push only on
mention instead of failing the lease update or publication, and
Notifications settings says how many. A relay too small even for DMs and
mentions fails push setup visibly instead of saving a lease it would
reject. Publication accepts only endpoint grants in the gateway's
JSON-safe alphabet, so the sizing assumption holds.

Thread replies outside DMs still do not push unless they mention you.
That is a separate gap.

### Related issue

None found.

### Validation

- Added test: the desired lease includes joined DMs and the viewer's own
Solo chats, and includes legacy Solo chats from the Solo record, and
excludes archived and muted Solo chats, other people's Solo chats, and
unlabelled private streams. It fails without the fix.
- Added test: the sync provider holds the desired lease while the Solo
record loads, proceeds without legacy chats if the record fails, and
adds them once it arrives. It fails if loading is treated as empty or if
a failure blocks updates.
- Added tests: over the channel count, and over each size limit (relay
plaintext, relay content, NIP-44 plaintext), the lease keeps every DM
and exactly the newest Solo chats, reports the rest as omitted, and is
accepted by `publishBuzzDevPushLease` with a grant at the relay's
endpoint limit, while a max-length grant that JSON would escape is
rejected as not a gateway grant. A relay advertising a 2^50-byte
endpoint limit is sized without allocating. Each row fails if its guard
is removed.
- Added test: a relay whose content or plaintext limit cannot fit even
the lease without Solo chats rejects that lease at publication, and the
provider fails setup instead of saving it. It fails if the guard is
removed.
- Added test: the NIP-44 payload length matches real encryption at every
padding boundary up to 65535 bytes.
- Added test: Notifications settings shows "Older Solo chats notify only
on mention" with the omitted count. It fails without the row.
- Mobile lint and the full mobile test suite pass.
- Not exercised end to end on a device. A signed dev build was installed
on a test iPhone against a personal dev relay, but the phone's dev
identity owns no assistant there, so the device could not create a Solo
chat without exporting its key. This will be validated in dogfood. The
relay matcher already handles channel-wide `#h` leases for DMs, and Solo
messages use the same kind and scoping.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-10-28 16:44:59 -07:00
committed by GitHub
parent 4ff02bf03b
commit bad9d26142
10 changed files with 604 additions and 49 deletions

View File

@@ -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';

View File

@@ -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.',
),
],
);
}

View File

@@ -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) {

View File

@@ -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 `<key id>.<base64url>` 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<BuzzPushSubscription> 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<BuzzPushSubscription> subscriptions,
}) => jsonEncode(<String, dynamic>{
'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<BuzzPushLeasePublication> publishBuzzDevPushLease({
throw const FormatException('Endpoint grant is already expired');
}
final plaintextMap = <String, dynamic>{
'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');
}

View File

@@ -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}$');

View File

@@ -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<void>((
///
/// Yields how many Solo chats the lease had no room for.
final pushSubscriptionSyncProvider = FutureProvider.autoDispose<int>((
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<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
/// The desired lease, and how many Solo chats it had no room for.
({List<BuzzPushSubscription> subscriptions, int omittedSoloChats})?
desiredBuzzPushSubscriptions({
required Community community,
required BuzzPushLeaseDescriptor descriptor,
required Iterable<Channel> channels,
required Iterable<String> mutedChannelIds,
Iterable<String> 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<BuzzPushSubscription> 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<String> dms, List<String> solos, int omitted}) _channelWideCandidates({
required String pubkey,
required Iterable<Channel> channels,
required Set<String> muted,
required Iterable<String> 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);
}

View File

@@ -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<Switch>(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;
});

View File

@@ -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);
});
}

View File

@@ -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,
);

View File

@@ -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<FormatException>().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<MeGroups>();
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<String> 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 = <List<BuzzPushSubscription>>[];
@override
Future<List<Community>> build() async => const [];
@override
Future<void> updateDesiredPushSubscriptions(
String id,
List<BuzzPushSubscription> desired,
) async => this.desired.add(desired);
}
class _FixedChannels extends ChannelsNotifier {
_FixedChannels(this._channels);
final List<Channel> _channels;
@override
Future<List<Channel>> build() async => _channels;
}
class _ReadyMutes extends ChannelMutesNotifier {
@override
ChannelMutesState build() => const ChannelMutesState(isReady: true);
}
class _Authenticated extends AuthNotifier {
@override
Future<AuthState> 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<BuzzPushLeasePublication> _publish(
BuzzPushLeaseDescriptor descriptor,
nostr.Keys keys,
List<BuzzPushSubscription> 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: '',
),
);