diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart index ab807ea92b..4e314dc38a 100644 --- a/mobile/lib/features/settings/settings_page/notifications_section.dart +++ b/mobile/lib/features/settings/settings_page/notifications_section.dart @@ -25,9 +25,9 @@ class _NotificationsSection extends ConsumerWidget { return const SizedBox.shrink(); } final canToggle = hasCapability || community.pushNotificationsEnabled; - final omittedSoloChats = community.pushNotificationsEnabled - ? ref.watch(pushSubscriptionSyncProvider).value ?? 0 - : 0; + final overflow = community.pushNotificationsEnabled + ? ref.watch(pushSubscriptionSyncProvider).value + : null; void setEnabled(bool enabled) { unawaited( @@ -75,15 +75,26 @@ class _NotificationsSection extends ConsumerWidget { ], ), ), - if (omittedSoloChats > 0) + if (overflow != null && overflow.chats > 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.', + subtitle: _soloOverflowText(overflow), ), ], ); } } + +String _soloOverflowText(SoloOverflow overflow) { + final past = overflow.chats == 1 + ? '1 chat is past this relay’s notification limit.' + : '${overflow.chats} chats are past this relay’s notification limit.'; + if (overflow.muted == 0) return past; + if (overflow.chats == 1) { + return '$past It is muted but still notifies on mention.'; + } + return overflow.muted == 1 + ? '$past 1 of them is muted but still notifies on mention.' + : '$past ${overflow.muted} of them are muted but still notify on mention.'; +} diff --git a/mobile/lib/shared/push/push_subscription_provider.dart b/mobile/lib/shared/push/push_subscription_provider.dart index c94953e218..dcb662b382 100644 --- a/mobile/lib/shared/push/push_subscription_provider.dart +++ b/mobile/lib/shared/push/push_subscription_provider.dart @@ -15,25 +15,25 @@ import 'push_setup_recovery.dart'; /// 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(( +/// Yields the Solo chats the lease had no room for. +final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( ref, ) async { try { if (ref.watch(authProvider).value?.status != AuthStatus.authenticated) { - return 0; + return _noOverflow; } final active = ref.watch(activeCommunityProvider).value; final descriptor = ref.watch(currentRelayPushDescriptorProvider).value; if (active == null || !active.pushNotificationsEnabled || descriptor == null) { - return 0; + return _noOverflow; } final channels = ref.watch(channelsProvider).value; final mutes = ref.watch(channelMutesProvider); if (channels == null || !mutes.isReady) { - return 0; + return _noOverflow; } // Legacy Solo chats are listed only in the Solo record. Wait while it @@ -41,7 +41,7 @@ final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( // 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; + if (soloRecord.isLoading && !soloRecord.hasValue) return _noOverflow; final desired = desiredBuzzPushSubscriptions( community: active, @@ -53,19 +53,25 @@ final pushSubscriptionSyncProvider = FutureProvider.autoDispose(( if (entry.value.muted) entry.key, ], ); - if (desired == null) return 0; + if (desired == null) return _noOverflow; await ref .read(communityListProvider.notifier) .updateDesiredPushSubscriptions(active.id, desired.subscriptions); - return desired.omittedSoloChats; + return desired.overflow; } 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 subscriptions, int omittedSoloChats})? +/// Solo chats a lease had no room for, and how many of those are muted. +/// Mentions in a muted one still notify, since its mute ignore did not fit. +typedef SoloOverflow = ({int chats, int muted}); + +const SoloOverflow _noOverflow = (chats: 0, muted: 0); + +/// The desired lease, and the Solo chats it had no room for. +({List subscriptions, SoloOverflow overflow})? desiredBuzzPushSubscriptions({ required Community community, required BuzzPushLeaseDescriptor descriptor, @@ -112,9 +118,15 @@ desiredBuzzPushSubscriptions({ } fitting = low; } + final trimmed = candidates.solos.skip(fitting); return ( subscriptions: lease(fitting), - omittedSoloChats: candidates.omitted + candidates.solos.length - fitting, + overflow: ( + chats: candidates.omitted + trimmed.length, + muted: + candidates.omittedMuted + + trimmed.where((id) => muted.contains(id.toLowerCase())).length, + ), ); } @@ -124,7 +136,8 @@ desiredBuzzPushSubscriptions({ /// /// DMs always stay, as before. Solo chats follow most recent first, within the /// lease's channel counts; the rest push only on mention. -({List dms, List solos, int omitted}) _channelWideCandidates({ +({List dms, List solos, int omitted, int omittedMuted}) +_channelWideCandidates({ required String pubkey, required Iterable channels, required Set muted, @@ -149,13 +162,22 @@ desiredBuzzPushSubscriptions({ buzzPushMaxChannelWideChannels - dms.where((id) => !isMuted(id)).length; var mutedRoom = buzzPushMaxMutedChannels - dms.where(isMuted).length; final solos = []; + var omittedMuted = 0; for (final solo in ordered) { final soloMuted = isMuted(solo.id); - if ((soloMuted ? mutedRoom : unmutedRoom) <= 0) continue; + if ((soloMuted ? mutedRoom : unmutedRoom) <= 0) { + if (soloMuted) omittedMuted++; + continue; + } solos.add(solo.id); soloMuted ? mutedRoom-- : unmutedRoom--; } - return (dms: dms, solos: solos, omitted: ordered.length - solos.length); + return ( + dms: dms, + solos: solos, + omitted: ordered.length - solos.length, + omittedMuted: omittedMuted, + ); } int _mostRecentFirst(Channel a, Channel b) { diff --git a/mobile/test/features/settings/settings_page_test.dart b/mobile/test/features/settings/settings_page_test.dart index 1d29e35d93..336984ca91 100644 --- a/mobile/test/features/settings/settings_page_test.dart +++ b/mobile/test/features/settings/settings_page_test.dart @@ -171,8 +171,10 @@ 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), + // The lease had no room for 12 older Solo chats, 3 of them muted. + pushSubscriptionSyncProvider.overrideWith( + (ref) async => (chats: 12, muted: 3), + ), appLifecycleProvider.overrideWith(_SettingsLifecycleNotifier.new), buzzPushAuthorizationStatusReaderProvider.overrideWithValue( () async => BuzzPushAuthorizationStatus.authorized, @@ -199,7 +201,10 @@ void main() { findsOneWidget, ); expect( - find.text('12 chats are past this relay’s notification limit.'), + find.text( + '12 chats are past this relay’s notification limit. ' + '3 of them are muted but still notify on mention.', + ), findsOneWidget, ); debugDefaultTargetPlatformOverride = null; diff --git a/mobile/test/shared/push/push_subscription_provider_test.dart b/mobile/test/shared/push/push_subscription_provider_test.dart index 92b0367f78..a7b8080aec 100644 --- a/mobile/test/shared/push/push_subscription_provider_test.dart +++ b/mobile/test/shared/push/push_subscription_provider_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; @@ -118,7 +119,7 @@ void main() { 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); + expect(desired.overflow, (chats: dmCount, muted: 0)); // A relay may advertise any endpoint limit; sizing must not allocate it. expect( @@ -145,6 +146,12 @@ void main() { 400, 100, ), + ( + 'content, trimming muted chats,', + _descriptor(contentLength: 24000, plaintextLength: 32768), + 400, + 300, + ), ('muted channel count', _descriptor(), 400, 400), ('NIP-44 plaintext', _descriptor(), 600, 200), ]) { @@ -175,8 +182,10 @@ void main() { for (final ignore in desired.subscriptions.first.ignore.skip(1)) ...?ignore.hTags, }; - final keptSolos = soloCount - desired.omittedSoloChats; + final keptSolos = soloCount - desired.overflow.chats; expect(keptSolos, inInclusiveRange(1, soloCount - 1)); + // Omitted muted chats lose their mute ignore, so Settings counts them. + expect(desired.overflow.muted, math.max(0, mutedSolos - keptSolos)); expect(kept, { for (var n = 0; n < dmCount; n++) _id(n), for (var n = channels.length - keptSolos; n < channels.length; n++)