diff --git a/docs/authored-thread-reply-push.md b/docs/authored-thread-reply-push.md index 7b66f940bd..2db5d3a620 100644 --- a/docs/authored-thread-reply-push.md +++ b/docs/authored-thread-reply-push.md @@ -54,6 +54,12 @@ push capability. Supporting mobile clients add a single self-scoped for the first activation. They do not enumerate or synchronize thread follows. Old relays retain the existing mention/DM policy. +The existing lease capacity limits still apply. If the authored rule cannot +represent all channel mutes (more than 350) or no subscription slot remains, +the client omits that rule while continuing to synchronize mention/DM policies. +It never truncates mute exclusions or abandons the whole update. The authored +rule returns when capacity is available again. + The authenticated `/query` bridge accepts the same predicate and derives the recipient from the authenticated identity. Historical query filtering and batch wake matching share the database root-author relation, including community, diff --git a/mobile/lib/shared/push/push_subscription.dart b/mobile/lib/shared/push/push_subscription.dart index 90dee1b5e0..40de6b839d 100644 --- a/mobile/lib/shared/push/push_subscription.dart +++ b/mobile/lib/shared/push/push_subscription.dart @@ -528,26 +528,6 @@ List buildDesiredBuzzPushSubscriptions({ ), ]; - if (authoredThreadReplies) { - final muted = normalizedMuted.toList()..sort(); - subscriptions.add( - BuzzPushSubscription( - filter: BuzzPushFilter( - kinds: buzzPushEligibleKinds, - authoredThreadReplies: true, - since: authoredThreadRepliesSince, - ), - notificationClass: 'default', - ignore: [ - selfAuthored, - for (final chunk in _chunks(muted, buzzPushChannelChunkSize)) - BuzzPushFilter(kinds: buzzPushEligibleKinds, hTags: chunk), - ], - suppress: suppression, - ), - ); - } - final channels = normalizedChannelIds.difference(normalizedMuted).toList() ..sort(); for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) { @@ -563,6 +543,32 @@ List buildDesiredBuzzPushSubscriptions({ if (subscriptions.length > buzzPushMaxSubscriptions) { throw const FormatException('Too many channels for a push lease.'); } + // Existing relay limits may leave no room for a fully muted authored rule. + // Omit that rule rather than dropping exclusions or aborting mention/DM sync. + final muted = normalizedMuted.toList()..sort(); + final authoredMuteChunks = _chunks(muted, buzzPushChannelChunkSize); + if (authoredThreadReplies && + authoredMuteChunks.length + 1 <= buzzPushMaxIgnoreFilters && + subscriptions.length < buzzPushMaxSubscriptions) { + subscriptions.insert( + 1, + BuzzPushSubscription( + filter: BuzzPushFilter( + kinds: buzzPushEligibleKinds, + authoredThreadReplies: true, + since: authoredThreadRepliesSince, + ), + notificationClass: 'default', + ignore: [ + selfAuthored, + for (final chunk in authoredMuteChunks) + BuzzPushFilter(kinds: buzzPushEligibleKinds, hTags: chunk), + ], + suppress: suppression, + ), + ); + } + return List.unmodifiable(subscriptions); } diff --git a/mobile/test/shared/push/push_subscription_test.dart b/mobile/test/shared/push/push_subscription_test.dart index 54e061fb3f..019f882bfd 100644 --- a/mobile/test/shared/push/push_subscription_test.dart +++ b/mobile/test/shared/push/push_subscription_test.dart @@ -8,6 +8,62 @@ void main() { const channelA = '123e4567-e89b-42d3-a456-426614174000'; const channelB = '123e4567-e89b-42d3-a456-426614174001'; + List channels(int count) => [ + for (var i = 0; i < count; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]; + + test( + 'authored mute capacity preserves every exclusion or omits the rule', + () { + final mutes = channels(351); + final atLimit = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + authoredThreadReplies: true, + mutedChannelIds: mutes.take(350), + ).singleWhere((s) => s.filter.authoredThreadReplies); + expect(atLimit.ignore, hasLength(buzzPushMaxIgnoreFilters)); + expect(atLimit.ignore.skip(1).expand((f) => f.hTags!), mutes.take(350)); + + final overLimit = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelA], + authoredThreadReplies: true, + mutedChannelIds: mutes, + ); + final baseline = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: [channelA], + mutedChannelIds: mutes, + ); + expect( + buzzPushSubscriptionsFingerprint(overLimit), + buzzPushSubscriptionsFingerprint(baseline), + ); + expect(overLimit.last.filter.hTags, [channelA]); + }, + ); + + test('authored rule never consumes a required DM subscription slot', () { + final dms = channels(750); + final withRoom = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: dms.take(700), + authoredThreadReplies: true, + ); + expect(withRoom, hasLength(buzzPushMaxSubscriptions)); + expect(withRoom.any((s) => s.filter.authoredThreadReplies), isTrue); + + final full = buildDesiredBuzzPushSubscriptions( + myPubkey: me, + channelIds: dms, + authoredThreadReplies: true, + ); + expect(full, hasLength(buzzPushMaxSubscriptions)); + expect(full.any((s) => s.filter.authoredThreadReplies), isFalse); + expect(full.skip(1).expand((s) => s.filter.hTags!), dms); + }); + test( 'authored replies opt in without enumerating roots and honor all mutes', () {