Files
buzz/mobile/lib/shared/push/push_subscription.dart
Tom Brow bad9d26142 fix(mobile): push every message in your own Solo chats (#8615)
## Summary

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

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

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

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

### Related issue

None found.

### Validation

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

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:44:59 -07:00

593 lines
20 KiB
Dart

import 'dart:convert';
/// User-visible Buzz message kinds. This mirrors
/// `EventKind.channelMessageEventKinds` without importing feature code into
/// the shared push layer.
const buzzPushEligibleKinds = [9, 40002, 45001, 45003];
const buzzPushSelfDirectedKinds = buzzPushEligibleKinds;
const buzzPushRenderableKinds = buzzPushEligibleKinds;
const buzzPushChannelKinds = [9];
const buzzPushChannelChunkSize = 50;
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}$');
// Channel IDs use canonical UUID text; starter channels are UUIDv5.
// Match the relay's UUID parser without imposing a version or variant.
final _channelIdPattern = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[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.
/// Keeping both sets makes relay rejection 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;
/// Monotonic generation of the relay-facing kind-30350 lease.
final int? acceptedGeneration;
/// Highest lease generation durably reserved by the client. This advances
/// before relay publication so a relay commit followed by a local failure
/// cannot make the next retry reuse a stale generation.
final int? generationCursor;
/// Higher-generation inactive lease that still needs relay acceptance.
///
/// This is persisted before publication. Ambiguous retries reserve a newer
/// generation so a relay-accepted tombstone whose local acknowledgement was
/// lost is safely superseded without weakening strict relay monotonicity.
final int? pendingTombstoneGeneration;
const BuzzPushLeaseSubscriptionState.desired({
this.desired = const [],
this.accepted,
this.acceptedGeneration,
this.generationCursor,
this.pendingTombstoneGeneration,
}) : assert(
pendingTombstoneGeneration == null ||
(pendingTombstoneGeneration > (acceptedGeneration ?? 0) &&
generationCursor != null &&
pendingTombstoneGeneration <= generationCursor),
),
authority = BuzzPushLeaseSubscriptionAuthority.desired;
BuzzPushLeaseSubscriptionState.accepted({
required Iterable<BuzzPushSubscription> desired,
required Iterable<BuzzPushSubscription> acceptedSubscriptions,
required this.acceptedGeneration,
this.generationCursor,
this.pendingTombstoneGeneration,
}) : authority = BuzzPushLeaseSubscriptionAuthority.accepted,
desired = List.unmodifiable(desired),
accepted = List.unmodifiable(acceptedSubscriptions) {
if (acceptedGeneration == null || acceptedGeneration! <= 0) {
throw const FormatException(
'Accepted push authority requires a positive lease generation.',
);
}
if (generationCursor != null && generationCursor! < acceptedGeneration!) {
throw const FormatException(
'Push lease generation cursor cannot trail the accepted generation.',
);
}
_validatePendingTombstone();
}
void _validatePendingTombstone() {
final pending = pendingTombstoneGeneration;
if (pending == null) return;
if (pending <= (acceptedGeneration ?? 0) ||
generationCursor == null ||
pending > generationCursor!) {
throw const FormatException(
'Pending push tombstone must be newer than accepted state and durably reserved.',
);
}
}
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,
acceptedGeneration: acceptedGeneration,
generationCursor: generationCursor,
pendingTombstoneGeneration: pendingTombstoneGeneration,
),
BuzzPushLeaseSubscriptionAuthority.accepted =>
BuzzPushLeaseSubscriptionState.accepted(
desired: updated,
acceptedSubscriptions: accepted!,
acceptedGeneration: acceptedGeneration,
generationCursor: generationCursor,
pendingTombstoneGeneration: pendingTombstoneGeneration,
),
};
}
BuzzPushLeaseSubscriptionState withAccepted({
required Iterable<BuzzPushSubscription> subscriptions,
required int generation,
}) => BuzzPushLeaseSubscriptionState.accepted(
desired: desired,
acceptedSubscriptions: subscriptions,
acceptedGeneration: generation,
generationCursor: generationCursor == null || generation > generationCursor!
? generation
: generationCursor,
pendingTombstoneGeneration:
pendingTombstoneGeneration != null &&
generation < pendingTombstoneGeneration!
? pendingTombstoneGeneration
: null,
);
BuzzPushLeaseSubscriptionState withReservedGeneration(int generation) {
if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) {
throw const FormatException(
'Reserved push lease generation must advance monotonically.',
);
}
return switch (authority) {
BuzzPushLeaseSubscriptionAuthority.desired =>
BuzzPushLeaseSubscriptionState.desired(
desired: desired,
accepted: accepted,
acceptedGeneration: acceptedGeneration,
generationCursor: generation,
pendingTombstoneGeneration: pendingTombstoneGeneration,
),
BuzzPushLeaseSubscriptionAuthority.accepted =>
BuzzPushLeaseSubscriptionState.accepted(
desired: desired,
acceptedSubscriptions: accepted!,
acceptedGeneration: acceptedGeneration,
generationCursor: generation,
pendingTombstoneGeneration: pendingTombstoneGeneration,
),
};
}
BuzzPushLeaseSubscriptionState withPendingTombstone(int generation) {
if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) {
throw const FormatException(
'Pending push tombstone generation must advance monotonically.',
);
}
return BuzzPushLeaseSubscriptionState.desired(
desired: desired,
accepted: accepted,
acceptedGeneration: acceptedGeneration,
generationCursor: generation,
pendingTombstoneGeneration: generation,
);
}
/// Migrates a generation reserved by an older client before the explicit
/// tombstone journal field existed.
BuzzPushLeaseSubscriptionState withPendingTombstoneAtCursor() {
final generation = generationCursor;
if (generation == null || generation <= (acceptedGeneration ?? 0)) {
return this;
}
return BuzzPushLeaseSubscriptionState.desired(
desired: desired,
accepted: accepted,
acceptedGeneration: acceptedGeneration,
generationCursor: generation,
pendingTombstoneGeneration: generation,
);
}
BuzzPushLeaseSubscriptionState withAcceptedTombstone(int generation) {
if (generation != pendingTombstoneGeneration ||
generation < (acceptedGeneration ?? 0)) {
return this;
}
return BuzzPushLeaseSubscriptionState.desired(
desired: desired,
acceptedGeneration: generation,
generationCursor:
generationCursor == null || generation > generationCursor!
? generation
: generationCursor,
);
}
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()],
if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration,
if (generationCursor != null) 'generationCursor': generationCursor,
if (pendingTombstoneGeneration != null)
'pendingTombstoneGeneration': pendingTombstoneGeneration,
};
factory BuzzPushLeaseSubscriptionState.fromJson(Map<String, dynamic> json) {
_rejectUnknownKeys(json, const {
'authority',
'desired',
'accepted',
'acceptedGeneration',
'generationCursor',
'pendingTombstoneGeneration',
}, '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');
final acceptedGeneration = json['acceptedGeneration'];
final generationCursor = json['generationCursor'];
final pendingTombstoneGeneration = json['pendingTombstoneGeneration'];
if (acceptedGeneration != null && acceptedGeneration is! int) {
throw const FormatException(
'Accepted push lease generation must be an integer.',
);
}
if (generationCursor != null && generationCursor is! int) {
throw const FormatException(
'Push lease generation cursor must be an integer.',
);
}
if (pendingTombstoneGeneration != null &&
pendingTombstoneGeneration is! int) {
throw const FormatException(
'Pending push tombstone generation must be an integer.',
);
}
if (authority == 'desired' &&
pendingTombstoneGeneration is int &&
(pendingTombstoneGeneration <= (acceptedGeneration as int? ?? 0) ||
generationCursor is! int ||
pendingTombstoneGeneration > generationCursor)) {
throw const FormatException(
'Pending push tombstone must be newer than accepted state and durably reserved.',
);
}
return switch (authority) {
'desired' => BuzzPushLeaseSubscriptionState.desired(
desired: desired,
accepted: accepted,
acceptedGeneration: acceptedGeneration as int?,
generationCursor: generationCursor as int?,
pendingTombstoneGeneration: pendingTombstoneGeneration as int?,
),
'accepted' when accepted != null && acceptedGeneration is int =>
BuzzPushLeaseSubscriptionState.accepted(
desired: desired,
acceptedSubscriptions: accepted,
acceptedGeneration: acceptedGeneration,
generationCursor: generationCursor as int?,
pendingTombstoneGeneration: pendingTombstoneGeneration as int?,
),
'accepted' => throw const FormatException(
'Accepted push authority requires accepted subscriptions and generations.',
),
_ => 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();
// Mutes sync across every community and channel type, so only mutes of
// subscribed channels matter here; others need not fit the push grammar.
final activeMuted = {
for (final id in mutedChannelIds)
if (normalizedChannelIds.contains(id.toLowerCase())) id.toLowerCase(),
}.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: buzzPushRenderableKinds,
authors: [normalizedPubkey],
);
final ignores = [selfAuthored, ...mutedIgnoreFilters];
const suppression = BuzzPushSuppression(
pTagsMax: buzzPushHellthreadParticipantLimit,
);
final subscriptions = <BuzzPushSubscription>[
BuzzPushSubscription(
filter: BuzzPushFilter(
kinds: buzzPushSelfDirectedKinds,
pTags: [normalizedPubkey],
),
notificationClass: 'default',
ignore: ignores,
suppress: suppression,
),
];
final channels = normalizedChannelIds.difference(activeMuted.toSet()).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}.');
}
}