mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
co-authored by
Claude Opus 5
parent
e47a2376aa
commit
a9ced24eb9
+11
@@ -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()
|
||||
|
||||
-8
@@ -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(
|
||||
|
||||
-2
@@ -143,8 +143,6 @@ fun BasicRelaySetupInfoClickableRow(
|
||||
|
||||
UsedBy(item, accountViewModel, nav)
|
||||
|
||||
RelayPurposeRow(item.purposes)
|
||||
|
||||
RelayEventCountRow(
|
||||
countResult = countResult,
|
||||
modifier = ReactionRowHeightChatMaxWidth,
|
||||
|
||||
-84
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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,
|
||||
|
||||
-5
@@ -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
|
||||
|
||||
+185
@@ -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)
|
||||
}
|
||||
+177
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user