fix(mobile): fit Solo chats within push lease capacity

DMs keep their channel-wide subscription. Own Solo chats fill the
remaining lease capacity most recent first, and the rest push only on
mention instead of failing the whole lease update.

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-26 21:22:41 -07:00
parent 160fce20b2
commit d7506c3e5d
3 changed files with 125 additions and 12 deletions

View File

@@ -12,6 +12,14 @@ const buzzPushMaxSubscriptions = 16;
const buzzPushMaxIgnoreFilters = 8;
const buzzPushHellthreadParticipantLimit = 20;
/// Channels one lease can follow channel-wide after its self-directed
/// subscription, and muted channels its ignores can cover after the
/// self-authored ignore.
const buzzPushMaxChannelWideChannels =
(buzzPushMaxSubscriptions - 1) * buzzPushChannelChunkSize;
const buzzPushMaxMutedChannels =
(buzzPushMaxIgnoreFilters - 1) * buzzPushChannelChunkSize;
const _supportedNotificationClasses = {'default'};
const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'};
final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$');

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../features/channels/channel.dart';
@@ -68,18 +69,68 @@ List<BuzzPushSubscription>? desiredBuzzPushSubscriptions({
if (pubkey == null || pubkey.isEmpty) return null;
return buildDesiredBuzzPushSubscriptions(
myPubkey: pubkey,
channelIds: [
// 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.
for (final channel in channels)
if ((channel.isDm ||
channel.isPersonalChat(pubkey, legacySoloChannelIds)) &&
channel.isMember &&
!channel.isArchived)
channel.id,
],
channelIds: _channelWideIds(
pubkey: pubkey,
channels: channels,
mutedChannelIds: mutedChannelIds,
legacySoloChannelIds: legacySoloChannelIds,
),
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 fit, as before. Solo chats fill the remaining lease capacity
/// most recent first; the rest push only on mention.
List<String> _channelWideIds({
required String pubkey,
required Iterable<Channel> channels,
required Iterable<String> mutedChannelIds,
required Iterable<String> legacySoloChannelIds,
}) {
final muted = {for (final id in mutedChannelIds) id.toLowerCase()};
bool isMuted(String id) => muted.contains(id.toLowerCase());
final joined = [
for (final channel in channels)
if (channel.isMember && !channel.isArchived) channel,
];
final ids = [
for (final channel in joined)
if (channel.isDm) channel.id,
];
final solos = [
for (final channel in joined)
if (!channel.isDm && channel.isPersonalChat(pubkey, legacySoloChannelIds))
channel,
]..sort(_mostRecentFirst);
var unmutedRoom =
buzzPushMaxChannelWideChannels - ids.where((id) => !isMuted(id)).length;
var mutedRoom = buzzPushMaxMutedChannels - ids.where(isMuted).length;
var omitted = 0;
for (final solo in solos) {
final soloMuted = isMuted(solo.id);
if ((soloMuted ? mutedRoom : unmutedRoom) <= 0) {
omitted++;
continue;
}
ids.add(solo.id);
soloMuted ? mutedRoom-- : unmutedRoom--;
}
if (omitted > 0) {
debugPrint(
'Push lease is full; $omitted older Solo chats push only on mention.',
);
}
return ids;
}
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

@@ -89,6 +89,58 @@ void main() {
expect(subscriptions.last.filter.hTags, [dmID, soloID, legacySoloID]);
});
test('fills lease capacity with the most recent Solo chats', () {
final keys = nostr.Keys.generate();
String id(int n) =>
'123e4567-e89b-42d3-a456-${n.toRadixString(16).padLeft(12, '0')}';
Channel solo(int n) => channel(
id(n),
channelType: 'stream',
visibility: 'private',
labels: const ['buzz.solo'],
createdBy: keys.public,
lastMessageAt: DateTime(2026).add(Duration(minutes: n)),
);
const dmCount = 10;
const unmutedSolos = buzzPushMaxChannelWideChannels;
const mutedSolos = buzzPushMaxMutedChannels + 5;
final channels = [
for (var n = 0; n < dmCount; n++) channel(id(n)),
for (var n = dmCount; n < dmCount + unmutedSolos + mutedSolos; n++)
solo(n),
];
final muted = [
for (var n = dmCount + unmutedSolos; n < channels.length; n++) id(n),
];
final subscriptions = desiredBuzzPushSubscriptions(
community: Community.create(
name: 'Team',
relayUrl: 'https://relay.example.com',
nsec: keys.nsec,
),
channels: channels,
mutedChannelIds: muted,
)!;
final followed = {
for (final subscription in subscriptions.skip(1))
...?subscription.filter.hTags,
};
final ignoredMutes = {
for (final ignore in subscriptions.first.ignore.skip(1)) ...?ignore.hTags,
};
expect(subscriptions, hasLength(buzzPushMaxSubscriptions));
expect(followed, hasLength(buzzPushMaxChannelWideChannels));
expect(followed, containsAll([for (var n = 0; n < dmCount; n++) id(n)]));
expect(followed, contains(id(dmCount + unmutedSolos - 1)));
expect(followed, isNot(contains(id(dmCount + dmCount - 1))));
expect(followed, contains(id(dmCount + dmCount)));
expect(ignoredMutes, hasLength(buzzPushMaxMutedChannels));
expect(ignoredMutes, contains(id(channels.length - 1)));
expect(ignoredMutes, isNot(contains(id(dmCount + unmutedSolos))));
});
test('waits for the Solo record but not past its failure', () async {
const legacySoloID = '123e4567-e89b-42d3-a456-426614174009';
final community = Community.create(
@@ -166,6 +218,7 @@ Channel channel(
String visibility = 'open',
List<String> labels = const [],
String createdBy = 'author',
DateTime? lastMessageAt,
}) => Channel(
id: id,
name: id,
@@ -178,6 +231,7 @@ Channel channel(
memberCount: 1,
isMember: isMember,
archivedAt: archived ? DateTime(2026) : null,
lastMessageAt: lastMessageAt,
);
class _RecordingCommunityList extends CommunityListNotifier {