From a9ced24eb9b2b0a786361d1a12e2241b60f0a141 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 30 Jul 2026 11:25:40 -0400 Subject: [PATCH] feat(relays): add an Active Subscriptions screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-relay purpose chips with a screen whose only job is to answer "why do I have this many subscriptions right now". The chips were the wrong shape. Pivoting on relay hides the thing worth finding: the notifications straggler probe holds ~670 filters across 168 relays, and on a relay-shaped list that is one unremarkable chip repeated on 168 rows. Pivoted on purpose it is a single line that dwarfs everything under it, which is exactly how it was spotted in the first place. Account is the outer grouping — several accounts are normally logged in, they do not share relay sets, and a mixed total cannot be acted on. Purposes sort by filter count, expand to per-entity rows, and carry an explainer describing the actual strategy rather than the intent. Those explainers are written from the code they describe: MODERATION says it asks each relay your follows publish to because UserReportsSubAssembler walks declaredFollowsPerOutboxRelay, and NOTIFICATIONS mentions the follows-wide probe because AccountNotificationsEoseFromRandomRelaysManager subscribes to every follows relay with no sampling. Names resolve at render time from LocalCache and fall back to a short id — a name captured when the filter was built would usually be missing (profiles arrive later) and would go stale on rename. Untagged filters are counted and shown rather than hidden. A total that claims to be fully attributed when it is not would defeat the point of the screen. Reached from the Connected Relays list, which is where the question occurs to people. Notification filters now carry accountPubKey so the largest purpose groups correctly; the remaining assemblers still report under "Not attributed to an account" until they are threaded through. Counts use two separate plurals composed at the call site rather than one string with two %d, so "filter" and "relay" decline independently. NOT visually verified: reaching the screen needs drawer navigation that was not worth scripting. Compile-clean, tests green, and it reads the same activeRequests data already verified on device. Co-Authored-By: Claude Opus 5 (1M context) --- .../FilterNotificationsToPubkey.kt | 11 ++ .../amethyst/ui/navigation/AppNavigation.kt | 3 + .../amethyst/ui/navigation/routes/Routes.kt | 3 + .../relays/common/BasicRelaySetupInfo.kt | 8 - .../common/BasicRelaySetupInfoClickableRow.kt | 2 - .../loggedIn/relays/common/RelayPurposeRow.kt | 84 -------- .../relays/common/SubPurposeLabels.kt | 27 +++ .../connected/ConnectedRelayListView.kt | 31 +++ .../connected/ConnectedRelayListViewModel.kt | 5 - .../ActiveSubscriptionsScreen.kt | 185 ++++++++++++++++++ .../ActiveSubscriptionsViewModel.kt | 177 +++++++++++++++++ amethyst/src/main/res/values/strings.xml | 34 ++++ 12 files changed, 471 insertions(+), 99 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt 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 ea6b03fbe1..0b211a0634 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,6 +157,7 @@ fun filterNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = AllNotificationKinds, tags = mapOf("p" to listOf(pubkey)), limit = limit, @@ -185,6 +186,7 @@ fun filterGroupNotificationsHistoryToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = limit, @@ -207,6 +209,7 @@ fun filterSummaryNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 2000, @@ -229,6 +232,7 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 500, @@ -240,6 +244,7 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 200, @@ -251,6 +256,7 @@ fun filterNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -280,6 +286,7 @@ fun filterGroupNotificationsToPubkey( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = GroupNotificationKinds, tags = mapOf("p" to listOf(pubkey), "h" to groupIds), limit = 200, @@ -302,6 +309,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = SummaryKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -313,6 +321,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds, tags = mapOf("p" to listOf(pubkey)), limit = 20, @@ -324,6 +333,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds2, tags = mapOf("p" to listOf(pubkey)), limit = 10, @@ -335,6 +345,7 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays( filter = ExplainedFilter( purpose = SubPurpose.NOTIFICATIONS, + accountPubKey = pubkey, kinds = NotificationsPerKeyKinds3, tags = mapOf("p" to listOf(pubkey)), limit = 2, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d520612fcb..41ca6d47ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -251,6 +251,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip43.RelayMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions.ActiveSubscriptionsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.RequestToVanishScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.vanish.VanishEventsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.ScheduledPostsScreen @@ -590,6 +591,8 @@ fun BuildNavigation( composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } + + composableFromEndArgs { ActiveSubscriptionsScreen() } composableFromEnd { EventSyncScreen(accountViewModel, nav) } composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 4392da65e9..57656e8fb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -459,6 +459,9 @@ sealed class Route { @Serializable object EditRelays : Route() + /** Diagnostic: explains why the app currently holds the subscriptions it holds. */ + @Serializable object ActiveSubscriptions : Route() + @Serializable object EventSync : Route() @Serializable object RequestToVanish : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt index 25d057fb4b..d4820a1b79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfo.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -34,13 +33,6 @@ data class BasicRelaySetupInfo( val paidRelay: Boolean = false, val forcesTor: Boolean = false, val users: List = emptyList(), - /** - * What this relay is currently doing for us, derived from the purposes tagged onto its in-flight - * filters. Empty when nothing on this relay has been tagged yet — the assemblers are being - * migrated to [com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter] - * incrementally, so an empty set means "not yet attributed", never "idle". - */ - val purposes: Set = emptySet(), ) fun relaySetupInfoBuilder( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt index e522caea3e..b18d85ea04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/BasicRelaySetupInfoClickableRow.kt @@ -143,8 +143,6 @@ fun BasicRelaySetupInfoClickableRow( UsedBy(item, accountViewModel, nav) - RelayPurposeRow(item.purposes) - RelayEventCountRow( countResult = countResult, modifier = ReactionRowHeightChatMaxWidth, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt deleted file mode 100644 index f157743ce2..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayPurposeRow.kt +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose -import com.vitorpamplona.amethyst.ui.stringRes - -/** - * What this relay is currently doing for us, as small chips under the relay's name. - * - * This is the detailed counterpart to the always-on notification: the notification names only the - * dozen jobs that keep running with the app closed, while here — a screen someone opened on purpose - * to inspect relays — every job is shown, including the feed and current-screen work. - * - * Renders nothing when the set is empty. Empty means "no tagged filter in flight on this relay right - * now", which is the honest reading; it must not be drawn as "idle", because a relay can be - * connected with its subscriptions still being assembled. - */ -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun RelayPurposeRow( - purposes: Set, - modifier: Modifier = Modifier, -) { - if (purposes.isEmpty()) return - - // Stable order so the chips do not reshuffle between recompositions as filters come and go. - val sorted = remember(purposes) { purposes.sortedWith(compareBy({ it.group.ordinal }, { it.ordinal })) } - - FlowRow( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - sorted.forEach { purpose -> - Surface( - shape = RoundedCornerShape(4.dp), - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - Text( - text = stringRes(SubPurposeLabels.labelOf(purpose)), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(PaddingValues(horizontal = 5.dp, vertical = 1.dp)), - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt index 693b3c31f2..0926c5268d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/SubPurposeLabels.kt @@ -80,4 +80,31 @@ object SubPurposeLabels { * backgrounding, so itemising them would add noise precisely when nobody is looking. */ fun isWorthNamingInNotification(purpose: SubPurpose): Boolean = purpose.group == SubPurposeGroup.ACCOUNT || purpose.group == SubPurposeGroup.MESSAGES + + /** + * How this subscription actually works — the strategy, not the intent. + * + * Only the jobs whose relay footprint surprises people have one; the rest are self-evident from + * their label. Written from the code they describe: `MODERATION` says it asks each relay your + * follows publish to because `UserReportsSubAssembler` walks `declaredFollowsPerOutboxRelay`, + * and `NOTIFICATIONS` mentions the follows-wide probe because + * `AccountNotificationsEoseFromRandomRelaysManager` subscribes to every follows relay. + */ + fun explainerOf(purpose: SubPurpose): Int? = + when (purpose) { + SubPurpose.NOTIFICATIONS -> R.string.relay_explain_notifications + SubPurpose.DIRECT_MESSAGES -> R.string.relay_explain_direct_messages + SubPurpose.PUBLIC_CHATS -> R.string.relay_explain_public_chats + SubPurpose.COMMUNITY_CHATS -> R.string.relay_explain_community_chats + SubPurpose.ENCRYPTED_GROUPS -> R.string.relay_explain_encrypted_groups + SubPurpose.LIVE_ROOMS -> R.string.relay_explain_live_rooms + SubPurpose.ACCOUNT_DATA -> R.string.relay_explain_account_data + SubPurpose.PROFILE_METADATA -> R.string.relay_explain_profiles + SubPurpose.RELAY_LISTS -> R.string.relay_explain_relay_lists + SubPurpose.FOLLOW_LISTS -> R.string.relay_explain_follows + SubPurpose.MODERATION -> R.string.relay_explain_moderation + SubPurpose.WALLET -> R.string.relay_explain_wallet + SubPurpose.HOME_FEED -> R.string.relay_explain_home + else -> null + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt index 6cba78aed4..8023dafcf3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListView.kt @@ -20,20 +20,31 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.HorzHalfVertPadding @@ -62,6 +73,26 @@ fun LazyListScope.renderConnectedItems( accountViewModel: AccountViewModel, nav: INav, ) { + // The list answers "which relays am I on"; this answers the follow-up question it always + // provokes — "and why are there this many". + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.ActiveSubscriptions) } + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = stringRes(R.string.active_subs_title), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + HorizontalDivider() + } + itemsIndexed(feedState, key = { _, item -> "Connected" + item.relay.url }) { _, item -> BasicRelaySetupInfoDialog( item, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index 6babfdc831..4f6de20024 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel @@ -54,12 +53,8 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { } } - // Every in-flight filter that has been tagged says why this relay is connected. - val purposes = reqs.values.flatten().purposes() - BasicRelaySetupInfo( relay = it, - purposes = purposes, relayStat = Amethyst.instance.relayStats.get(it), forcesTor = Amethyst.instance.torEvaluatorFlow.flow.value 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 new file mode 100644 index 0000000000..7381020bd6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Explains why the app is holding the number of subscriptions it currently holds. + * + * Pivoted on purpose rather than relay, because the relay-shaped view hides exactly the thing worth + * finding: a probe holding hundreds of filters across hundreds of relays looks like one ordinary + * entry repeated on every row, while here it is a single line that dwarfs everything under it. + * + * Account is the outer grouping — several are normally logged in, they do not share relay sets, and + * a mixed total cannot be acted on. + */ +@Composable +fun ActiveSubscriptionsScreen(viewModel: ActiveSubscriptionsViewModel = viewModel()) { + LaunchedEffect(Unit) { viewModel.startPolling() } + val state by viewModel.state.collectAsStateWithLifecycle() + + LazyColumn(Modifier.fillMaxWidth()) { + item { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text( + countsLine(state.totalFilters, state.totalRelays), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (state.untaggedFilters > 0) { + // Stated rather than hidden: an untagged filter is a subscription this screen + // cannot explain, and pretending the total is fully attributed would be a lie. + Text( + pluralStringResource(R.plurals.active_subs_untagged, state.untaggedFilters, state.untaggedFilters), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + HorizontalDivider() + } + + items(state.accounts, key = { it.accountPubKey ?: "unattributed" }) { account -> + AccountSection(account) + HorizontalDivider() + } + } +} + +@Composable +private fun AccountSection(account: SubscriptionAccountRow) { + val name = account.accountPubKey?.let { displayNameOf(it) } ?: stringRes(R.string.active_subs_unattributed) + + Column(Modifier.padding(vertical = 4.dp)) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + account.purposes.forEach { PurposeSection(it) } + } +} + +@Composable +private fun PurposeSection(purposeRow: SubscriptionPurposeRow) { + var expanded by rememberSaveable(purposeRow.purpose) { mutableStateOf(false) } + + Column( + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 16.dp, vertical = 6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = stringRes(SubPurposeLabels.labelOf(purposeRow.purpose)), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = countsLine(purposeRow.filterCount, purposeRow.relays.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (expanded) { + SubPurposeLabels.explainerOf(purposeRow.purpose)?.let { + Text( + text = stringRes(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) + } + purposeRow.entities.forEach { entity -> + val label = + entity.entityId?.let { displayNameOf(it) } + ?: entity.detail + ?: stringRes(R.string.active_subs_no_entity) + Text( + text = stringRes(R.string.active_subs_pair, label, pluralStringResource(R.plurals.active_subs_relays, entity.relays.size, entity.relays.size)), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 12.dp, top = 2.dp), + ) + } + } + } +} + +/** "N filters · M relays", each noun pluralised on its own count. */ +@Composable +private fun countsLine( + filters: Int, + relays: Int, +): String = + stringRes( + R.string.active_subs_pair, + pluralStringResource(R.plurals.active_subs_filters, filters, filters), + pluralStringResource(R.plurals.active_subs_relays, relays, relays), + ) + +/** + * Resolves an id to whatever name is loaded right now, falling back to a short id. + * + * Deliberately at render time rather than baked into the filter: names arrive after the subscription + * that needed them, so a name captured at filter-construction would usually be missing and would go + * stale when the user renames. + */ +@Composable +private fun displayNameOf(id: HexKey): String { + val cached = + remember(id) { + LocalCache.getUserIfExists(id)?.toBestDisplayName() + ?: LocalCache.getNoteIfExists(id)?.event?.let { LocalCache.getUserIfExists(it.pubKey)?.toBestDisplayName() } + } + return cached ?: id.take(8) +} 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 new file mode 100644 index 0000000000..24f04cf36f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * "Why do I have this many subscriptions right now?" + * + * Pivots on **purpose**, not on relay. The relay-shaped view cannot answer the question: a probe + * holding 670 filters across 168 relays looks like one unremarkable chip repeated on 168 rows, + * whereas here it is a single line that reads `Notifications · 670 filters · 168 relays` and is + * immediately obviously the largest thing running. + * + * Grouped by account first because several accounts are normally logged in and they do not share + * relay sets — a total that mixes them cannot be acted on. + * + * Everything is a **snapshot**, polled rather than observed: `activeRequests` is a plain map read + * off the relay pool with no change feed, and subscriptions churn constantly (every EOSE advances a + * `since`). Polling on a visible screen is honest and cheap; a push feed would mean instrumenting + * the pool for a diagnostic screen. + */ +@Immutable +data class SubscriptionEntityRow( + /** Null when the filter named no entity — "the rest of this purpose", not a real entity. */ + val entityId: HexKey?, + val detail: String?, + val relays: List, + val filterCount: Int, +) + +@Immutable +data class SubscriptionPurposeRow( + val purpose: SubPurpose, + val filterCount: Int, + val relays: List, + val entities: List, +) + +@Immutable +data class SubscriptionAccountRow( + /** Null groups everything not yet attributed to an account. Shown last, never hidden. */ + val accountPubKey: HexKey?, + val filterCount: Int, + val relays: List, + val purposes: List, +) + +@Immutable +data class ActiveSubscriptionsState( + val accounts: List = emptyList(), + val totalFilters: Int = 0, + val totalRelays: Int = 0, + /** Filters in flight that carry no purpose — assemblers not yet migrated. Honesty, not a bug. */ + val untaggedFilters: Int = 0, +) + +class ActiveSubscriptionsViewModel : ViewModel() { + private val _state = MutableStateFlow(ActiveSubscriptionsState()) + val state: StateFlow = _state.asStateFlow() + + /** Polls while the screen is on. [REFRESH_MS] is slow enough to be free, fast enough to feel live. */ + fun startPolling() { + viewModelScope.launch(Dispatchers.Default) { + while (isActive) { + _state.value = snapshot() + kotlinx.coroutines.delay(REFRESH_MS) + } + } + } + + private suspend fun snapshot(): ActiveSubscriptionsState = + withContext(Dispatchers.Default) { + val client = Amethyst.instance.client + + // account -> purpose -> entity -> relays / count + val byAccount = mutableMapOf>>>() + val detailOf = mutableMapOf, String?>() + var total = 0 + var untagged = 0 + val allRelays = mutableSetOf() + + client.connectedRelaysFlow().value.forEach { relay -> + client.activeRequests(relay).values.flatten().forEach { filter -> + total++ + val explained = filter as? ExplainedFilter + if (explained == null) { + untagged++ + return@forEach + } + allRelays.add(relay) + byAccount + .getOrPut(explained.accountPubKey) { mutableMapOf() } + .getOrPut(explained.purpose) { mutableMapOf() } + .getOrPut(explained.entityId) { mutableListOf() } + .add(relay) + detailOf[explained.purpose to explained.entityId] = explained.purposeDetail + } + } + + val accounts = + byAccount + .map { (account, purposes) -> + val purposeRows = + purposes + .map { (purpose, entities) -> + val entityRows = + entities + .map { (entityId, relays) -> + SubscriptionEntityRow( + entityId = entityId, + detail = detailOf[purpose to entityId], + relays = relays.distinct().sortedBy { it.url }, + filterCount = relays.size, + ) + }.sortedByDescending { it.filterCount } + SubscriptionPurposeRow( + purpose = purpose, + filterCount = entityRows.sumOf { it.filterCount }, + relays = entityRows.flatMap { it.relays }.distinct(), + entities = entityRows, + ) + }.sortedByDescending { it.filterCount } + SubscriptionAccountRow( + accountPubKey = account, + filterCount = purposeRows.sumOf { it.filterCount }, + relays = purposeRows.flatMap { it.relays }.distinct(), + purposes = purposeRows, + ) + } + // unattributed group last, so it reads as a remainder rather than a headline + .sortedWith(compareBy { it.accountPubKey == null }.thenByDescending { it.filterCount }) + + ActiveSubscriptionsState( + accounts = accounts, + totalFilters = total, + totalRelays = allRelays.size, + untaggedFilters = untagged, + ) + } + + companion object { + const val REFRESH_MS = 2_000L + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 06eda5cabe..6f979366fc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2085,6 +2085,40 @@ Add-ons Relay info Other + + Your inbox relays, plus a probe on every relay your follows post to, in case a mention was delivered somewhere else. + Your DM inbox relays, where gift-wrapped messages are delivered. + The home relay of each chat you have open or joined. + The relays each community publishes its planes to. + Group messages and key packages, on each group\'s relays. + The room\'s relays, while it is open. + Your own profile, settings and drafts, on your home relays. + Profiles of the people currently on screen. + Finds which relays each person publishes to, so their posts can be fetched from the right place. + Follow lists, used to build your feed and your web of trust. + Reports written by people you follow, asked of each relay those people post to. + Your mints, wallet state and incoming nutzaps. + Active Subscriptions + + + %1$d filter + %1$d filters + + + %1$d relay + %1$d relays + + + %1$d filter is not attributed yet + %1$d filters are not attributed yet + + %1$s \u00b7 %2$s + Not attributed to an account + All + Posts by people you follow, read from the relays each of them publishes to. Connecting to inbox relays\u2026 Always-on notification service Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message.