diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 38d8cdeadb..fbf19b4aaf 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -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}$'); diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart index c3951be880..76543251d9 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -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? 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 _channelWideIds({ + required String pubkey, + required Iterable channels, + required Iterable mutedChannelIds, + required Iterable 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); +} diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart index ee60509a93..7035da5050 100644 --- a/mobile/test/shared/push/push_subscription_provider_test.dart +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -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 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 {