feat(relays): add an Active Subscriptions screen

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-30 11:25:40 -04:00
co-authored by Claude Opus 5
parent e47a2376aa
commit a9ced24eb9
12 changed files with 471 additions and 99 deletions
@@ -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,
@@ -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<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.UpdateZapAmount> { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ActiveSubscriptions> { ActiveSubscriptionsScreen() }
composableFromEnd<Route.EventSync> { EventSyncScreen(accountViewModel, nav) }
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
@@ -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()
@@ -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<User> = 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<SubPurpose> = emptySet(),
)
fun relaySetupInfoBuilder(
@@ -143,8 +143,6 @@ fun BasicRelaySetupInfoClickableRow(
UsedBy(item, accountViewModel, nav)
RelayPurposeRow(item.purposes)
RelayEventCountRow(
countResult = countResult,
modifier = ReactionRowHeightChatMaxWidth,
@@ -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<SubPurpose>,
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)),
)
}
}
}
}
@@ -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
}
}
@@ -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,
@@ -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
@@ -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)
}
@@ -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<NormalizedRelayUrl>,
val filterCount: Int,
)
@Immutable
data class SubscriptionPurposeRow(
val purpose: SubPurpose,
val filterCount: Int,
val relays: List<NormalizedRelayUrl>,
val entities: List<SubscriptionEntityRow>,
)
@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<NormalizedRelayUrl>,
val purposes: List<SubscriptionPurposeRow>,
)
@Immutable
data class ActiveSubscriptionsState(
val accounts: List<SubscriptionAccountRow> = 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<ActiveSubscriptionsState> = _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<HexKey?, MutableMap<SubPurpose, MutableMap<HexKey?, MutableList<NormalizedRelayUrl>>>>()
val detailOf = mutableMapOf<Pair<SubPurpose, HexKey?>, String?>()
var total = 0
var untagged = 0
val allRelays = mutableSetOf<NormalizedRelayUrl>()
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<SubscriptionAccountRow> { it.accountPubKey == null }.thenByDescending { it.filterCount })
ActiveSubscriptionsState(
accounts = accounts,
totalFilters = total,
totalRelays = allRelays.size,
untaggedFilters = untagged,
)
}
companion object {
const val REFRESH_MS = 2_000L
}
}
+34
View File
@@ -2085,6 +2085,40 @@
<string name="relay_purpose_add_ons">Add-ons</string>
<string name="relay_purpose_relay_info">Relay info</string>
<string name="relay_purpose_other">Other</string>
<!-- How each subscription actually works, shown on the Active Subscriptions screen.
Describe the real strategy, not the intent — these are read by people trying to explain
a relay count they think is too high. -->
<string name="relay_explain_notifications">Your inbox relays, plus a probe on every relay your follows post to, in case a mention was delivered somewhere else.</string>
<string name="relay_explain_direct_messages">Your DM inbox relays, where gift-wrapped messages are delivered.</string>
<string name="relay_explain_public_chats">The home relay of each chat you have open or joined.</string>
<string name="relay_explain_community_chats">The relays each community publishes its planes to.</string>
<string name="relay_explain_encrypted_groups">Group messages and key packages, on each group\'s relays.</string>
<string name="relay_explain_live_rooms">The room\'s relays, while it is open.</string>
<string name="relay_explain_account_data">Your own profile, settings and drafts, on your home relays.</string>
<string name="relay_explain_profiles">Profiles of the people currently on screen.</string>
<string name="relay_explain_relay_lists">Finds which relays each person publishes to, so their posts can be fetched from the right place.</string>
<string name="relay_explain_follows">Follow lists, used to build your feed and your web of trust.</string>
<string name="relay_explain_moderation">Reports written by people you follow, asked of each relay those people post to.</string>
<string name="relay_explain_wallet">Your mints, wallet state and incoming nutzaps.</string>
<string name="active_subs_title">Active Subscriptions</string>
<!-- Two countable nouns, so two plurals composed at the call site rather than one string with
two %d in it: filter and relay decline independently in Slavic/Baltic/Semitic languages. -->
<plurals name="active_subs_filters">
<item quantity="one">%1$d filter</item>
<item quantity="other">%1$d filters</item>
</plurals>
<plurals name="active_subs_relays">
<item quantity="one">%1$d relay</item>
<item quantity="other">%1$d relays</item>
</plurals>
<plurals name="active_subs_untagged">
<item quantity="one">%1$d filter is not attributed yet</item>
<item quantity="other">%1$d filters are not attributed yet</item>
</plurals>
<string name="active_subs_pair">%1$s \u00b7 %2$s</string>
<string name="active_subs_unattributed">Not attributed to an account</string>
<string name="active_subs_no_entity">All</string>
<string name="relay_explain_home">Posts by people you follow, read from the relays each of them publishes to.</string>
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
<string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">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.</string>