diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 6c1f63faed..08ec932e0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -181,7 +181,7 @@ class AccountFollowsLoaderSubAssembler( purpose = SubPurpose.RELAY_LISTS, kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted(), - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ), ) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index be972b722f..6d1c1fdb74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -34,107 +35,142 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch +/** + * The live notification tail, for **every** logged-in account, in one subscription. + * + * Notifications are `#p`-scoped, so a relay that serves several of the user's accounts can be asked + * about all of them in one filter naming every pubkey — the query is identical in shape, only wider. + * That is what keeps this within a relay's `max_subscriptions`: one REQ per relay instead of one per + * (account, relay). With four accounts open, the per-account form pushed strfry relays past their + * 20-subscription cap and they answered `ERROR: too many concurrent REQs` — a NOTICE, carrying no + * subscription id, so the client could not even tell which REQ had been dropped. Those filters stayed + * "live" in our books and silently never delivered. + * + * Gift wraps deliberately do **not** merge this way. They are unsolicited and their content is opaque + * to the relay, so it cannot rate-limit them per recipient; a merged query would let one spammed + * account consume the shared `limit` and starve every other account's DMs. Each account keeps its own + * budget there — see [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager]. + */ class AccountNotificationsEoseFromInboxRelaysManager( client: INostrClient, allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() +) : SingleSubEoseManager(client, allKeys) { + override fun distinct(key: AccountQueryState) = key.account.userProfile() /** - * Downloads most notifications from the user's own inbox relays. - * But also connects to all the follows relays to check for new notifications that are not in the user's - * own inbox. + * One filter set per inbox relay, naming every account that reads from it. + * + * The `since` floor is per relay rather than per account, because the merged filter is per relay: + * the **oldest** of the participating accounts' floors wins, so widening the query for one account + * can never cut another one short. */ override fun updateFilter( - key: AccountQueryState, + keys: List, since: SincePerRelayMap?, ): List { - // A cold-start floor, NOT paging — backward paging lives in - // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it - // does, the EOSE time wins and this is never consulted. - // - // `since` means *newer than*, so this floors the query at the depth the feed already reaches - // rather than asking all-time again. It stays null until the feed holds a full page — and a - // background account has no feed at all (no screen ever mounted one), which is why the key - // carries none and this reads null there. - val pagingBoundary = key.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() - - val inbox = - key.account.notificationRelays.flow.value.flatMap { - // No `since` floor on the first fetch. These filters are scoped by `#p` to my own - // key and carry a relay-side `limit`, so an all-time query costs one index scan and - // returns at most `limit` events, newest first — exactly what Home does (it passes - // `since ?: boundary`, i.e. null on a cold start). - // - // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for - // anyone whose last mention was older than a week: EOSE `since` is in-memory only, - // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above - // could never rescue it — it only arms once the feed holds a full page, and the feed - // could not fill because the query only ever asked for a week. A fresh install of an - // established account hit the same deadlock. - val notificationSince = since?.get(it)?.time ?: pagingBoundary - - filterSummaryNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = notificationSince, - ) + - filterNotificationsToPubkey( - relay = it, - pubkey = user(key).pubkeyHex, - since = notificationSince, - ) + val accountsPerRelay = mutableMapOf>() + keys.forEach { key -> + key.account.notificationRelays.flow.value.forEach { relay -> + accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key) } + } - // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested here. - // It lives on the group's host relay and used to be one `#h` filter per relay carrying every - // joined group id — but this subscription also carries the inbox filters above, which have no - // `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or multi- - // channel) filter to "global", a class that by design never receives channel-scoped events. So - // those filters answered the stored query at EOSE and then went deaf until the next launch. - // - // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped to - // exactly one channel — RelayGroupJoinedChatTailSubAssembler and BuzzDmJoinedChatTailSubAssembler, - // both mounted app-wide from LoggedInPage, so coverage is unchanged and delivery is live. - return inbox + return accountsPerRelay.flatMap { (relay, accounts) -> + val pubkeys = accounts.map { it.account.userProfile().pubkeyHex } + + // A cold-start floor, NOT paging — backward paging lives in + // [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it + // does, the EOSE time wins and this is never consulted. + // + // `since` means *newer than*, so this floors the query at the depth the feed already reaches + // rather than asking all-time again. It stays null until the feed holds a full page — and a + // background account has no feed at all (no screen ever mounted one), which is why the key + // carries none and this reads null there. Null for ANY account on the relay means no floor + // at all, since a floor derived from one account's feed would truncate the others'. + val floors = accounts.map { it.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() } + val pagingBoundary = if (floors.any { it == null }) null else floors.filterNotNull().min() + + // No `since` floor on the first fetch. These filters are scoped by `#p` to my own + // keys and carry a relay-side `limit`, so an all-time query costs one index scan and + // returns at most `limit` events, newest first — exactly what Home does (it passes + // `since ?: boundary`, i.e. null on a cold start). + // + // This used to fall back to `oneWeekAgo()`, which silently emptied the tab for + // anyone whose last mention was older than a week: EOSE `since` is in-memory only, + // so EVERY cold start re-pinned the window to 7 days, and the paging boundary above + // could never rescue it — it only arms once the feed holds a full page, and the feed + // could not fill because the query only ever asked for a week. A fresh install of an + // established account hit the same deadlock. + val notificationSince = since?.get(relay)?.time ?: pagingBoundary + + // NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested + // here. It lives on the group's host relay and used to be one `#h` filter per relay carrying + // every joined group id — but this subscription also carries the inbox filters below, which + // have no `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or + // multi-channel) filter to "global", a class that by design never receives channel-scoped + // events. So those filters answered the stored query at EOSE and then went deaf until the + // next launch. + // + // Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped + // to exactly one channel — RelayGroupJoinedChatTailSubAssembler and + // BuzzDmJoinedChatTailSubAssembler, both mounted app-wide from LoggedInPage, so coverage is + // unchanged and delivery is live. + filterSummaryNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) + + filterNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) + } } - val userJobMap = mutableMapOf>() + /** + * Per-account watchers, rebuilt as accounts come and go. + * + * There is only one subscription now, so these cannot hang off a per-key `newSub`. They are keyed + * by user and reconciled here: an account that leaves has its jobs cancelled, and one that arrives + * gets its own. Re-entrancy is safe — the watchers call `invalidateFilters()`, which lands back + * here and finds every account already watched. + */ + private val userJobMap = mutableMapOf>() @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - key.account.notificationRelays.flow.sample(1000).collectLatest { - invalidateFilters() - } - }, - // No group/Buzz-DM watchers here any more: those filters moved to the per-channel - // subscriptions, which mount and unmount with the channel itself. - ) + - // Only a screen can fill a feed, so there is nothing to watch for a - // background account. - listOfNotNull( - key.feedContentStates?.let { feeds -> - key.account.scope.launch(Dispatchers.IO) { - feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { - invalidateFilters() - } - } - }, - ) + override fun updateSubscriptions(keys: Set) { + val wanted = keys.associateBy { it.account.userProfile() } - return super.newSub(key) + (userJobMap.keys - wanted.keys).toList().forEach { user -> + userJobMap.remove(user)?.forEach { it.cancel() } + } + + wanted.forEach { (user, key) -> + if (user !in userJobMap) { + userJobMap[user] = + listOf( + key.account.scope.launch(Dispatchers.IO) { + key.account.notificationRelays.flow.sample(1000).collectLatest { + invalidateFilters() + } + }, + ) + + // Only a screen can fill a feed, so there is nothing to watch for a + // background account. + listOfNotNull( + key.feedContentStates?.let { feeds -> + key.account.scope.launch(Dispatchers.IO) { + feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + } + }, + ) + } + } + + super.updateSubscriptions(keys) } - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } + override fun destroy() { + userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } } + userJobMap.clear() + super.destroy() } + + /** Unused here, but kept so callers reading a key's owner do not reach into the account. */ + fun pubkeyOf(key: AccountQueryState): HexKey = key.account.userProfile().pubkeyHex } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 0b211a0634..899e24ec45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -157,7 +157,7 @@ fun filterNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = AllNotificationKinds, tags = mapOf("p" to listOf(pubkey)), limit = limit, @@ -186,7 +186,7 @@ fun filterGroupNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = limit, @@ -196,12 +196,12 @@ fun filterGroupNotificationsHistoryToPubkey( ) } -fun filterSummaryNotificationsToPubkey( +fun filterSummaryNotificationsToPubkeys( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -209,9 +209,9 @@ fun filterSummaryNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = SummaryKinds, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 2000, since = since, ), @@ -219,12 +219,12 @@ fun filterSummaryNotificationsToPubkey( ) } -fun filterNotificationsToPubkey( +fun filterNotificationsToPubkeys( relay: NormalizedRelayUrl, - pubkey: HexKey?, + pubkeys: List, since: Long?, ): List { - if (pubkey.isNullOrEmpty()) return emptyList() + if (pubkeys.isEmpty()) return emptyList() return listOf( RelayBasedFilter( @@ -232,9 +232,9 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 500, since = since, ), @@ -244,9 +244,9 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds2, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 200, since = since, ), @@ -256,9 +256,9 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = pubkeys, kinds = NotificationsPerKeyKinds3, - tags = mapOf("p" to listOf(pubkey)), + tags = mapOf("p" to pubkeys), limit = 10, since = since, ), @@ -286,7 +286,7 @@ fun filterGroupNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = 200, @@ -309,7 +309,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -321,7 +321,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -333,7 +333,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -345,7 +345,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt index 87f42c8d1c..cce973f915 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToAddresses.kt @@ -96,7 +96,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = RepliesAndReactionsToAddressesKinds1, tags = mapOf("a" to sortedList), since = since, @@ -109,7 +109,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = PostsAndChatMessagesToAddresses, tags = mapOf("a" to sortedList), since = since, @@ -122,7 +122,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = DeletionKindList, tags = mapOf("a" to sortedList), since = since, @@ -135,7 +135,7 @@ fun filterRepliesAndReactionsToAddresses( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = TextNoteKindList, tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 3763929013..64510793f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -103,7 +103,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = RepliesAndReactionsKinds, tags = mapOf("e" to sortedList), since = since, @@ -116,7 +116,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = RepliesAndReactionsKinds2, tags = mapOf("e" to sortedList), since = since, @@ -128,7 +128,7 @@ fun filterRepliesAndReactionsToNotes( filter = ExplainedFilter( purpose = SubPurpose.ENGAGEMENT, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND), tags = mapOf("q" to sortedList), since = since, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 1e535e8e97..7743fb7c7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -135,7 +135,7 @@ class UserOutboxFinderSubAssembler( kinds = relayListKinds, authors = sortedUsers, purpose = SubPurpose.RELAY_LISTS, - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ), ) } else { @@ -172,7 +172,7 @@ class UserOutboxFinderSubAssembler( authors = sortedAbandoned, purpose = SubPurpose.RELAY_LISTS, purposeDetail = "outbox discovery for users whose relay list we lost", - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt index ba3022af91..a369d824e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterReportsToKey.kt @@ -44,7 +44,7 @@ fun filterReportsToKeysFromTrusted( purpose = SubPurpose.MODERATION, kinds = ReportKindList, authors = trustedAccounts, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), tags = mapOf("p" to targets.sorted()), since = since, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 488b4b1297..e52167f763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -115,7 +115,7 @@ fun filterUserMetadataForKey( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = UserMetadataForKeyKinds, authors = firstTimers.sorted(), ), @@ -129,7 +129,7 @@ fun filterUserMetadataForKey( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = UserMetadataForKeyKinds, authors = updates.sorted(), since = minimumTime, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 52dc8b0c68..722c6b9389 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -128,7 +128,7 @@ fun ActiveSubscriptionsScreen( item(key = "acct-${account.accountPubKey ?: "none"}") { AccountHeader(account) } items(account.purposes, key = { "${account.accountPubKey}-${it.purpose.name}" }) { purpose -> - PurposeCard(purpose, state.totalFilters, accountViewModel, nav) + PurposeCard(purpose, state.attributedFilters, accountViewModel, nav) } } } @@ -194,7 +194,11 @@ private fun AccountHeader(account: SubscriptionAccountRow) { @Composable private fun PurposeCard( row: SubscriptionPurposeRow, - totalFilters: Int, + /** + * The sum of the per-account counts, not the wire total: a merged filter serving four accounts + * contributes to four cards, so dividing by the wire total would overstate every one of them. + */ + attributedFilters: Int, accountViewModel: AccountViewModel, nav: INav, ) { @@ -233,7 +237,7 @@ private fun PurposeCard( // Share of ALL subscriptions, not of the biggest one: measuring against the biggest // makes one bar permanently full and says nothing about how much of the app's traffic a // purpose actually accounts for. - val share = if (totalFilters > 0) row.filterCount / totalFilters.toFloat() else 0f + val share = if (attributedFilters > 0) row.filterCount / attributedFilters.toFloat() else 0f ShareBar(fraction = share, color = accent) Spacer(Modifier.height(8.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index 3cd5f1fe55..2a34e09b2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -127,6 +127,15 @@ data class ActiveSubscriptionsState( val totalRelays: Int = 0, /** Filters in flight that carry no purpose — assemblers not yet migrated. Honesty, not a bug. */ val untaggedFilters: Int = 0, + /** + * The sum of the per-account counts, which is what a card's share must be drawn against. + * + * It exceeds [totalFilters] when a filter serves several accounts at once — one merged + * notifications REQ naming four pubkeys is one filter on the wire but four accounts' worth of + * explanation. Dividing a per-account count by [totalFilters] would compare the two units and + * overstate every card, which is the mistake the per-entity rows already taught once. + */ + val attributedFilters: Int = 0, ) { /** The largest purpose, so every card can draw its share against a common scale. */ val busiestPurposeFilters: Int = accounts.flatMap { it.purposes }.maxOfOrNull { it.filterCount } ?: 0 @@ -192,27 +201,34 @@ fun aggregateSubscriptions(filtersByRelay: Map> // is what keeps them from collapsing into one nameless row per purpose. val scopeKey = explained.scope?.let { it::class.simpleName } - val tally = - byAccount - .getOrPut(explained.accountPubKey) { mutableMapOf() } - .getOrPut(explained.purpose) { PurposeTally() } + // A merged filter serves several accounts at once — notifications are `#p`-scoped, so + // every account reading a relay is asked for in one filter naming all of them. It shows + // under each of those accounts, because "why is this relay busy for me" has to be + // answerable per account. That makes the per-account counts a breakdown of a shared + // filter rather than a partition of the total, which is what [attributedFilters] exists + // to keep straight. + val accounts: List = explained.accountPubKeys?.takeIf { it.isNotEmpty() } ?: listOf(null) + accounts.forEach { account -> + val tally = + byAccount + .getOrPut(account) { mutableMapOf() } + .getOrPut(explained.purpose) { PurposeTally() } - // The filter counts ONCE, here — before it is fanned out below. This is the - // number that shares a unit with `total`, so a card's share of the whole is a - // comparison of like with like. - tally.filters++ - tally.relays.add(relay) + // Once per account this filter serves, never once per entity it names. + tally.filters++ + tally.relays.add(relay) - // A batched filter serves several entities at once — relay-group state is one #d - // filter per host relay carrying every joined group on it — so it contributes a - // row to each of them rather than collapsing to "All". These rows are a - // breakdown, never a total: see [SubscriptionEntityRow.namedInFilters]. - val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) - entities.forEach { entityId -> - val key = EntityKey(entityId, scopeKey) - tally.entities.getOrPut(key) { mutableListOf() }.add(relay) - detailOf[explained.purpose to key] = explained.purposeDetail - explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } + // A batched filter serves several entities at once — relay-group state is one #d + // filter per host relay carrying every joined group on it — so it contributes a + // row to each of them rather than collapsing to "All". These rows are a + // breakdown, never a total: see [SubscriptionEntityRow.namedInFilters]. + val entities: List = explained.entityIds?.takeIf { it.isNotEmpty() } ?: listOf(null) + entities.forEach { entityId -> + val key = EntityKey(entityId, scopeKey) + tally.entities.getOrPut(key) { mutableListOf() }.add(relay) + detailOf[explained.purpose to key] = explained.purposeDetail + explained.scope?.let { scopeOf.getOrPut(explained.purpose to key) { it } } + } } } } @@ -258,5 +274,6 @@ fun aggregateSubscriptions(filtersByRelay: Map> totalFilters = total, totalRelays = allRelays.size, untaggedFilters = untagged, + attributedFilters = accounts.sumOf { it.filterCount }, ) } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt index 88f32a424c..87b3beca2f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/AggregateSubscriptionsTest.kt @@ -50,7 +50,7 @@ class AggregateSubscriptionsTest { entityIds = chats, kinds = listOf(kind), tags = mapOf("e" to chats), - accountPubKey = account, + accountPubKeys = listOfNotNull(account), ) /** Six relays, each carrying four batched filters that each name all six chats. */ @@ -99,6 +99,49 @@ class AggregateSubscriptionsTest { ) } + /** The merged notifications filter: one REQ per relay naming every account that reads it. */ + private fun mergedNotifications(vararg accounts: String) = + ExplainedFilter( + purpose = SubPurpose.NOTIFICATIONS, + accountPubKeys = accounts.toList(), + kinds = listOf(1), + tags = mapOf("p" to accounts.toList()), + ) + + @Test + fun `a merged filter explains itself to every account it serves`() { + val a = "aa".repeat(32) + val b = "bb".repeat(32) + val c = "cc".repeat(32) + val state = aggregateSubscriptions(mapOf(relay(1) to listOf(mergedNotifications(a, b, c)))) + + // One filter on the wire... + assertEquals(1, state.totalFilters) + // ...but all three accounts can see why their relay is busy. + assertEquals(3, state.accounts.size) + state.accounts.forEach { assertEquals(1, it.filterCount) } + } + + @Test + fun `a card's share is drawn against the attributed total, not the wire total`() { + val a = "aa".repeat(32) + val b = "bb".repeat(32) + val state = aggregateSubscriptions(mapOf(relay(1) to listOf(mergedNotifications(a, b)))) + + // Dividing the per-account count by totalFilters would read as 100% for each of two + // accounts. attributedFilters is the unit those counts actually belong to. + assertEquals(1, state.totalFilters) + assertEquals(2, state.attributedFilters) + assertEquals(state.attributedFilters, state.accounts.sumOf { it.filterCount }) + } + + @Test + fun `unmerged filters keep attributed and wire totals identical`() { + val state = aggregateSubscriptions(sixChatsOnSixRelays()) + + assertEquals(state.totalFilters, state.attributedFilters) + } + @Test fun `untagged filters are reported but never attributed to a purpose`() { val state = diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 74ee3ca92b..52b4436475 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -183,7 +183,7 @@ object ConcordSubscriptionPlanner { purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", entityIds = listOf(entry.id), - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), // -1 so the last-read message (created_at == lastRead) is itself returned: @@ -196,7 +196,7 @@ object ConcordSubscriptionPlanner { purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord community planes", entityIds = listOf(entry.id), - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), limit = previewLimit, @@ -288,7 +288,7 @@ object ConcordSubscriptionPlanner { purpose = SubPurpose.COMMUNITY_CHATS, purposeDetail = "concord live planes", entityIds = communitiesByRelay[relay]?.sorted(), - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the // live-only typing heartbeats a relay broadcasts but never stores. kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index 1831b0353e..78fb9a191d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -138,7 +138,7 @@ private class CashuWalletSubAssembler( MintRecommendationEvent.KIND, ), authors = listOf(pubkey), - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), ) val inboundNutzapsFilter = @@ -146,7 +146,7 @@ private class CashuWalletSubAssembler( purpose = SubPurpose.NUTZAP_INBOX, kinds = listOf(NutzapEvent.KIND), tags = mapOf("p" to listOf(pubkey)), - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), ) // Own NIP-60 events are read from the user's outbox; inbound nutzaps diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 3c654bf55b..b7bfc89949 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -48,7 +48,7 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( filter = ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = accountPubKey, + accountPubKeys = listOfNotNull(accountPubKey), kinds = ContactCardKindList, authors = trustedAccounts, // kind:30382 addresses the target user in the d-tag @@ -75,7 +75,7 @@ fun filterContactCardsByAuthorInTheRelay( ExplainedFilter( purpose = SubPurpose.PROFILE_METADATA, // This variant fetches an account's OWN contact card, so the author is the owner. - accountPubKey = author, + accountPubKeys = listOfNotNull(author), kinds = ContactCardKindList, authors = listOf(author), limit = limit, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt index 5ba35efcdc..12bb81893d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -85,7 +85,7 @@ class MetadataFilterAssembler( authors = pubkeyList, limit = pubkeyList.size, purpose = SubPurpose.PROFILE_METADATA, - accountPubKey = soleAccountPubKey, + accountPubKeys = listOfNotNull(soleAccountPubKey), ) // Apply since times per relay diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt index 7c46aea5ac..68d943bd36 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilter.kt @@ -82,15 +82,20 @@ class ExplainedFilter( */ val entityIds: List? = null, /** - * Which logged-in account asked for this. Several accounts are commonly active at once and they + * Which logged-in accounts asked for this. Several accounts are commonly active at once and they * do not share relay sets, so "why is this relay connected" is only answerable per account — * without it, one account's communities look like another's. * - * A pubkey rather than an account reference: filters outlive the objects that created them and - * are held by the relay pool for the session, so holding an `Account` here would keep a logged-out + * A **list**, because a filter can legitimately serve several accounts at once. Notifications are + * `#p`-scoped, so every account reading the same relay can be asked for in one filter naming all + * of them — which is what keeps a relay under its `max_subscriptions` cap when the app holds four + * accounts open. Filters that serve one account carry a single-element list. + * + * Pubkeys rather than account references: filters outlive the objects that created them and are + * held by the relay pool for the session, so holding an `Account` here would keep a logged-out * account alive. */ - val accountPubKey: HexKey? = null, + val accountPubKeys: List? = null, /** * The top-nav selection that produced this filter — Global, the user's follows, a hashtag, a * geohash, a community. @@ -120,7 +125,7 @@ class ExplainedFilter( until: Long?, limit: Int?, search: String?, - ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKey, scope) + ) = ExplainedFilter(ids, authors, kinds, tags, tagsAll, since, until, limit, search, purpose, purposeDetail, entityIds, accountPubKeys, scope) companion object { /** Tags [filter] with a [purpose], preserving every protocol field. */ @@ -129,7 +134,7 @@ class ExplainedFilter( purpose: SubPurpose, detail: String? = null, entityIds: List? = null, - accountPubKey: HexKey? = null, + accountPubKeys: List? = null, scope: IFeedTopNavPerRelayFilter? = null, ) = ExplainedFilter( filter.ids, @@ -144,7 +149,7 @@ class ExplainedFilter( purpose, detail, entityIds, - accountPubKey, + accountPubKeys, scope, ) } @@ -168,9 +173,9 @@ fun Collection.purposeEntities(): Set = val explained = filter as? ExplainedFilter ?: return@flatMapTo emptyList() val ids = explained.entityIds if (ids.isNullOrEmpty()) { - listOf(PurposeEntity(explained.purpose, null, explained.accountPubKey, explained.purposeDetail)) + listOf(PurposeEntity(explained.purpose, null, explained.accountPubKeys?.firstOrNull(), explained.purposeDetail)) } else { - ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKey, explained.purposeDetail) } + ids.map { PurposeEntity(explained.purpose, it, explained.accountPubKeys?.firstOrNull(), explained.purposeDetail) } } } @@ -192,10 +197,10 @@ data class PurposeEntity( fun List.attributedTo(accountPubKey: HexKey): List = map { relayFilter -> val filter = relayFilter.filter - if (filter is ExplainedFilter && filter.accountPubKey == null) { + if (filter is ExplainedFilter && filter.accountPubKeys.isNullOrEmpty()) { RelayBasedFilter( relay = relayFilter.relay, - filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, accountPubKey, filter.scope), + filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, listOf(accountPubKey), filter.scope), ) } else { relayFilter @@ -219,7 +224,7 @@ fun List.scopedTo(feedSettings: IFeedTopNavPerRelayFilterSet): if (filter is ExplainedFilter && scope != null) { RelayBasedFilter( relay = relayFilter.relay, - filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, filter.accountPubKey, scope), + filter = ExplainedFilter.of(filter, filter.purpose, filter.purposeDetail, filter.entityIds, filter.accountPubKeys, scope), ) } else { relayFilter diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt index 61baa6bc71..a983b300e2 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/ExplainedFilterTest.kt @@ -58,7 +58,7 @@ class ExplainedFilterTest { purpose = SubPurpose.NOTIFICATIONS, purposeDetail = "inbox relays for the active account", entityIds = listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), - accountPubKey = pubkey, + accountPubKeys = listOfNotNull(pubkey), scope = HashtagTopNavPerRelayFilter(setOf("askednostr")), ) @@ -115,7 +115,7 @@ class ExplainedFilterTest { assertEquals("inbox relays for the active account", (advanced as ExplainedFilter).purposeDetail) // entityIds/accountPubKey/scope ride the same path and would vanish just as silently assertEquals(listOf("cafe0000000000000000000000000000000000000000000000000000000000ff"), advanced.entityIds) - assertEquals(pubkey, advanced.accountPubKey) + assertEquals(listOf(pubkey), advanced.accountPubKeys) assertEquals(setOf("askednostr"), (advanced.scope as? HashtagTopNavPerRelayFilter)?.hashtags) assertEquals(1_785_379_272L, advanced.since) // and the protocol fields came along untouched