mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+84
-27
@@ -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() {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
@@ -138,6 +138,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
}
|
||||
}
|
||||
},
|
||||
reason = "Contacts' relay lists",
|
||||
)
|
||||
|
||||
fun updateFilterForAllAccounts(accounts: Collection<Account>): List<RelayBasedFilter>? {
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+2
@@ -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),
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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).
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
+2
@@ -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?,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
+41
-1
@@ -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)
|
||||
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ class BlockedRelayFilteringClientTest {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
subscribedFilters = filters
|
||||
}
|
||||
|
||||
+1
@@ -89,6 +89,7 @@ class FeedMetadataCoordinatorTest {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
subscriptions[subId] = listener
|
||||
subscribeCalls.add(filters)
|
||||
|
||||
+1
@@ -91,6 +91,7 @@ class DmInboxRelayResolverOutboxTest {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
filters.forEach { (relay, filterList) ->
|
||||
queriedRelays.add(relay)
|
||||
|
||||
+1
@@ -165,6 +165,7 @@ class OutboxDispatcherTest {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
allSubscribeCalls.add(filters)
|
||||
filters.forEach { (relay, filterList) ->
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
+36
@@ -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)
|
||||
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
|
||||
+6
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ class FetchAllIdleTimeoutTest {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
+1
@@ -128,6 +128,7 @@ class NostrConnectSignerServiceTest {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
this.listener = listener
|
||||
}
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ private class TrackingNostrClient : INostrClient {
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: SubscriptionListener?,
|
||||
reason: String,
|
||||
) {
|
||||
subscriptions.add(SubscriptionRecord(subId, filters))
|
||||
}
|
||||
|
||||
+2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user