From 2ebbba4fe9e23177bf83ded44c02b4473e7b3f13 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 01:11:01 +0000 Subject: [PATCH] feat(relay): attach human-readable reasons to relay subscriptions Every relay subscription now carries a short, client-side "reason" string explaining why it is open (e.g. "Your private messages", "Notifications", "Home feed", "Your Concord groups"). The always-on notification service uses these to show not just HOW MANY relay connections are established, but WHAT each one is doing. Mechanism: - quartz: `Subscription` gains a `reason` field, threaded through `SubscriptionController` into `INostrClient.subscribe(..., reason)`. `NostrClient` keeps a `subscriptionReasonsFlow()` (subId -> reason), populated on subscribe and pruned on unsubscribe, so it mirrors exactly the subscriptions actively sending REQs to relays. - commons: `BaseEoseManager` exposes an overridable `subscriptionReason`; when unset it falls back to a humanized class name, so every subscription is labeled even without an explicit override. - Headline managers (DMs, notifications, account info, drafts, home, video, discovery, profile, hashtag, community, Concord/relay groups, NWC, Cashu, search, finders, secure groups) declare friendly labels. - NotificationRelayService combines the connected-relay count with the grouped reasons and renders them via InboxStyle (identical reasons collapse to "reason xN", overflow folds into a "+N more" line). The collapsed view is unchanged. The reason is purely an in-app diagnostic label; it never reaches relays (a REQ still carries only the subId and filters). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EyFmjFs1KUe2PUQHxkbRP1 --- .../notifications/NotificationRelayService.kt | 111 +++++++++++++----- .../drafts/AccountDraftsEoseManager.kt | 2 + .../AccountFollowsLoaderSubAssembler.kt | 1 + .../marmot/MarmotGroupEventsEoseManager.kt | 2 + .../metadata/AccountMetadataEoseManager.kt | 2 + ...NotificationsEoseFromInboxRelaysManager.kt | 2 + ...otificationsEoseFromRandomRelaysManager.kt | 2 + .../AccountNotificationsHistoryEoseManager.kt | 2 + .../AccountGiftWrapsEoseManager.kt | 2 + .../AccountGiftWrapsHistoryEoseManager.kt | 2 + .../loaders/NoteEventLoaderSubAssembler.kt | 2 + .../watchers/EventWatcherSubAssembler.kt | 2 + .../nwc/NWCPaymentWatcherSubAssembler.kt | 2 + .../user/watchers/UserWatcherSubAssembler.kt | 2 + .../SearchPostWatcherSubAssembler.kt | 2 + .../SearchUserWatcherSubAssembler.kt | 2 + .../datasource/ChatroomNip04SubAssembler.kt | 2 + .../ConcordChannelFilterAssembler.kt | 2 + .../ConcordChannelHistoryFilterAssembler.kt | 2 + ...RelayGroupJoinedChatTailFilterAssembler.kt | 2 + .../RelayGroupJoinedStateFilterAssembler.kt | 2 + .../ChatroomListNip04SubAssembler.kt | 2 + .../CommunityFeedFilterSubAssembler.kt | 2 + ...yFollowsSetsAndLiveStreamsSubAssembler2.kt | 2 + .../HashtagFeedFilterSubAssembler.kt | 2 + .../HomeOutboxEventsEoseManager.kt | 2 + .../UserProfilePostsFilterSubAssembler.kt | 2 + .../VideoOutboxEventsFilterSubAssembler.kt | 2 + amethyst/src/main/res/values/strings.xml | 4 + .../BlockedRelayFilteringClient.kt | 3 +- .../CashuMintDirectoryFilterAssembler.kt | 2 + .../assemblers/CashuWalletFilterAssembler.kt | 2 + .../assemblers/MetadataFilterAssembler.kt | 2 + .../assemblers/ReactionsFilterAssembler.kt | 2 + .../eoseManagers/BaseEoseManager.kt | 42 ++++++- .../BlockedRelayFilteringClientTest.kt | 1 + .../assemblers/FeedMetadataCoordinatorTest.kt | 1 + .../nip17Dm/DmInboxRelayResolverOutboxTest.kt | 1 + .../commons/wot/OutboxDispatcherTest.kt | 1 + .../nip01Core/relay/client/INostrClient.kt | 13 ++ .../nip01Core/relay/client/NostrClient.kt | 36 ++++++ .../client/subscriptions/Subscription.kt | 9 ++ .../subscriptions/SubscriptionController.kt | 10 +- .../accessories/FetchAllIdleTimeoutTest.kt | 1 + .../server/NostrConnectSignerServiceTest.kt | 1 + .../signer/NostrSignerRemoteIsolationTest.kt | 1 + .../signer/RemoteSignerManagerRetryTest.kt | 2 + 47 files changed, 265 insertions(+), 33 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index c2fa2eab2d..fa62569384 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch @@ -141,6 +142,15 @@ class NotificationRelayService : Service() { private var relayServiceCollectorJob: Job? = null private var connectedRelayCount = 0 + // Cap of reason lines shown in the expanded notification; the rest collapse into a + // "+N more" line so the notification stays readable when many screens are open. + private val maxReasonLines = 8 + + // The grouped, human-readable reasons for the currently-active subscriptions + // ("Your DMs", "Home feed ×2", …). Kept so ensureForeground() can rebuild the same + // expanded notification on every re-promotion, not just on a reason change. + private var activeReasons: List = emptyList() + override fun onBind(intent: Intent?): IBinder? = null override fun onCreate() { @@ -235,7 +245,7 @@ class NotificationRelayService : Service() { */ private fun ensureForeground(): Boolean { try { - val notification = buildNotification(connectedRelayCount) + val notification = buildNotification(connectedRelayCount, activeReasons) ServiceCompat.startForeground( this, NOTIFICATION_ID, @@ -298,32 +308,57 @@ class NotificationRelayService : Service() { launch { // sample() caps how often we touch the notification. During feed - // load/teardown connectedRelaysFlow churns dozens of times per second; - // posting on every delta blows past Android's notification rate limit - // (~10/s), which silently drops updates and leaves the visible count - // stuck on a stale intermediate value. One refresh per second stays - // well under the limit and always lands the settled count. - Amethyst.instance.client - .connectedRelaysFlow() - .sample(NOTIFICATION_REFRESH_MS) - .collectLatest { relays -> - val count = relays.size - if (count != connectedRelayCount) { + // load/teardown both flows churn dozens of times per second; posting on + // every delta blows past Android's notification rate limit (~10/s), which + // silently drops updates and leaves the visible state stale. One refresh + // per second stays well under the limit and always lands the settled value. + // + // We combine the connected-relay count with the per-subscription reasons + // so the ongoing notification shows not just HOW MANY connections are open + // but WHAT each one is doing ("Your DMs", "Notifications", "Home feed", …). + combine( + Amethyst.instance.client.connectedRelaysFlow(), + Amethyst.instance.client.subscriptionReasonsFlow(), + ) { relays, reasons -> + relays.size to groupReasons(reasons) + }.sample(NOTIFICATION_REFRESH_MS) + .collectLatest { (count, reasons) -> + if (count != connectedRelayCount || reasons != activeReasons) { connectedRelayCount = count - updateNotification(count) + activeReasons = reasons + updateNotification(count, reasons) } } } } } - private fun updateNotification(connectedRelays: Int) { - val notification = buildNotification(connectedRelays) + private fun updateNotification( + connectedRelays: Int, + reasons: List, + ) { + val notification = buildNotification(connectedRelays, reasons) val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(NOTIFICATION_ID, notification) } - private fun buildNotification(connectedRelays: Int): Notification { + /** + * Turns the raw subId -> reason map into a display list: identical reasons collapse + * into one line with a "×N" multiplier (e.g. two open threads -> "A profile's posts ×2"), + * sorted by how many subscriptions share each reason so the busiest work shows first. + */ + private fun groupReasons(reasons: Map): List = + reasons.values + .groupingBy { it } + .eachCount() + .entries + .sortedWith(compareByDescending> { it.value }.thenBy { it.key }) + .map { (reason, count) -> if (count > 1) "$reason ×$count" else reason } + + private fun buildNotification( + connectedRelays: Int, + reasons: List, + ): Notification { val contentText = when { connectedRelays <= 0 -> getString(R.string.always_on_notif_connecting) @@ -348,17 +383,39 @@ class NotificationRelayService : Service() { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - return NotificationCompat - .Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.always_on_notif_title)) - .setContentText(contentText) - .setSmallIcon(R.drawable.amethyst_service) - .setContentIntent(pendingIntent) - .setOngoing(true) - .setSilent(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .build() + val builder = + NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.always_on_notif_title)) + .setContentText(contentText) + .setSmallIcon(R.drawable.amethyst_service) + .setContentIntent(pendingIntent) + .setOngoing(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + + // When expanded, list what each open connection is doing. InboxStyle shows one + // line per active subscription reason (capped at [maxReasonLines], the overflow + // folded into a "+N more" line), with the relay-count line as the summary. The + // collapsed view keeps showing just [contentText], so nothing changes for users + // who never expand the notification. + if (reasons.isNotEmpty()) { + val inbox = + NotificationCompat + .InboxStyle() + .setBigContentTitle(getString(R.string.always_on_notif_title)) + .setSummaryText(contentText) + + reasons.take(maxReasonLines).forEach { inbox.addLine(it) } + val overflow = reasons.size - maxReasonLines + if (overflow > 0) { + inbox.addLine(pluralStringRes(this, R.plurals.always_on_notif_more_subscriptions, overflow, overflow)) + } + builder.setStyle(inbox) + } + + return builder.build() } private fun createNotificationChannel() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.kt index 1d87c2f002..440dec2046 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/drafts/AccountDraftsEoseManager.kt @@ -37,6 +37,8 @@ class AccountDraftsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your drafts" + override fun user(key: AccountQueryState) = key.account.userProfile() fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 5a88cff00b..21c9d0dcd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -138,6 +138,7 @@ class AccountFollowsLoaderSubAssembler( } } }, + reason = "Contacts' relay lists", ) fun updateFilterForAllAccounts(accounts: Collection): List? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt index 4dc904d02e..cfc850fadc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/marmot/MarmotGroupEventsEoseManager.kt @@ -45,6 +45,8 @@ class MarmotGroupEventsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your secure groups" + override fun user(key: AccountQueryState) = key.account.userProfile() override fun updateFilter( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt index db90fd8b7c..9ca64e95cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/AccountMetadataEoseManager.kt @@ -38,6 +38,8 @@ class AccountMetadataEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your account info" + override fun user(key: AccountQueryState) = key.account.userProfile() fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 06d29d6bbd..9d2368a10e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -41,6 +41,8 @@ class AccountNotificationsEoseFromInboxRelaysManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your notifications" + override fun user(key: AccountQueryState) = key.account.userProfile() /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 7cfc8cd385..12f57fd3a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -39,6 +39,8 @@ class AccountNotificationsEoseFromRandomRelaysManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your notifications" + override fun user(key: AccountQueryState) = key.account.userProfile() /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt index 485f8fb9f9..a5f68b5463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt @@ -74,6 +74,8 @@ class AccountNotificationsHistoryEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your notifications (history)" + override fun user(key: AccountQueryState) = key.account.userProfile() // A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt index 3c14d32848..4d57e8e184 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt @@ -54,6 +54,8 @@ class AccountGiftWrapsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your private messages" + override fun user(key: AccountQueryState) = key.account.userProfile() // The initial-load tracker drives the boot spinner: it stays true until every DM relay has diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 02d351be88..25f331e78c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -60,6 +60,8 @@ class AccountGiftWrapsHistoryEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your private messages (history)" + override fun user(key: AccountQueryState) = key.account.userProfile() private val pager = BackwardRelayPager("giftwrap.history") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt index 9ff15e6407..098337d08d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/loaders/NoteEventLoaderSubAssembler.kt @@ -28,6 +28,8 @@ class NoteEventLoaderSubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { + override val subscriptionReason get() = "Loading posts" + override fun updateFilter(keys: List) = listOfNotNull( filterMissingEvents(keys), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt index 91358cdfd0..cea338dfaf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/EventWatcherSubAssembler.kt @@ -36,6 +36,8 @@ class EventWatcherSubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { + override val subscriptionReason get() = "Watching replies & reactions" + var lastNotesOnFilter = emptyList() var latestEOSEs: EOSEAccountFast = EOSEAccountFast(1000) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt index b179e5a042..b06bd882c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -28,6 +28,8 @@ class NWCPaymentWatcherSubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys) { + override val subscriptionReason get() = "Wallet payments" + override fun updateFilter(keys: List): List? { if (keys.isEmpty()) return null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index 6d8f7efb23..be18956ef4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -41,6 +41,8 @@ class UserWatcherSubAssembler( val failureTracker: RelayOfflineTracker, allKeys: () -> Set, ) : BaseEoseManager(client, allKeys) { + override val subscriptionReason get() = "Loading profiles" + /** * This assembler saves the EOSE per user key. That EOSE includes their metadata, etc * and reports, but only from trusted accounts (follows of all logged in users). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostWatcherSubAssembler.kt index 94b63bcadf..ac26729163 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostWatcherSubAssembler.kt @@ -47,6 +47,8 @@ class SearchPostWatcherSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "Search results" + override fun updateFilter( key: SearchQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt index 84e06563e5..7e98d27d76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchUserWatcherSubAssembler.kt @@ -48,6 +48,8 @@ class SearchUserWatcherSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "Searching people" + override fun updateFilter( key: SearchQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt index f7c8bfb686..d67c115d82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04SubAssembler.kt @@ -42,6 +42,8 @@ class ChatroomNip04SubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { + override val subscriptionReason get() = "A direct message chat" + private val windowLoad = WindowLoadTracker("convo.nip04.live") val loadingMore: StateFlow = windowLoad.loading diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index e90d18bc91..06a5eac243 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -68,6 +68,8 @@ class ConcordChannelSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your Concord groups" + override fun updateFilter( key: ConcordChannelQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt index af85aac007..e30257dfac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -79,6 +79,8 @@ class ConcordChannelHistorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "A Concord group's history" + // Floor at `now` (liveTailSeconds = 0), NOT the DM 7-day tail: the Concord live subscription isn't a // strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging // must walk the WHOLE history from the top to reach "recent but capped" messages. Overlap with the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt index 8a6e02f52e..6d4cc37444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedChatTailFilterAssembler.kt @@ -75,6 +75,8 @@ class RelayGroupJoinedChatTailSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your group chats" + private val windowLoad = WindowLoadTracker("relayGroup.preview.live") val loadingMore: StateFlow = windowLoad.loading diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt index 3f55679e1b..8cd8bd52fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupJoinedStateFilterAssembler.kt @@ -65,6 +65,8 @@ class RelayGroupJoinedStateSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your group info" + override fun updateFilter( key: RelayGroupJoinedStateQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt index 2c951d7823..876e0381eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04SubAssembler.kt @@ -50,6 +50,8 @@ class ChatroomListNip04SubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "Your direct messages" + private val windowLoad = WindowLoadTracker("rooms.nip04.live") val loadingMore: StateFlow = windowLoad.loading diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt index d3571be89c..433246edc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFeedFilterSubAssembler.kt @@ -32,6 +32,8 @@ class CommunityFeedFilterSubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys) { + override val subscriptionReason get() = "Community feed" + override fun updateFilter( keys: List, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt index 78eb084d1b..70f2d92166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt @@ -41,6 +41,8 @@ class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { + override val subscriptionReason get() = "Discover feed" + override fun updateFilter( key: DiscoveryQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt index 23a6b1d224..35af3bfecc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/HashtagFeedFilterSubAssembler.kt @@ -29,6 +29,8 @@ class HashtagFeedFilterSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + override val subscriptionReason get() = "Hashtag feed" + override fun updateFilter( key: HashtagQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index ce93d4018a..df32ed9d60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -56,6 +56,8 @@ class HomeOutboxEventsEoseManager( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { + override val subscriptionReason get() = "Home feed" + override fun updateFilter( key: HomeQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt index 2ef23e9a92..414cc4b705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfilePostsFilterSubAssembler.kt @@ -29,6 +29,8 @@ class UserProfilePostsFilterSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserEoseManager(client, allKeys) { + override val subscriptionReason get() = "A profile's posts" + override fun updateFilter( key: UserProfileQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index 5b02873027..e132bbfdb2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -55,6 +55,8 @@ class VideoOutboxEventsFilterSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUserAndFollowListEoseManager(client, allKeys) { + override val subscriptionReason get() = "Video feed" + override fun updateFilter( key: VideoQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d820e271eb..511d47c23e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1948,6 +1948,10 @@ Connected to %1$d relays Connecting to inbox relays\u2026 + + +%1$d more + +%1$d more + 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. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt index 80e446bc51..7f8293d1e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt @@ -57,8 +57,9 @@ class BlockedRelayFilteringClient( subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { - delegate.subscribe(subId, filters.withoutBlocked(), listener) + delegate.subscribe(subId, filters.withoutBlocked(), listener, reason) } override fun count( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt index d96bb24b14..f116d64e82 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuMintDirectoryFilterAssembler.kt @@ -70,6 +70,8 @@ private class CashuMintDirectorySubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override val subscriptionReason get() = "Cashu mint directory" + override fun distinct(key: CashuMintDirectoryQueryState): Any = key.relays.hashCode() override fun updateFilter( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt index c761981205..de74cd77f2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/CashuWalletFilterAssembler.kt @@ -92,6 +92,8 @@ private class CashuWalletSubAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override val subscriptionReason get() = "Your Cashu wallet" + override fun distinct(key: CashuWalletQueryState): Any = key.pubkey override fun updateFilter( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt index 1dffa08b4e..cc23786061 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/MetadataFilterAssembler.kt @@ -51,6 +51,8 @@ class MetadataFilterAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override val subscriptionReason get() = "Loading profile info" + override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode() override fun updateFilter( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt index 72c371f65d..89943be72b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ReactionsFilterAssembler.kt @@ -51,6 +51,8 @@ class ReactionsFilterAssembler( client: INostrClient, allKeys: () -> Set, ) : SingleSubEoseManager(client, allKeys, invalidateAfterEose = true) { + override val subscriptionReason get() = "Loading reactions" + override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode() override fun updateFilter( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index a16d3734e7..d337355430 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -35,11 +35,51 @@ abstract class BaseEoseManager( ) : IEoseManager { private val orchestrator = SubscriptionController(client) + /** + * A short, human-readable explanation of what this manager's subscriptions are + * doing (e.g. "Your DMs", "Notifications", "Home feed"). Surfaced by the always-on + * notification so the user can see what each open relay connection is for. + * + * Override with a friendly, CONSTANT label (do not reference constructor fields that + * may not be initialized yet: [SingleSubEoseManager] creates its subscription in a + * property initializer, so this getter can run before the leaf class finishes + * constructing). When null, a readable name derived from the class name is used, so + * every subscription is labeled even without an override. + */ + open val subscriptionReason: String? get() = null + abstract fun updateSubscriptions(keys: Set) fun getSubscription(subId: String) = orchestrator.getSub(subId) - fun requestNewSubscription(listener: SubscriptionListener) = orchestrator.requestNewSubscription(newSubId(), listener) + fun requestNewSubscription(listener: SubscriptionListener) = orchestrator.requestNewSubscription(newSubId(), listener, resolveReason()) + + fun requestNewSubscription( + reason: String, + listener: SubscriptionListener, + ) = orchestrator.requestNewSubscription(newSubId(), listener, reason) + + private fun resolveReason(): String = subscriptionReason ?: humanizeClassName() + + /** + * Fallback label for managers that don't override [subscriptionReason]: strips the + * infrastructure suffix from the class name and splits camelCase into words, e.g. + * `HomeOutboxEventsEoseManager` -> "Home Outbox Events". Not pretty for every class, + * but always non-blank and good enough to tell subscriptions apart in the list. + */ + private fun humanizeClassName(): String { + val raw = this::class.simpleName ?: return "Subscription" + val stripped = + raw + .removeSuffix("SubAssembler") + .removeSuffix("SubAssembly") + .removeSuffix("EoseManager") + .removeSuffix("FilterAssembler") + .removeSuffix("Assembler") + .removeSuffix("Manager") + .ifEmpty { raw } + return stripped.replace(Regex("(?<=[a-z0-9])(?=[A-Z])"), " ").trim() + } fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt index 4965c0cc40..36d790a2a2 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt @@ -55,6 +55,7 @@ class BlockedRelayFilteringClientTest { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { subscribedFilters = filters } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt index c0f8a609d4..b7b6ba7cb3 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt @@ -89,6 +89,7 @@ class FeedMetadataCoordinatorTest { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { subscriptions[subId] = listener subscribeCalls.add(filters) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt index 1e22c715f1..1db4549661 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverOutboxTest.kt @@ -91,6 +91,7 @@ class DmInboxRelayResolverOutboxTest { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { filters.forEach { (relay, filterList) -> queriedRelays.add(relay) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt index 9238b5ce1d..81e531049c 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt @@ -165,6 +165,7 @@ class OutboxDispatcherTest { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { allSubscribeCalls.add(filters) filters.forEach { (relay, filterList) -> diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index f57e48c462..d249de1ab3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -69,8 +69,20 @@ interface INostrClient : AutoCloseable { subId: String = newSubId(), filters: Map>, listener: SubscriptionListener? = null, + reason: String = "", ) + /** + * Maps each currently-active subscription id to a short, human-readable + * explanation of why it is open (e.g. "Your DMs", "Notifications"). Only + * subscriptions that were opened with a non-blank reason appear here, and + * an entry is removed as soon as its subscription is closed — so the map + * reflects what the live relay connections are actually doing right now. + * Consumed by the always-on notification service. The default is an empty, + * never-changing flow for clients that don't track reasons. + */ + fun subscriptionReasonsFlow(): StateFlow> = MutableStateFlow(emptyMap()) + fun count( subId: String = newSubId(), filters: Map>, @@ -142,6 +154,7 @@ class EmptyNostrClient : INostrClient { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { } override fun count( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 8378d8140a..ef136aaef3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -46,12 +46,14 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -102,6 +104,17 @@ class NostrClient( private val activeCounts: PoolCounts = PoolCounts() private val eventOutbox: PoolEventOutbox = PoolEventOutbox() + /** + * subId -> human-readable reason for every subscription currently open with a + * non-blank reason. Populated in [subscribe] and pruned in [unsubscribe], so it + * mirrors what the live connections are doing. Exposed via [subscriptionReasonsFlow] + * for the always-on notification. Updated only when the mapping actually changes to + * avoid churning the flow on every filter refresh (subscribe fires on each change). + */ + private val subscriptionReasons = MutableStateFlow>(emptyMap()) + + override fun subscriptionReasonsFlow(): StateFlow> = subscriptionReasons + private var listeners = setOf() // controls the state of the client in such a way that if it is active @@ -218,7 +231,10 @@ class NostrClient( subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { + registerReason(subId, reason) + val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener) if (isActive()) { @@ -272,7 +288,27 @@ class NostrClient( } } + /** + * Records (or clears) the display reason for [subId]. A blank reason removes any + * existing mapping, so a subscription that stops passing a reason disappears from + * the list. No-ops when the mapping is unchanged to keep [subscriptionReasons] quiet. + */ + private fun registerReason( + subId: String, + reason: String, + ) { + subscriptionReasons.update { current -> + if (reason.isBlank()) { + if (subId in current) current - subId else current + } else { + if (current[subId] == reason) current else current + (subId to reason) + } + } + } + override fun unsubscribe(subId: String) { + registerReason(subId, "") + val relaysToUpdateReqs = activeRequests.remove(subId) val relaysToUpdateCounts = activeCounts.remove(subId) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt index ef2a72ed08..aa647f7b8e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/Subscription.kt @@ -28,6 +28,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl data class Subscription( val id: String = newSubId(), val listener: SubscriptionListener, + /** + * A short, human-readable explanation of WHY this subscription exists + * (e.g. "Your DMs", "Notifications", "Home feed"). Purely a client-side + * diagnostic label — it never reaches the relay (a REQ only carries the + * [id] and the filters). Surfaced by the always-on notification so the + * user can see what each open connection is actually doing. Defaults to + * empty for one-off/internal subscriptions that don't need to be shown. + */ + val reason: String = "", ) { private var currentVersion: Map>? = null // Inactive when null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt index 6e0f4883e4..cc35c8b99c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/subscriptions/SubscriptionController.kt @@ -54,7 +54,8 @@ class SubscriptionController( fun requestNewSubscription( subId: String, listener: SubscriptionListener, - ): Subscription = Subscription(subId, listener).also { subscriptions.put(it.id, it) } + reason: String = "", + ): Subscription = Subscription(subId, listener, reason).also { subscriptions.put(it.id, it) } fun dismissSubscription(subId: String) = getSub(subId)?.let { dismissSubscription(it) } @@ -71,7 +72,7 @@ class SubscriptionController( } subscriptions.forEach { id, sub -> - updateRelaysIfNeeded(id, sub.listener, sub.filters(), currentFilters[id]) + updateRelaysIfNeeded(id, sub.listener, sub.filters(), currentFilters[id], sub.reason) } } @@ -80,20 +81,21 @@ class SubscriptionController( listener: SubscriptionListener, newFilters: Map>?, oldFilters: Map>?, + reason: String = "", ) { if (oldFilters != null) { if (newFilters == null) { // was active and is not active anymore, just close. client.unsubscribe(subId) } else { - client.subscribe(subId, newFilters, listener) + client.subscribe(subId, newFilters, listener, reason) } } else { if (newFilters == null) { // was not active and is still not active, does nothing } else { // was not active and becomes active, sends the entire filter. - client.subscribe(subId, newFilters, listener) + client.subscribe(subId, newFilters, listener, reason) } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt index fb713bf9bd..c5f5768287 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/FetchAllIdleTimeoutTest.kt @@ -50,6 +50,7 @@ class FetchAllIdleTimeoutTest { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { this.listener = listener } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 9f3ac113f1..4bde5a9577 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -128,6 +128,7 @@ class NostrConnectSignerServiceTest { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { this.listener = listener } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index 5ea4738247..e48c1c92fa 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -70,6 +70,7 @@ private class TrackingNostrClient : INostrClient { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) { subscriptions.add(SubscriptionRecord(subId, filters)) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt index 77ca2c5d3c..af6b2fa28f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt @@ -399,6 +399,7 @@ private class CapturingNostrClient : INostrClient { subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) {} override fun count( @@ -460,6 +461,7 @@ private class CountingNostrClient( subId: String, filters: Map>, listener: SubscriptionListener?, + reason: String, ) {} override fun count(