mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}$');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user