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

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 <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-10-29 15:36:16 -07:00
committed by GitHub
parent 4b21a10c4b
commit a94ebece0e
10 changed files with 49 additions and 604 deletions

View File

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

View File

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

View File

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

View File

@@ -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 `<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;
@@ -377,12 +332,18 @@ Future<BuzzPushLeasePublication> publishBuzzDevPushLease({
throw const FormatException('Endpoint grant is already expired');
}
final plaintext = _activeLeasePlaintext(
descriptor: descriptor,
endpoint: grant.endpointGrant,
generation: effectiveLeaseGeneration,
subscriptions: subscriptions,
);
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);
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');
}

View File

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

View File

@@ -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<int>((
final pushSubscriptionSyncProvider = FutureProvider.autoDispose<void>((
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<BuzzPushSubscription> subscriptions, int omittedSoloChats})?
desiredBuzzPushSubscriptions({
List<BuzzPushSubscription>? 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;
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,
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<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,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<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,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);
});
}

View File

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

View File

@@ -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<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',
@@ -300,130 +51,16 @@ void main() {
});
}
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: '',
),
);
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,
);