mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
feat(mobile): carry desired lease subscriptions in push snapshots
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
This commit is contained in:
parent
8912fdc5ac
commit
4e98d17817
@@ -13,6 +13,7 @@ import 'features/profile/user_status_cache_provider.dart';
|
||||
import 'shared/auth/auth.dart';
|
||||
import 'shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'shared/relay/relay.dart';
|
||||
import 'shared/push/push_subscription_provider.dart';
|
||||
import 'shared/theme/theme.dart';
|
||||
|
||||
class App extends HookConsumerWidget {
|
||||
@@ -39,6 +40,7 @@ class App extends HookConsumerWidget {
|
||||
ref.watch(observerRelayProvider);
|
||||
ref.watch(appLifecycleProvider);
|
||||
ref.watch(userStatusCacheProvider);
|
||||
ref.watch(pushSubscriptionSyncProvider);
|
||||
}
|
||||
|
||||
// Start listening for buzz:// links immediately (even pre-auth) so a
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../push/push_subscription.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
const _sentinel = Object();
|
||||
|
||||
@@ -9,6 +11,7 @@ class Community {
|
||||
final String relayUrl;
|
||||
final String? pubkey;
|
||||
final String? nsec;
|
||||
final BuzzPushLeaseSubscriptionState pushSubscriptionState;
|
||||
final DateTime addedAt;
|
||||
|
||||
const Community({
|
||||
@@ -17,6 +20,7 @@ class Community {
|
||||
required this.relayUrl,
|
||||
this.pubkey,
|
||||
this.nsec,
|
||||
this.pushSubscriptionState = const BuzzPushLeaseSubscriptionState.desired(),
|
||||
required this.addedAt,
|
||||
});
|
||||
|
||||
@@ -41,6 +45,7 @@ class Community {
|
||||
String? relayUrl,
|
||||
Object? pubkey = _sentinel,
|
||||
Object? nsec = _sentinel,
|
||||
BuzzPushLeaseSubscriptionState? pushSubscriptionState,
|
||||
}) {
|
||||
return Community(
|
||||
id: id,
|
||||
@@ -48,6 +53,8 @@ class Community {
|
||||
relayUrl: relayUrl ?? this.relayUrl,
|
||||
pubkey: pubkey == _sentinel ? this.pubkey : pubkey as String?,
|
||||
nsec: nsec == _sentinel ? this.nsec : nsec as String?,
|
||||
pushSubscriptionState:
|
||||
pushSubscriptionState ?? this.pushSubscriptionState,
|
||||
addedAt: addedAt,
|
||||
);
|
||||
}
|
||||
@@ -58,6 +65,7 @@ class Community {
|
||||
'relayUrl': relayUrl,
|
||||
if (pubkey != null) 'pubkey': pubkey,
|
||||
if (nsec != null) 'nsec': nsec,
|
||||
'pushSubscriptionState': pushSubscriptionState.toJson(),
|
||||
'addedAt': addedAt.toIso8601String(),
|
||||
};
|
||||
|
||||
@@ -67,6 +75,11 @@ class Community {
|
||||
relayUrl: json['relayUrl'] as String,
|
||||
pubkey: json['pubkey'] as String?,
|
||||
nsec: json['nsec'] as String?,
|
||||
pushSubscriptionState: json['pushSubscriptionState'] == null
|
||||
? const BuzzPushLeaseSubscriptionState.desired()
|
||||
: BuzzPushLeaseSubscriptionState.fromJson(
|
||||
Map<String, dynamic>.from(json['pushSubscriptionState'] as Map),
|
||||
),
|
||||
addedAt: DateTime.parse(json['addedAt'] as String),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../auth/auth_provider.dart';
|
||||
import '../push/push_bridge.dart';
|
||||
import '../push/push_subscription.dart';
|
||||
import 'community.dart';
|
||||
import 'community_storage.dart';
|
||||
|
||||
@@ -40,6 +41,9 @@ class _CommunitySnapshotSync {
|
||||
community.relayUrl,
|
||||
community.pubkey,
|
||||
community.nsec,
|
||||
buzzPushSubscriptionStateFingerprint(
|
||||
community.pushSubscriptionState,
|
||||
),
|
||||
].join('\u0000'),
|
||||
)
|
||||
.join('\u0001');
|
||||
@@ -141,6 +145,33 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
|
||||
ref.invalidate(authProvider);
|
||||
}
|
||||
|
||||
Future<void> updateDesiredPushSubscriptions(
|
||||
String id,
|
||||
List<BuzzPushSubscription> desired,
|
||||
) async {
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
final current = state.value ?? await storage.loadAll();
|
||||
final index = current.indexWhere((community) => community.id == id);
|
||||
if (index < 0) return;
|
||||
|
||||
final community = current[index];
|
||||
if (buzzPushSubscriptionsFingerprint(
|
||||
community.pushSubscriptionState.desired,
|
||||
) ==
|
||||
buzzPushSubscriptionsFingerprint(desired)) {
|
||||
return;
|
||||
}
|
||||
final updated = community.copyWith(
|
||||
pushSubscriptionState: community.pushSubscriptionState.withDesired(
|
||||
desired,
|
||||
),
|
||||
);
|
||||
await storage.save(updated);
|
||||
final updatedList = [...current]..[index] = updated;
|
||||
state = AsyncData(updatedList);
|
||||
await syncCommunitySnapshot(ref, updatedList);
|
||||
}
|
||||
|
||||
Future<void> renameCommunity(String id, String name) async {
|
||||
final storage = ref.read(communityStorageProvider);
|
||||
final current = state.value ?? [];
|
||||
|
||||
@@ -37,6 +37,7 @@ Future<void> registerBuzzPushCommunitySnapshot(
|
||||
name: community.name,
|
||||
relayUrl: community.relayUrl,
|
||||
pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec),
|
||||
pushSubscriptionState: community.pushSubscriptionState,
|
||||
),
|
||||
];
|
||||
final signingKeys = <String, String>{};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../relay/nostr_models.dart';
|
||||
import 'push_subscription.dart';
|
||||
|
||||
const buzzPushFallbackBody = 'Reconnect to your relay now';
|
||||
|
||||
@@ -7,12 +8,14 @@ class BuzzPushCommunitySnapshot {
|
||||
final String name;
|
||||
final String relayUrl;
|
||||
final String? pubkey;
|
||||
final BuzzPushLeaseSubscriptionState pushSubscriptionState;
|
||||
|
||||
const BuzzPushCommunitySnapshot({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.relayUrl,
|
||||
this.pubkey,
|
||||
required this.pushSubscriptionState,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -20,6 +23,7 @@ class BuzzPushCommunitySnapshot {
|
||||
'name': name,
|
||||
'relayUrl': relayUrl,
|
||||
if (pubkey != null) 'pubkey': pubkey,
|
||||
'pushSubscriptionState': pushSubscriptionState.toJson(),
|
||||
};
|
||||
|
||||
factory BuzzPushCommunitySnapshot.fromJson(Map<String, dynamic> json) {
|
||||
@@ -28,6 +32,9 @@ class BuzzPushCommunitySnapshot {
|
||||
name: json['name'] as String,
|
||||
relayUrl: json['relayUrl'] as String,
|
||||
pubkey: json['pubkey'] as String?,
|
||||
pushSubscriptionState: BuzzPushLeaseSubscriptionState.fromJson(
|
||||
Map<String, dynamic>.from(json['pushSubscriptionState'] as Map),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
391
mobile/lib/shared/push/push_subscription.dart
Normal file
391
mobile/lib/shared/push/push_subscription.dart
Normal file
@@ -0,0 +1,391 @@
|
||||
import 'dart:convert';
|
||||
|
||||
const buzzPushEligibleKinds = [7, 9, 1059, 40007, 46010];
|
||||
const buzzPushRenderableKinds = [9];
|
||||
const buzzPushChannelKinds = [9];
|
||||
const buzzPushChannelChunkSize = 50;
|
||||
const buzzPushMaxSubscriptions = 16;
|
||||
const buzzPushMaxIgnoreFilters = 8;
|
||||
const buzzPushHellthreadParticipantLimit = 20;
|
||||
|
||||
const _supportedNotificationClasses = {'silent', 'default', 'time_sensitive'};
|
||||
const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'};
|
||||
final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$');
|
||||
final _channelIdPattern = RegExp(
|
||||
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
||||
);
|
||||
|
||||
enum BuzzPushLeaseSubscriptionAuthority { desired, accepted }
|
||||
|
||||
class BuzzPushFilter {
|
||||
final List<int> kinds;
|
||||
final List<String>? authors;
|
||||
final List<String>? pTags;
|
||||
final List<String>? hTags;
|
||||
final List<String>? eTags;
|
||||
|
||||
BuzzPushFilter({
|
||||
required Iterable<int> kinds,
|
||||
Iterable<String>? authors,
|
||||
Iterable<String>? pTags,
|
||||
Iterable<String>? hTags,
|
||||
Iterable<String>? eTags,
|
||||
}) : kinds = List.unmodifiable(kinds),
|
||||
authors = _optionalList(authors),
|
||||
pTags = _optionalList(pTags),
|
||||
hTags = _optionalList(hTags),
|
||||
eTags = _optionalList(eTags) {
|
||||
_validate();
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'kinds': kinds,
|
||||
if (authors != null) 'authors': authors,
|
||||
if (pTags != null) '#p': pTags,
|
||||
if (hTags != null) '#h': hTags,
|
||||
if (eTags != null) '#e': eTags,
|
||||
};
|
||||
|
||||
factory BuzzPushFilter.fromJson(Map<String, dynamic> json) {
|
||||
_rejectUnknownKeys(json, _filterKeys, 'push filter');
|
||||
return BuzzPushFilter(
|
||||
kinds: _intList(json, 'kinds'),
|
||||
authors: _optionalStringList(json, 'authors'),
|
||||
pTags: _optionalStringList(json, '#p'),
|
||||
hTags: _optionalStringList(json, '#h'),
|
||||
eTags: _optionalStringList(json, '#e'),
|
||||
);
|
||||
}
|
||||
|
||||
void _validate() {
|
||||
if (kinds.isEmpty ||
|
||||
kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) {
|
||||
throw const FormatException('Push filter contains invalid kinds.');
|
||||
}
|
||||
for (final value in [...?authors, ...?pTags, ...?eTags]) {
|
||||
if (!_exactHexPattern.hasMatch(value)) {
|
||||
throw const FormatException(
|
||||
'Push filter contains a non-exact hex value.',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final value in hTags ?? const <String>[]) {
|
||||
if (!_channelIdPattern.hasMatch(value)) {
|
||||
throw const FormatException(
|
||||
'Push filter contains an invalid channel ID.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BuzzPushSuppression {
|
||||
final int pTagsMax;
|
||||
|
||||
const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0);
|
||||
|
||||
Map<String, dynamic> toJson() => {'p_tags_max': pTagsMax};
|
||||
|
||||
factory BuzzPushSuppression.fromJson(Map<String, dynamic> json) {
|
||||
_rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression');
|
||||
final value = json['p_tags_max'];
|
||||
if (value is! int || value <= 0) {
|
||||
throw const FormatException('p_tags_max must be a positive integer.');
|
||||
}
|
||||
return BuzzPushSuppression(pTagsMax: value);
|
||||
}
|
||||
}
|
||||
|
||||
class BuzzPushSubscription {
|
||||
final BuzzPushFilter filter;
|
||||
final String notificationClass;
|
||||
final List<BuzzPushFilter> ignore;
|
||||
final BuzzPushSuppression? suppress;
|
||||
|
||||
BuzzPushSubscription({
|
||||
required this.filter,
|
||||
required this.notificationClass,
|
||||
Iterable<BuzzPushFilter> ignore = const [],
|
||||
this.suppress,
|
||||
}) : ignore = List.unmodifiable(ignore) {
|
||||
if (!_supportedNotificationClasses.contains(notificationClass)) {
|
||||
throw const FormatException('Unsupported push notification class.');
|
||||
}
|
||||
if (filter.authors == null &&
|
||||
filter.pTags == null &&
|
||||
filter.hTags == null) {
|
||||
throw const FormatException('Push subscription filter is not narrowed.');
|
||||
}
|
||||
if (this.ignore.length > buzzPushMaxIgnoreFilters) {
|
||||
throw const FormatException(
|
||||
'Push subscription has too many ignore filters.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'filter': filter.toJson(),
|
||||
'class': notificationClass,
|
||||
if (ignore.isNotEmpty)
|
||||
'ignore': [for (final filter in ignore) filter.toJson()],
|
||||
if (suppress != null) 'suppress': suppress!.toJson(),
|
||||
};
|
||||
|
||||
factory BuzzPushSubscription.fromJson(Map<String, dynamic> json) {
|
||||
_rejectUnknownKeys(json, const {
|
||||
'filter',
|
||||
'class',
|
||||
'ignore',
|
||||
'suppress',
|
||||
}, 'push subscription');
|
||||
final filter = json['filter'];
|
||||
final notificationClass = json['class'];
|
||||
final ignore = json['ignore'];
|
||||
final suppress = json['suppress'];
|
||||
if (filter is! Map || notificationClass is! String) {
|
||||
throw const FormatException('Malformed push subscription.');
|
||||
}
|
||||
if (ignore != null && ignore is! List) {
|
||||
throw const FormatException('Push subscription ignore must be a list.');
|
||||
}
|
||||
if (suppress != null && suppress is! Map) {
|
||||
throw const FormatException(
|
||||
'Push subscription suppress must be an object.',
|
||||
);
|
||||
}
|
||||
return BuzzPushSubscription(
|
||||
filter: BuzzPushFilter.fromJson(Map<String, dynamic>.from(filter)),
|
||||
notificationClass: notificationClass,
|
||||
ignore: [
|
||||
for (final raw in ignore as List<dynamic>? ?? const [])
|
||||
if (raw is Map)
|
||||
BuzzPushFilter.fromJson(Map<String, dynamic>.from(raw))
|
||||
else
|
||||
throw const FormatException('Malformed push ignore filter.'),
|
||||
],
|
||||
suppress: suppress == null
|
||||
? null
|
||||
: BuzzPushSuppression.fromJson(Map<String, dynamic>.from(suppress)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Desired and relay-accepted lease subscriptions are intentionally separate.
|
||||
///
|
||||
/// Workstream A has no lease publisher, so snapshots use [desired] authority.
|
||||
/// A later publisher must persist an observed [accepted] lease before changing
|
||||
/// authority. Keeping both sets makes relay rejection, clamping, or expiry
|
||||
/// detectable instead of assuming the desired lease was accepted unchanged.
|
||||
class BuzzPushLeaseSubscriptionState {
|
||||
final BuzzPushLeaseSubscriptionAuthority authority;
|
||||
final List<BuzzPushSubscription> desired;
|
||||
final List<BuzzPushSubscription>? accepted;
|
||||
|
||||
const BuzzPushLeaseSubscriptionState.desired({
|
||||
this.desired = const [],
|
||||
this.accepted,
|
||||
}) : authority = BuzzPushLeaseSubscriptionAuthority.desired;
|
||||
|
||||
BuzzPushLeaseSubscriptionState.accepted({
|
||||
required Iterable<BuzzPushSubscription> desired,
|
||||
required Iterable<BuzzPushSubscription> acceptedSubscriptions,
|
||||
}) : authority = BuzzPushLeaseSubscriptionAuthority.accepted,
|
||||
desired = List.unmodifiable(desired),
|
||||
accepted = List.unmodifiable(acceptedSubscriptions);
|
||||
|
||||
List<BuzzPushSubscription> get authoritative => switch (authority) {
|
||||
BuzzPushLeaseSubscriptionAuthority.desired => desired,
|
||||
BuzzPushLeaseSubscriptionAuthority.accepted => accepted!,
|
||||
};
|
||||
|
||||
BuzzPushLeaseSubscriptionState withDesired(
|
||||
Iterable<BuzzPushSubscription> subscriptions,
|
||||
) {
|
||||
final updated = List<BuzzPushSubscription>.unmodifiable(subscriptions);
|
||||
return switch (authority) {
|
||||
BuzzPushLeaseSubscriptionAuthority.desired =>
|
||||
BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: updated,
|
||||
accepted: accepted,
|
||||
),
|
||||
BuzzPushLeaseSubscriptionAuthority.accepted =>
|
||||
BuzzPushLeaseSubscriptionState.accepted(
|
||||
desired: updated,
|
||||
acceptedSubscriptions: accepted!,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'authority': authority.name,
|
||||
'desired': [for (final subscription in desired) subscription.toJson()],
|
||||
if (accepted != null)
|
||||
'accepted': [for (final subscription in accepted!) subscription.toJson()],
|
||||
};
|
||||
|
||||
factory BuzzPushLeaseSubscriptionState.fromJson(Map<String, dynamic> json) {
|
||||
_rejectUnknownKeys(json, const {
|
||||
'authority',
|
||||
'desired',
|
||||
'accepted',
|
||||
}, 'push subscription state');
|
||||
final authority = json['authority'];
|
||||
final desired = _subscriptionList(
|
||||
json['desired'],
|
||||
'desired',
|
||||
allowEmpty: authority == 'desired',
|
||||
);
|
||||
final acceptedRaw = json['accepted'];
|
||||
final accepted = acceptedRaw == null
|
||||
? null
|
||||
: _subscriptionList(acceptedRaw, 'accepted');
|
||||
return switch (authority) {
|
||||
'desired' => BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: desired,
|
||||
accepted: accepted,
|
||||
),
|
||||
'accepted' when accepted != null =>
|
||||
BuzzPushLeaseSubscriptionState.accepted(
|
||||
desired: desired,
|
||||
acceptedSubscriptions: accepted,
|
||||
),
|
||||
'accepted' => throw const FormatException(
|
||||
'Accepted push authority requires accepted subscriptions.',
|
||||
),
|
||||
_ => throw const FormatException('Unknown push subscription authority.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
List<BuzzPushSubscription> buildDesiredBuzzPushSubscriptions({
|
||||
required String myPubkey,
|
||||
Iterable<String> channelIds = const [],
|
||||
Iterable<String> mutedChannelIds = const [],
|
||||
}) {
|
||||
final normalizedPubkey = myPubkey.toLowerCase();
|
||||
if (!_exactHexPattern.hasMatch(normalizedPubkey)) {
|
||||
throw const FormatException('Push subscription pubkey must be exact hex.');
|
||||
}
|
||||
|
||||
final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet();
|
||||
final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet();
|
||||
final activeMuted =
|
||||
normalizedMuted.intersection(normalizedChannelIds).toList()..sort();
|
||||
final mutedIgnoreFilters = <BuzzPushFilter>[];
|
||||
for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) {
|
||||
mutedIgnoreFilters.add(
|
||||
BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk),
|
||||
);
|
||||
}
|
||||
if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) {
|
||||
throw const FormatException('Too many muted channels for a push lease.');
|
||||
}
|
||||
|
||||
final selfAuthored = BuzzPushFilter(
|
||||
kinds: buzzPushEligibleKinds,
|
||||
authors: [normalizedPubkey],
|
||||
);
|
||||
final ignores = [selfAuthored, ...mutedIgnoreFilters];
|
||||
const suppression = BuzzPushSuppression(
|
||||
pTagsMax: buzzPushHellthreadParticipantLimit,
|
||||
);
|
||||
final subscriptions = <BuzzPushSubscription>[
|
||||
BuzzPushSubscription(
|
||||
filter: BuzzPushFilter(
|
||||
kinds: buzzPushEligibleKinds,
|
||||
pTags: [normalizedPubkey],
|
||||
),
|
||||
notificationClass: 'default',
|
||||
ignore: ignores,
|
||||
suppress: suppression,
|
||||
),
|
||||
];
|
||||
|
||||
final channels = normalizedChannelIds.difference(normalizedMuted).toList()
|
||||
..sort();
|
||||
for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) {
|
||||
subscriptions.add(
|
||||
BuzzPushSubscription(
|
||||
filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk),
|
||||
notificationClass: 'default',
|
||||
ignore: ignores,
|
||||
suppress: suppression,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (subscriptions.length > buzzPushMaxSubscriptions) {
|
||||
throw const FormatException('Too many channels for a push lease.');
|
||||
}
|
||||
return List.unmodifiable(subscriptions);
|
||||
}
|
||||
|
||||
String buzzPushSubscriptionsFingerprint(
|
||||
List<BuzzPushSubscription> subscriptions,
|
||||
) => jsonEncode([
|
||||
for (final subscription in subscriptions) subscription.toJson(),
|
||||
]);
|
||||
|
||||
String buzzPushSubscriptionStateFingerprint(
|
||||
BuzzPushLeaseSubscriptionState state,
|
||||
) => jsonEncode(state.toJson());
|
||||
|
||||
List<List<String>> _chunks(List<String> values, int size) => [
|
||||
for (var offset = 0; offset < values.length; offset += size)
|
||||
values.sublist(offset, (offset + size).clamp(0, values.length)),
|
||||
];
|
||||
|
||||
String _normalizeChannelID(String value) {
|
||||
final normalized = value.toLowerCase();
|
||||
if (!_channelIdPattern.hasMatch(normalized)) {
|
||||
throw const FormatException('Push subscription channel ID is invalid.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
List<String>? _optionalList(Iterable<String>? values) =>
|
||||
values == null ? null : List.unmodifiable(values);
|
||||
|
||||
List<int> _intList(Map<String, dynamic> json, String key) {
|
||||
final raw = json[key];
|
||||
if (raw is! List || raw.any((value) => value is! int)) {
|
||||
throw FormatException('$key must be an integer list.');
|
||||
}
|
||||
return raw.cast<int>();
|
||||
}
|
||||
|
||||
List<String>? _optionalStringList(Map<String, dynamic> json, String key) {
|
||||
if (!json.containsKey(key)) return null;
|
||||
final raw = json[key];
|
||||
if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) {
|
||||
throw FormatException('$key must be a non-empty string list.');
|
||||
}
|
||||
return raw.cast<String>();
|
||||
}
|
||||
|
||||
List<BuzzPushSubscription> _subscriptionList(
|
||||
Object? raw,
|
||||
String label, {
|
||||
bool allowEmpty = false,
|
||||
}) {
|
||||
if (raw is! List || (!allowEmpty && raw.isEmpty)) {
|
||||
throw FormatException('$label subscriptions must be a non-empty list.');
|
||||
}
|
||||
return [
|
||||
for (final item in raw)
|
||||
if (item is Map)
|
||||
BuzzPushSubscription.fromJson(Map<String, dynamic>.from(item))
|
||||
else
|
||||
throw FormatException('Malformed $label subscription.'),
|
||||
];
|
||||
}
|
||||
|
||||
void _rejectUnknownKeys(
|
||||
Map<String, dynamic> json,
|
||||
Set<String> allowed,
|
||||
String label,
|
||||
) {
|
||||
final unknown = json.keys.where((key) => !allowed.contains(key));
|
||||
if (unknown.isNotEmpty) {
|
||||
throw FormatException('$label contains unknown field ${unknown.first}.');
|
||||
}
|
||||
}
|
||||
53
mobile/lib/shared/push/push_subscription_provider.dart
Normal file
53
mobile/lib/shared/push/push_subscription_provider.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../features/channels/channel.dart';
|
||||
import '../../features/channels/channel_mutes/channel_mutes_provider.dart';
|
||||
import '../../features/channels/channels_provider.dart';
|
||||
import '../community/community.dart';
|
||||
import '../community/community_provider.dart';
|
||||
import '../relay/relay_provider.dart';
|
||||
import 'push_subscription.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 = Provider<void>((ref) {
|
||||
final active = ref.watch(activeCommunityProvider).value;
|
||||
final channels = ref.watch(channelsProvider).value;
|
||||
final mutes = ref.watch(channelMutesProvider);
|
||||
if (active == null || channels == null || !mutes.isReady) return;
|
||||
|
||||
final subscriptions = desiredBuzzPushSubscriptions(
|
||||
community: active,
|
||||
channels: channels,
|
||||
mutedChannelIds: [
|
||||
for (final entry in mutes.store.channels.entries)
|
||||
if (entry.value.muted) entry.key,
|
||||
],
|
||||
);
|
||||
if (subscriptions == null) return;
|
||||
unawaited(
|
||||
ref
|
||||
.read(communityListProvider.notifier)
|
||||
.updateDesiredPushSubscriptions(active.id, subscriptions),
|
||||
);
|
||||
});
|
||||
|
||||
List<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
|
||||
required Community community,
|
||||
required Iterable<Channel> channels,
|
||||
required Iterable<String> mutedChannelIds,
|
||||
}) {
|
||||
final pubkey = community.pubkey ?? pubkeyFromNsec(community.nsec);
|
||||
if (pubkey == null || pubkey.isEmpty) return null;
|
||||
return buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: pubkey,
|
||||
channelIds: [
|
||||
for (final channel in channels)
|
||||
if (channel.isMember && !channel.isArchived) channel.id,
|
||||
],
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/community/community.dart';
|
||||
import 'package:buzz/shared/community/community_storage.dart';
|
||||
import 'package:buzz/shared/push/push_subscription.dart';
|
||||
|
||||
/// In-memory fake that extends Fake to satisfy all FlutterSecureStorage
|
||||
/// interface methods, but implements the core read/write/delete with real
|
||||
@@ -117,6 +118,36 @@ void main() {
|
||||
expect(loaded.first.name, 'Test');
|
||||
expect(loaded.first.relayUrl, 'https://relay.example.com');
|
||||
expect(loaded.first.pubkey, 'abc123');
|
||||
expect(
|
||||
loaded.first.pushSubscriptionState.authority,
|
||||
BuzzPushLeaseSubscriptionAuthority.desired,
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trips desired push subscription state', () async {
|
||||
final pubkey = 'a' * 64;
|
||||
final subscriptions = buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: pubkey,
|
||||
channelIds: const ['123e4567-e89b-42d3-a456-426614174000'],
|
||||
);
|
||||
final community =
|
||||
Community.create(
|
||||
name: 'Push',
|
||||
relayUrl: 'https://relay.example.com',
|
||||
pubkey: pubkey,
|
||||
).copyWith(
|
||||
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: subscriptions,
|
||||
),
|
||||
);
|
||||
|
||||
await storage.save(community);
|
||||
final loaded = (await storage.loadAll()).single;
|
||||
|
||||
expect(
|
||||
loaded.pushSubscriptionState.toJson(),
|
||||
community.pushSubscriptionState.toJson(),
|
||||
);
|
||||
});
|
||||
|
||||
test('save updates existing community with same id', () async {
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/push/push_models.dart';
|
||||
import 'package:buzz/shared/relay/nostr_models.dart';
|
||||
import 'package:buzz/shared/push/push_subscription.dart';
|
||||
|
||||
void main() {
|
||||
test('push community snapshot carries explicit subscription authority', () {
|
||||
final subscription = buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: 'a' * 64,
|
||||
).single;
|
||||
final snapshot = BuzzPushCommunitySnapshot(
|
||||
id: 'community',
|
||||
name: 'Team',
|
||||
relayUrl: 'https://relay.example.com',
|
||||
pubkey: 'a' * 64,
|
||||
pushSubscriptionState: BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: [subscription],
|
||||
),
|
||||
);
|
||||
|
||||
final decoded = BuzzPushCommunitySnapshot.fromJson(snapshot.toJson());
|
||||
|
||||
expect(decoded.toJson(), snapshot.toJson());
|
||||
expect(
|
||||
decoded.pushSubscriptionState.authority,
|
||||
BuzzPushLeaseSubscriptionAuthority.desired,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolves newest user-visible event into notification content', () {
|
||||
final mine = 'a' * 64;
|
||||
final alice = 'b' * 64;
|
||||
|
||||
64
mobile/test/shared/push/push_subscription_provider_test.dart
Normal file
64
mobile/test/shared/push/push_subscription_provider_test.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/shared/community/community.dart';
|
||||
import 'package:buzz/shared/push/push_subscription_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
void main() {
|
||||
const activeID = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const archivedID = '123e4567-e89b-42d3-a456-426614174001';
|
||||
const nonMemberID = '123e4567-e89b-42d3-a456-426614174002';
|
||||
|
||||
test(
|
||||
'derives desired subscriptions from nsec, membership, and mute state',
|
||||
() {
|
||||
final nsec = nostr.Keys.generate().nsec;
|
||||
final subscriptions = desiredBuzzPushSubscriptions(
|
||||
community: Community.create(
|
||||
name: 'Team',
|
||||
relayUrl: 'https://relay.example.com',
|
||||
nsec: nsec,
|
||||
),
|
||||
channels: [
|
||||
channel(activeID),
|
||||
channel(archivedID, archived: true),
|
||||
channel(nonMemberID, isMember: false),
|
||||
],
|
||||
mutedChannelIds: const [activeID, archivedID],
|
||||
);
|
||||
|
||||
expect(subscriptions, isNotNull);
|
||||
expect(subscriptions, hasLength(1));
|
||||
expect(subscriptions!.single.filter.pTags, hasLength(1));
|
||||
expect(subscriptions.single.ignore, hasLength(2));
|
||||
expect(subscriptions.single.ignore.last.hTags, [activeID]);
|
||||
},
|
||||
);
|
||||
|
||||
test('returns no desired subscriptions without a signing identity', () {
|
||||
final subscriptions = desiredBuzzPushSubscriptions(
|
||||
community: Community.create(
|
||||
name: 'Team',
|
||||
relayUrl: 'https://relay.example.com',
|
||||
),
|
||||
channels: [channel(activeID)],
|
||||
mutedChannelIds: const [],
|
||||
);
|
||||
|
||||
expect(subscriptions, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Channel channel(String id, {bool isMember = true, bool archived = false}) =>
|
||||
Channel(
|
||||
id: id,
|
||||
name: id,
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'author',
|
||||
createdAt: DateTime(2026),
|
||||
memberCount: 1,
|
||||
isMember: isMember,
|
||||
archivedAt: archived ? DateTime(2026) : null,
|
||||
);
|
||||
124
mobile/test/shared/push/push_subscription_test.dart
Normal file
124
mobile/test/shared/push/push_subscription_test.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/push/push_subscription.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final me = 'a' * 64;
|
||||
const channelA = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const channelB = '123e4567-e89b-42d3-a456-426614174001';
|
||||
|
||||
test(
|
||||
'desired subscription state round-trips with an accepted-state seam',
|
||||
() {
|
||||
final subscriptions = buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: me,
|
||||
channelIds: [channelB, channelA],
|
||||
);
|
||||
final state = BuzzPushLeaseSubscriptionState.desired(
|
||||
desired: subscriptions,
|
||||
);
|
||||
|
||||
final decoded = BuzzPushLeaseSubscriptionState.fromJson(
|
||||
jsonDecode(jsonEncode(state.toJson())) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
expect(decoded.authority, BuzzPushLeaseSubscriptionAuthority.desired);
|
||||
expect(decoded.accepted, isNull);
|
||||
expect(decoded.toJson(), state.toJson());
|
||||
expect(decoded.authoritative, hasLength(2));
|
||||
expect(decoded.authoritative.last.filter.hTags, [channelA, channelB]);
|
||||
},
|
||||
);
|
||||
|
||||
test('accepted authority requires observed accepted subscriptions', () {
|
||||
final subscription = buildDesiredBuzzPushSubscriptions(myPubkey: me).single;
|
||||
|
||||
expect(
|
||||
() => BuzzPushLeaseSubscriptionState.fromJson({
|
||||
'authority': 'accepted',
|
||||
'desired': [subscription.toJson()],
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('builds aligned self and unmuted channel subscriptions', () {
|
||||
final subscriptions = buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: me.toUpperCase(),
|
||||
channelIds: [channelB, channelA],
|
||||
mutedChannelIds: [channelB, '123e4567-e89b-42d3-a456-426614174099'],
|
||||
);
|
||||
|
||||
expect(subscriptions, hasLength(2));
|
||||
expect(subscriptions.first.filter.kinds, buzzPushEligibleKinds);
|
||||
expect(subscriptions.first.filter.pTags, [me]);
|
||||
expect(subscriptions.last.filter.kinds, buzzPushChannelKinds);
|
||||
expect(subscriptions.last.filter.hTags, [channelA]);
|
||||
expect(subscriptions.first.ignore, hasLength(2));
|
||||
expect(subscriptions.first.ignore.last.hTags, [channelB]);
|
||||
expect(
|
||||
subscriptions.first.suppress?.pTagsMax,
|
||||
buzzPushHellthreadParticipantLimit,
|
||||
);
|
||||
});
|
||||
|
||||
test('chunks channel subscriptions to relay limits deterministically', () {
|
||||
final channels = [
|
||||
for (var i = 0; i < 51; i++)
|
||||
'00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}',
|
||||
]..shuffle();
|
||||
|
||||
final subscriptions = buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: me,
|
||||
channelIds: channels,
|
||||
);
|
||||
|
||||
expect(subscriptions, hasLength(3));
|
||||
expect(subscriptions[1].filter.hTags, hasLength(50));
|
||||
expect(subscriptions[2].filter.hTags, hasLength(1));
|
||||
expect(
|
||||
subscriptions[1].filter.hTags,
|
||||
orderedEquals([...subscriptions[1].filter.hTags!]..sort()),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed and unsupported subscription fields', () {
|
||||
final valid = buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: me,
|
||||
).single.toJson();
|
||||
|
||||
expect(
|
||||
() => buildDesiredBuzzPushSubscriptions(
|
||||
myPubkey: me,
|
||||
channelIds: const ['not-a-channel'],
|
||||
),
|
||||
throwsFormatException,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => BuzzPushSubscription.fromJson({...valid, 'unexpected': true}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => BuzzPushSubscription.fromJson({
|
||||
'filter': {
|
||||
'kinds': [40002],
|
||||
'#p': [me],
|
||||
},
|
||||
'class': 'default',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => BuzzPushSubscription.fromJson({
|
||||
'filter': {
|
||||
'kinds': [9],
|
||||
'#p': ['not-a-pubkey'],
|
||||
},
|
||||
'class': 'default',
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user