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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyFmjFs1KUe2PUQHxkbRP1
This commit is contained in:
Claude
2026-07-24 01:11:01 +00:00
parent 5800571b77
commit 2ebbba4fe9
47 changed files with 265 additions and 33 deletions
@@ -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<String> = 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<String>,
) {
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<String, String>): List<String> =
reasons.values
.groupingBy { it }
.eachCount()
.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.map { (reason, count) -> if (count > 1) "$reason ×$count" else reason }
private fun buildNotification(
connectedRelays: Int,
reasons: List<String>,
): 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() {
@@ -37,6 +37,8 @@ class AccountDraftsEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override val subscriptionReason get() = "Your drafts"
override fun user(key: AccountQueryState) = key.account.userProfile()
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
@@ -138,6 +138,7 @@ class AccountFollowsLoaderSubAssembler(
}
}
},
reason = "Contacts' relay lists",
)
fun updateFilterForAllAccounts(accounts: Collection<Account>): List<RelayBasedFilter>? {
@@ -45,6 +45,8 @@ class MarmotGroupEventsEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override val subscriptionReason get() = "Your secure groups"
override fun user(key: AccountQueryState) = key.account.userProfile()
override fun updateFilter(
@@ -38,6 +38,8 @@ class AccountMetadataEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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
@@ -41,6 +41,8 @@ class AccountNotificationsEoseFromInboxRelaysManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override val subscriptionReason get() = "Your notifications"
override fun user(key: AccountQueryState) = key.account.userProfile()
/**
@@ -39,6 +39,8 @@ class AccountNotificationsEoseFromRandomRelaysManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override val subscriptionReason get() = "Your notifications"
override fun user(key: AccountQueryState) = key.account.userProfile()
/**
@@ -74,6 +74,8 @@ class AccountNotificationsHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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
@@ -54,6 +54,8 @@ class AccountGiftWrapsEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(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
@@ -60,6 +60,8 @@ class AccountGiftWrapsHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override val subscriptionReason get() = "Your private messages (history)"
override fun user(key: AccountQueryState) = key.account.userProfile()
private val pager = BackwardRelayPager("giftwrap.history")
@@ -28,6 +28,8 @@ class NoteEventLoaderSubAssembler(
client: INostrClient,
allKeys: () -> Set<EventFinderQueryState>,
) : SingleSubNoEoseCacheEoseManager<EventFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override val subscriptionReason get() = "Loading posts"
override fun updateFilter(keys: List<EventFinderQueryState>) =
listOfNotNull(
filterMissingEvents(keys),
@@ -36,6 +36,8 @@ class EventWatcherSubAssembler(
client: INostrClient,
allKeys: () -> Set<EventFinderQueryState>,
) : SingleSubEoseManager<EventFinderQueryState>(client, allKeys) {
override val subscriptionReason get() = "Watching replies & reactions"
var lastNotesOnFilter = emptyList<Note>()
var latestEOSEs: EOSEAccountFast<Note> = EOSEAccountFast(1000)
@@ -28,6 +28,8 @@ class NWCPaymentWatcherSubAssembler(
client: INostrClient,
allKeys: () -> Set<NWCPaymentQueryState>,
) : SingleSubNoEoseCacheEoseManager<NWCPaymentQueryState>(client, allKeys) {
override val subscriptionReason get() = "Wallet payments"
override fun updateFilter(keys: List<NWCPaymentQueryState>): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
@@ -41,6 +41,8 @@ class UserWatcherSubAssembler(
val failureTracker: RelayOfflineTracker,
allKeys: () -> Set<UserFinderQueryState>,
) : BaseEoseManager<UserFinderQueryState>(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).
@@ -47,6 +47,8 @@ class SearchPostWatcherSubAssembler(
client: INostrClient,
allKeys: () -> Set<SearchQueryState>,
) : PerUniqueIdEoseManager<SearchQueryState, Int>(client, allKeys) {
override val subscriptionReason get() = "Search results"
override fun updateFilter(
key: SearchQueryState,
since: SincePerRelayMap?,
@@ -48,6 +48,8 @@ class SearchUserWatcherSubAssembler(
client: INostrClient,
allKeys: () -> Set<SearchQueryState>,
) : PerUniqueIdEoseManager<SearchQueryState, Int>(client, allKeys) {
override val subscriptionReason get() = "Searching people"
override fun updateFilter(
key: SearchQueryState,
since: SincePerRelayMap?,
@@ -42,6 +42,8 @@ class ChatroomNip04SubAssembler(
client: INostrClient,
allKeys: () -> Set<ChatroomQueryState>,
) : PerUserAndFollowListEoseManager<ChatroomQueryState, String>(client, allKeys) {
override val subscriptionReason get() = "A direct message chat"
private val windowLoad = WindowLoadTracker("convo.nip04.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
@@ -68,6 +68,8 @@ class ConcordChannelSubAssembler(
client: INostrClient,
allKeys: () -> Set<ConcordChannelQueryState>,
) : PerUniqueIdEoseManager<ConcordChannelQueryState, Account>(client, allKeys) {
override val subscriptionReason get() = "Your Concord groups"
override fun updateFilter(
key: ConcordChannelQueryState,
since: SincePerRelayMap?,
@@ -79,6 +79,8 @@ class ConcordChannelHistorySubAssembler(
client: INostrClient,
allKeys: () -> Set<ConcordChannelHistoryQueryState>,
) : PerUniqueIdEoseManager<ConcordChannelHistoryQueryState, ConcordChannelId>(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
@@ -75,6 +75,8 @@ class RelayGroupJoinedChatTailSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupJoinedChatTailQueryState>,
) : PerUniqueIdEoseManager<RelayGroupJoinedChatTailQueryState, Account>(client, allKeys) {
override val subscriptionReason get() = "Your group chats"
private val windowLoad = WindowLoadTracker("relayGroup.preview.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
@@ -65,6 +65,8 @@ class RelayGroupJoinedStateSubAssembler(
client: INostrClient,
allKeys: () -> Set<RelayGroupJoinedStateQueryState>,
) : PerUniqueIdEoseManager<RelayGroupJoinedStateQueryState, Account>(client, allKeys) {
override val subscriptionReason get() = "Your group info"
override fun updateFilter(
key: RelayGroupJoinedStateQueryState,
since: SincePerRelayMap?,
@@ -50,6 +50,8 @@ class ChatroomListNip04SubAssembler(
client: INostrClient,
allKeys: () -> Set<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
override val subscriptionReason get() = "Your direct messages"
private val windowLoad = WindowLoadTracker("rooms.nip04.live")
val loadingMore: StateFlow<Boolean> = windowLoad.loading
@@ -32,6 +32,8 @@ class CommunityFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<CommunityQueryState>,
) : SingleSubEoseManager<CommunityQueryState>(client, allKeys) {
override val subscriptionReason get() = "Community feed"
override fun updateFilter(
keys: List<CommunityQueryState>,
since: SincePerRelayMap?,
@@ -41,6 +41,8 @@ class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(
client: INostrClient,
allKeys: () -> Set<DiscoveryQueryState>,
) : PerUserAndFollowListEoseManager<DiscoveryQueryState, TopFilter>(client, allKeys) {
override val subscriptionReason get() = "Discover feed"
override fun updateFilter(
key: DiscoveryQueryState,
since: SincePerRelayMap?,
@@ -29,6 +29,8 @@ class HashtagFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<HashtagQueryState>,
) : PerUniqueIdEoseManager<HashtagQueryState, String>(client, allKeys) {
override val subscriptionReason get() = "Hashtag feed"
override fun updateFilter(
key: HashtagQueryState,
since: SincePerRelayMap?,
@@ -56,6 +56,8 @@ class HomeOutboxEventsEoseManager(
client: INostrClient,
allKeys: () -> Set<HomeQueryState>,
) : PerUserAndFollowListEoseManager<HomeQueryState, TopFilter>(client, allKeys) {
override val subscriptionReason get() = "Home feed"
override fun updateFilter(
key: HomeQueryState,
since: SincePerRelayMap?,
@@ -29,6 +29,8 @@ class UserProfilePostsFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<UserProfileQueryState>,
) : PerUserEoseManager<UserProfileQueryState>(client, allKeys) {
override val subscriptionReason get() = "A profile's posts"
override fun updateFilter(
key: UserProfileQueryState,
since: SincePerRelayMap?,
@@ -55,6 +55,8 @@ class VideoOutboxEventsFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<VideoQueryState>,
) : PerUserAndFollowListEoseManager<VideoQueryState, TopFilter>(client, allKeys) {
override val subscriptionReason get() = "Video feed"
override fun updateFilter(
key: VideoQueryState,
since: SincePerRelayMap?,
+4
View File
@@ -1948,6 +1948,10 @@
<item quantity="other">Connected to %1$d relays</item>
</plurals>
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
<plurals name="always_on_notif_more_subscriptions">
<item quantity="one">+%1$d more</item>
<item quantity="other">+%1$d more</item>
</plurals>
<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>
@@ -57,8 +57,9 @@ class BlockedRelayFilteringClient(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
delegate.subscribe(subId, filters.withoutBlocked(), listener)
delegate.subscribe(subId, filters.withoutBlocked(), listener, reason)
}
override fun count(
@@ -70,6 +70,8 @@ private class CashuMintDirectorySubAssembler(
client: INostrClient,
allKeys: () -> Set<CashuMintDirectoryQueryState>,
) : SingleSubEoseManager<CashuMintDirectoryQueryState>(client, allKeys, invalidateAfterEose = true) {
override val subscriptionReason get() = "Cashu mint directory"
override fun distinct(key: CashuMintDirectoryQueryState): Any = key.relays.hashCode()
override fun updateFilter(
@@ -92,6 +92,8 @@ private class CashuWalletSubAssembler(
client: INostrClient,
allKeys: () -> Set<CashuWalletQueryState>,
) : SingleSubEoseManager<CashuWalletQueryState>(client, allKeys, invalidateAfterEose = true) {
override val subscriptionReason get() = "Your Cashu wallet"
override fun distinct(key: CashuWalletQueryState): Any = key.pubkey
override fun updateFilter(
@@ -51,6 +51,8 @@ class MetadataFilterAssembler(
client: INostrClient,
allKeys: () -> Set<MetadataQueryState>,
) : SingleSubEoseManager<MetadataQueryState>(client, allKeys, invalidateAfterEose = true) {
override val subscriptionReason get() = "Loading profile info"
override fun distinct(key: MetadataQueryState): Any = key.pubkeys.hashCode()
override fun updateFilter(
@@ -51,6 +51,8 @@ class ReactionsFilterAssembler(
client: INostrClient,
allKeys: () -> Set<ReactionsQueryState>,
) : SingleSubEoseManager<ReactionsQueryState>(client, allKeys, invalidateAfterEose = true) {
override val subscriptionReason get() = "Loading reactions"
override fun distinct(key: ReactionsQueryState): Any = key.noteIds.hashCode()
override fun updateFilter(
@@ -35,11 +35,51 @@ abstract class BaseEoseManager<T>(
) : 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<T>)
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)
@@ -55,6 +55,7 @@ class BlockedRelayFilteringClientTest {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
subscribedFilters = filters
}
@@ -89,6 +89,7 @@ class FeedMetadataCoordinatorTest {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
subscriptions[subId] = listener
subscribeCalls.add(filters)
@@ -91,6 +91,7 @@ class DmInboxRelayResolverOutboxTest {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
filters.forEach { (relay, filterList) ->
queriedRelays.add(relay)
@@ -165,6 +165,7 @@ class OutboxDispatcherTest {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
allSubscribeCalls.add(filters)
filters.forEach { (relay, filterList) ->
@@ -69,8 +69,20 @@ interface INostrClient : AutoCloseable {
subId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
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<Map<String, String>> = MutableStateFlow(emptyMap())
fun count(
subId: String = newSubId(),
filters: Map<NormalizedRelayUrl, List<Filter>>,
@@ -142,6 +154,7 @@ class EmptyNostrClient : INostrClient {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) { }
override fun count(
@@ -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<Map<String, String>>(emptyMap())
override fun subscriptionReasonsFlow(): StateFlow<Map<String, String>> = subscriptionReasons
private var listeners = setOf<RelayConnectionListener>()
// 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<NormalizedRelayUrl, List<Filter>>,
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)
@@ -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<NormalizedRelayUrl, List<Filter>>? = null // Inactive when null
@@ -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<NormalizedRelayUrl, List<Filter>>?,
oldFilters: Map<NormalizedRelayUrl, List<Filter>>?,
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)
}
}
}
@@ -50,6 +50,7 @@ class FetchAllIdleTimeoutTest {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
this.listener = listener
}
@@ -128,6 +128,7 @@ class NostrConnectSignerServiceTest {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
this.listener = listener
}
@@ -70,6 +70,7 @@ private class TrackingNostrClient : INostrClient {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {
subscriptions.add(SubscriptionRecord(subId, filters))
}
@@ -399,6 +399,7 @@ private class CapturingNostrClient : INostrClient {
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {}
override fun count(
@@ -460,6 +461,7 @@ private class CountingNostrClient(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
reason: String,
) {}
override fun count(