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>
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
- ViewModel scope leak —
remember(key)doesn't callonCleared(), leaking coroutines on feedMode switch - Note.flowSet allocation —
note.flow()createsNoteFlowSetduring composition (side effect); needsremember - Coordinator needs SupervisorJob — one failed consume shouldn't kill all subscriptions
- @Volatile followedUsers insufficient — use
MutableStateFlow<Set<HexKey>>for thread safety + observability - No error handling in consume→emit pipeline — unhandled exception kills scope
Key Improvements
- ViewModel lifecycle:
DisposableEffectcleanup pattern for Desktop - FeedNoteCard:
NoteInteractionSnapshotcollapses 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
whenblock
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 navigation —
remember-scopedEventCollectionStatedestroyed when sidebar tab changes - Duplicate relay subscriptions — FeedScreen alone has 8
rememberSubscription()calls - Fragile count tracking — zaps/reactions/replies tracked in per-screen
mutableStateOfmaps, 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)
- Coordinator owns all subscriptions — each screen has at most ONE subscription request to Coordinator, never multiple
rememberSubscription()calls - Counts live on Note model —
note.replies.size,note.countReactions(),note.zapsAmount(observed viaNote.flowSet) - Relay health indicator — "last event received" per subscription, hidden when <30s, shown as duration when stale
- Always-alive core subscriptions — contact list + home feed stay open even on Settings screen
- WebSocket push-based — "pull-to-refresh" = close + reopen subscription with fresh
sincefilter - 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:
getOrCreateNote(event.id)— deduplicate- Check
note.event != null→ already seen, return false getOrCreateUser(event.pubKey)— ensure author existsnote.loadEvent(event, author, relatedNotes)— populate- Link relationships (
addReply,addReaction,addZap,addBoost) - 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 allrememberstate maps- All 8
rememberSubscription()blocks zapsByEvent,reactionIdsByEvent,replyIdsByEvent,repostIdsByEventmapsfollowedUserslocal state (read fromlocalCache.followedUsers)eoseReceivedCounttracking
Add:
DesktopFeedViewModelkeyed onfeedModewithDisposableEffectcleanup:
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 allocatesNoteFlowSetif null. Wrap inremember(note)to avoid repeated allocation during recomposition.@Immutable NoteInteractionSnapshotprevents downstream recomposition wheninvalidateData()fires but counts haven't actually changed.NoteActionsRowonly recomposes when snapshot content differs.DisposableEffectforclearFlow()— without cleanup, every Note that was ever visible retains itsNoteFlowSetpermanently.clearFlow()checksisInUse()and nullifies when subscriber count = 0.- 3 StateFlow collectors per card is acceptable — they're lightweight. With 50 visible cards = 150 collectors. The
NoteInteractionSnapshotpattern 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
whenblock) followedUsersisMutableStateFlow(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— noEventCollectionState, norememberSubscription() - FeedScreen has
DisposableEffect(viewModel) { onDispose { viewModel.clear() } } - FeedScreen uses
DisposableEffectfor Coordinator subscription cleanup - FeedNoteCard takes
Note, usesrememberNoteInteractionState()+NoteInteractionSnapshot - FeedNoteCard has
DisposableEffectfornote.clearFlow()cleanup - Feed mode switch (Following ↔ Global) works via ViewModel recreation
- Navigation Home → Profile → Home shows cached data instantly (<100ms)
./gradlew spotlessApply :desktopApp:compileKotlinpasses
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.usersfor 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 andBoundedLargeCacheiteration 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()sincefeedKeyhash changes with bookmark set.
File: desktopApp/.../ui/BookmarksScreen.kt
2d. ReadsScreen
DesktopFeedViewModel(DesktopReadsFeedFilter(cache), cache)- Card rendering: ReadsScreen needs
LongFormNoteCardthat extracts title/summary/topics fromNote.event as? LongTextNoteEvent. Separate composable, not FeedNoteCard.
File: desktopApp/.../ui/ReadsScreen.kt
2e. NotificationsScreen
DesktopNotificationFeedFilterreturnsList<Note>. NotificationsScreen transforms to typedNotificationItemin 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
DisposableEffectcleanup 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> subscriptionHealthStateFlow 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 SinglePaneLayout → RootContent → 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:
FeedContentStatecatches →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
DisposableEffectcallingnote.clearFlow()when FeedNoteCard leaves composition. - ViewModel recreation on mode switch: Old scope cancelled via
DisposableEffect, new one created. 250ms gap caught on nextrefreshSuspended(). - Note mutation thread safety:
Note.replies,Note.reactionsetc. 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
EventCollectionStateusage in any feed screen - No
rememberSubscription()in any feed screen - Each screen has at most ONE subscription request to Coordinator
- Every screen has
DisposableEffectcleanup 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:compileKotlinpasses 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
- Brainstorm: docs/brainstorms/2026-03-22-clean-cache-architecture-brainstorm.md — Key decisions: cache as single source of truth, Coordinator owns subscriptions (always alive), relay health indicator, counts on Note model, SearchScreen migrates with AdvancedSearchBarState retained
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