Files
amethyst/docs/plans/2026-03-22-feat-clean-cache-single-source-of-truth-plan.md
T
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:35:33 +03:00

34 KiB

title, type, status, date, origin
title type status date origin
feat: Clean Cache Architecture — Single Source of Truth feat active 2026-03-22 docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md

feat: Clean Cache Architecture — Single Source of Truth

Enhancement Summary

Deepened on: 2026-03-23 Agents used: kotlin-coroutines, compose-expert, desktop-expert, kotlin-expert

Critical Issues Discovered

  1. ViewModel scope leakremember(key) doesn't call onCleared(), leaking coroutines on feedMode switch
  2. Note.flowSet allocationnote.flow() creates NoteFlowSet during composition (side effect); needs remember
  3. Coordinator needs SupervisorJob — one failed consume shouldn't kill all subscriptions
  4. @Volatile followedUsers insufficient — use MutableStateFlow<Set<HexKey>> for thread safety + observability
  5. No error handling in consume→emit pipeline — unhandled exception kills scope

Key Improvements

  • ViewModel lifecycle: DisposableEffect cleanup pattern for Desktop
  • FeedNoteCard: NoteInteractionSnapshot collapses 3 flow observers into recomposition-safe snapshot
  • Coordinator: SupervisorJob + ConcurrentHashMap<String, Job> for subscription tracking
  • consume() dispatch: kind-based registry map (O(1)) replaces growing when block

Overview

Migrate all desktop feed screens from inline EventCollectionState + direct relay subscriptions (System A) to the cache-centric DesktopFeedViewModel + FeedFilter + FeedContentState architecture (System B). Cache becomes the single source of truth. No screen ever reads relay callbacks directly.

WebSocket (OkHttp, persistent)
    ↓ push-based
DesktopRelaySubscriptionsCoordinator
    ↓ consume()
DesktopLocalCache (Note model with replies, reactions, zaps)
    ↓ eventStream (SharedFlow<Set<Note>>)
DesktopFeedViewModel (per-screen, with FeedFilter)
    ↓ FeedContentState (Loading/Loaded/Empty/Error)
UI (collects StateFlow, reads Note.flowSet for live counts)

(see brainstorm: docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md)

Problem Statement

Current system (System A) causes:

  • State loss on navigationremember-scoped EventCollectionState destroyed when sidebar tab changes
  • Duplicate relay subscriptions — FeedScreen alone has 8 rememberSubscription() calls
  • Fragile count tracking — zaps/reactions/replies tracked in per-screen mutableStateOf maps, lost on recompose
  • Slow tab switching — 2-5s reload on Home→Profile→Home because data refetched from relays
  • No freshness indicator — user can't tell if feed is current or stale

System B infrastructure already exists but is not wired to any screen:

Component Status Location
DesktopFeedViewModel Built desktopApp/.../viewmodels/
8 FeedFilter implementations Built desktopApp/.../feeds/DesktopFeedFilters.kt
FeedContentState Built commons/.../ui/feeds/FeedContentState.kt
DesktopRelaySubscriptionsCoordinator.consumeEvent() Built desktopApp/.../subscriptions/
DesktopLocalCache + eventStream Built desktopApp/.../cache/

Proposed Solution

Four-phase migration: FeedScreen first (proves pattern), then expand cache coverage and migrate remaining screens, consolidate subscriptions, add health UI.

Key Design Decisions (from brainstorm)

  1. Coordinator owns all subscriptions — each screen has at most ONE subscription request to Coordinator, never multiple rememberSubscription() calls
  2. Counts live on Note modelnote.replies.size, note.countReactions(), note.zapsAmount (observed via Note.flowSet)
  3. Relay health indicator — "last event received" per subscription, hidden when <30s, shown as duration when stale
  4. Always-alive core subscriptions — contact list + home feed stay open even on Settings screen
  5. WebSocket push-based — "pull-to-refresh" = close + reopen subscription with fresh since filter
  6. SearchScreen migrates — NIP-50 results route through cache, AdvancedSearchBarState stays for query/history management, ViewModel reads cache for results

Technical Approach

Architecture

┌─────────────────────────────────────────────┐
│                   Relays                     │
│         (persistent WebSocket via OkHttp)    │
└──────────────────┬──────────────────────────┘
                   │ push events
    ┌──────────────▼──────────────┐
    │  DesktopRelaySubscriptions  │
    │       Coordinator           │
    │  (SupervisorJob scope)      │
    │                             │
    │  Always-alive:              │
    │  - contact list (kind 3)    │
    │  - home feed (kind 1)       │
    │  - metadata (rate-limited)  │
    │                             │
    │  Screen-requested:          │
    │  - one sub per screen       │
    │  - interactions (7,9735,6)  │
    │  - reads (kind 30023)       │
    │  - search (NIP-50)          │
    └──────────────┬──────────────┘
                   │ consumeEvent() [try-catch per event]
    ┌──────────────▼──────────────┐
    │     DesktopLocalCache       │
    │  (Single Source of Truth)   │
    │                             │
    │  notes: BoundedLargeCache   │
    │  users: BoundedLargeCache   │
    │  _followedUsers: StateFlow  │
    │  subscriptionHealth: Map    │
    │                             │
    │  consume(): kind registry   │
    │  eventStream:               │
    │    newEventBundles (250ms)  │
    │    deletedEventBundles      │
    └──────────────┬──────────────┘
                   │ SharedFlow<Set<Note>>
    ┌──────────────▼──────────────┐
    │   DesktopFeedViewModel(s)   │
    │  + DisposableEffect cleanup │
    │                             │
    │  One per screen:            │
    │  - HomeFeedVM (Following)   │
    │  - GlobalFeedVM             │
    │  - ProfileFeedVM(pubkey)    │
    │  - ThreadVM(noteId)         │
    │  - BookmarksVM(ids)         │
    │  - ReadsVM                  │
    │  - NotificationsVM(pubkey)  │
    │  - SearchVM(query)          │
    │                             │
    │  init: refreshSuspended()   │
    │  collect: eventStream →     │
    │    FeedFilter.applyFilter() │
    └──────────────┬──────────────┘
                   │ FeedState (Loaded/Loading/Empty/Error)
    ┌──────────────▼──────────────┐
    │           UI Layer          │
    │                             │
    │  NoteInteractionSnapshot    │
    │  (recomposition-safe)       │
    │                             │
    │  DisposableEffect for:      │
    │  - ViewModel cleanup        │
    │  - Note.clearFlow()         │
    │  - Coordinator release      │
    └─────────────────────────────┘

Implementation Phases


Phase 1: FeedScreen Migration (Foundation)

Goal: Prove the pattern on the most complex screen. Remove all inline state management.

1a. Expand DesktopLocalCache.consume() coverage

Currently handles: MetadataEvent, TextNoteEvent, ReactionEvent, LnZapRequestEvent, LnZapEvent.

Add support for:

Event Type Kind Method Links
RepostEvent 6 consumeRepost() Note.addBoost() on target
ContactListEvent 3 consumeContactList() Updates _followedUsers StateFlow
LongTextNoteEvent 30023 consumeLongTextNote() Creates Note like TextNote
BookmarkListEvent 30001 consumeBookmarkList() Stores on addressableNotes or dedicated field

File: desktopApp/.../cache/DesktopLocalCache.kt

Each consume*() method follows existing pattern:

  1. getOrCreateNote(event.id) — deduplicate
  2. Check note.event != null → already seen, return false
  3. getOrCreateUser(event.pubKey) — ensure author exists
  4. note.loadEvent(event, author, relatedNotes) — populate
  5. Link relationships (addReply, addReaction, addZap, addBoost)
  6. Return true (new event)

Research Insight: Use kind-based registry instead of growing when block:

// O(1) dispatch, extensible without editing consume()
private val consumers = mutableMapOf<Int, (Event, NormalizedRelayUrl?) -> Boolean>()

init {
    consumers[MetadataEvent.KIND] = { e, r -> consumeMetadata(e as MetadataEvent); true }
    consumers[TextNoteEvent.KIND] = { e, r -> consumeTextNote(e as TextNoteEvent, r) }
    consumers[ReactionEvent.KIND] = { e, r -> consumeReaction(e as ReactionEvent, r) }
    consumers[RepostEvent.KIND] = { e, r -> consumeRepost(e as RepostEvent, r) }
    consumers[ContactListEvent.KIND] = { e, r -> consumeContactList(e as ContactListEvent); true }
    // ...
}

fun consume(event: Event, relay: NormalizedRelayUrl?): Boolean =
    consumers[event.kind]?.invoke(event, relay) ?: false

Research Insight: Replace @Volatile followedUsers with MutableStateFlow:

// Thread-safe atomic updates + Compose-observable
private val _followedUsers = MutableStateFlow<Set<HexKey>>(emptySet())
val followedUsers: StateFlow<Set<HexKey>> = _followedUsers.asStateFlow()

fun updateFollowedUsers(users: Set<HexKey>) {
    _followedUsers.value = users
}

@Volatile only guarantees reference visibility — read-modify-write races are still possible. MutableStateFlow gives atomic value swaps plus observability for free (DesktopFollowingFeedFilter can react to changes without polling).

1b. Coordinator always-alive subscriptions

Move home feed + contact list subscriptions from FeedScreen into Coordinator.

File: desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt

// New methods on Coordinator
fun subscribeToHomeFeed(relays: Set<NormalizedRelayUrl>, followedUsers: Set<String>)
fun subscribeToContactList(relays: Set<NormalizedRelayUrl>, pubKeyHex: String)
fun requestInteractions(noteIds: List<String>, relays: Set<NormalizedRelayUrl>): String
fun releaseInteractions(subId: String)

requestInteractions() replaces per-screen zap/reaction/reply/repost subscriptions. Subscribes to kinds 7, 9735, 6, and reply-kind-1 for given note IDs. Results route through consumeEvent() → cache → eventStream. Each screen makes at most ONE interaction request.

Subscription health tracking:

data class SubscriptionHealth(
    val lastEventReceivedAt: Long?,
    val eoseReceived: Boolean,
)

val subscriptionHealth: StateFlow<Map<String, SubscriptionHealth>>

Updated on every event and EOSE callback.

Research Insight: Coordinator scope MUST use SupervisorJob:

// In Main.kt where Coordinator is created:
val coordinatorScope = CoroutineScope(
    SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { _, throwable ->
        println("Coordinator error: ${throwable.message}")
    }
)

Without SupervisorJob, one failed consume() call kills ALL subscription processing. Each subscription should be an independent child coroutine.

Research Insight: Track subscription Jobs for proper cancellation:

private val screenSubscriptions = ConcurrentHashMap<String, Job>()

fun requestInteractions(noteIds: List<String>, relays: Set<NormalizedRelayUrl>): String {
    val subId = generateSubId("interactions")
    val job = scope.launch {
        // subscription logic
    }
    screenSubscriptions[subId] = job
    return subId
}

fun releaseInteractions(subId: String) {
    screenSubscriptions.remove(subId)?.cancel()
    client.close(subId)
}

Research Insight: Add try-catch in consumeEvent:

fun consumeEvent(event: Event, relay: NormalizedRelayUrl?) {
    scope.launch(Dispatchers.IO) {
        try {
            val consumed = localCache.consume(event, relay)
            if (consumed) {
                val note = localCache.getNoteIfExists(event.id) ?: return@launch
                eventBundler.invalidateList(note) { batch ->
                    localCache.eventStream.emitNewNotes(batch)
                }
            }
        } catch (e: Exception) {
            // Don't rethrow — other events should continue processing
            println("Failed to consume ${event.kind}: ${e.message}")
        }
    }
}
1c. Rewrite FeedScreen to use DesktopFeedViewModel

File: desktopApp/.../ui/FeedScreen.kt

Remove:

  • EventCollectionState<Event> and all remember state maps
  • All 8 rememberSubscription() blocks
  • zapsByEvent, reactionIdsByEvent, replyIdsByEvent, repostIdsByEvent maps
  • followedUsers local state (read from localCache.followedUsers)
  • eoseReceivedCount tracking

Add:

  • DesktopFeedViewModel keyed on feedMode with DisposableEffect cleanup:
val viewModel = remember(feedMode) {
    val filter = when (feedMode) {
        FeedMode.GLOBAL -> DesktopGlobalFeedFilter(localCache)
        FeedMode.FOLLOWING -> DesktopFollowingFeedFilter(localCache) {
            localCache.followedUsers.value
        }
    }
    DesktopFeedViewModel(filter, localCache)
}

// CRITICAL: Cancel old ViewModel's viewModelScope on recreation
DisposableEffect(viewModel) {
    onDispose { viewModel.clear() }
}

val feedState by viewModel.feedState.feedContent.collectAsState()
  • Render based on FeedState: Loading, Loaded, Empty, FeedError
  • Relay health indicator from Coordinator

Research Insight: ViewModel scope leak is the #1 risk.

remember(key) creates a new ViewModel on key change but does NOT call onCleared(). On JVM Desktop, there is no ViewModelStoreOwner. The old ViewModel's viewModelScope coroutines keep running — leaked collectors on eventStream. DisposableEffect(viewModel) { onDispose { viewModel.clear() } } fixes this.

If ViewModel.clear() is not accessible in lifecycle 2.10.0 KMP, add a fun destroy() method on DesktopFeedViewModel that cancels a custom scope.

Research Insight: Use DisposableEffect (not LaunchedEffect) for Coordinator subscriptions:

// Interaction subscription with proper cleanup
DisposableEffect(loadedNoteIds) {
    val subId = coordinator.requestInteractions(loadedNoteIds, relays)
    onDispose { coordinator.releaseInteractions(subId) }
}

LaunchedEffect cancels its coroutine on dispose but doesn't call explicit cleanup. DisposableEffect guarantees the onDispose block runs.

1d. Rewrite FeedNoteCard to accept Note

File: desktopApp/.../ui/FeedScreen.kt (FeedNoteCard composable)

Before:

fun FeedNoteCard(
    event: Event,
    zapReceipts: List<ZapReceipt>,
    reactionCount: Int,
    replyCount: Int,
    repostCount: Int,
    ...
)

After — using NoteInteractionSnapshot for recomposition safety:

@Immutable
data class NoteInteractionSnapshot(
    val reactionCount: Int,
    val replyCount: Int,
    val boostCount: Int,
    val zapAmount: BigDecimal,
)

@Composable
fun rememberNoteInteractionState(note: Note): NoteInteractionSnapshot {
    // Cache flowSet reference — note.flow() allocates if null (side effect)
    val flowSet = remember(note) { note.flow() }

    val reactionsState by flowSet.reactions.stateFlow.collectAsState()
    val repliesState by flowSet.replies.stateFlow.collectAsState()
    val zapsState by flowSet.zaps.stateFlow.collectAsState()

    // Clean up flowSet when note card leaves composition
    DisposableEffect(note) {
        onDispose { note.clearFlow() }
    }

    return remember(reactionsState, repliesState, zapsState) {
        NoteInteractionSnapshot(
            reactionCount = note.countReactions(),
            replyCount = note.replies.size,
            boostCount = note.boosts.size,
            zapAmount = note.zapsAmount,
        )
    }
}

@Composable
fun FeedNoteCard(
    note: Note,
    localCache: DesktopLocalCache,
    ...
) {
    val interactions = rememberNoteInteractionState(note)

    NoteCard(
        note = note.event!!.toNoteDisplayData(localCache),
        ...
    )
    NoteActionsRow(
        reactionCount = interactions.reactionCount,
        replyCount = interactions.replyCount,
        repostCount = interactions.boostCount,
        zapAmountSats = interactions.zapAmount.toLong(),
        ...
    )
}

Research Insights:

  • note.flow() is a side effect — it allocates NoteFlowSet if null. Wrap in remember(note) to avoid repeated allocation during recomposition.
  • @Immutable NoteInteractionSnapshot prevents downstream recomposition when invalidateData() fires but counts haven't actually changed. NoteActionsRow only recomposes when snapshot content differs.
  • DisposableEffect for clearFlow() — without cleanup, every Note that was ever visible retains its NoteFlowSet permanently. clearFlow() checks isInUse() and nullifies when subscriber count = 0.
  • 3 StateFlow collectors per card is acceptable — they're lightweight. With 50 visible cards = 150 collectors. The NoteInteractionSnapshot pattern prevents cascading recomposition.
1e. Render FeedState in FeedScreen
when (val state = feedState) {
    is FeedState.Loading -> LoadingState("Loading notes...")
    is FeedState.Empty -> EmptyState(title = "...", onRefresh = { ... })
    is FeedState.FeedError -> ErrorState(message = state.errorMessage)
    is FeedState.Loaded -> {
        val notes by state.feed.collectAsState()
        LazyColumn {
            items(notes.list, key = { it.idHex }) { note ->
                FeedNoteCard(note = note, localCache = localCache, ...)
            }
        }
    }
}

Research Insight: key = { it.idHex } is critical. Since Note is @Stable, Compose trusts referential equality. The same Note object across two list emissions = skipped recomposition for that item. Cache reuses Note instances, so this works correctly.

Research Insight: First frame shows Loading briefly. refreshSuspended() runs on Dispatchers.IO. The first compose frame sees FeedState.Loading until the IO dispatch completes. This is sub-100ms with warm cache but not truly instant. Acceptable for the UX.

Phase 1 Acceptance Criteria
  • DesktopLocalCache.consume() handles RepostEvent, ContactListEvent, LongTextNoteEvent, BookmarkListEvent
  • consume() uses kind-based registry dispatch (not growing when block)
  • followedUsers is MutableStateFlow (not @Volatile)
  • Coordinator scope uses SupervisorJob + CoroutineExceptionHandler
  • Coordinator manages always-alive home feed + contact list subscriptions
  • Coordinator exposes requestInteractions() / releaseInteractions() with Job tracking
  • consumeEvent() has try-catch per event
  • FeedScreen uses DesktopFeedViewModel — no EventCollectionState, no rememberSubscription()
  • FeedScreen has DisposableEffect(viewModel) { onDispose { viewModel.clear() } }
  • FeedScreen uses DisposableEffect for Coordinator subscription cleanup
  • FeedNoteCard takes Note, uses rememberNoteInteractionState() + NoteInteractionSnapshot
  • FeedNoteCard has DisposableEffect for note.clearFlow() cleanup
  • Feed mode switch (Following ↔ Global) works via ViewModel recreation
  • Navigation Home → Profile → Home shows cached data instantly (<100ms)
  • ./gradlew spotlessApply :desktopApp:compileKotlin passes

Phase 2: Migrate Remaining Feed Screens

Apply the proven pattern from Phase 1 to all other screens. Each screen gets at most ONE subscription request to Coordinator. Every screen uses DisposableEffect for ViewModel + subscription cleanup.

2a. UserProfileScreen

Dual concern: Profile header (metadata, follow status) + notes feed.

  • Notes feed → DesktopFeedViewModel(DesktopProfileFeedFilter(cache, pubKeyHex), cache)
  • Profile header → reads from localCache.users for metadata, single Coordinator request for fresh metadata
  • Tab switching (Notes/Replies/Gallery) → different FeedFilter per tab, keyed on remember(tab)
  • DisposableEffect cleanup for ViewModel on tab switch and screen exit

File: desktopApp/.../ui/UserProfileScreen.kt

2b. ThreadScreen

DesktopThreadFilter extends FeedFilter (not AdditiveFeedFilter) — does graph walk via Note.replies.

  • On new event bundle → full refreshSuspended() (re-walks graph from cache). Acceptable because thread size is bounded and BoundedLargeCache iteration is lock-free.
  • Thread level tracking: DesktopThreadFilter.feed() returns flattened list with depth info.

File: desktopApp/.../ui/ThreadScreen.kt

2c. BookmarksScreen
  • DesktopBookmarkFeedFilter(cache) { bookmarkedIds() } — lambda provides current bookmark IDs
  • Bookmark change propagation: After publishing BookmarkListEvent, call viewModel.feedState.checkKeysInvalidateDataAndSendToTop() since feedKey hash changes with bookmark set.

File: desktopApp/.../ui/BookmarksScreen.kt

2d. ReadsScreen
  • DesktopFeedViewModel(DesktopReadsFeedFilter(cache), cache)
  • Card rendering: ReadsScreen needs LongFormNoteCard that extracts title/summary/topics from Note.event as? LongTextNoteEvent. Separate composable, not FeedNoteCard.

File: desktopApp/.../ui/ReadsScreen.kt

2e. NotificationsScreen
  • DesktopNotificationFeedFilter returns List<Note>. NotificationsScreen transforms to typed NotificationItem in screen layer:
    fun Note.toNotificationItem(): NotificationItem? = when (event) {
        is ReactionEvent -> NotificationItem.Reaction(this)
        is LnZapEvent -> NotificationItem.Zap(this)
        is TextNoteEvent -> if (isReply) NotificationItem.Reply(this) else NotificationItem.Mention(this)
        else -> null
    }
    

File: desktopApp/.../ui/NotificationsScreen.kt

2f. SearchScreen (Cache-Backed + AdvancedSearchBarState)
  • AdvancedSearchBarState stays — handles query management, history, operator hints
  • NIP-50 relay results route through coordinator.consumeEvent() → cache
  • DesktopFeedViewModel(DesktopSearchFeedFilter(cache, query), cache) reads cached results
  • AdvancedSearchBarState triggers relay subscription via Coordinator; ViewModel reads cache
  • Search results persist across navigation (in cache)

File: desktopApp/.../ui/SearchScreen.kt

Phase 2 Acceptance Criteria
  • All screens use DesktopFeedViewModel (including Search)
  • Each screen has at most ONE subscription request to Coordinator
  • Every screen has DisposableEffect cleanup for ViewModel + Coordinator subscription
  • UserProfileScreen: separate header state from feed ViewModel
  • BookmarksScreen: bookmark changes propagate to ViewModel
  • ReadsScreen: uses LongFormNoteCard for kind 30023
  • NotificationsScreen: Note → NotificationItem transformation in screen layer
  • SearchScreen: NIP-50 results go through cache, AdvancedSearchBarState retained
  • All screens show cached data instantly on navigation

Phase 3: Coordinator Subscription Consolidation

Ensure clean subscription lifecycle. No screen calls rememberSubscription().

3a. Always-alive subscriptions
Subscription Kind(s) Trigger Lifecycle
Contact list 3 App start Permanent
Home feed (following) 1 App start Permanent
Metadata (rate-limited) 0 On-demand via loadMetadataForPubkeys() Permanent (rate limiter)
3b. Screen-triggered subscriptions (one per screen)
Subscription Kind(s) Trigger Lifecycle
Global feed 1 Global mode active Active while mode shown
Profile feed + interactions 1, 7, 9735, 6 ProfileScreen visible Active while screen visible
Thread + interactions 1, 7, 9735, 6 ThreadScreen visible Active while screen visible
Reads 30023 ReadsScreen visible Active while screen visible
Notifications 1, 7, 9735 NotificationsScreen visible Active while screen visible
Search (NIP-50) varies Search query submitted Active while search active
Home interactions 7, 9735, 6 FeedScreen visible Active while screen visible

Each screen requests ONE consolidated subscription from Coordinator that covers all event types it needs.

3c. Subscription health tracking
// On Coordinator
private val _subscriptionHealth = MutableStateFlow<Map<String, SubscriptionHealth>>(emptyMap())
val subscriptionHealth: StateFlow<Map<String, SubscriptionHealth>>

// Updated in subscription callbacks
onEvent = { event, _, relay, _ ->
    consumeEvent(event, relay)
    updateHealth(subId, lastEventReceivedAt = System.currentTimeMillis())
}
onEose = { _, _ ->
    updateHealth(subId, eoseReceived = true)
}
Phase 3 Acceptance Criteria
  • No screen calls rememberSubscription() directly
  • Each screen has exactly ONE subscription request to Coordinator
  • Coordinator manages all subscription lifecycles via ConcurrentHashMap<String, Job>
  • subscriptionHealth StateFlow available for UI consumption

Phase 4: Relay Health UI

4a. Health indicator component
@Composable
fun RelayHealthIndicator(
    lastEventReceivedAt: Long?,
    modifier: Modifier = Modifier,
) {
    val elapsed = /* current time - lastEventReceivedAt */
    if (elapsed > 30_000) {  // only show if > 30s
        Text(
            text = formatElapsed(elapsed),  // "45s ago", "3m ago"
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

Display rules:

  • < 30s → hidden (healthy)
  • 30s - 60s → show seconds
  • > 1 min → show minutes — indicates relay slowness or offline

Research Insight: Place health indicator in NavigationRail (next to existing BunkerHeartbeatIndicator at SinglePaneLayout.kt:148). This is the established pattern. Also add per-feed indicator in each screen's header.

4b. Pull-to-refresh

Desktop doesn't have swipe gestures. Options:

  • Refresh button in feed header (already exists)
  • Keyboard shortcut (Cmd+R / Ctrl+R) via MenuBar
  • Click on health indicator to force refresh

Refresh action: Coordinator closes + reopens relevant subscription with since filter.

Research Insight: Wire Cmd+R from MenuBar to active screen via onRefresh callback hoisted through SinglePaneLayoutRootContent → active screen (same pattern as existing onShowComposeDialog).

Phase 4 Acceptance Criteria
  • Relay health indicator shown when last event > 30s ago
  • Health indicator in NavigationRail (global) + per-feed header (screen-specific)
  • Refresh button triggers subscription reopen
  • Cmd+R / Ctrl+R keyboard shortcut works
  • Health indicator clears on new event received

System-Wide Impact

Interaction Graph

User navigates to screen
  → Screen creates DesktopFeedViewModel(filter, cache)
  → DisposableEffect registers cleanup (viewModel.clear(), coordinator.release())
    → ViewModel.init: refreshSuspended() queries cache via filter
    → ViewModel.init: collects cache.eventStream.newEventBundles
      → FeedContentState.updateFeedWith(newNotes)
        → AdditiveFeedFilter.applyFilter() + sort()
          → FeedState.Loaded emits ImmutableList<Note>

Relay event arrives (WebSocket push)
  → Coordinator.consumeEvent(event, relay) [try-catch]
    → DesktopLocalCache.consume(event, relay) [kind registry dispatch]
      → Note.loadEvent() + Note.addReply/addReaction/addZap
        → Note.flowSet?.reactions?.invalidateData()
    → eventBundler.invalidateList(note)
      → (250ms batch)
      → localCache.eventStream.emitNewNotes(batch)
        → All active FeedViewModels receive bundle
          → Each filter decides if relevant
            → UI updates via NoteInteractionSnapshot

User leaves screen
  → DisposableEffect.onDispose fires
    → viewModel.clear() cancels viewModelScope
    → coordinator.releaseInteractions(subId) closes subscription
    → note.clearFlow() releases NoteFlowSet

Error Propagation

  • WebSocket disconnect: Coordinator detects → health indicator shows elapsed → reconnect with since → events resume → health clears
  • Cache consume fails: try-catch in consumeEvent() logs and continues. SupervisorJob ensures other subscriptions are unaffected.
  • Filter throws: FeedContentState catches → FeedState.FeedError → UI shows error state
  • ViewModel scope leak: Prevented by DisposableEffect(viewModel) { onDispose { viewModel.clear() } }

State Lifecycle Risks

  • Note eviction: If cache evicts a Note in a ViewModel's feed, ImmutableList<Note> still holds a reference. No crash, but Note won't receive flowSet updates. Acceptable at 50k limit.
  • Note.flowSet leak: Prevented by DisposableEffect calling note.clearFlow() when FeedNoteCard leaves composition.
  • ViewModel recreation on mode switch: Old scope cancelled via DisposableEffect, new one created. 250ms gap caught on next refreshSuspended().
  • Note mutation thread safety: Note.replies, Note.reactions etc. use immutable list swap (reference assignment is atomic on JVM). Last-write-wins race is possible under contention but self-corrects on next event. This matches Android's pattern.

Acceptance Criteria

Functional

  • All feed screens render from cache via DesktopFeedViewModel
  • Navigation between screens shows cached data instantly (<100ms)
  • Zap/reaction/reply/repost counts update live via NoteInteractionSnapshot
  • Feed mode switch (Following ↔ Global) works without reload delay
  • New events from relays appear in feeds within 250ms (batch delay)
  • Contact list always cached, Following feed hydrates instantly
  • Search results persist across navigation (cached)

Non-Functional

  • No EventCollectionState usage in any feed screen
  • No rememberSubscription() in any feed screen
  • Each screen has at most ONE subscription request to Coordinator
  • Every screen has DisposableEffect cleanup for ViewModel + subscription + flowSet
  • Coordinator uses SupervisorJob — one failed event doesn't kill all processing
  • consumeEvent() has try-catch — no unhandled exceptions
  • ./gradlew spotlessApply :desktopApp:compileKotlin passes after each phase

Dependencies & Risks

Risk Probability Impact Mitigation
ViewModel.clear() not accessible in lifecycle KMP Medium High Add custom destroy() method with own CoroutineScope
Note.flowSet observation doesn't trigger recomposition Low High Prototype rememberNoteInteractionState() early in Phase 1d
Missing consume() methods cause blank feeds Low High Add all in Phase 1a before screen migration
Always-alive subs overwhelm relays Low Medium Start with contact list + home feed only
SearchScreen cache filter semantics differ from NIP-50 Low Medium Search filter only matches cached results; NIP-50 provides the actual search
BoundedLargeCache eviction is pseudo-random (by hex key order) Low Low At 50k limit, probability of evicting active feed Note is negligible

Success Metrics

  • Back navigation: 2-5s → <100ms
  • Metadata refetch on navigation: always → never (cached)
  • Zap/reaction counts on back nav: lost → preserved
  • Subscriptions per screen: 4-8 → 1
  • Coroutine scope leaks: possible → prevented by DisposableEffect

Sources & References

Origin

Internal References

  • Prior cache plan: docs/plans/2026-03-18-feat-desktop-cache-navigation-persistence-plan.md
  • Relay subscription stability: docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md
  • Cache architecture rationale: docs/brainstorms/2026-03-17-desktop-cache-architecture-brainstorm.md
  • FeedContentState: commons/.../ui/feeds/FeedContentState.kt
  • FeedViewModel: commons/.../viewmodels/FeedViewModel.kt
  • DesktopFeedFilters: desktopApp/.../feeds/DesktopFeedFilters.kt
  • DesktopFeedViewModel: desktopApp/.../viewmodels/DesktopFeedViewModel.kt
  • Note model: commons/.../model/Note.kt
  • DesktopLocalCache: desktopApp/.../cache/DesktopLocalCache.kt
  • Coordinator: desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt
  • SinglePaneLayout: desktopApp/.../ui/deck/SinglePaneLayout.kt (NavigationRail, BunkerHeartbeatIndicator pattern)

Research Insights Applied

  • kotlin-coroutines: SupervisorJob for Coordinator, DisposableEffect for ViewModel lifecycle, try-catch in consume pipeline, Job tracking for screen subscriptions
  • compose-expert: NoteInteractionSnapshot pattern, remember(note) { note.flow() }, DisposableEffect for clearFlow(), key-based LazyColumn skip optimization
  • desktop-expert: ViewModel.clear() accessibility concern, MenuBar keyboard shortcut wiring, NavigationRail health indicator placement, overlay subscription cleanup
  • kotlin-expert: Kind-based registry for consume(), MutableStateFlow for followedUsers, Note mutation thread safety analysis, BoundedLargeCache eviction characteristics