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.